mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user