From 61590395efe963f30599ba3c329f96f54ba261cd Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 28 Sep 2026 11:36:01 +0800 Subject: [PATCH] fix: retain renderer exit details in diagnostic logs Persist Electron renderer exit reasons and exit codes through diagnostic writes, reads, and exports so abnormal window exits can be investigated. Keep older records readable and existing log bounds unchanged. Validation: 18 focused tests, typecheck, lint, and diff checks passed. Full test run timed out after 60 seconds with a SettingsPanel test failure. Release note: Diagnostic logs now retain renderer exit reasons and exit codes to help distinguish out-of-memory exits from other window failures. --- docs/development/window-recovery.md | 6 +++-- src/main/desktop-diagnostics.test.ts | 34 ++++++++++++++++++++++++++++ src/main/desktop-diagnostics.ts | 23 ++++++++++++++++--- src/main/window.test.ts | 7 +++++- src/main/window.ts | 2 ++ 5 files changed, 66 insertions(+), 6 deletions(-) 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