fix(main): log unhandled rejections durably and stop them killing the app (#9441)

Main had no unhandledRejection handler — Node's default killed the process
with a clean exit code and no macOS crash report, which is exactly the
silent startup-restore exit in #9441. Rejections now record a durable crash
breadcrumb (flushed to the trace sink) and the app stays alive; uncaught
exceptions still terminate but record the same breadcrumb first so field
exits stop being undiagnosable.
This commit is contained in:
Brennan Benson
2026-07-22 11:49:16 -07:00
parent dfbc2e8ba7
commit 7b87f3e4fe
3 changed files with 110 additions and 0 deletions
+3
View File
@@ -72,6 +72,7 @@ import {
installDevParentSignalQuit,
installDevParentWatchdog,
installUncaughtPipeErrorGuard,
installUnhandledRejectionLogging,
isDevParentShutdownRequested,
patchPackagedProcessPath,
shouldInstallManagedHooks
@@ -451,6 +452,8 @@ const devAgentHookEndpointNamespace = devInstanceIdentity.isDev
: undefined
installUncaughtPipeErrorGuard()
// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report).
installUnhandledRejectionLogging()
// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron.
process.env.ORCA_APP_VERSION = app.getVersion()
patchPackagedProcessPath()
@@ -596,3 +596,74 @@ describe('enableMainProcessGpuFeatures', () => {
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-features', 'ExistingFeature')
})
})
describe('main-process fatal error guards (issue #9441)', () => {
it('installUnhandledRejectionLogging records durably and keeps the process alive', async () => {
vi.resetModules()
const record = vi.fn()
vi.doMock('../crash-reporting/durable-crash-breadcrumb', () => ({
recordDurableCrashBreadcrumb: record
}))
const { installUnhandledRejectionLogging } = await import('./configure-process')
const before = process.listeners('unhandledRejection').length
installUnhandledRejectionLogging()
const listeners = process.listeners('unhandledRejection')
expect(listeners.length).toBe(before + 1)
const listener = listeners.at(-1) as (reason: unknown, p: Promise<unknown>) => void
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
// Why: invoking the listener directly must not throw — a throwing handler would still kill main.
expect(() =>
listener(Object.assign(new Error('spawn EAGAIN'), { code: 'EAGAIN' }), Promise.resolve())
).not.toThrow()
} finally {
process.removeListener('unhandledRejection', listener as never)
consoleError.mockRestore()
}
expect(record).toHaveBeenCalledWith(
'main_unhandled_rejection',
expect.objectContaining({ errorMessage: 'spawn EAGAIN', errorCode: 'EAGAIN' }),
'main_unhandled_rejection'
)
})
it('recordFatalMainProcessError never throws even when the breadcrumb sink fails', async () => {
vi.resetModules()
vi.doMock('../crash-reporting/durable-crash-breadcrumb', () => ({
recordDurableCrashBreadcrumb: vi.fn(() => {
throw new Error('sink offline')
})
}))
const { recordFatalMainProcessError } = await import('./configure-process')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
expect(() =>
recordFatalMainProcessError('main_uncaught_exception', 'not-an-error')
).not.toThrow()
} finally {
consoleError.mockRestore()
}
})
it('uncaught pipe errors stay swallowed without a durable record', async () => {
vi.resetModules()
const record = vi.fn()
vi.doMock('../crash-reporting/durable-crash-breadcrumb', () => ({
recordDurableCrashBreadcrumb: record
}))
const { installUncaughtPipeErrorGuard } = await import('./configure-process')
const before = process.listeners('uncaughtException').length
installUncaughtPipeErrorGuard()
const listeners = process.listeners('uncaughtException')
expect(listeners.length).toBe(before + 1)
const listener = listeners.at(-1) as (error: unknown) => void
try {
listener(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
} finally {
process.removeListener('uncaughtException', listener as never)
}
// Why: EPIPE/EIO are expected pipe churn; recording them would flood the breadcrumb store.
expect(record).not.toHaveBeenCalled()
// The guard must still be installed after a swallowed pipe error (only real errors detach it).
})
})
+36
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { getVersionManagerBinPaths } from '../codex-cli/command'
import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb'
import { getMainE2EConfig } from '../e2e-config'
const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000
@@ -90,6 +91,26 @@ export function resetDevParentShutdownRequestForTests(): void {
devParentShutdownRequested = false
}
/** Durably record a main-process fatal/near-fatal error before default handling runs. Exported for tests. */
export function recordFatalMainProcessError(kind: string, error: unknown): void {
try {
const err = error instanceof Error ? error : null
recordDurableCrashBreadcrumb(
kind,
{
errorName: err?.name ?? typeof error,
errorMessage: (err?.message ?? String(error)).slice(0, 500),
errorStack: (err?.stack ?? '').split('\n').slice(0, 12).join('\n'),
errorCode: String((error as NodeJS.ErrnoException | null)?.code ?? '')
},
kind
)
} catch {
// Why: diagnostics must never turn a fatal-error report into a second fault.
}
console.error(`[${kind}]`, error)
}
export function installUncaughtPipeErrorGuard(): void {
const onUncaughtException = (error: unknown): void => {
if (
@@ -102,6 +123,8 @@ export function installUncaughtPipeErrorGuard(): void {
return
}
// Why (issue #9441): the re-throw below exits with a clean code and no macOS crash report; record durably first or the death is undiagnosable in the field.
recordFatalMainProcessError('main_uncaught_exception', error)
process.off('uncaughtException', onUncaughtException)
// Why: throwing inside an uncaughtException handler exits with status 7 and hides the fault; re-throw next tick for the real stack.
setImmediate(() => {
@@ -112,6 +135,19 @@ export function installUncaughtPipeErrorGuard(): void {
process.on('uncaughtException', onUncaughtException)
}
/** Keep one failed background promise from silently killing the whole app.
*
* Node's default kills the process on an unhandled rejection. Large-profile startup restore runs
* hundreds of concurrent async chains (worktree scans, terminal reconnects) in main; a single
* rejection in any of them exited the app with no crash report (issue #9441). Log it durably and
* stay alive — dying cannot be less disruptive than continuing with one failed background task.
*/
export function installUnhandledRejectionLogging(): void {
process.on('unhandledRejection', (reason) => {
recordFatalMainProcessError('main_unhandled_rejection', reason)
})
}
export function patchPackagedProcessPath(): void {
if (!app.isPackaged) {
return