From 0313e53b8ee8227fa651ed4c79b1f3e58d7f68a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:25:23 -0700 Subject: [PATCH] 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 --- src/main/ipc/app-relaunch-handlers.ts | 71 ++++++++ src/main/ipc/app.test.ts | 169 +++++++++++++++++- src/main/ipc/app.ts | 44 +---- src/main/ipc/relaunch-window-preparation.ts | 78 ++++++++ src/preload/index.ts | 4 + src/preload/renderer-restart-wiring.test.ts | 77 ++++++++ src/preload/renderer-restart-wiring.ts | 41 +++++ .../src/web/web-preload-api-restart.test.ts | 74 ++++++++ src/renderer/src/web/web-preload-api.ts | 21 ++- src/shared/relaunch-preparation-ipc.ts | 9 + 10 files changed, 539 insertions(+), 49 deletions(-) create mode 100644 src/main/ipc/app-relaunch-handlers.ts create mode 100644 src/main/ipc/relaunch-window-preparation.ts create mode 100644 src/renderer/src/web/web-preload-api-restart.test.ts create mode 100644 src/shared/relaunch-preparation-ipc.ts diff --git a/src/main/ipc/app-relaunch-handlers.ts b/src/main/ipc/app-relaunch-handlers.ts new file mode 100644 index 00000000000..07189789e29 --- /dev/null +++ b/src/main/ipc/app-relaunch-handlers.ts @@ -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 +} + +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 | 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 +): Promise { + 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 + ) + } +} diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 95255e22e87..9e18aa25e91 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -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 unknown>(), + ipcMainListeners: new Map 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 } + } + + 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 }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 044f6c73e2b..0f5d6a942f8 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -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 | 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 -): Promise { - 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 - ) - } -} diff --git a/src/main/ipc/relaunch-window-preparation.ts b/src/main/ipc/relaunch-window-preparation.ts new file mode 100644 index 00000000000..ea996279fc3 --- /dev/null +++ b/src/main/ipc/relaunch-window-preparation.ts @@ -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 { + 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 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((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) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d6405f171be..38e2655992c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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') { diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index bd896109b57..6b0919c4b9e 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -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 void> + sent: [string, unknown][] + register: (awaitCheckpoint: () => Promise) => void +} { + const eventTarget = new EventTarget() + const listeners = new Map 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[0] + return { + eventTarget, + listeners, + sent, + register: (awaitCheckpoint) => + registerRelaunchPreparationRequestHandler(ipcRenderer, eventTarget, awaitCheckpoint) + } +} + +const flushMicrotasks = (): Promise => 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[] = [] diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 2dd0ad316aa..8a7d7b716a4 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -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, + eventTarget: EventTarget, + awaitCheckpoint: () => Promise +): 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, diff --git a/src/renderer/src/web/web-preload-api-restart.test.ts b/src/renderer/src/web/web-preload-api-restart.test.ts new file mode 100644 index 00000000000..c47fb78178e --- /dev/null +++ b/src/renderer/src/web/web-preload-api-restart.test.ts @@ -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).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() + }) +}) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 06622bbca93..90b65aba9d9 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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 { } } +// 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 { + 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 { const webOrcaProfileAuthStatus = () => Promise.resolve({ @@ -565,7 +584,7 @@ function createWebPreloadApi(): Partial { 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 }) => { diff --git a/src/shared/relaunch-preparation-ipc.ts b/src/shared/relaunch-preparation-ipc.ts new file mode 100644 index 00000000000..8b0ef8c9d87 --- /dev/null +++ b/src/shared/relaunch-preparation-ipc.ts @@ -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 }