fix(app): await durable checkpoints before restart actions (#12433)

* fix(app): await durable checkpoints before restart actions

* fix(app): clear restart latch after refused reload

* fix(persistence): invalidate hash after stale rename
This commit is contained in:
Brennan Benson 2026-08-03 23:11:36 -07:00 committed by GitHub
parent dbbeca0916
commit ed4d6979b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 346 additions and 168 deletions

View File

@ -74,7 +74,7 @@ describe('registerRendererShutdownCheckpointHandler', () => {
)
expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' })
expect(store.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1)
// Why: a live app keeps mutating state, so draining to a stable generation would livelock.
// Why: Store fences the staged generation without draining unrelated live mutations.
expect(store.flushPendingOrThrowAsync).toHaveBeenCalledWith(
expect.objectContaining({ drainToStableGeneration: false })
)

View File

@ -23,8 +23,8 @@ function flushStagedStateWithDeadline(store: Store): Promise<ShutdownCheckpointR
resolve({ ok: false })
}, SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS)
})
// Why not drain to a stable generation: the staged snapshot lands in the first
// write, and a live app keeps mutating state, which would livelock the drain.
// Why not drain to stable: Store retries a superseded staged write without
// chasing unrelated live mutations, which the deadline would otherwise cut off.
const flush = store
.flushPendingOrThrowAsync({ signal: controller.signal, drainToStableGeneration: false })
.then((): ShutdownCheckpointResult => ({ ok: true }))

View File

