diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index ed08a903f79..22ccaa73076 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -48,6 +48,7 @@ function input(reason = 'crashed'): CrashReportCreateInput { } afterEach(async () => { + vi.useRealTimers() vi.restoreAllMocks() grantDirAclAsyncMock.mockReset() grantDirAclAsyncMock.mockResolvedValue(undefined) @@ -125,6 +126,29 @@ describe('CrashReportStore', () => { }) }) + it('never sweeps a renderer crash the auto-recovery cutoff excluded', async () => { + const { store } = await createStore() + // Why fake Date: the sweep matches within 5s of an anchor, so the two + // records must sit on either side of the cutoff by construction. + vi.useFakeTimers({ toFake: ['Date'] }) + const startedAtMs = Date.parse('2026-08-01T00:00:00.000Z') + vi.setSystemTime(startedAtMs) + const stale = await store.record(input()) + vi.setSystemTime(startedAtMs + 4_000) + const anchor = await store.record(input()) + + // The reload was armed 6s after the stale crash, so only the anchor is in scope. + await store.markRendererCrashesAutoRecovered(startedAtMs + 1_000) + + const reports = await store.listRecent() + expect(reports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: anchor.id, status: 'dismissed' }), + expect.objectContaining({ id: stale.id, status: 'pending' }) + ]) + ) + }) + it('dismisses sibling pending records after one crash report is sent', async () => { const { store } = await createStore() await store.record(input()) diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index 6ca3ff63f17..ff1b89755a3 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -166,13 +166,12 @@ export class CrashReportStore { // healed crash still reaches the user, just wearing the sibling's report. const anchors = [...resolved] const sweptReports = nextReports.map((report) => { - // Why: relation matching ignores processType, so re-state the anchor - // pass's exclusion — a renderer-sourced report that is not the renderer - // process is the error boundary reporting itself, not a burst sibling. - if ( - report.status !== 'pending' || - (report.source === 'renderer' && report.processType !== 'renderer') - ) { + // Why: no renderer-sourced report is a burst sibling. The anchor pass + // already ruled on the renderer process, and relation matching (which + // ignores processType and reaches 5s either side of an anchor) would + // otherwise re-admit the older crashes `notBeforeMs` just excluded, or + // an error-boundary report that no reload caused. + if (report.status !== 'pending' || report.source === 'renderer') { return report } if (!anchors.some((anchor) => isRelatedCrashEvent(anchor, report))) {