fix(crash-reporting): trace all app relaunch causes

This commit is contained in:
Brennan Benson
2026-07-17 14:57:41 -07:00
parent 66b919d1ce
commit f52f4fe276
7 changed files with 86 additions and 7 deletions
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { appRelaunchMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({
appRelaunchMock: vi.fn(),
recordDurableCrashBreadcrumbMock: vi.fn()
}))
vi.mock('electron', () => ({ app: { relaunch: appRelaunchMock } }))
vi.mock('./crash-reporting/durable-crash-breadcrumb', () => ({
recordDurableCrashBreadcrumb: recordDurableCrashBreadcrumbMock
}))
import { relaunchApp } from './app-relaunch'
beforeEach(() => {
appRelaunchMock.mockReset()
recordDurableCrashBreadcrumbMock.mockReset()
})
describe('relaunchApp', () => {
it('durably records the reason before scheduling the replacement process', () => {
relaunchApp('gpu-fallback', { processReason: 'crashed', exitCode: 5 })
expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledOnce()
expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('app_relaunch_requested', {
processReason: 'crashed',
exitCode: 5,
reason: 'gpu-fallback'
})
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(recordDurableCrashBreadcrumbMock.mock.invocationCallOrder[0]).toBeLessThan(
appRelaunchMock.mock.invocationCallOrder[0]
)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { app } from 'electron'
import type { CrashReportBreadcrumbData } from '../shared/crash-reporting'
import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb'
export type AppRelaunchReason =
| 'admin-restart'
| 'gpu-fallback'
| 'profile-switch'
| 'profile-transfer'
| 'renderer-request'
export function relaunchApp(reason: AppRelaunchReason, data?: CrashReportBreadcrumbData): void {
// Why: the current process can exit immediately after app.relaunch(), so
// persist the cause before Electron schedules the replacement process.
recordDurableCrashBreadcrumb('app_relaunch_requested', { ...data, reason })
app.relaunch()
}
+6 -1
View File
@@ -18,6 +18,7 @@ import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/p
import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config'
import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths'
import { applyAppIcon } from './app-icon'
import { relaunchApp } from './app-relaunch'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
@@ -1319,7 +1320,11 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void {
return
}
isQuitting = true
app.relaunch()
relaunchApp('gpu-fallback', {
processReason: reason,
exitCode,
crashesInWindow: result.crashesInWindow
})
app.exit(0)
}
+10
View File
@@ -8,6 +8,7 @@ const {
appRelaunchMock,
spawnMock,
destroySystemTrayMock,
relaunchAppMock,
showOpenDialogMock,
grantFloatingWorkspaceDirectoryMock
} = vi.hoisted(() => ({
@@ -17,6 +18,7 @@ const {
appRelaunchMock: vi.fn(),
spawnMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
relaunchAppMock: vi.fn(),
showOpenDialogMock: vi.fn(),
grantFloatingWorkspaceDirectoryMock: vi.fn()
}))
@@ -91,6 +93,10 @@ vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
vi.mock('../app-relaunch', () => ({
relaunchApp: relaunchAppMock
}))
vi.mock('./floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn(),
grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock,
@@ -113,6 +119,8 @@ describe('registerAppHandlers', () => {
appRelaunchMock.mockReset()
spawnMock.mockReset()
destroySystemTrayMock.mockReset()
relaunchAppMock.mockReset()
relaunchAppMock.mockImplementation(() => appRelaunchMock())
showOpenDialogMock.mockReset()
grantFloatingWorkspaceDirectoryMock.mockReset()
processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true)
@@ -139,6 +147,7 @@ describe('registerAppHandlers', () => {
await vi.advanceTimersByTimeAsync(150)
expect(destroySystemTrayMock).toHaveBeenCalledTimes(1)
expect(relaunchAppMock).toHaveBeenCalledWith('renderer-request')
expect(appRelaunchMock).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledWith(0)
expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan(
@@ -187,6 +196,7 @@ describe('registerAppHandlers', () => {
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledTimes(1)
expect(relaunchAppMock).toHaveBeenCalledWith('admin-restart')
expect(appQuitMock).toHaveBeenCalledTimes(1)
expect(appExitMock).not.toHaveBeenCalled()
})
+3 -2
View File
@@ -6,6 +6,7 @@ import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'el
import { is } from '@electron-toolkit/utils'
import type { AppIdentity } from '../../shared/app-identity'
import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/types'
import { relaunchApp } from '../app-relaunch'
import type { Store } from '../persistence'
import { getDevInstanceIdentity } from '../startup/dev-instance-identity'
import { isPwshAvailable } from '../pwsh'
@@ -314,7 +315,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
// Why: app.exit(0) skips before-quit/will-quit, so clean the Windows tray
// explicitly before relaunching to avoid a stale notification-area icon.
destroySystemTray()
app.relaunch()
relaunchApp('renderer-request')
app.exit(0)
}, 150)
})
@@ -325,7 +326,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
// checkpoints, runtime metadata, and telemetry flush before exit.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
app.relaunch()
relaunchApp('admin-restart')
app.quit()
}, 150)
})
+10
View File
@@ -5,6 +5,7 @@ const {
appExitMock,
appQuitMock,
appRelaunchMock,
relaunchAppMock,
destroySystemTrayMock,
createLocalOrcaProfileMock,
getOrcaProfileListStateMock,
@@ -16,6 +17,7 @@ const {
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
relaunchAppMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
createLocalOrcaProfileMock: vi.fn(),
getOrcaProfileListStateMock: vi.fn(),
@@ -42,6 +44,10 @@ vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
vi.mock('../app-relaunch', () => ({
relaunchApp: relaunchAppMock
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: createLocalOrcaProfileMock,
getOrcaProfileListState: getOrcaProfileListStateMock,
@@ -70,6 +76,8 @@ describe('registerOrcaProfileHandlers', () => {
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
relaunchAppMock.mockReset()
relaunchAppMock.mockImplementation(() => appRelaunchMock())
destroySystemTrayMock.mockReset()
createLocalOrcaProfileMock.mockReset()
getOrcaProfileListStateMock.mockReset()
@@ -159,6 +167,7 @@ describe('registerOrcaProfileHandlers', () => {
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(relaunchAppMock).toHaveBeenCalledWith('profile-switch')
// Why quit, not exit: before-quit/will-quit teardown (scrollback capture,
// PTY kill, daemon checkpoints) must run on a profile switch.
expect(appQuitMock).toHaveBeenCalledOnce()
@@ -294,6 +303,7 @@ describe('registerOrcaProfileHandlers', () => {
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer')
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
+5 -4
View File
@@ -1,5 +1,6 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import { relaunchApp, type AppRelaunchReason } from '../app-relaunch'
import type {
CreateLocalOrcaProfileArgs,
CreateLocalOrcaProfileResult,
@@ -153,9 +154,9 @@ async function runBeforeProfileRelaunch(
}
}
function scheduleProfileRelaunch(): void {
function scheduleProfileRelaunch(reason: Extract<AppRelaunchReason, `profile-${string}`>): void {
setTimeout(() => {
app.relaunch()
relaunchApp(reason)
// Why: app.quit() (not app.exit) so before-quit/will-quit still run —
// renderer scrollback capture, PTY kill, stats flush, and daemon final
// checkpoints must not be skipped on a profile switch.
@@ -215,7 +216,7 @@ export function registerOrcaProfileHandlers(
store.flush()
setActiveOrcaProfile(profileId)
scheduleProfileRelaunch()
scheduleProfileRelaunch('profile-switch')
return { status: 'relaunching' }
}
@@ -244,7 +245,7 @@ export function registerOrcaProfileHandlers(
store.freezeWrites()
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
setActiveOrcaProfile(args.targetProfileId)
scheduleProfileRelaunch()
scheduleProfileRelaunch('profile-transfer')
return { ...result, willRelaunch: true }
}
return result