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