From 50e1b50b09a1b87effc2ec49e4eee8d6df5af32a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:21:07 -0700 Subject: [PATCH] fix(crash-reporting): disarm the recovery flag when auto-recovery gives up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arm was set in one place and cancelled in none. When the renderer recovery circuit breaker opens, the arm from the last allowed attempt stays live for 30s, so the user's own reload from the "Orca keeps failing to load" dialog consumes it — and the whole crash-on-load loop is written dismissed with rendererAutoRecovered: true. That flag is a direct lie there: the same code path had just recorded renderer_recovery_circuit_breaker_open. It silences the highest-signal crash class we have, repeated launch-failed before bootstrap. Clearing on exhaustion leaves the manual retry unarmed, so the crash prompts on its own evidence at the next launch. --- .../renderer-recovery-crash-outcome.ts | 12 +++++++++++- src/main/index.ts | 8 +++++++- ...-reporting-recovered-renderer-prompt.test.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/main/crash-reporting/renderer-recovery-crash-outcome.ts b/src/main/crash-reporting/renderer-recovery-crash-outcome.ts index 20d47b3d330..4fb52b3c151 100644 --- a/src/main/crash-reporting/renderer-recovery-crash-outcome.ts +++ b/src/main/crash-reporting/renderer-recovery-crash-outcome.ts @@ -13,6 +13,16 @@ export function noteRendererRecoveryReloadIssued(nowMs = Date.now()): void { recoveryReloadIssuedAtMs = nowMs } +/** + * Disarm the outcome check because auto-recovery gave up. + * + * Why: the next bootstrap then comes from the user's own retry, and a crash the + * breaker refused to keep reloading is the one they most need prompted about. + */ +export function clearRendererRecoveryReloadIssued(): void { + recoveryReloadIssuedAtMs = null +} + function takeRendererRecoveryReloadIssuedAt(nowMs: number): number | null { const issuedAtMs = recoveryReloadIssuedAtMs recoveryReloadIssuedAtMs = null @@ -55,5 +65,5 @@ export function resolveRecoveredRendererCrashReports( } export function _resetRendererRecoveryOutcomeForTests(): void { - recoveryReloadIssuedAtMs = null + clearRendererRecoveryReloadIssued() } diff --git a/src/main/index.ts b/src/main/index.ts index 857211b0111..d3a88ce2e8b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -281,7 +281,10 @@ import { type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' -import { noteRendererRecoveryReloadIssued } from './crash-reporting/renderer-recovery-crash-outcome' +import { + clearRendererRecoveryReloadIssued, + noteRendererRecoveryReloadIssued +} from './crash-reporting/renderer-recovery-crash-outcome' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry @@ -1228,6 +1231,9 @@ function openMainWindow(): BrowserWindow { expectedTeardown: getExpectedTeardownScope(webContentsId) }), onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { + // Why: the pending arm belongs to a reload that never brought the renderer + // back, so the user's retry below must not resolve these as auto-recovered. + clearRendererRecoveryReloadIssued() recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, diff --git a/src/main/ipc/crash-reporting-recovered-renderer-prompt.test.ts b/src/main/ipc/crash-reporting-recovered-renderer-prompt.test.ts index b3454423ecc..a80db0398aa 100644 --- a/src/main/ipc/crash-reporting-recovered-renderer-prompt.test.ts +++ b/src/main/ipc/crash-reporting-recovered-renderer-prompt.test.ts @@ -56,6 +56,7 @@ vi.mock('../observability/tracer', () => ({ import { CrashReportStore } from '../crash-reporting/crash-report-store' import { _resetRendererRecoveryOutcomeForTests, + clearRendererRecoveryReloadIssued, noteRendererRecoveryReloadIssued, resolveRecoveredRendererCrashReports } from '../crash-reporting/renderer-recovery-crash-outcome' @@ -259,6 +260,22 @@ describe('auto-recovered renderer crash reporting', () => { await expect(getLatestPending()).resolves.toMatchObject({ id: recorded.id, status: 'pending' }) }) + it('still prompts after the recovery breaker opened and the user reloaded by hand', async () => { + const store = await createStore() + registerCrashReportingHandlers(store) + // The crash-on-load loop: every recovery reload dies before bootstrap. + const recorded = await store.record({ ...killedRendererCrash(), reason: 'launch-failed' }) + + noteRendererRecoveryReloadIssued() + // The breaker refuses the next attempt and Orca asks the user to retry. + clearRendererRecoveryReloadIssued() + // The user's own reload finally boots; it did not auto-recover anything. + emitRendererBootstrapRendered() + + await expect(getLatestPending()).resolves.toMatchObject({ id: recorded.id, status: 'pending' }) + expect((await getLatestReport())?.details.rendererAutoRecovered).toBeUndefined() + }) + it('leaves an unresolved crash from a previous session pending', async () => { const store = await createStore() registerCrashReportingHandlers(store)