diff --git a/docs/development/window-recovery.md b/docs/development/window-recovery.md index ea5b71ad..091fb2d5 100644 --- a/docs/development/window-recovery.md +++ b/docs/development/window-recovery.md @@ -21,8 +21,10 @@ diagnosis of their cause. - Native crashes and main-frame load failures use the existing bounded desktop diagnostics store (`desktop.renderer.gone`, `desktop.renderer.load-failed`, stage `renderer`). Persistent records retain fixed summaries rather than raw - errors. Crash reason/exit code and React errors/component stacks go to their - respective process consoles. There is no new diagnostic IPC. + errors. Renderer exit records also retain Electron's enumerated `reason` and + numeric `exitCode` when written, read back and exported; older records without + these fields remain readable. The exit details still go to the Main console. + React errors/component stacks remain console-only. There is no new diagnostic IPC. The boundary does not catch module evaluation/startup failures before React mounts, event-handler exceptions, asynchronous rejections, a hung renderer, or diff --git a/src/main/desktop-diagnostics.test.ts b/src/main/desktop-diagnostics.test.ts index d8ca0a22..c40424f5 100644 --- a/src/main/desktop-diagnostics.test.ts +++ b/src/main/desktop-diagnostics.test.ts @@ -34,6 +34,40 @@ afterEach(async () => { }) describe('DesktopDiagnostics', () => { + it('preserves renderer exit details on disk and in exports after restart', async () => { + const directory = await temporaryDirectory() + const diagnostics = new DesktopDiagnostics(directory) + await diagnostics.recordFailure({ + component: 'desktop', stage: 'renderer', code: 'desktop.renderer.gone', + error: new Error('private renderer details'), reason: 'oom', exitCode: -9 + }) + await diagnostics.dispose() + const persisted = await readFile(join(directory, 'desktop-diagnostics.ndjson'), 'utf8') + expect(JSON.parse(persisted)).toMatchObject({ reason: 'oom', exitCode: -9 }) + expect(persisted).not.toContain('private renderer details') + const restarted = new DesktopDiagnostics(directory) + try { + expect(JSON.parse((await restarted.exportRecent()).toString('utf8'))) + .toMatchObject({ reason: 'oom', exitCode: -9 }) + } finally { + await restarted.dispose() + } + }) + + it('omits invalid exit details and keeps older records readable', () => { + const record = { + timestamp: '2026-09-28T03:19:18.062Z', component: 'desktop', + stage: 'renderer', code: 'desktop.renderer.gone', errorType: 'Error' + } + const normalized = normalizeDesktopDiagnosticRecord(record) + expect(normalized).toMatchObject({ code: 'desktop.renderer.gone' }) + expect(normalized).not.toHaveProperty('reason') + expect(normalized).not.toHaveProperty('exitCode') + expect(normalizeDesktopDiagnosticRecord({ + ...record, reason: 'private error content', exitCode: Infinity + })).toEqual(normalized) + }) + it('persists renderer failure codes without storing raw error content', async () => { const diagnostics = new DesktopDiagnostics(await temporaryDirectory()) for (const code of ['desktop.renderer.gone', 'desktop.renderer.load-failed']) { diff --git a/src/main/desktop-diagnostics.ts b/src/main/desktop-diagnostics.ts index d958016a..d72a0430 100644 --- a/src/main/desktop-diagnostics.ts +++ b/src/main/desktop-diagnostics.ts @@ -13,6 +13,10 @@ 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 rendererExitReasons = new Set([ + 'clean-exit', 'abnormal-exit', 'killed', 'crashed', 'oom', + 'launch-failed', 'integrity-failure' +]) const allowedStages = new Set([ 'renderer', 'startup', @@ -69,6 +73,8 @@ export type DesktopDiagnosticFailure = Readonly<{ stage: string code: string error: unknown + reason?: string + exitCode?: number }> export type DesktopDiagnosticFailureObserver = ( @@ -82,6 +88,8 @@ export type DesktopDiagnosticRecord = Readonly<{ code: string errorType: string message: string + reason?: string + exitCode?: number }> export type DesktopDiagnosticsOptions = Readonly<{ @@ -169,13 +177,20 @@ export function normalizeDesktopDiagnosticRecord( } const component = safeComponent(candidate.component) const stage = safeStage(candidate.stage) + const code = safeCode(candidate.code) + const rendererGone = component === 'desktop' && stage === 'renderer' && + code === 'desktop.renderer.gone' return Object.freeze({ timestamp: timestamp.toISOString(), component, stage, - code: safeCode(candidate.code), + code, errorType: safeStoredErrorType(candidate.errorType), - message: fixedMessage(component, stage) + message: fixedMessage(component, stage), + ...(rendererGone && typeof candidate.reason === 'string' && + rendererExitReasons.has(candidate.reason) ? { reason: candidate.reason } : {}), + ...(rendererGone && Number.isSafeInteger(candidate.exitCode) + ? { exitCode: candidate.exitCode } : {}) }) } @@ -249,7 +264,9 @@ export class DesktopDiagnostics { component: failure.component, stage: failure.stage, code: failure.code, - errorType: safeErrorType(failure.error) + errorType: safeErrorType(failure.error), + reason: failure.reason, + exitCode: failure.exitCode }) if (normalized === undefined) { return Promise.reject( diff --git a/src/main/window.test.ts b/src/main/window.test.ts index 460ea241..9274a421 100644 --- a/src/main/window.test.ts +++ b/src/main/window.test.ts @@ -78,7 +78,12 @@ describe('window recovery', () => { gone({}, { reason: 'crashed', exitCode: 1 }) expect(dialog.showMessageBox).toHaveBeenCalledTimes(before + 1) expect(window.webContents.reload).not.toHaveBeenCalled() - expect(observe).toHaveBeenCalledWith(expect.objectContaining({ code: 'desktop.renderer.gone' })) + expect(observe).toHaveBeenNthCalledWith(1, expect.objectContaining({ + code: 'desktop.renderer.gone', reason: 'oom', exitCode: 9 + })) + expect(observe).toHaveBeenNthCalledWith(2, expect.objectContaining({ + code: 'desktop.renderer.gone', reason: 'crashed', exitCode: 1 + })) respond({ response: 0, checkboxChecked: false }) await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce()) gone({}, { reason: 'crashed', exitCode: 1 }) diff --git a/src/main/window.ts b/src/main/window.ts index e92e9ad2..bf18ebc6 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -114,6 +114,8 @@ export function createMainWindow( component: 'desktop', stage: 'renderer', code: 'desktop.renderer.gone', + reason: details.reason, + exitCode: details.exitCode, error: new Error(`Renderer exited: ${details.reason} (${details.exitCode})`) }) if (recoveryPending) return