fix(crash-reporting): stop filing Windows OS shutdown as renderer crashes (#12938)

This commit is contained in:
Jinwoo Hong 2026-08-07 15:51:24 -07:00 committed by GitHub
parent f8786d5224
commit 2a93cae293
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 540 additions and 9 deletions

View File

@ -0,0 +1,193 @@
import { performance } from 'node:perf_hooks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
shouldRecordProcessGoneCrash,
shouldRecoverRendererAfterProcessGone
} from './process-gone-classification'
import {
markSystemSessionEnding,
resetExpectedTeardownStateForTest,
resolveExpectedTeardownScope,
WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
} from './expected-teardown-state'
function shouldRecordKilledRenderer(expectedTeardown: 'none' | 'renderer-reload' | 'app-shutdown') {
return shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown
})
}
let now: number
beforeEach(() => {
now = 1_000
resetExpectedTeardownStateForTest(() => now)
})
afterEach(() => {
vi.restoreAllMocks()
resetExpectedTeardownStateForTest()
})
describe('expected teardown state', () => {
it('uses a product-chosen five-second harm bound, not a Windows lifetime guarantee', () => {
// Restart Manager may wait 30s; tree-kills after this bound remain reportable by design.
expect(WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS).toBe(5_000)
})
it('uses the production monotonic clock by default', () => {
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValue(1_001)
vi.spyOn(Date, 'now').mockReturnValueOnce(10_000).mockReturnValue(15_000)
resetExpectedTeardownStateForTest()
markSystemSessionEnding()
expect(
resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
).toBe('app-shutdown')
})
it('does not resurrect session-end after an injected clock rollback and catch-up', () => {
now = 3_600_000
markSystemSessionEnding()
now = 0
const rollbackScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
now = 3_600_001
const catchUpScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
expect(rollbackScope).toBe('none')
expect(catchUpScope).toBe('none')
})
it('does not resurrect session-end after expiry and an injected backtrack', () => {
markSystemSessionEnding()
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
const expiredScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
now -= 1
const backtrackScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
expect(expiredScope).toBe('none')
expect(backtrackScope).toBe('none')
})
it('re-arms the suppression window on repeated session-end events', () => {
markSystemSessionEnding()
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1
markSystemSessionEnding()
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1
expect(
resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
).toBe('app-shutdown')
})
it('classifies killed/1 just inside the session-end window as app shutdown', () => {
markSystemSessionEnding()
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1
const scope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
expect(scope).toBe('app-shutdown')
expect(shouldRecordKilledRenderer(scope)).toBe(false)
})
it('keeps killed/1 reportable at and just outside the session-end boundary', () => {
markSystemSessionEnding()
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
const boundaryScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
now += 1
const outsideScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
expect(boundaryScope).toBe('none')
expect(outsideScope).toBe('none')
expect(shouldRecordKilledRenderer(outsideScope)).toBe(true)
})
it('excludes session-end from recovery while preserving in-app quit suppression', () => {
markSystemSessionEnding()
const sessionEndScope = resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false,
includeSystemSessionEnd: false
})
const inAppQuitScope = resolveExpectedTeardownScope({
isQuitting: true,
isQuittingForUpdate: false,
isExpectedRendererReload: false,
includeSystemSessionEnd: false
})
expect(sessionEndScope).toBe('none')
expect(
shouldRecoverRendererAfterProcessGone({
reason: 'killed',
expectedTeardown: sessionEndScope
})
).toBe(true)
expect(inAppQuitScope).toBe('app-shutdown')
expect(
shouldRecoverRendererAfterProcessGone({
reason: 'killed',
expectedTeardown: inAppQuitScope
})
).toBe(false)
})
it('preserves existing update and renderer-reload scopes', () => {
expect(
resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: true,
isExpectedRendererReload: false
})
).toBe('app-shutdown')
expect(
resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: true,
includeSystemSessionEnd: false
})
).toBe('renderer-reload')
})
})

View File

