fix(crash-reporting): stop the sibling sweep re-admitting excluded crashes

`markRendererCrashesAutoRecovered` rules on each pending renderer-process
report against `notBeforeMs`, then sweeps burst siblings. The sweep did not
honour the first pass's decision: `isRelatedCrashEvent` ignores processType
and reaches 5s either side of any anchor, and the guard only excluded
react-render. A renderer crash the cutoff had just excluded was therefore
dismissed anyway as a "burst sibling" of a crash from the same process,
pushing effective reach to cutoff + 5s and dropping the
`rendererAutoRecovered` flag that records why it was resolved.

Collapse the guard to `status !== 'pending' || source === 'renderer'`. Every
renderer-sourced report is either an anchor the first pass already ruled on,
or a react-render boundary report the reload did not cause; neither is ever a
burst sibling, which is what the sweep exists to catch. This subsumes the
previous react-render exclusion rather than replacing it — deleting the whole
guard reds three tests, the two pre-existing boundary ones plus the new
cutoff one, so both protections still ride on it.

Scope, so a reader does not overrate this: the newest anchor normally sits
250ms before the arm (the recovery timer delay), so the naturally exploitable
band is about 250ms wide. It only widens toward the full 5s when the arm
lands late relative to its crash, or through a chain of anchors. The fix is
worth making on structural grounds regardless — the sweep should not be able
to override the cutoff the anchor pass just applied.
This commit is contained in:
Neil
2026-08-01 06:53:17 -07:00
parent d43712f658
commit 01e08ea43d
2 changed files with 30 additions and 7 deletions
@@ -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())
@@ -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))) {