diff --git a/src/main/app-relaunch.test.ts b/src/main/app-relaunch.test.ts new file mode 100644 index 00000000000..26c98c98bb3 --- /dev/null +++ b/src/main/app-relaunch.test.ts @@ -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] + ) + }) +}) diff --git a/src/main/app-relaunch.ts b/src/main/app-relaunch.ts new file mode 100644 index 00000000000..ab7a725c847 --- /dev/null +++ b/src/main/app-relaunch.ts @@ -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() +} diff --git a/src/main/index.ts b/src/main/index.ts index 58e472aa578..d69013e5689 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) } diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 1eb9bbb17a2..de9a13753db 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -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() }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 2dc3c81f91b..f97ae1e2c24 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -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) }) diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 291a3dfe2f2..3cad49f00ff 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -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() }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 6fbd877552f..b11478bdf89 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -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): 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