diff --git a/src/main/crash-reporting/expected-teardown-state.test.ts b/src/main/crash-reporting/expected-teardown-state.test.ts new file mode 100644 index 000000000..17a296bb5 --- /dev/null +++ b/src/main/crash-reporting/expected-teardown-state.test.ts @@ -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') + }) +}) diff --git a/src/main/crash-reporting/expected-teardown-state.ts b/src/main/crash-reporting/expected-teardown-state.ts new file mode 100644 index 000000000..1480ecbbf --- /dev/null +++ b/src/main/crash-reporting/expected-teardown-state.ts @@ -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 +} diff --git a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts new file mode 100644 index 000000000..7f47e7942 --- /dev/null +++ b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 912941275..e5a0050a7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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', { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 7dc276219..001bc5252 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -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(') diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bc595f10b..bbff3b994 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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(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() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 327772164..f04cfb724 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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.