fix(updater): surface and degrade renderer shutdown checkpoint failures

The in-app updater could refuse to install with 'Renderer shutdown
checkpoint was not completed.' while the actual persist() error was
swallowed unlogged, leaving users stranded on old builds (STA-5505).

- report the swallowed persist error: console, crash breadcrumb, and a
  cross-world DOM attribute so the thrown error (and the Update Error
  dialog) names the underlying cause
- stop failing the checkpoint on sleeping-agent quit-capture errors; the
  periodic capture bounds the loss to one minute
- extend the existing durable-session degradation to full-session staging
  failures during an intentional restart, preserving the dirty-draft guard
This commit is contained in:
Brennan Benson
2026-08-25 14:33:27 -07:00
parent bc98655a39
commit 10ea2a8c00
9 changed files with 372 additions and 56 deletions
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from 'vitest'
import {
runShutdownCheckpointPersist,
type ShutdownCheckpointPersistDeps
} from './shutdown-checkpoint-persist'
function makeDeps(
overrides: Partial<ShutdownCheckpointPersistDeps> = {}
): ShutdownCheckpointPersistDeps {
return {
shouldCaptureSession: () => true,
captureTerminalBuffers: vi.fn(),
captureSleepingAgentSessions: vi.fn(),
buildSessionSnapshots: () => [{ state: { activeTabId: 't1' } }] as never,
buildUiPatch: () => ({ activeView: 'workspace' }) as never,
hasDirtyOpenFiles: () => false,
isIntentionalAppRestartInProgress: () => true,
stageBeforeUnloadSync: vi.fn(),
...overrides
}
}
describe('runShutdownCheckpointPersist', () => {
it('does not fail the checkpoint when the sleeping-agent quit capture throws (STA-5505)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const deps = makeDeps({
captureSleepingAgentSessions: () => {
throw new Error('capture exploded')
}
})
expect(() => runShutdownCheckpointPersist(deps)).not.toThrow()
// The full snapshot must still be staged — losing at most one minute of
// resume records is strictly better than blocking the update.
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({
sessions: [{ state: { activeTabId: 't1' } }],
ui: { activeView: 'workspace' }
})
vi.restoreAllMocks()
})
it('degrades to durable-only staging when full staging throws during an intentional restart (STA-5505)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const stageBeforeUnloadSync = vi.fn((args: { sessions: unknown[] }) => {
if (args.sessions.length > 0) {
throw new Error('sync IPC staging failed')
}
})
const deps = makeDeps({ stageBeforeUnloadSync })
expect(() => runShutdownCheckpointPersist(deps)).not.toThrow()
expect(stageBeforeUnloadSync).toHaveBeenCalledTimes(2)
expect(stageBeforeUnloadSync).toHaveBeenLastCalledWith({
sessions: [],
ui: { activeView: 'workspace' }
})
vi.restoreAllMocks()
})
it('still fails the checkpoint when staging throws and dirty editor buffers exist', () => {
const deps = makeDeps({
hasDirtyOpenFiles: () => true,
stageBeforeUnloadSync: vi.fn(() => {
throw new Error('sync IPC staging failed')
})
})
expect(() => runShutdownCheckpointPersist(deps)).toThrow('sync IPC staging failed')
})
it('still fails the checkpoint when staging throws outside an intentional restart', () => {
const deps = makeDeps({
isIntentionalAppRestartInProgress: () => false,
stageBeforeUnloadSync: vi.fn(() => {
throw new Error('sync IPC staging failed')
})
})
expect(() => runShutdownCheckpointPersist(deps)).toThrow('sync IPC staging failed')
})
it('fails the checkpoint when even durable-only staging throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const deps = makeDeps({
buildSessionSnapshots: () => {
throw new Error('snapshot build failed')
},
stageBeforeUnloadSync: vi.fn(() => {
throw new Error('durable staging failed')
})
})
expect(() => runShutdownCheckpointPersist(deps)).toThrow('durable staging failed')
vi.restoreAllMocks()
})
it('keeps the durable-session fallback for snapshot build failures', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const deps = makeDeps({
buildSessionSnapshots: () => {
throw new Error('snapshot build failed')
}
})
expect(() => runShutdownCheckpointPersist(deps)).not.toThrow()
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledTimes(1)
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({
sessions: [],
ui: { activeView: 'workspace' }
})
vi.restoreAllMocks()
})
it('rethrows snapshot build failures when dirty editor buffers exist', () => {
const deps = makeDeps({
hasDirtyOpenFiles: () => true,
buildSessionSnapshots: () => {
throw new Error('snapshot build failed')
}
})
expect(() => runShutdownCheckpointPersist(deps)).toThrow('snapshot build failed')
expect(deps.stageBeforeUnloadSync).not.toHaveBeenCalled()
})
it('skips capture and stages empty sessions before hydration completes', () => {
const deps = makeDeps({ shouldCaptureSession: () => false })
runShutdownCheckpointPersist(deps)
expect(deps.captureTerminalBuffers).not.toHaveBeenCalled()
expect(deps.captureSleepingAgentSessions).not.toHaveBeenCalled()
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({
sessions: [],
ui: { activeView: 'workspace' }
})
})
})
@@ -0,0 +1,64 @@
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
import type { WorkspaceSessionHostSnapshot } from '../lib/workspace-session-host-persistence'
export type ShutdownCheckpointStageArgs = {
sessions: WorkspaceSessionHostSnapshot[]
ui: Partial<PersistedUIState>
}
export type ShutdownCheckpointPersistDeps = {
shouldCaptureSession: () => boolean
/** Per-pane failures are already swallowed inside the capture loop. */
captureTerminalBuffers: () => void
captureSleepingAgentSessions: () => void
buildSessionSnapshots: () => WorkspaceSessionHostSnapshot[]
buildUiPatch: () => Partial<PersistedUIState>
hasDirtyOpenFiles: () => boolean
isIntentionalAppRestartInProgress: () => boolean
stageBeforeUnloadSync: (args: ShutdownCheckpointStageArgs) => void
}
/** The synchronous body of the shutdown checkpoint: capture renderer-owned state,
* then stage everything durable through one main-process call. Throwing here fails
* the checkpoint and blocks the restart, so only unstageable data may throw. */
export function runShutdownCheckpointPersist(deps: ShutdownCheckpointPersistDeps): void {
const shouldCaptureSession = deps.shouldCaptureSession()
if (shouldCaptureSession) {
deps.captureTerminalBuffers()
try {
deps.captureSleepingAgentSessions()
} catch (error) {
// Why: the periodic capture refreshes resume records every minute, so a failed
// quit capture costs at most that window — never strand the restart on it.
console.error('[app] Sleeping-agent quit capture failed; continuing checkpoint', error)
}
}
// Why: dirty drafts exist only in the full session snapshot, so their loss is the
// one thing this checkpoint may never trade away for an update.
const canDegradeToDurableSession = (): boolean =>
deps.isIntentionalAppRestartInProgress() && !deps.hasDirtyOpenFiles()
let sessionSnapshots: WorkspaceSessionHostSnapshot[] = []
let degraded = false
try {
sessionSnapshots = shouldCaptureSession ? deps.buildSessionSnapshots() : []
} catch (error) {
if (!canDegradeToDurableSession()) {
throw error
}
console.error('[app] Full renderer session snapshot failed; using durable session', error)
degraded = true
}
try {
deps.stageBeforeUnloadSync({
sessions: degraded ? [] : sessionSnapshots,
ui: deps.buildUiPatch()
})
} catch (error) {
// A durable-only stage has nothing safer left to fall back to.
if (degraded || !canDegradeToDurableSession()) {
throw error
}
console.error('[app] Staging the full renderer session failed; using durable session', error)
deps.stageBeforeUnloadSync({ sessions: [], ui: deps.buildUiPatch() })
}
}
@@ -20,6 +20,7 @@ import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard
} from '../lib/shutdown-checkpoint-guard'
import { runShutdownCheckpointPersist } from './shutdown-checkpoint-persist'
import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures'
import { dispatchWindowCloseRequest } from '../components/window-close-request-coordinator'
import {
@@ -117,51 +118,40 @@ export function useAppSessionPersistence(): void {
// two firings, PTY exit events can arrive and unmount TerminalPanes,
// emptying shutdownBufferCaptures. The guard prevents the second call
// from overwriting the good session data with an empty snapshot.
const shutdownCheckpoint = createShutdownCheckpointGuard(() => {
const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState())
if (shouldCaptureSession) {
for (const capture of shutdownBufferCaptures.values()) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
const shutdownCheckpoint = createShutdownCheckpointGuard(() =>
runShutdownCheckpointPersist({
shouldCaptureSession: () => shouldPersistWorkspaceSession(useAppStore.getState()),
captureTerminalBuffers: () => {
for (const capture of shutdownBufferCaptures.values()) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
}
}
}
},
// Why: agent provider session ids live only in agentStatusByPaneKey,
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
useAppStore.getState().captureAllSleepingAgentSessions('quit')
}
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The earlier read is only for the
// gating flags and would miss those updates.
const freshState = useAppStore.getState()
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)
captureSleepingAgentSessions: () =>
useAppStore.getState().captureAllSleepingAgentSessions('quit'),
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The shouldCaptureSession read is
// only for the gating flags and would miss those updates.
buildSessionSnapshots: () => {
const freshState = useAppStore.getState()
return buildWorkspaceSessionHostSnapshots(
buildWorkspaceSessionPayload(freshState),
freshState
)
},
buildUiPatch: () => buildActiveViewUnloadPatch(useAppStore.getState()),
hasDirtyOpenFiles: () => useAppStore.getState().openFiles.some((file) => file.isDirty),
isIntentionalAppRestartInProgress,
stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args)
})
})
)
const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)
window.addEventListener('beforeunload', persistBeforeUnload)
window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
+8 -11
View File
@@ -498,20 +498,17 @@ describe('renderer startup runtime routing', () => {
expect(checkpointEnd).toBeGreaterThan(checkpointStart)
const checkpointBlock = source.slice(checkpointStart, checkpointEnd)
expect(checkpointBlock).toContain('runShutdownCheckpointPersist({')
expect(checkpointBlock).toContain(
'let sessionSnapshots: ReturnType<typeof buildWorkspaceSessionHostSnapshots> = []'
'buildWorkspaceSessionHostSnapshots(\n buildWorkspaceSessionPayload(freshState),\n freshState\n )'
)
expect(checkpointBlock).toContain('buildUiPatch: () => buildActiveViewUnloadPatch(')
expect(checkpointBlock).toContain('isIntentionalAppRestartInProgress')
expect(checkpointBlock).toContain(
'useAppStore.getState().openFiles.some((file) => file.isDirty)'
)
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'
'stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args)'
)
expect(source).toContain(
'window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)'
@@ -1,17 +1,25 @@
// @vitest-environment happy-dom
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard,
preventUnloadAndScheduleShutdownCheckpointReset
} from './shutdown-checkpoint-guard'
import {
consumeShutdownCheckpointFailureReason,
ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT,
ORCA_RENDERER_UNLOAD_PREVENTED_EVENT
} from '../../../shared/renderer-shutdown-events'
describe('createShutdownCheckpointGuard', () => {
afterEach(() => {
vi.restoreAllMocks()
delete (window as unknown as { api?: unknown }).api
consumeShutdownCheckpointFailureReason()
})
it('dedupes the synthetic and native unload events in one close attempt', () => {
const persist = vi.fn()
const guard = createShutdownCheckpointGuard(persist)
@@ -45,6 +53,37 @@ describe('createShutdownCheckpointGuard', () => {
expect(persist).toHaveBeenCalledTimes(2)
})
it('publishes the failure cause and records a crash breadcrumb (STA-5505)', () => {
const recordBreadcrumb = vi.fn()
;(window as unknown as { api: unknown }).api = { crashReports: { recordBreadcrumb } }
vi.spyOn(console, 'error').mockImplementation(() => {})
const guard = createShutdownCheckpointGuard(() => {
throw new Error('sendSync payload rejected')
})
expect(guard.persistOnce()).toBe(false)
expect(consumeShutdownCheckpointFailureReason()).toBe('sendSync payload rejected')
expect(recordBreadcrumb).toHaveBeenCalledWith({
name: 'renderer_shutdown_checkpoint_failed',
data: { message: 'sendSync payload rejected' }
})
})
it('clears a stale failure cause once a later checkpoint succeeds (STA-5505)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const guard = createShutdownCheckpointGuard(
vi.fn().mockImplementationOnce(() => {
throw new Error('disk full')
})
)
expect(guard.persistOnce()).toBe(false)
expect(guard.persistOnce()).toBe(true)
expect(consumeShutdownCheckpointFailureReason()).toBeNull()
})
it('reports checkpoint failure separately from the unload verdict', () => {
const eventTarget = new EventTarget()
const failed = vi.fn()
@@ -1,13 +1,25 @@
import {
clearShutdownCheckpointFailureReason,
ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT,
ORCA_RENDERER_UNLOAD_PREVENTED_EVENT
ORCA_RENDERER_UNLOAD_PREVENTED_EVENT,
publishShutdownCheckpointFailureReason
} from '../../../shared/renderer-shutdown-events'
import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder'
export type ShutdownCheckpointGuard = {
persistOnce: () => boolean
reset: () => void
}
// Why: without this, a reproducible checkpoint failure strands the user on an old
// build behind an error that names the symptom while the cause is swallowed (STA-5505).
function reportShutdownCheckpointFailure(error: unknown): void {
console.error('[app] Shutdown checkpoint persist failed:', error)
const message = error instanceof Error ? error.message : String(error)
publishShutdownCheckpointFailureReason(message)
recordRendererCrashBreadcrumb('renderer_shutdown_checkpoint_failed', { message })
}
export function createShutdownCheckpointGuard(persist: () => void): ShutdownCheckpointGuard {
let persisted = false
return {
@@ -17,12 +29,14 @@ export function createShutdownCheckpointGuard(persist: () => void): ShutdownChec
}
try {
persist()
} catch {
} catch (error) {
// Why: browser event targets swallow listener exceptions. Returning a
// failure lets the caller cancel unload and keep this attempt retryable.
reportShutdownCheckpointFailure(error)
return false
}
persisted = true
clearShutdownCheckpointFailureReason()
return true
},
reset(): void {
@@ -1,6 +1,10 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import type { UpdateStatus } from './update-status-types'
import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT } from './renderer-shutdown-events'
import {
ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT,
publishShutdownCheckpointFailureReason
} from './renderer-shutdown-events'
import {
createUpdaterQuitAbortRelay,
prepareRendererForAppRestart
@@ -32,6 +36,24 @@ describe('prepareRendererForAppRestart', () => {
expect(aborted).toHaveBeenCalledTimes(1)
})
it('names the published failure cause in the thrown checkpoint error (STA-5505)', async () => {
const eventTarget = new EventTarget()
eventTarget.addEventListener('beforeunload', (event) => {
// Mirrors the checkpoint guard: publish the cause, then fail the checkpoint.
publishShutdownCheckpointFailureReason('sendSync payload rejected')
event.currentTarget?.dispatchEvent(new Event(ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT))
event.preventDefault()
})
await expect(
prepareRendererForAppRestart(eventTarget, {
startedEventName: 'restart-started',
abortedEventName: 'restart-aborted',
awaitCheckpoint: () => Promise.resolve()
})
).rejects.toThrow('Renderer shutdown checkpoint was not completed: sendSync payload rejected')
})
it('does not mistake an unrelated unload veto for checkpoint failure', async () => {
const eventTarget = new EventTarget()
const veto = vi.fn((event: Event) => event.preventDefault())
+12 -2
View File
@@ -2,7 +2,10 @@ 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 {
consumeShutdownCheckpointFailureReason,
ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT
} from './renderer-shutdown-events'
import type { UpdateStatus } from './update-status-types'
export type AppRestartPrepOptions = {
@@ -63,7 +66,14 @@ export async function prepareRendererForAppRestart(
)
}
if (checkpointFailed) {
throw new Error('Renderer shutdown checkpoint was not completed.')
// Why: the guard publishes the swallowed persist error out-of-band; naming it
// here is the only way the update-error dialog can say what actually failed.
const reason = consumeShutdownCheckpointFailureReason()
throw new Error(
reason
? `Renderer shutdown checkpoint was not completed: ${reason}`
: 'Renderer shutdown checkpoint was not completed.'
)
}
// Why: the checkpoint only stages synchronously. Navigating before that
// write lands loses the session snapshot to a crash or power loss.
+42
View File
@@ -1,3 +1,45 @@
export const ORCA_RENDERER_UNLOAD_PREVENTED_EVENT = 'orca:renderer-unload-prevented'
export const ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT =
'orca:renderer-shutdown-checkpoint-failed'
// Why a DOM attribute: the checkpoint guard runs in the renderer's main world while
// prepareRendererForAppRestart runs in the context-isolated preload world. Events
// cross worlds but their JS payloads don't; document attributes are shared platform
// state, so this is the one channel that carries the failure reason to the thrower.
export const ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE =
'data-orca-shutdown-checkpoint-failure'
export function publishShutdownCheckpointFailureReason(reason: string): void {
try {
globalThis.document?.documentElement?.setAttribute(
ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE,
reason
)
} catch {
// Best-effort diagnostics; the checkpoint verdict itself is carried by the event.
}
}
export function clearShutdownCheckpointFailureReason(): void {
try {
globalThis.document?.documentElement?.removeAttribute(
ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE
)
} catch {
// Best-effort diagnostics only.
}
}
/** Read and clear the published reason so a stale one can't label a later failure. */
export function consumeShutdownCheckpointFailureReason(): string | null {
try {
const root = globalThis.document?.documentElement
const reason = root?.getAttribute(ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE)
if (reason) {
root?.removeAttribute(ORCA_SHUTDOWN_CHECKPOINT_FAILURE_REASON_ATTRIBUTE)
}
return reason || null
} catch {
return null
}
}