@ -143,7 +143,7 @@ type TestStore = {
waitForPendingWrite(): Promise<void>
flushOrThrow(): void
flushPendingAsync(): Promise<void>
flushPendingOrThrowAsync(): Promise<void>
flushPendingOrThrowAsync(options?: { drainToStableGeneration?: boolean }): Promise<void>
upsertSshPtyConsumerRecovery(record: {
targetId: string
clientInstanceId: string
@ -185,6 +185,36 @@ function dataFile(dir: string): string {
return join(dir, 'orca-data.json')
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((next) => {
resolve = next
})
return { promise, resolve }
}
function recordFsCalls(dir: string): void {
fsCalls.dirPrefix = dir
fsCalls.recording = true
}
function delayNextDataFileRename(dir: string): ReturnType<typeof deferred> & {
started: Promise<void>
} {
const release = deferred()
const started = deferred()
let held = false
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) {
return null
}
held = true
started.resolve()
return release.promise
}
return { ...release, started: started.promise }
}
function seedStaleBackup(dir: string): void {
const path = `${dataFile(dir)}.bak.0`
writeFileSync(path, '{"stale":true}', 'utf-8')
@ -232,8 +262,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
sidebarWidth: number
): Promise<void> {
store.updateUI({ sidebarWidth })
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS)
await store.waitForPendingWrite()
@ -322,8 +351,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8')
store.updateUI({ sidebarWidth: 361 })
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
let flushed = false
fsCalls.beforeAsync = (fn, target) => {
if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) {
@ -351,34 +379,19 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
it('a sync checkpoint vetoes an async write already parked on rename', async () => {
const dir = makeDir()
const store = await createStore(dir)
let releaseRename!: () => void
const renameRelease = new Promise<void>((resolve) => {
releaseRename = resolve
})
let signalRename!: () => void
const renameStarted = new Promise<void>((resolve) => {
signalRename = resolve
})
fsCalls.waitAsync = (fn, target) => {
if (fn !== 'rename' || target === dataFile(dir) || !target.startsWith(dataFile(dir))) {
return null
}
signalRename()
return renameRelease
}
const rename = delayNextDataFileRename(dir)
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 501 })
vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)
const pending = store.waitForPendingWrite()
await renameStarted
await rename.started
store.updateUI({ sidebarWidth: 502 })
store.flushOrThrow()
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502)
releaseRename()
rename.resolve()
await pending
fsCalls.recording = false
fsCalls.waitAsync = null
@ -391,8 +404,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const dir = makeDir()
const store = await createStore(dir)
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
fsCalls.failAsync = (fn, target) =>
fn === 'rename' && target.startsWith(dataFile(dir)) && !target.includes('.bak.')
? Object.assign(new Error('mount disappeared'), { code: 'ENOENT' })
@ -413,31 +425,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
it('the throwing async barrier drains mutations made during its write', async () => {
const dir = makeDir()
const store = await createStore(dir)
let releaseRename!: () => void
const renameRelease = new Promise<void>((resolve) => {
releaseRename = resolve
})
let signalRename!: () => void
const renameStarted = new Promise<void>((resolve) => {
signalRename = resolve
})
let held = false
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) {
return null
}
held = true
signalRename()
return renameRelease
}
fsCalls.dirPrefix = dir
fsCalls.recording = true
const rename = delayNextDataFileRename(dir)
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 601 })
const barrier = store.flushPendingOrThrowAsync()
await renameStarted
await rename.started
store.updateUI({ sidebarWidth: 602 })
releaseRename()
rename.resolve()
await barrier
fsCalls.recording = false
@ -447,31 +442,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
it('bounds a best-effort flush to one state generation', async () => {
const dir = makeDir()
const store = await createStore(dir)
let releaseRename!: () => void
const renameRelease = new Promise<void>((resolve) => {
releaseRename = resolve
})
let signalRename!: () => void
const renameStarted = new Promise<void>((resolve) => {
signalRename = resolve
})
let held = false
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) {
return null
}
held = true
signalRename()
return renameRelease
}
fsCalls.dirPrefix = dir
fsCalls.recording = true
const rename = delayNextDataFileRename(dir)
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 621 })
const flush = store.flushPendingAsync()
await renameStarted
await rename.started
store.updateUI({ sidebarWidth: 622 })
releaseRename()
rename.resolve()
await flush
fsCalls.recording = false
@ -481,33 +459,96 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(622)
})
it('keeps a bounded barrier open when its staged writer is superseded before rename', async () => {
const dir = makeDir()
const store = await createStore(dir)
const openRelease = deferred()
const openStarted = deferred()
const renameRelease = deferred()
const renameStarted = deferred()
let heldOpen = false
let heldRename = false
fsCalls.waitAsync = (fn, target) => {
if (
!heldOpen &&
fn === 'open' &&
target.startsWith(dataFile(dir)) &&
target.endsWith('.tmp')
) {
heldOpen = true
openStarted.resolve()
return openRelease.promise
}
if (!heldRename && fn === 'rename' && target.startsWith(dataFile(dir))) {
heldRename = true
renameStarted.resolve()
return renameRelease.promise
}
return null
}
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 631 })
const barrier = store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
await openStarted.promise
store.updateUI({ sidebarWidth: 632 })
openRelease.resolve()
const firstOutcome = await Promise.race([
barrier.then(() => 'settled' as const),
renameStarted.promise.then(() => 'retrying' as const)
])
expect(firstOutcome).toBe('retrying')
renameRelease.resolve()
await barrier
fsCalls.recording = false
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(632)
})
it('rewrites a matching hash after a superseded rename installed stale state', async () => {
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 641 })
await store.flushPendingOrThrowAsync()
const rename = delayNextDataFileRename(dir)
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 642 })
vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)
const staleWrite = store.waitForPendingWrite()
await rename.started
store.updateUI({ sidebarWidth: 641 })
rename.resolve()
await staleWrite
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(642)
await store.flushPendingOrThrowAsync({ drainToStableGeneration: false })
fsCalls.recording = false
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(641)
})
it('the throwing async barrier drains mutations made during sidecar I/O', async () => {
const dir = makeDir()
const store = await createStore(dir)
let releaseRename!: () => void
const renameRelease = new Promise<void>((resolve) => {
releaseRename = resolve
})
let signalRename!: () => void
const renameStarted = new Promise<void>((resolve) => {
signalRename = resolve
})
const renameRelease = deferred()
const renameStarted = deferred()
fsCalls.waitAsync = (fn, target) => {
if (fn !== 'rename' || !target.includes('orca-github-cache.json.')) {
return null
}
signalRename()
return renameRelease
renameStarted.resolve()
return renameRelease.promise
}
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 611 })
store.setGitHubCache({ pr: {}, issue: {} })
const barrier = store.flushPendingOrThrowAsync()
await renameStarted
await renameStarted.promise
store.updateUI({ sidebarWidth: 612 })
releaseRename()
renameRelease.resolve()
await barrier
fsCalls.recording = false
@ -522,42 +563,35 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds)
const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8')
const statCall = `stat:${dataFile(dir)}.bak.0`
let releaseRotation!: () => void
const rotationRelease = new Promise<void>((resolve) => {
releaseRotation = resolve
})
let signalRotation!: () => void
const rotationStarted = new Promise<void>((resolve) => {
signalRotation = resolve
})
const rotationRelease = deferred()
const rotationStarted = deferred()
let held = false
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) {
return null
}
held = true
signalRotation()
return rotationRelease
rotationStarted.resolve()
return rotationRelease.promise
}
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
store.updateUI({ sidebarWidth: 371 })
vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)
const firstWrite = store.waitForPendingWrite()
let allWrites = firstWrite
try {
await rotationStarted
await rotationStarted.promise
store.updateUI({ sidebarWidth: 372 })
store.flushOrThrow()
store.updateUI({ sidebarWidth: 373 })
vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)
allWrites = store.waitForPendingWrite()
expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1)
releaseRotation()
rotationRelease.resolve()
await Promise.all([firstWrite, allWrites])
} finally {
releaseRotation()
rotationRelease.resolve()
await allWrites
fsCalls.recording = false
fsCalls.waitAsync = null
@ -579,8 +613,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const expectedTarget = `${dataFile(dir)}${targetSuffix}`
store.updateUI({ sidebarWidth: 363 })
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
let flushed = false
fsCalls.beforeAsync = (fn, target) => {
if (flushed || fn !== expectedFn || target !== expectedTarget) {
@ -688,8 +721,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const dir = makeDir()
const store = await createStore(dir)
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1'))
} finally {
@ -710,8 +742,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const store = await createStore(dir)
const writeError = Object.assign(new Error('profile mount rejected write'), { code: 'EIO' })
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
fsCalls.failAsync = (fn, target) =>
fn === 'open' && target.startsWith(`${dataFile(dir)}.`) ? writeError : null
@ -730,8 +761,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const store = await createStore(dir)
await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1'))
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
await store.removeSshPtyConsumerRecovery('ssh-1')
} finally {
@ -750,8 +780,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
const store = await createStore(dir)
store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' })
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
await store.markSshRemotePtyLeasesAsync('ssh-1', 'detached')
} finally {
@ -774,8 +803,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
// Why: a PTY that exits mid-reattach is terminated before the batch write lands; it must stay dead.
store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-4', state: 'terminated' })
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1', 'pty-2', 'pty-4'])
} finally {
@ -799,39 +827,32 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
it('keeps async writers serialized across a synchronous shutdown flush', async () => {
const dir = makeDir()
const store = await createStore(dir)
let signalFirstOpen!: () => void
const firstOpen = new Promise<void>((resolve) => {
signalFirstOpen = resolve
})
let releaseFirstOpen!: () => void
const firstOpenRelease = new Promise<void>((resolve) => {
releaseFirstOpen = resolve
})
const firstOpen = deferred()
const firstOpenRelease = deferred()
let held = false
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'open' || !target.endsWith('.tmp')) {
return null
}
held = true
signalFirstOpen()
return firstOpenRelease
firstOpen.resolve()
return firstOpenRelease.promise
}
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
const firstWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1'))
await firstOpen
await firstOpen.promise
store.flushOrThrow()
const secondWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-2'))
await Promise.resolve()
await Promise.resolve()
expect(fsCalls.asyncCalls.filter((call) => call.startsWith('open:'))).toHaveLength(1)
releaseFirstOpen()
firstOpenRelease.resolve()
await Promise.all([firstWrite, secondWrite])
} finally {
releaseFirstOpen()
firstOpenRelease.resolve()
fsCalls.recording = false
fsCalls.waitAsync = null
}
@ -885,8 +906,7 @@ describe('async persistence write path avoids synchronous fs syscalls', () => {
seedStaleBackup(dir)
store.updateUI({ sidebarWidth: 331 })
fsCalls.dirPrefix = dir
fsCalls.recording = true
recordFsCalls(dir)
try {
store.flushOrThrow()
} finally {

View File

@ -2786,6 +2786,7 @@ export class Store {
private quitFlushPromise: Promise<void> | null = null
// Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call.
private lastWrittenStateHash: string | null = null
private lastDurableWriteGeneration = -1
private firstPendingSaveAt: number | null = null
private githubCacheDirty = false
private githubCacheGeneration = 0
@ -3941,6 +3942,7 @@ export class Store {
const { payload, stateHash } = this.buildStateToSave()
// Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted.
if (stateHash === this.lastWrittenStateHash) {
this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen)
return
}
const dataFile = this.dataFile
@ -3976,9 +3978,14 @@ export class Store {
this.inFlightAsyncTmpFile = null
}
}
// Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it.
// Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard.
if (renamed && this.writeGeneration === gen) {
this.lastWrittenStateHash = stateHash
} else if (renamed) {
this.lastWrittenStateHash = null
}
if (renamed) {
this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen)
}
} finally {
if (!renamed) {
@ -4020,6 +4027,10 @@ export class Store {
writeFileDurableSync(tmpFile, dataFile, payload)
renamed = true
this.lastWrittenStateHash = stateHash
this.lastDurableWriteGeneration = Math.max(
this.lastDurableWriteGeneration,
this.writeGeneration
)
} finally {
if (!renamed) {
try {
@ -7297,7 +7308,7 @@ export class Store {
if (this.writesFrozen || this.quitFlushStarted) {
return Promise.reject(new Error('Cannot flush while persistence is finalized'))
}
return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration)
return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true)
}
// Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are
@ -7323,8 +7334,10 @@ export class Store {
private async flushCurrentStateAsync(
final: boolean,
signal?: AbortSignal,
drainToStableGeneration = true
drainToStableGeneration = true,
requireInitialGenerationDurable = false
): Promise<void> {
const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null
for (;;) {
if (signal?.aborted) {
throw new Error('Persistence flush aborted')
@ -7351,7 +7364,16 @@ export class Store {
if (signal?.aborted) {
throw new Error('Persistence flush aborted')
}
if (!drainToStableGeneration || generation === this.writeGeneration) {
if (!drainToStableGeneration) {
if (
requiredDurableGeneration === null ||
this.lastDurableWriteGeneration >= requiredDurableGeneration
) {
break
}
continue
}
if (generation === this.writeGeneration) {
break
}
}

View File

@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreloadApi } from './api-types'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_APP_RESTART_STARTED_EVENT
} from '../shared/updater-renderer-events'
const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({
exposeInMainWorld: vi.fn(),
invoke: vi.fn(),
on: vi.fn(),
removeListener: vi.fn(),
send: vi.fn(),
sendSync: vi.fn()
}))
vi.mock('electron', () => ({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on, removeListener, send, sendSync },
webFrame: {
getZoomFactor: vi.fn(() => 1),
setZoomFactor: vi.fn(),
setVisualZoomLevelLimits: vi.fn()
},
webUtils: { getPathForFile: vi.fn(() => '') }
}))
vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} }))
describe('native preload destructive app actions', () => {
const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated')
let eventTarget: EventTarget
beforeEach(() => {
vi.resetModules()
exposeInMainWorld.mockReset()
invoke.mockReset()
on.mockReset()
removeListener.mockReset()
send.mockReset()
sendSync.mockReset()
Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true })
eventTarget = new EventTarget()
vi.stubGlobal('window', eventTarget)
vi.stubGlobal('document', { addEventListener: vi.fn() })
})
afterEach(() => {
vi.unstubAllGlobals()
if (originalContextIsolated) {
Object.defineProperty(process, 'contextIsolated', originalContextIsolated)
} else {
Reflect.deleteProperty(process, 'contextIsolated')
}
})
const loadApi = async (): Promise<PreloadApi> => {
await import('./index')
return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi
}
for (const action of ['reload', 'relaunch'] as const) {
it(`prepares and awaits durability before ${action}`, async () => {
const api = await loadApi()
const calls: string[] = []
eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, () => calls.push('prepared'))
invoke.mockImplementation(async (channel: string) => {
calls.push(channel)
return channel === 'app:await-before-unload-checkpoint' ? { ok: true } : undefined
})
await api.app[action]()
expect(calls).toEqual(['prepared', 'app:await-before-unload-checkpoint', `app:${action}`])
})
it(`refuses ${action} when the durable checkpoint fails`, async () => {
const api = await loadApi()
const aborted = vi.fn()
eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted)
invoke.mockImplementation(async (channel: string) =>
channel === 'app:await-before-unload-checkpoint' ? { ok: false } : undefined
)
await expect(api.app[action]()).rejects.toThrow(
'Failed to persist renderer state before unload.'
)
expect(invoke).not.toHaveBeenCalledWith(`app:${action}`)
expect(aborted).toHaveBeenCalledTimes(1)
})
}
})

