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:
@@ -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