mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user