View File

@ -241,11 +241,7 @@ import type {
} from '../shared/ai-vault-types'
import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation'
import type { AgentType } from '../shared/native-chat-types'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_APP_RESTART_STARTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT
} from '../shared/updater-renderer-events'
import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events'
import {
ORCA_INTERNAL_FILE_DRAG_TYPE,
createNativeFileDropPayload,
@ -281,11 +277,9 @@ import type {
} from '../shared/crash-reporting'
import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics'
import { readRendererHeapStatistics } from './renderer-heap-statistics-reader'
import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation'
import {
createUpdaterQuitAbortRelay,
prepareRendererForAppRestart
} from '../shared/renderer-restart-preparation'
import {
prepareAndInvokeAppRestart,
prepareAndInvokeUpdaterInstall,
registerRendererRestartIpcRelays
} from './renderer-restart-wiring'
@ -486,21 +480,24 @@ const api = {
getIdentity: (): Promise<AppIdentity> => ipcRenderer.invoke('app:getIdentity'),
getFeatureWallAssetBaseUrl: (): Promise<string> =>
ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'),
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
restart: async (): Promise<void> => {
await prepareRendererForAppRestart(window, {
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT,
awaitCheckpoint: awaitBeforeUnloadCheckpoint
})
try {
return await ipcRenderer.invoke('app:restart')
} catch (error) {
window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT))
throw error
}
},
reload: (): Promise<void> => ipcRenderer.invoke('app:reload'),
relaunch: (): Promise<void> =>
prepareAndInvokeAppRestart(
window,
() => ipcRenderer.invoke('app:relaunch'),
awaitBeforeUnloadCheckpoint
),
restart: (): Promise<void> =>
prepareAndInvokeAppRestart(
window,
() => ipcRenderer.invoke('app:restart'),
awaitBeforeUnloadCheckpoint
),
reload: (): Promise<void> =>
prepareAndInvokeAppRestart(
window,
() => ipcRenderer.invoke('app:reload'),
awaitBeforeUnloadCheckpoint
),
stageBeforeUnloadSync: (args: Parameters<PreloadApi['app']['stageBeforeUnloadSync']>[0]) => {
const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as {
ok?: unknown

View File

@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events'
import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
import {
prepareAndInvokeUpdaterInstall,
registerRendererRestartIpcRelays
@ -10,6 +13,7 @@ describe('renderer restart wiring', () => {
it('relays updater status and prevented unload events', () => {
const eventTarget = new EventTarget()
const unloadPrevented = vi.fn()
const restartAborted = vi.fn()
const handleStatus = vi.fn()
const listeners = new Map<string, (...args: unknown[]) => void>()
const ipcRenderer = {
@ -19,6 +23,7 @@ describe('renderer restart wiring', () => {
})
} as unknown as Parameters<typeof registerRendererRestartIpcRelays>[0]
eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented)
eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted)
registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus })
listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' })
@ -27,6 +32,7 @@ describe('renderer restart wiring', () => {
expect(ipcRenderer.on).toHaveBeenCalledTimes(2)
expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' })
expect(unloadPrevented).toHaveBeenCalledTimes(1)
expect(restartAborted).toHaveBeenCalledTimes(1)
})
it('marks preparation before invoking main and aborts on IPC failure', async () => {

View File

@ -6,6 +6,8 @@ import {
} from '../shared/renderer-restart-preparation'
import type { UpdateStatus } from '../shared/types'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_APP_RESTART_STARTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
@ -20,6 +22,7 @@ export function registerRendererRestartIpcRelays(
})
ipcRenderer.on('window:unload-prevented', () => {
eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT))
})
}
@ -42,3 +45,21 @@ export async function prepareAndInvokeUpdaterInstall(
throw error
}
}
export async function prepareAndInvokeAppRestart(
eventTarget: EventTarget,
invoke: () => Promise<unknown>,
awaitCheckpoint: () => Promise<void>
): Promise<void> {
await prepareRendererForAppRestart(eventTarget, {
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT,
awaitCheckpoint
})
try {
await invoke()
} catch (error) {
eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT))
throw error
}
}

