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:
@@ -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,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']) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user