@ -0,0 +1,58 @@
import { performance } from 'node:perf_hooks'
import type { ExpectedTeardownScope } from './process-gone-classification'
// Why: 5s bounds harm; Restart Manager may wait 30s, so later kills remain reportable.
export const WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS = 5_000
type Clock = () => number
// Windows performance.now is monotonic and includes time spent suspended.
const monotonicNow = (): number => performance.now()
let now: Clock = monotonicNow
let systemSessionEndedAt: number | null = null
export function markSystemSessionEnding(): void {
systemSessionEndedAt = now()
}
function isRecentSystemSessionEnd(): boolean {
if (systemSessionEndedAt === null) {
return false
}
const elapsed = now() - systemSessionEndedAt
if (elapsed < 0) {
systemSessionEndedAt = null
return false
}
if (elapsed >= WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS) {
systemSessionEndedAt = null
return false
}
return true
}
export function resolveExpectedTeardownScope({
isQuitting,
isQuittingForUpdate,
isExpectedRendererReload,
includeSystemSessionEnd = true
}: {
isQuitting: boolean
isQuittingForUpdate: boolean
isExpectedRendererReload: boolean
includeSystemSessionEnd?: boolean
}): ExpectedTeardownScope {
if (
isQuitting ||
isQuittingForUpdate ||
(includeSystemSessionEnd && isRecentSystemSessionEnd())
) {
return 'app-shutdown'
}
return isExpectedRendererReload ? 'renderer-reload' : 'none'
}
export function resetExpectedTeardownStateForTest(clock: Clock = monotonicNow): void {
now = clock
systemSessionEndedAt = null
}

View File

@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
getVersion: () => '1.2.3-test',
getAppMetrics: () => []
}
}))
import { clearCrashBreadcrumbsForTest } from './crash-breadcrumb-store'
import {
markSystemSessionEnding,
resetExpectedTeardownStateForTest,
resolveExpectedTeardownScope,
WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
} from './expected-teardown-state'
import { ProcessGoneDedupe } from './process-gone-dedupe'
import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder'
function event(overrides: Partial<ProcessGoneCrashEvent> = {}): ProcessGoneCrashEvent {
return {
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'none',
details: { processType: 'renderer' },
...overrides
}
}
const gpuKill = event({
source: 'child',
processType: 'GPU',
details: { serviceName: 'GPU', type: 'GPU' }
})
const networkServiceKill = event({
source: 'child',
processType: 'Utility',
details: {
name: 'Network Service',
serviceName: 'network.mojom.NetworkService',
type: 'Utility'
}
})
const rendererKill = event()
function currentTeardownScope() {
return resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
}
let now: number
beforeEach(() => {
now = 1_000
resetExpectedTeardownStateForTest(() => now)
clearCrashBreadcrumbsForTest()
})
afterEach(() => {
vi.restoreAllMocks()
resetExpectedTeardownStateForTest()
clearCrashBreadcrumbsForTest()
})
describe('recordProcessGoneCrash killed/1 ordering', () => {
it('reports a genuine lone renderer killed/1 without teardown intent', () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
recordProcessGoneCrash({ record } as never, rendererKill, new ProcessGoneDedupe())
expect(record).toHaveBeenCalledOnce()
})
it('reports R3 after matching recoverable sibling churn', () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const dedupe = new ProcessGoneDedupe()
recordProcessGoneCrash({ record } as never, gpuKill, dedupe)
recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe)
recordProcessGoneCrash({ record } as never, rendererKill, dedupe)
expect(record).toHaveBeenCalledOnce()
})
it('suppresses the fleet sequence only after independent session-end intent', () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const dedupe = new ProcessGoneDedupe()
markSystemSessionEnding()
const expectedTeardown = currentTeardownScope()
recordProcessGoneCrash({ record } as never, event({ ...gpuKill, expectedTeardown }), dedupe)
recordProcessGoneCrash(
{ record } as never,
event({ ...networkServiceKill, expectedTeardown }),
dedupe
)
recordProcessGoneCrash(
{ record } as never,
event({ ...rendererKill, expectedTeardown }),
dedupe
)
expect(expectedTeardown).toBe('app-shutdown')
expect(record).not.toHaveBeenCalled()
})
it('durably reports killed/1 after the session-end window expires', () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
markSystemSessionEnding()
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
const expectedTeardown = currentTeardownScope()
recordProcessGoneCrash(
{ record } as never,
event({ ...rendererKill, expectedTeardown }),
new ProcessGoneDedupe()
)
expect(expectedTeardown).toBe('none')
expect(record).toHaveBeenCalledOnce()
})
it('keeps a renderer report filed when session-end intent arrives later', () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const scopeBeforeSessionEnd = currentTeardownScope()
recordProcessGoneCrash(
{ record } as never,
event({ ...rendererKill, expectedTeardown: scopeBeforeSessionEnd }),
new ProcessGoneDedupe()
)
markSystemSessionEnding()
const scopeAfterSessionEnd = currentTeardownScope()
recordProcessGoneCrash(
{ record } as never,
event({ ...rendererKill, expectedTeardown: scopeAfterSessionEnd }),
new ProcessGoneDedupe()
)
expect(scopeBeforeSessionEnd).toBe('none')
expect(scopeAfterSessionEnd).toBe('app-shutdown')
expect(record).toHaveBeenCalledOnce()
})
})

