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.
This commit is contained in:
mesalogo
2026-09-28 11:36:01 +08:00
parent 4e5239256d
commit 61590395ef
5 changed files with 66 additions and 6 deletions
+4 -2
View File
@@ -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
+34
View File
@@ -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']) {
+20 -3
View File
@@ -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(
+6 -1
View File
@@ -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 })
+2
View File
@@ -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