Files
goodbuddy/src/main/desktop-diagnostics.ts
T
mesalogo b755951ecd fix: offer recovery for blank desktop windows
React render failures now show a localized reload page, and renderer crashes offer native reload confirmation. Record renderer and load failures in existing diagnostics without automatic retry loops. Reload may lose unsaved input; the intermittent failure cause remains unconfirmed.

Release note: Recover from desktop rendering failures using a visible Reload action instead of being left without a recovery entry point.
2026-09-15 08:07:08 +08:00

447 lines
11 KiB
TypeScript

import {
mkdir,
open,
readFile,
rename,
unlink,
type FileHandle
} from 'node:fs/promises'
import { join } from 'node:path'
const DEFAULT_MAXIMUM_FILE_BYTES = 256 * 1024
const DEFAULT_MAXIMUM_FILES = 4
const DEFAULT_MAXIMUM_RECORDS = 1_000
export const MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES = 32
const DIAGNOSTIC_FILE_NAME = 'desktop-diagnostics.ndjson'
const allowedStages = new Set([
'renderer',
'startup',
'create',
'status',
'connection-test',
'native-snapshot',
'compact',
'run',
'connect',
'disconnect'
])
const allowedCodes = new Set([
'desktop.renderer.gone',
'desktop.renderer.load-failed',
'desktop.startup.failed',
'runtime.operation.failed',
'runtime.run.failed',
'remote.connection.network',
'remote.connection.host-invalidated',
'remote.connection.host-identity',
'remote.connection.authentication',
'remote.connection.installation',
'remote.connection.protocol',
'remote.connection.daemon-status',
'remote.connection.platform',
'remote.connection.capability',
'remote.connection.shutdown',
'remote.connection.lost'
])
const safeErrorTypes = new Set([
'AbortError',
'AggregateError',
'Error',
'EvalError',
'RangeError',
'ReferenceError',
'RemoteAgentConnectionError',
'StartupPrerequisiteError',
'SyntaxError',
'TimeoutError',
'TypeError',
'URIError'
])
export type DesktopDiagnosticComponent =
| 'desktop'
| 'runtime'
| 'remote-agent'
export type DesktopDiagnosticFailure = Readonly<{
component: DesktopDiagnosticComponent
stage: string
code: string
error: unknown
}>
export type DesktopDiagnosticFailureObserver = (
failure: DesktopDiagnosticFailure
) => void
export type DesktopDiagnosticRecord = Readonly<{
timestamp: string
component: DesktopDiagnosticComponent
stage: string
code: string
errorType: string
message: string
}>
export type DesktopDiagnosticsOptions = Readonly<{
maximumFileBytes?: number
maximumFiles?: number
maximumRecords?: number
now?: () => Date
}>
function safeErrorType(error: unknown): string {
if (!(error instanceof Error)) {
return 'NonError'
}
let name: unknown
try {
name = error.name
} catch {
return 'Error'
}
return typeof name === 'string' && safeErrorTypes.has(name)
? name
: 'Error'
}
function fixedMessage(
component: DesktopDiagnosticComponent,
stage: string
): string {
if (component === 'desktop') {
if (stage === 'renderer') return 'Desktop renderer failed'
return 'Desktop startup failed'
}
if (component === 'remote-agent') {
return stage === 'disconnect'
? 'Remote connection was lost'
: 'Remote connection failed'
}
return stage === 'run'
? 'Runtime request failed'
: 'Runtime operation failed'
}
function safeComponent(component: unknown): DesktopDiagnosticComponent {
switch (component) {
case 'runtime':
case 'remote-agent':
return component
default:
return 'desktop'
}
}
function safeStage(stage: unknown): string {
return typeof stage === 'string' && allowedStages.has(stage)
? stage
: 'unknown'
}
function safeCode(code: unknown): string {
return typeof code === 'string' && allowedCodes.has(code)
? code
: 'diagnostic.failure'
}
function safeStoredErrorType(errorType: unknown): string {
return typeof errorType === 'string' &&
(errorType === 'NonError' || safeErrorTypes.has(errorType))
? errorType
: 'Error'
}
export function normalizeDesktopDiagnosticRecord(
value: unknown
): DesktopDiagnosticRecord | undefined {
if (typeof value !== 'object' || value === null) {
return undefined
}
const candidate = value as Partial<DesktopDiagnosticRecord>
if (typeof candidate.timestamp !== 'string') {
return undefined
}
const timestamp = new Date(candidate.timestamp)
if (Number.isNaN(timestamp.valueOf())) {
return undefined
}
const component = safeComponent(candidate.component)
const stage = safeStage(candidate.stage)
return Object.freeze({
timestamp: timestamp.toISOString(),
component,
stage,
code: safeCode(candidate.code),
errorType: safeStoredErrorType(candidate.errorType),
message: fixedMessage(component, stage)
})
}
function ignoreMissing(error: unknown): void {
if (
!(error instanceof Error) ||
!('code' in error) ||
error.code !== 'ENOENT'
) {
throw error
}
}
export class DesktopDiagnostics {
readonly #directoryPath: string
readonly #maximumFileBytes: number
readonly #maximumFiles: number
readonly #maximumRecords: number
readonly #now: () => Date
#handle: FileHandle | undefined
#currentBytes = 0
#initialized = false
#accepting = true
#pendingWrites = 0
#queue: Promise<void> = Promise.resolve()
constructor(
directoryPath: string,
options: DesktopDiagnosticsOptions = {}
) {
this.#directoryPath = directoryPath
this.#maximumFileBytes =
options.maximumFileBytes ?? DEFAULT_MAXIMUM_FILE_BYTES
this.#maximumFiles =
options.maximumFiles ?? DEFAULT_MAXIMUM_FILES
this.#maximumRecords =
options.maximumRecords ?? DEFAULT_MAXIMUM_RECORDS
this.#now = options.now ?? (() => new Date())
if (
!Number.isSafeInteger(this.#maximumFileBytes) ||
this.#maximumFileBytes < 256 ||
!Number.isSafeInteger(this.#maximumFiles) ||
this.#maximumFiles < 1 ||
this.#maximumFiles > 16 ||
!Number.isSafeInteger(this.#maximumRecords) ||
this.#maximumRecords < 1 ||
this.#maximumRecords > 10_000
) {
throw new RangeError('Invalid desktop diagnostic bounds')
}
}
initialize(): Promise<void> {
return this.#enqueue(async () => this.#ensureInitialized())
}
get pendingWriteCount(): number {
return this.#pendingWrites
}
recordFailure(
failure: DesktopDiagnosticFailure
): Promise<void> {
if (!this.#accepting) {
return Promise.resolve()
}
let record: DesktopDiagnosticRecord
try {
const normalized = normalizeDesktopDiagnosticRecord({
timestamp: this.#now().toISOString(),
component: failure.component,
stage: failure.stage,
code: failure.code,
errorType: safeErrorType(failure.error)
})
if (normalized === undefined) {
return Promise.reject(
new Error('Desktop diagnostic record is invalid')
)
}
record = normalized
} catch (error) {
return Promise.reject(error)
}
if (
this.#pendingWrites >=
MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES
) {
return Promise.resolve()
}
this.#pendingWrites += 1
return this.#enqueue(async () => {
try {
await this.#ensureInitialized()
const encoded = Buffer.from(
`${JSON.stringify(record)}\n`,
'utf8'
)
if (encoded.byteLength > this.#maximumFileBytes) {
throw new RangeError(
'Desktop diagnostic record exceeds file bound'
)
}
if (
this.#currentBytes > 0 &&
this.#currentBytes + encoded.byteLength >
this.#maximumFileBytes
) {
await this.#rotate()
}
await this.#handle!.write(encoded)
this.#currentBytes += encoded.byteLength
} finally {
this.#pendingWrites -= 1
}
})
}
readRecent(
limit = this.#maximumRecords
): Promise<readonly DesktopDiagnosticRecord[]> {
const requestedLimit = Number.isFinite(limit)
? Math.trunc(limit)
: this.#maximumRecords
const boundedLimit = Math.min(
Math.max(0, requestedLimit),
this.#maximumRecords
)
if (boundedLimit === 0) {
return Promise.resolve([])
}
return this.#enqueue(async () => {
await this.#ensureInitialized()
await this.#handle!.sync()
const records: DesktopDiagnosticRecord[] = []
for (let index = this.#maximumFiles - 1; index >= 0; index -= 1) {
const contents = await this.#readFile(index)
if (contents === undefined) {
continue
}
for (const line of contents.split('\n')) {
if (line.length === 0) {
continue
}
try {
const parsed: unknown = JSON.parse(line)
const record = normalizeDesktopDiagnosticRecord(parsed)
if (record !== undefined) {
records.push(record)
}
} catch {
// Ignore a final partial or externally damaged record.
}
}
}
return Object.freeze(records.slice(-boundedLimit))
})
}
async exportRecent(
limit = this.#maximumRecords
): Promise<Buffer> {
const records = await this.readRecent(limit)
return Buffer.from(
records.map((record) => JSON.stringify(record)).join('\n') +
(records.length > 0 ? '\n' : ''),
'utf8'
)
}
flush(): Promise<void> {
return this.#enqueue(async () => {
if (this.#handle) {
await this.#handle.sync()
}
})
}
dispose(): Promise<void> {
if (!this.#accepting) {
return this.#queue
}
this.#accepting = false
return this.#enqueue(async () => {
const handle = this.#handle
this.#handle = undefined
this.#initialized = false
if (handle) {
await handle.sync()
await handle.close()
}
})
}
#enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.#queue.then(operation)
this.#queue = result.then(
() => undefined,
() => undefined
)
return result
}
async #ensureInitialized(): Promise<void> {
if (this.#initialized) {
return
}
await mkdir(this.#directoryPath, { recursive: true })
const currentPath = this.#filePath(0)
this.#handle = await open(currentPath, 'a+')
this.#currentBytes = (await this.#handle.stat()).size
this.#initialized = true
if (this.#currentBytes > this.#maximumFileBytes) {
await this.#handle.close()
this.#handle = undefined
await unlink(currentPath)
this.#handle = await open(currentPath, 'a+')
this.#currentBytes = 0
}
}
async #rotate(): Promise<void> {
const handle = this.#handle
this.#handle = undefined
if (handle) {
await handle.close()
}
await unlink(this.#filePath(this.#maximumFiles - 1)).catch(
ignoreMissing
)
for (
let index = this.#maximumFiles - 2;
index >= 0;
index -= 1
) {
await rename(
this.#filePath(index),
this.#filePath(index + 1)
).catch(ignoreMissing)
}
this.#handle = await open(this.#filePath(0), 'a+')
this.#currentBytes = 0
}
async #readFile(index: number): Promise<string | undefined> {
try {
return await readFile(this.#filePath(index), 'utf8')
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined
}
throw error
}
}
#filePath(index: number): string {
return join(
this.#directoryPath,
index === 0
? DIAGNOSTIC_FILE_NAME
: `${DIAGNOSTIC_FILE_NAME}.${index}`
)
}
}