From d16092e503161abba8b2a5032dbf70f9ba78e2a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:06:36 -0700 Subject: [PATCH] fix(updater): recover renderer shutdown checkpoint (#14373) * fix(updater): recover renderer shutdown checkpoint * test(updater): cover checkpoint recovery in Electron * fix(updater): keep staging failures blocking --- src/renderer/src/App.tsx | 28 ++++++- src/renderer/src/app-startup-routing.test.ts | 12 ++- .../src/lib/shutdown-checkpoint-guard.test.ts | 18 ++++- .../src/lib/shutdown-checkpoint-guard.ts | 6 +- .../renderer-restart-preparation.test.ts | 24 +++++- src/shared/renderer-restart-preparation.ts | 23 +++++- src/shared/renderer-shutdown-events.ts | 2 + ...stall-renderer-checkpoint-recovery.spec.ts | 79 +++++++++++++++++++ 8 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6c0f0a1fa44..02d9a2c38ba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -116,7 +116,10 @@ import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { MacosTccPromptNoticeHost } from './hooks/MacosTccPromptNoticeHost' import { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery' -import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' +import { + isIntentionalAppRestartInProgress, + registerUpdaterBeforeUnloadBypass +} from './lib/updater-beforeunload' import { ORCA_APP_RESTART_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT @@ -1346,9 +1349,26 @@ function App(): React.JSX.Element { // into the store via Zustand setters. The earlier read is only for the // gating flags and would miss those updates. const freshState = useAppStore.getState() - const sessionSnapshots = shouldCaptureSession - ? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState) - : [] + let sessionSnapshots: ReturnType = [] + try { + sessionSnapshots = shouldCaptureSession + ? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState) + : [] + } catch (error) { + // Why: dirty drafts exist only in the full session snapshot. + if ( + !isIntentionalAppRestartInProgress() || + freshState.openFiles.some((file) => file.isDirty) + ) { + throw error + } + console.error('[app] Full renderer session snapshot failed; using durable session', error) + window.api.app.stageBeforeUnloadSync({ + sessions: [], + ui: buildActiveViewUnloadPatch(freshState) + }) + return + } window.api.app.stageBeforeUnloadSync({ sessions: sessionSnapshots, ui: buildActiveViewUnloadPatch(freshState) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 9a64224f072..5e58328306c 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -468,7 +468,7 @@ describe('renderer startup runtime routing', () => { it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const checkpointStart = source.indexOf( - 'const shutdownCheckpoint = createShutdownCheckpointGuard(() => {' + 'const shutdownCheckpoint = createShutdownCheckpointGuard(' ) const checkpointEnd = source.indexOf( 'const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)', @@ -478,13 +478,21 @@ describe('renderer startup runtime routing', () => { expect(checkpointEnd).toBeGreaterThan(checkpointStart) const checkpointBlock = source.slice(checkpointStart, checkpointEnd) - expect(checkpointBlock).toContain('const sessionSnapshots = shouldCaptureSession') + expect(checkpointBlock).toContain( + 'let sessionSnapshots: ReturnType = []' + ) expect(checkpointBlock).toContain( 'buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)' ) expect(checkpointBlock).toContain('window.api.app.stageBeforeUnloadSync({') expect(checkpointBlock).toContain('sessions: sessionSnapshots') expect(checkpointBlock).toContain('ui: buildActiveViewUnloadPatch(freshState)') + expect(checkpointBlock).toContain('!isIntentionalAppRestartInProgress()') + expect(checkpointBlock).toContain('freshState.openFiles.some((file) => file.isDirty)') + expect(checkpointBlock).toContain('sessions: []') + expect(checkpointBlock).toContain( + 'return\n }\n window.api.app.stageBeforeUnloadSync({\n sessions: sessionSnapshots' + ) expect(source).toContain( 'window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)' ) diff --git a/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts b/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts index 516b60354e0..688d396d3b8 100644 --- a/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts +++ b/src/renderer/src/lib/shutdown-checkpoint-guard.test.ts @@ -6,7 +6,10 @@ import { createShutdownCheckpointGuard, preventUnloadAndScheduleShutdownCheckpointReset } from './shutdown-checkpoint-guard' -import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +import { + ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, + ORCA_RENDERER_UNLOAD_PREVENTED_EVENT +} from '../../../shared/renderer-shutdown-events' describe('createShutdownCheckpointGuard', () => { it('dedupes the synthetic and native unload events in one close attempt', () => { @@ -42,6 +45,19 @@ describe('createShutdownCheckpointGuard', () => { expect(persist).toHaveBeenCalledTimes(2) }) + it('reports checkpoint failure separately from the unload verdict', () => { + const eventTarget = new EventTarget() + const failed = vi.fn() + const guard = createShutdownCheckpointGuard(() => { + throw new Error('invalid session') + }) + eventTarget.addEventListener(ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, failed) + eventTarget.addEventListener('beforeunload', createShutdownCheckpointBeforeUnloadHandler(guard)) + + expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(false) + expect(failed).toHaveBeenCalledTimes(1) + }) + it('retries after a prevented reload resets the completed checkpoint', () => { const eventTarget = new EventTarget() const persist = vi.fn() diff --git a/src/renderer/src/lib/shutdown-checkpoint-guard.ts b/src/renderer/src/lib/shutdown-checkpoint-guard.ts index b2f81eed832..129c67406d0 100644 --- a/src/renderer/src/lib/shutdown-checkpoint-guard.ts +++ b/src/renderer/src/lib/shutdown-checkpoint-guard.ts @@ -1,4 +1,7 @@ -import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +import { + ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, + ORCA_RENDERER_UNLOAD_PREVENTED_EVENT +} from '../../../shared/renderer-shutdown-events' export type ShutdownCheckpointGuard = { persistOnce: () => boolean @@ -33,6 +36,7 @@ export function createShutdownCheckpointBeforeUnloadHandler( ): (event: Event) => void { return (event): void => { if (!guard.persistOnce()) { + event.currentTarget?.dispatchEvent(new Event(ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT)) event.preventDefault() } } diff --git a/src/shared/renderer-restart-preparation.test.ts b/src/shared/renderer-restart-preparation.test.ts index 925aedbf845..b755496e0c2 100644 --- a/src/shared/renderer-restart-preparation.test.ts +++ b/src/shared/renderer-restart-preparation.test.ts @@ -1,16 +1,20 @@ import { describe, expect, it, vi } from 'vitest' import type { UpdateStatus } from './types' +import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT } from './renderer-shutdown-events' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart } from './renderer-restart-preparation' describe('prepareRendererForAppRestart', () => { - it('aborts when the dispatched shutdown checkpoint prevents unload', async () => { + it('aborts when the dispatched shutdown checkpoint reports failure', async () => { const eventTarget = new EventTarget() const started = vi.fn() const aborted = vi.fn() - const checkpoint = vi.fn((event: Event) => event.preventDefault()) + const checkpoint = vi.fn((event: Event) => { + event.currentTarget?.dispatchEvent(new Event(ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT)) + event.preventDefault() + }) eventTarget.addEventListener('restart-started', started) eventTarget.addEventListener('restart-aborted', aborted) eventTarget.addEventListener('beforeunload', checkpoint) @@ -28,6 +32,22 @@ describe('prepareRendererForAppRestart', () => { expect(aborted).toHaveBeenCalledTimes(1) }) + it('does not mistake an unrelated unload veto for checkpoint failure', async () => { + const eventTarget = new EventTarget() + const veto = vi.fn((event: Event) => event.preventDefault()) + const awaitCheckpoint = vi.fn(() => Promise.resolve()) + eventTarget.addEventListener('beforeunload', veto) + + await prepareRendererForAppRestart(eventTarget, { + startedEventName: 'restart-started', + abortedEventName: 'restart-aborted', + awaitCheckpoint + }) + + expect(veto).toHaveBeenCalledTimes(1) + expect(awaitCheckpoint).toHaveBeenCalledTimes(1) + }) + it('waits for the durable checkpoint write before the restart proceeds', async () => { const eventTarget = new EventTarget() const order: string[] = [] diff --git a/src/shared/renderer-restart-preparation.ts b/src/shared/renderer-restart-preparation.ts index 19aeb20ff44..20a5311cd6a 100644 --- a/src/shared/renderer-restart-preparation.ts +++ b/src/shared/renderer-restart-preparation.ts @@ -2,6 +2,7 @@ import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, type EditorPrepareHotExitDetail } from './editor-save-events' +import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT } from './renderer-shutdown-events' import type { UpdateStatus } from './types' export type AppRestartPrepOptions = { @@ -44,10 +45,24 @@ export async function prepareRendererForAppRestart( try { await requestEditorHotExitBackup(eventTarget) - // Why: update installs can bypass native close. A cancelable synthetic - // unload both captures mounted terminals and reports checkpoint failure. - const accepted = eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true })) - if (!accepted) { + let checkpointFailed = false + const markCheckpointFailed = (): void => { + checkpointFailed = true + } + eventTarget.addEventListener( + ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, + markCheckpointFailed + ) + try { + // Why: the aggregate unload verdict also includes unrelated listeners. + eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true })) + } finally { + eventTarget.removeEventListener( + ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT, + markCheckpointFailed + ) + } + if (checkpointFailed) { throw new Error('Renderer shutdown checkpoint was not completed.') } // Why: the checkpoint only stages synchronously. Navigating before that diff --git a/src/shared/renderer-shutdown-events.ts b/src/shared/renderer-shutdown-events.ts index 6d679e33bdb..3aa3dbcc25d 100644 --- a/src/shared/renderer-shutdown-events.ts +++ b/src/shared/renderer-shutdown-events.ts @@ -1 +1,3 @@ export const ORCA_RENDERER_UNLOAD_PREVENTED_EVENT = 'orca:renderer-unload-prevented' +export const ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT = + 'orca:renderer-shutdown-checkpoint-failed' diff --git a/tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts b/tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts new file mode 100644 index 00000000000..9a521926f19 --- /dev/null +++ b/tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts @@ -0,0 +1,79 @@ +import path from 'node:path' +import { test, expect } from './helpers/orca-app' + +const CHECKPOINT_ERROR = 'Renderer shutdown checkpoint was not completed.' + +test('recovers update install from a corrupt clean session but preserves dirty drafts', async ({ + orcaPage, + testRepoPath +}) => { + const fallbackLogs: string[] = [] + orcaPage.on('console', (message) => { + if (message.text().includes('Full renderer session snapshot failed; using durable session')) { + fallbackLogs.push(message.text()) + } + }) + + const dirtyResult = await orcaPage.evaluate( + async ({ filePath, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const originalHistory = state.browserUrlHistory + state.browserUrlHistory = [ + { url: null, title: 'corrupt persisted history', lastVisitedAt: 0 } + ] as unknown as typeof state.browserUrlHistory + const fileId = state.openFile({ + filePath, + relativePath: 'checkpoint-draft.txt', + worktreeId, + language: 'plaintext', + mode: 'edit' + }) + state.setEditorDraft(fileId, 'unsaved draft') + state.markFileDirty(fileId, true) + + try { + await window.api.updater.quitAndInstall() + return null + } catch (error) { + return String((error as Error)?.message ?? error) + } finally { + state.markFileDirty(fileId, false) + state.closeFile(fileId) + state.browserUrlHistory = originalHistory + } + }, + { + filePath: path.join(testRepoPath, 'checkpoint-draft.txt'), + worktreeId: await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId ?? '') + } + ) + + expect(dirtyResult).toBe(CHECKPOINT_ERROR) + + const cleanResult = await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const originalHistory = state.browserUrlHistory + state.browserUrlHistory = [ + { url: null, title: 'corrupt persisted history', lastVisitedAt: 0 } + ] as unknown as typeof state.browserUrlHistory + try { + await window.api.updater.quitAndInstall() + return 'continued' + } catch (error) { + return String((error as Error)?.message ?? error) + } finally { + state.browserUrlHistory = originalHistory + } + }) + + expect(cleanResult).toBe('continued') + expect(fallbackLogs).toHaveLength(1) +})