fix(restart): prepare renderers before Restart Orca discards them

- web relaunch runs restart preparation (hot-exit backup + checkpoint)
  before reloading instead of a bare location.reload
- a relaunch invoked from one window (e.g. dashboard popout) now asks every
  other window to prepare before app.exit(0); explicit refusal keeps the
  app open, silence degrades after a bounded wait
- the relaunch singleflight re-arms when preparation is refused or the
  exit pair throws, so retry is not a permanent no-op
This commit is contained in:
Brennan Benson
2026-08-16 22:25:23 -07:00
parent 653b1c1cc7
commit 0313e53b8e
10 changed files with 539 additions and 49 deletions
+71
View File
@@ -0,0 +1,71 @@
import { app, ipcMain } from 'electron'
import { relaunchApp } from '../app-relaunch'
import { destroySystemTray } from '../tray/system-tray'
import { prepareOtherWindowsForRelaunch } from './relaunch-window-preparation'
export type AppRelaunchHandlerOptions = {
onBeforeRelaunch?: () => void | Promise<void>
}
export function registerAppRelaunchHandlers(options: AppRelaunchHandlerOptions): void {
// Renderer-side click guards cannot see each other across surfaces. Share the
// whole cleanup + exit sequence so concurrent invokes cannot race checkpoints
// or register multiple replacement instances.
let relaunchExitPromise: Promise<void> | null = null
ipcMain.handle('app:relaunch', (event) => {
relaunchExitPromise ??= (async () => {
try {
// Why: app.exit(0) below skips unload, so windows other than the
// invoker need an explicit hot-exit backup + checkpoint pass first.
await prepareOtherWindowsForRelaunch(event?.sender)
} catch (error) {
// Why: a refused checkpoint keeps the app open; re-arm so a retry can
// run a fresh preparation instead of joining this rejected promise.
relaunchExitPromise = null
throw error
}
// Why: brief delay lets the renderer paint "Restarting…" before the window tears down.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
try {
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
destroySystemTray()
relaunchApp('renderer-request')
app.exit(0)
} catch (error) {
// Why: the process is still alive; re-arm the singleflight so a retry
// schedules a fresh exit pair instead of joining a settled no-op.
relaunchExitPromise = null
console.warn(
'[app] Relaunch exit failed; retry re-armed:',
error instanceof Error ? error.name : typeof error
)
}
}, 150)
})()
return relaunchExitPromise
})
ipcMain.handle('app:restart', async () => {
// Why: use the normal quit pipeline so daemon checkpoints and telemetry flush before exit.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
relaunchApp('admin-restart')
app.quit()
}, 150)
})
}
async function runBeforeRelaunchCleanup(
onBeforeRelaunch?: () => void | Promise<void>
): Promise<void> {
try {
await onBeforeRelaunch?.()
} catch (error) {
// Why: best-effort cleanup must never block relaunch; log only error.name to avoid leaking secrets.
console.warn(
'[app] Pre-relaunch cleanup failed; continuing relaunch:',
error instanceof Error ? error.name : typeof error
)
}
}
+163 -6
View File
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
ipcMainListeners,
getAllWindowsMock,
appExitMock,
appQuitMock,
appRelaunchMock,
@@ -15,6 +17,8 @@ const {
registerMacKeyboardLayoutChangeNotificationsMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
ipcMainListeners: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
getAllWindowsMock: vi.fn<() => unknown[]>(() => []),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
@@ -77,7 +81,8 @@ vi.mock('electron', () => ({
relaunch: appRelaunchMock
},
BrowserWindow: {
fromWebContents: vi.fn(() => null)
fromWebContents: vi.fn(() => null),
getAllWindows: getAllWindowsMock
},
dialog: {
showOpenDialog: showOpenDialogMock
@@ -85,6 +90,12 @@ vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
}),
on: vi.fn((channel: string, listener: (_event: unknown, args?: unknown) => unknown) => {
ipcMainListeners.set(channel, listener)
}),
removeListener: vi.fn((channel: string) => {
ipcMainListeners.delete(channel)
})
}
}))
@@ -147,6 +158,9 @@ describe('registerAppHandlers', () => {
beforeEach(() => {
vi.useFakeTimers()
handlers.clear()
ipcMainListeners.clear()
getAllWindowsMock.mockReset()
getAllWindowsMock.mockReturnValue([])
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
@@ -184,8 +198,10 @@ describe('registerAppHandlers', () => {
const onBeforeRelaunch = vi.fn()
registerAppHandlers({} as never, { onBeforeRelaunch })
const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.(null))
const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
// Why: with no other windows to prepare, cleanup starts on the next microtask.
await vi.advanceTimersByTimeAsync(0)
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
expect(appRelaunchMock).not.toHaveBeenCalled()
expect(appExitMock).not.toHaveBeenCalled()
@@ -212,8 +228,9 @@ describe('registerAppHandlers', () => {
)
registerAppHandlers({} as never, { onBeforeRelaunch })
const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.(null))
const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(0)
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(150)
@@ -240,15 +257,16 @@ describe('registerAppHandlers', () => {
// Two surfaces (error-boundary Restart, hydration toast) can race a relaunch;
// they must join the same checkpoint as well as the same replacement exit.
const first = Promise.resolve(handlers.get('app:relaunch')?.(null))
const second = Promise.resolve(handlers.get('app:relaunch')?.(null))
const first = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
const second = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(0)
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(150)
expect(relaunchAppMock).not.toHaveBeenCalled()
finishCleanup()
await Promise.all([first, second])
const third = Promise.resolve(handlers.get('app:relaunch')?.(null))
const third = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await third
await vi.advanceTimersByTimeAsync(150)
@@ -257,6 +275,145 @@ describe('registerAppHandlers', () => {
expect(appExitMock).toHaveBeenCalledTimes(1)
})
// Fake BrowserWindow for the pre-relaunch preparation handshake.
type FakeRelaunchWindow = {
isDestroyed: () => boolean
webContents: { id: number; isDestroyed: () => boolean; send: ReturnType<typeof vi.fn> }
}
function createFakeRelaunchWindow(id: number): FakeRelaunchWindow {
return {
isDestroyed: () => false,
webContents: { id, isDestroyed: () => false, send: vi.fn() }
}
}
function sentPrepareRequest(win: FakeRelaunchWindow): { requestId: number } {
const call = win.webContents.send.mock.calls.find(
([channel]) => channel === 'app:relaunch-prepare'
)
expect(call).toBeDefined()
return call?.[1] as { requestId: number }
}
function replyToPrepare(windowId: number, requestId: number, ok: boolean): void {
ipcMainListeners.get('app:relaunch-prepare-reply')?.(
{ sender: { id: windowId } },
{
requestId,
ok
}
)
}
it('prepares the other windows before a relaunch invoked from a popout exits', async () => {
const onBeforeRelaunch = vi.fn()
const invoker = createFakeRelaunchWindow(1)
const mainWindow = createFakeRelaunchWindow(2)
getAllWindowsMock.mockReturnValue([invoker, mainWindow])
registerAppHandlers({} as never, { onBeforeRelaunch })
const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(0)
// Why: the invoking preload already prepared its own document.
expect(invoker.webContents.send).not.toHaveBeenCalled()
const request = sentPrepareRequest(mainWindow)
// Nothing may tear down before the other window confirms its backup.
expect(onBeforeRelaunch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appExitMock).not.toHaveBeenCalled()
replyToPrepare(2, request.requestId, true)
await relaunchPromise
await vi.advanceTimersByTimeAsync(150)
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledWith(0)
})
it('keeps the app open and re-arms when a window refuses its pre-relaunch checkpoint', async () => {
const onBeforeRelaunch = vi.fn()
const invoker = createFakeRelaunchWindow(1)
const mainWindow = createFakeRelaunchWindow(2)
getAllWindowsMock.mockReturnValue([invoker, mainWindow])
registerAppHandlers({} as never, { onBeforeRelaunch })
let relaunchError: unknown = null
const relaunchPromise = Promise.resolve(
handlers.get('app:relaunch')?.({ sender: { id: 1 } })
).catch((error: unknown) => {
relaunchError = error
})
await vi.advanceTimersByTimeAsync(0)
const request = sentPrepareRequest(mainWindow)
replyToPrepare(2, request.requestId, false)
await relaunchPromise
await vi.advanceTimersByTimeAsync(150)
expect(relaunchError).toBeInstanceOf(Error)
expect(onBeforeRelaunch).not.toHaveBeenCalled()
expect(appExitMock).not.toHaveBeenCalled()
// Why: the refusing window (and any prepared sibling) must release its restart latch.
expect(mainWindow.webContents.send).toHaveBeenCalledWith('app:relaunch-prepare-abort')
// A retry runs a fresh preparation instead of joining the rejected round.
const retryPromise = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(0)
const retryCall = mainWindow.webContents.send.mock.calls.filter(
([channel]) => channel === 'app:relaunch-prepare'
)
expect(retryCall).toHaveLength(2)
const retryRequest = retryCall[1][1] as { requestId: number }
replyToPrepare(2, retryRequest.requestId, true)
await retryPromise
await vi.advanceTimersByTimeAsync(150)
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledWith(0)
})
it('degrades to an unprepared relaunch when a window never answers the handshake', async () => {
const onBeforeRelaunch = vi.fn()
const invoker = createFakeRelaunchWindow(1)
const unresponsive = createFakeRelaunchWindow(2)
getAllWindowsMock.mockReturnValue([invoker, unresponsive])
registerAppHandlers({} as never, { onBeforeRelaunch })
const relaunchPromise = Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(0)
expect(onBeforeRelaunch).not.toHaveBeenCalled()
// Why: a hung renderer must not pin every future relaunch open.
await vi.advanceTimersByTimeAsync(5_000)
await relaunchPromise
await vi.advanceTimersByTimeAsync(150)
expect(onBeforeRelaunch).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledWith(0)
})
it('re-arms the relaunch singleflight when the exit pair throws', async () => {
const onBeforeRelaunch = vi.fn()
registerAppHandlers({} as never, { onBeforeRelaunch })
destroySystemTrayMock.mockImplementationOnce(() => {
throw new Error('tray teardown failed')
})
await Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(150)
expect(appExitMock).not.toHaveBeenCalled()
// Why: a settled no-op promise would make every retry resolve instantly.
await Promise.resolve(handlers.get('app:relaunch')?.({ sender: { id: 1 } }))
await vi.advanceTimersByTimeAsync(150)
expect(relaunchAppMock).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledWith(0)
})
it('marks restart as expected shutdown before quitting through the normal pipeline', async () => {
const onBeforeRelaunch = vi.fn()
registerAppHandlers({} as never, { onBeforeRelaunch })
+2 -42
View File
@@ -7,14 +7,12 @@ import { is } from '@electron-toolkit/utils'
import type { AppIdentity } from '../../shared/app-identity'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types'
import { relaunchApp } from '../app-relaunch'
import type { Store } from '../persistence'
import { getDevInstanceIdentity } from '../startup/dev-instance-identity'
import { isPwshAvailableAsync } from '../pwsh'
import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl'
import { isGitBashAvailable } from '../git-bash'
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
import { destroySystemTray } from '../tray/system-tray'
import { authorizeExternalPath } from './filesystem-auth'
import {
ensureDefaultFloatingWorkspacePath,
@@ -22,6 +20,7 @@ import {
resolveFloatingTerminalCwd
} from './floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents'
import { registerAppRelaunchHandlers } from './app-relaunch-handlers'
import { registerMacSymbolicHotkeysProbeHandler } from './macos-symbolic-hotkeys-probe'
import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint'
import { readMacKeyboardLayoutSnapshot } from './macos-keyboard-layout-snapshot'
@@ -288,32 +287,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
ipcMain.handle('app:getKeyboardLayoutSnapshot', () => readMacKeyboardLayoutSnapshot())
// Renderer-side click guards cannot see each other across surfaces. Share the
// whole cleanup + exit sequence so concurrent invokes cannot race checkpoints
// or register multiple replacement instances.
let relaunchExitPromise: Promise<void> | null = null
ipcMain.handle('app:relaunch', () => {
relaunchExitPromise ??= (async () => {
// Why: brief delay lets the renderer paint "Restarting…" before the window tears down.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
destroySystemTray()
relaunchApp('renderer-request')
app.exit(0)
}, 150)
})()
return relaunchExitPromise
})
ipcMain.handle('app:restart', async () => {
// Why: use the normal quit pipeline so daemon checkpoints and telemetry flush before exit.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
relaunchApp('admin-restart')
app.quit()
}, 150)
})
registerAppRelaunchHandlers(options)
registerMacSymbolicHotkeysProbeHandler(readCommandStdout)
@@ -333,17 +307,3 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
pickFloatingWorkspaceDirectory(event, store)
)
}
async function runBeforeRelaunchCleanup(
onBeforeRelaunch?: () => void | Promise<void>
): Promise<void> {
try {
await onBeforeRelaunch?.()
} catch (error) {
// Why: best-effort cleanup must never block relaunch; log only error.name to avoid leaking secrets.
console.warn(
'[app] Pre-relaunch cleanup failed; continuing relaunch:',
error instanceof Error ? error.name : typeof error
)
}
}
@@ -0,0 +1,78 @@
import { BrowserWindow, ipcMain, type IpcMainEvent, type WebContents } from 'electron'
import {
APP_RELAUNCH_PREPARE_ABORT_CHANNEL,
APP_RELAUNCH_PREPARE_CHANNEL,
APP_RELAUNCH_PREPARE_REPLY_CHANNEL,
type AppRelaunchPrepareReply
} from '../../shared/relaunch-preparation-ipc'
// Why: a hung renderer can never confirm a backup; a bounded wait keeps one
// unresponsive window from pinning every future relaunch open.
export const RELAUNCH_PREPARE_REPLY_TIMEOUT_MS = 5_000
let nextRelaunchPrepareRequestId = 1
type PrepareOutcome = 'prepared' | 'refused' | 'unresponsive'
/**
* The invoking preload preps only its own document before app:relaunch, and
* app.exit(0) skips unload — so every other window (e.g. the main window when
* a dashboard popout initiates the restart) would lose dirty editor buffers.
* Ask each of them to run the same restart preparation and wait for a verdict.
*
* An explicit refusal (checkpoint could not persist) throws so the relaunch is
* abandoned with the app still open. Silence (no preload handler, hung
* renderer) degrades after the timeout to the unprepared pre-handshake
* behavior for that window only, rather than blocking recovery forever.
*/
export async function prepareOtherWindowsForRelaunch(
sender: WebContents | null | undefined
): Promise<void> {
const targets = BrowserWindow.getAllWindows().filter(
(win) =>
!win.isDestroyed() &&
!win.webContents.isDestroyed() &&
(!sender || win.webContents.id !== sender.id)
)
if (targets.length === 0) {
return
}
const requestId = nextRelaunchPrepareRequestId++
const resolvers = new Map<number, (outcome: PrepareOutcome) => void>()
const onReply = (event: IpcMainEvent, reply: AppRelaunchPrepareReply): void => {
if (reply?.requestId !== requestId) {
return
}
resolvers.get(event.sender.id)?.(reply.ok === true ? 'prepared' : 'refused')
}
ipcMain.on(APP_RELAUNCH_PREPARE_REPLY_CHANNEL, onReply)
try {
const outcomes = await Promise.all(
targets.map(
(win) =>
new Promise<PrepareOutcome>((resolve) => {
const timer = setTimeout(
() => resolve('unresponsive'),
RELAUNCH_PREPARE_REPLY_TIMEOUT_MS
)
resolvers.set(win.webContents.id, (outcome) => {
clearTimeout(timer)
resolve(outcome)
})
win.webContents.send(APP_RELAUNCH_PREPARE_CHANNEL, { requestId })
})
)
)
if (outcomes.includes('refused')) {
// Why: windows that already prepared armed their restart latch; release it.
for (const win of targets) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(APP_RELAUNCH_PREPARE_ABORT_CHANNEL)
}
}
throw new Error('A window refused its pre-relaunch checkpoint; keeping the app open.')
}
} finally {
ipcMain.removeListener(APP_RELAUNCH_PREPARE_REPLY_CHANNEL, onReply)
}
}
+4
View File
@@ -332,6 +332,7 @@ import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparat
import {
prepareAndInvokeAppRestart,
prepareAndInvokeUpdaterInstall,
registerRelaunchPreparationRequestHandler,
registerRendererRestartIpcRelays
} from './renderer-restart-wiring'
@@ -356,6 +357,9 @@ const updaterQuitAbortRelay = createUpdaterQuitAbortRelay(
)
registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay)
// Why: a relaunch invoked from another window (e.g. a dashboard popout) must
// still back this window's dirty editors up before main calls app.exit(0).
registerRelaunchPreparationRequestHandler(ipcRenderer, window, awaitBeforeUnloadCheckpoint)
function getLinuxDisplayServer(): 'wayland' | 'x11' | null {
if (process.platform !== 'linux') {
@@ -1,14 +1,50 @@
import { describe, expect, it, vi } from 'vitest'
import {
APP_RELAUNCH_PREPARE_ABORT_CHANNEL,
APP_RELAUNCH_PREPARE_CHANNEL,
APP_RELAUNCH_PREPARE_REPLY_CHANNEL
} from '../shared/relaunch-preparation-ipc'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_APP_RESTART_STARTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
import {
prepareAndInvokeUpdaterInstall,
registerRelaunchPreparationRequestHandler,
registerRendererRestartIpcRelays
} from './renderer-restart-wiring'
function createPreparationHarness(): {
eventTarget: EventTarget
listeners: Map<string, (...args: unknown[]) => void>
sent: [string, unknown][]
register: (awaitCheckpoint: () => Promise<void>) => void
} {
const eventTarget = new EventTarget()
const listeners = new Map<string, (...args: unknown[]) => void>()
const sent: [string, unknown][] = []
const ipcRenderer = {
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
listeners.set(channel, listener)
return ipcRenderer
}),
send: vi.fn((channel: string, payload: unknown) => {
sent.push([channel, payload])
})
} as unknown as Parameters<typeof registerRelaunchPreparationRequestHandler>[0]
return {
eventTarget,
listeners,
sent,
register: (awaitCheckpoint) =>
registerRelaunchPreparationRequestHandler(ipcRenderer, eventTarget, awaitCheckpoint)
}
}
const flushMicrotasks = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
describe('renderer restart wiring', () => {
it('relays updater status, aborted installs, and prevented unload events', () => {
const eventTarget = new EventTarget()
@@ -39,6 +75,47 @@ describe('renderer restart wiring', () => {
expect(restartAborted).toHaveBeenCalledTimes(1)
})
it('prepares this window and confirms a main-driven relaunch preparation request', async () => {
const { eventTarget, listeners, sent, register } = createPreparationHarness()
const order: string[] = []
eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, () => order.push('started'))
const awaitCheckpoint = vi.fn(async () => {
order.push('checkpoint-joined')
})
register(awaitCheckpoint)
listeners.get(APP_RELAUNCH_PREPARE_CHANNEL)?.({}, { requestId: 7 })
await flushMicrotasks()
expect(order).toEqual(['started', 'checkpoint-joined'])
expect(sent).toEqual([[APP_RELAUNCH_PREPARE_REPLY_CHANNEL, { requestId: 7, ok: true }]])
})
it('reports a refused checkpoint so main keeps the app open', async () => {
const { eventTarget, listeners, sent, register } = createPreparationHarness()
const aborted = vi.fn()
eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted)
register(() => Promise.reject(new Error('Failed to persist renderer state before unload.')))
listeners.get(APP_RELAUNCH_PREPARE_CHANNEL)?.({}, { requestId: 3 })
await flushMicrotasks()
expect(sent).toEqual([[APP_RELAUNCH_PREPARE_REPLY_CHANNEL, { requestId: 3, ok: false }]])
// Why: prepareRendererForAppRestart releases the restart latch on failure.
expect(aborted).toHaveBeenCalledTimes(1)
})
it('releases the restart latch when main aborts the relaunch after preparation', () => {
const { eventTarget, listeners, register } = createPreparationHarness()
const aborted = vi.fn()
eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted)
register(async () => {})
listeners.get(APP_RELAUNCH_PREPARE_ABORT_CHANNEL)?.({})
expect(aborted).toHaveBeenCalledTimes(1)
})
it('marks preparation before invoking main and aborts on IPC failure', async () => {
const eventTarget = new EventTarget()
const calls: string[] = []
+41
View File
@@ -1,4 +1,10 @@
import type { IpcRenderer } from 'electron'
import {
APP_RELAUNCH_PREPARE_ABORT_CHANNEL,
APP_RELAUNCH_PREPARE_CHANNEL,
APP_RELAUNCH_PREPARE_REPLY_CHANNEL,
type AppRelaunchPrepareRequest
} from '../shared/relaunch-preparation-ipc'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events'
import {
prepareRendererForAppRestart,
@@ -30,6 +36,41 @@ export function registerRendererRestartIpcRelays(
})
}
/**
* Answers main's pre-relaunch handshake for windows that did not invoke the
* relaunch themselves: run the same restart preparation the invoking preload
* ran (hot-exit backup + checkpoint join) and report a verdict so main can
* abandon the exit instead of discarding buffers that could not be backed up.
*/
export function registerRelaunchPreparationRequestHandler(
ipcRenderer: Pick<IpcRenderer, 'on' | 'send'>,
eventTarget: EventTarget,
awaitCheckpoint: () => Promise<void>
): void {
ipcRenderer.on(APP_RELAUNCH_PREPARE_CHANNEL, (_event, request: AppRelaunchPrepareRequest) => {
prepareRendererForAppRestart(eventTarget, {
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT,
awaitCheckpoint
}).then(
() =>
ipcRenderer.send(APP_RELAUNCH_PREPARE_REPLY_CHANNEL, {
requestId: request?.requestId,
ok: true
}),
() =>
ipcRenderer.send(APP_RELAUNCH_PREPARE_REPLY_CHANNEL, {
requestId: request?.requestId,
ok: false
})
)
})
ipcRenderer.on(APP_RELAUNCH_PREPARE_ABORT_CHANNEL, () => {
// Why: main abandoned the relaunch after this window prepared; release the restart latch.
eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT))
})
}
export async function prepareAndInvokeUpdaterInstall(
eventTarget: EventTarget,
relay: Pick<UpdaterQuitAbortRelay, 'markPrepared' | 'abort'>,
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import {
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
type EditorPrepareHotExitDetail
} from '../../../shared/editor-save-events'
import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT } from '../../../shared/renderer-shutdown-events'
import { installApi } from './web-preload-api-test-harness'
// The harness window stubs event methods with bare vi.fn(); relaunch runs the
// real restart preparation, so give it a live event target to dispatch through.
function wireRealEventTarget(win: Window & typeof globalThis): EventTarget {
const target = new EventTarget()
Object.assign(win, {
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
dispatchEvent: target.dispatchEvent.bind(target)
})
return target
}
function hotExitDetail(event: Event): EditorPrepareHotExitDetail {
return (event as CustomEvent<EditorPrepareHotExitDetail>).detail
}
describe('web preload relaunch', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('backs dirty editor buffers up before reloading the document', async () => {
const { api, window: win } = await installApi()
wireRealEventTarget(win)
const order: string[] = []
win.addEventListener(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, (event) => {
const detail = hotExitDetail(event)
detail.claim()
order.push('hot-exit-backup')
detail.resolve()
})
;(win.location.reload as Mock).mockImplementation(() => order.push('reload'))
await api.app.relaunch()
expect(order).toEqual(['hot-exit-backup', 'reload'])
})
it('never reloads when the hot-exit backup refuses', async () => {
const { api, window: win } = await installApi()
wireRealEventTarget(win)
win.addEventListener(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, (event) => {
const detail = hotExitDetail(event)
detail.claim()
detail.reject('editor backup failed')
})
await expect(api.app.relaunch()).rejects.toThrow('editor backup failed')
expect(win.location.reload).not.toHaveBeenCalled()
})
it('never reloads when the shutdown checkpoint reports a failure', async () => {
const { api, window: win } = await installApi()
wireRealEventTarget(win)
win.addEventListener('beforeunload', (event) => {
win.dispatchEvent(new Event(ORCA_RENDERER_SHUTDOWN_CHECKPOINT_FAILED_EVENT))
event.preventDefault()
})
await expect(api.app.relaunch()).rejects.toThrow(
'Renderer shutdown checkpoint was not completed.'
)
expect(win.location.reload).not.toHaveBeenCalled()
})
})
+20 -1
View File
@@ -7,6 +7,11 @@ import type {
NativeChatAppendedMessages
} from '../../../preload/api-types'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { prepareRendererForAppRestart } from '../../../shared/renderer-restart-preparation'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_APP_RESTART_STARTED_EVENT
} from '../../../shared/updater-renderer-events'
import { parseHostAccessLink } from '../../../shared/remote-pairing-address'
import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification'
import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion'
@@ -542,6 +547,20 @@ async function writeWebClipboardText(text: string): Promise<void> {
}
}
// Why: app.relaunch on web is an in-place reload, but a bare reload discards
// dirty editor buffers. Run the same restart preparation as the Electron
// preload (hot-exit backup + checkpoint) and never navigate if it refuses.
async function prepareAndReloadForRelaunch(): Promise<void> {
await prepareRendererForAppRestart(window, {
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT,
// Why: web staging writes through to browser storage synchronously
// (see stageBeforeUnloadSync below), so there is nothing left to join.
awaitCheckpoint: () => Promise.resolve()
})
window.location.reload()
}
function createWebPreloadApi(): Partial<PreloadApi> {
const webOrcaProfileAuthStatus = () =>
Promise.resolve({
@@ -565,7 +584,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
dockBadgeLabel: null
}),
getFeatureWallAssetBaseUrl: () => Promise.resolve('/'),
relaunch: () => Promise.resolve(window.location.reload()),
relaunch: prepareAndReloadForRelaunch,
restart: () => Promise.resolve(window.location.reload()),
reload: () => Promise.resolve(window.location.reload()),
stageBeforeUnloadSync: ({ sessions, ui }) => {
+9
View File
@@ -0,0 +1,9 @@
// Why: app.exit(0) skips before-quit/unload, so main must explicitly ask the
// windows that did not invoke the relaunch to run their restart preparation
// (editor hot-exit backup + shutdown checkpoint) before the process dies.
export const APP_RELAUNCH_PREPARE_CHANNEL = 'app:relaunch-prepare'
export const APP_RELAUNCH_PREPARE_REPLY_CHANNEL = 'app:relaunch-prepare-reply'
export const APP_RELAUNCH_PREPARE_ABORT_CHANNEL = 'app:relaunch-prepare-abort'
export type AppRelaunchPrepareRequest = { requestId: number }
export type AppRelaunchPrepareReply = { requestId: number; ok: boolean }