View File

@ -298,6 +298,7 @@ import {
type ExpectedTeardownScope
} from './crash-reporting/process-gone-classification'
import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder'
import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
@ -697,14 +698,17 @@ function clearExpectedRendererReload(webContentsId?: number): void {
expectedRendererReload.clear(webContentsId)
}
function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope {
if (isQuitting || isQuittingForUpdate()) {
return 'app-shutdown'
}
if (webContentsId === undefined) {
return 'none'
}
return expectedRendererReload.matches(webContentsId) ? 'renderer-reload' : 'none'
function getExpectedTeardownScope(
webContentsId?: number,
includeSystemSessionEnd = true
): ExpectedTeardownScope {
return resolveExpectedTeardownScope({
isQuitting,
isQuittingForUpdate: isQuittingForUpdate(),
isExpectedRendererReload:
webContentsId !== undefined && expectedRendererReload.matches(webContentsId),
includeSystemSessionEnd
})
}
function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void {
@ -1263,7 +1267,7 @@ function openMainWindow(): BrowserWindow {
shouldRecoverRenderer: (details, webContentsId) =>
shouldRecoverRendererAfterProcessGone({
reason: details.reason,
expectedTeardown: getExpectedTeardownScope(webContentsId)
expectedTeardown: getExpectedTeardownScope(webContentsId, false)
}),
onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => {
recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', {

View File

@ -156,6 +156,33 @@ describe('startup ordering', () => {
expect(startIndex).toBeGreaterThan(attachIndex)
})
it('wires bounded teardown state to reporting but not recovery or close behavior', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const scopeStart = source.indexOf('function getExpectedTeardownScope(')
const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart)
const scope = source.slice(scopeStart, scopeEnd)
const windowStart = source.indexOf('const window = createMainWindow(store, {')
const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart)
const windowOptions = source.slice(windowStart, windowEnd)
const recorderStart = source.indexOf('function recordProcessGoneCrash(')
const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart)
const recorder = source.slice(recorderStart, recorderEnd)
expect(scopeStart).toBeGreaterThanOrEqual(0)
expect(scopeEnd).toBeGreaterThan(scopeStart)
expect(scope).toContain('resolveExpectedTeardownScope({')
expect(scope).toContain('includeSystemSessionEnd')
expect(windowStart).toBeGreaterThanOrEqual(0)
expect(windowEnd).toBeGreaterThan(windowStart)
expect(windowOptions).toContain('getIsQuitting: () => isQuitting')
expect(windowOptions).toContain(
'expectedTeardown: getExpectedTeardownScope(webContentsId, false)'
)
expect(recorderStart).toBeGreaterThanOrEqual(0)
expect(recorderEnd).toBeGreaterThan(recorderStart)
expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)')
})
it('attaches renderer services before starting the TCC prompt watcher', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const attachIndex = source.indexOf('attachMainWindowServices(')

View File

@ -74,6 +74,11 @@ import {
} from './createMainWindow'
import { ipcMain } from 'electron'
import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification'
import {
resetExpectedTeardownStateForTest,
resolveExpectedTeardownScope,
WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
} from '../crash-reporting/expected-teardown-state'
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
const original = process.platform
@ -102,6 +107,7 @@ describe('createMainWindow', () => {
vi.mocked(ipcMain.removeListener).mockReset()
vi.mocked(ipcMain.handle).mockReset()
vi.mocked(ipcMain.removeHandler).mockReset()
resetExpectedTeardownStateForTest()
vi.useRealTimers()
})
@ -3143,6 +3149,40 @@ describe('createMainWindow', () => {
consoleError.mockRestore()
})
it('still preserves PTYs and reloads after Windows session-end', () => {
vi.useFakeTimers()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness()
const onBeforeRecoveryReload = vi.fn()
withPlatform('win32', () => {
createMainWindow(null, {
onBeforeRecoveryReload,
shouldRecoverRenderer: (details) =>
shouldRecoverRendererAfterProcessGone({
reason: details.reason,
expectedTeardown: resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false,
includeSystemSessionEnd: false
})
})
})
})
windowHandlers['session-end']?.({} as never)
windowHandlers['render-process-gone']?.(
{} as never,
{ reason: 'killed', exitCode: 1 } as Electron.RenderProcessGoneDetails
)
vi.runAllTimers()
expect(onBeforeRecoveryReload).toHaveBeenCalledWith(143)
expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(2)
consoleError.mockRestore()
})
it('does not reload after renderer loss when recovery is disabled', () => {
vi.useFakeTimers()
@ -3730,6 +3770,60 @@ describe('createMainWindow', () => {
setPlatform(originalPlatform)
})
it('marks production teardown state on irrevocable Windows session end', () => {
setPlatform('win32')
resetExpectedTeardownStateForTest(() => 1_000)
const { windowHandlers } = setupCloseWindow()
createMainWindow(null)
windowHandlers['session-end']?.({} as never)
expect(
resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
).toBe('app-shutdown')
})
it.each(['darwin', 'linux'] as const)(
'does not mark session teardown state on %s',
(platform) => {
setPlatform(platform)
const { windowHandlers } = setupCloseWindow()
createMainWindow(null)
expect(windowHandlers['session-end']).toBeUndefined()
expect(
resolveExpectedTeardownScope({
isQuitting: false,
isQuittingForUpdate: false,
isExpectedRendererReload: false
})
).toBe('none')
}
)
it('still minimizes to tray after the session-end reporting window expires', () => {
setPlatform('win32')
let now = 1_000
resetExpectedTeardownStateForTest(() => now)
const { windowHandlers, webContents, instance } = setupCloseWindow()
const store = makeStore(true, true)
createMainWindow(store as never)
windowHandlers['session-end']?.({} as never)
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(instance.hide).toHaveBeenCalledOnce()
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
})
it('hides to the tray instead of closing when the setting is on', () => {
setPlatform('win32')
const { windowHandlers, webContents, instance } = setupCloseWindow()

View File

@ -19,6 +19,7 @@ import { translateMain } from '../i18n/main-i18n'
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
import { isCrashReportReason } from '../../shared/crash-reporting'
import { markSystemSessionEnding } from '../crash-reporting/expected-teardown-state'
import {
DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES,
DEFAULT_RENDERER_RECOVERY_WINDOW_MS,
@ -304,6 +305,11 @@ export function createMainWindow(
// Why: native paste fallback is privileged IPC; only the top-level renderer may request it.
setTrustedUIRendererWebContentsId(rendererWebContentsId)
// Unlike query-session-end, session-end cannot be canceled before this signal is recorded.
if (process.platform === 'win32') {
mainWindow.on('session-end', markSystemSessionEnding)
}
if (process.platform === 'darwin') {
// Why: preserve hidden-window power savings; stable native sizing and frame-only invalidation
// make wake recovery independent of the throttled viewport.