diff --git a/src/main/index.ts b/src/main/index.ts index a24373bd647..6ff6f05feb2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d582880f28..311b5cda6e3 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -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) => 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). + }) +}) diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index a0f2f023678..fb5614ae797 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -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