View File

@ -1,11 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GhAuthDiagnostic } from '../../../../shared/github-auth-types'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
import { buildRemediation } from './GhAuthErrorHelp'
import { buildRemediation, reloadOrcaRenderer } from './GhAuthErrorHelp'
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function diagnostic(overrides: Partial<GhAuthDiagnostic> = {}): GhAuthDiagnostic {
return {
@ -57,4 +62,20 @@ describe('GitHub Project auth remediation host routing', () => {
}
])
})
it('does not force navigation when the checkpointed reload is refused', async () => {
const reload = vi.fn(() => Promise.reject(new Error('checkpoint failed')))
const locationReload = vi.fn()
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal('window', {
api: { app: { reload } },
location: { reload: locationReload }
})
reloadOrcaRenderer()
await vi.waitFor(() => expect(errors).toHaveBeenCalledTimes(1))
expect(reload).toHaveBeenCalledTimes(1)
expect(locationReload).not.toHaveBeenCalled()
})
})

View File

@ -40,14 +40,12 @@ function refreshCommandForHost(host: string | null | undefined): string {
// macOS/Linux vs PowerShell on Windows.
const IS_WINDOWS = typeof navigator !== 'undefined' && /Win(dows|32|64)/i.test(navigator.userAgent)
function reloadOrcaRenderer(): void {
const reload = window.api.app.reload
if (typeof reload !== 'function') {
window.location.reload()
return
}
void reload().catch(() => {
window.location.reload()
export function reloadOrcaRenderer(): void {
void window.api.app.reload().catch((error) => {
console.error(
'[github-projects] Renderer reload refused:',
error instanceof Error ? error.name : typeof error
)
})
}