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
This commit is contained in:
Brennan Benson
2026-08-13 17:06:36 -07:00
committed by GitHub
parent 537864a248
commit d16092e503
8 changed files with 178 additions and 14 deletions
+24 -4
View File
@@ -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<typeof buildWorkspaceSessionHostSnapshots> = []
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)
+10 -2
View File
@@ -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<typeof buildWorkspaceSessionHostSnapshots> = []'
)
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)'
)
@@ -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()
@@ -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()
}
}
@@ -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[] = []
+19 -4
View File
@@ -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
+2
View File
@@ -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'
@@ -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)
})