diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 21fe57252fb..95255e22e87 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -229,18 +229,30 @@ describe('registerAppHandlers', () => { }) it('schedules a single relaunch exit no matter how many surfaces invoke it', async () => { - registerAppHandlers({} as never, {}) + let finishCleanup!: () => void + const onBeforeRelaunch = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + registerAppHandlers({} as never, { onBeforeRelaunch }) // Two surfaces (error-boundary Restart, hydration toast) can race a relaunch; - // each app.relaunch() registration would start another replacement instance. + // 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)) - await first - await second + 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)) await third await vi.advanceTimersByTimeAsync(150) + expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) expect(relaunchAppMock).toHaveBeenCalledTimes(1) expect(appExitMock).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index a168d2e351e..044f6c73e2b 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -288,25 +288,22 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('app:getKeyboardLayoutSnapshot', () => readMacKeyboardLayoutSnapshot()) - // Every app.relaunch() registration starts another replacement instance after - // exit, and renderer-side click guards cannot see each other across surfaces - // (error-boundary Restart, hydration toast, settings panes). Once the exit - // pair is scheduled the process is committed, so later invokes must not - // schedule a second one. - let relaunchExitScheduled = false - ipcMain.handle('app:relaunch', async () => { - // Why: brief delay lets the renderer paint "Restarting…" before the window tears down. - await runBeforeRelaunchCleanup(options.onBeforeRelaunch) - if (relaunchExitScheduled) { - return - } - relaunchExitScheduled = true - 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) + // 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 () => { diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.restart-orca.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.restart-orca.test.tsx index 8e5a9dd39ee..542bb762961 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.restart-orca.test.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.restart-orca.test.tsx @@ -13,8 +13,13 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LazyChunkLoadError } from '@/lib/lazy-with-retry' +import { + isIntentionalAppRestartInProgress, + registerUpdaterBeforeUnloadBypass +} from '@/lib/updater-beforeunload' import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' import { RELAUNCH_SETTLE_GRACE_MS } from './StaleChunkRecoveryActions' +import { ORCA_APP_RESTART_STARTED_EVENT } from '../../../../shared/updater-renderer-events' const reportCrashMock = vi.hoisted(() => vi.fn()) @@ -48,6 +53,7 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => { let root: Root | null = null let container: HTMLDivElement | null = null let consoleError: ReturnType + let unregisterRestartBypass = (): void => undefined beforeEach(() => { reportCrashMock.mockReset() @@ -62,6 +68,8 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => { root = null container = null delete (window as unknown as { api?: unknown }).api + unregisterRestartBypass() + unregisterRestartBypass = () => undefined consoleError.mockRestore() vi.useRealTimers() }) @@ -146,9 +154,13 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => { it('gives the buttons back with a notice when the document survives a resolved relaunch', async () => { vi.useFakeTimers() + unregisterRestartBypass = registerUpdaterBeforeUnloadBypass() // Browser-fallback host shape: relaunch is an in-place reload that resolves // immediately; a broken document can swallow the navigation entirely. - const relaunch = vi.fn<() => Promise>().mockResolvedValue(undefined) + const relaunch = vi.fn<() => Promise>().mockImplementation(() => { + window.dispatchEvent(new Event(ORCA_APP_RESTART_STARTED_EVENT)) + return Promise.resolve() + }) ;(window as unknown as { api: unknown }).api = { app: { relaunch } } renderBoundaryWith(new LazyChunkLoadError(new SyntaxError("Unexpected token '}'"))) @@ -156,6 +168,7 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => { const restart = findRestartButton(container!) act(() => restart?.click()) expect(restart?.disabled).toBe(true) + expect(isIntentionalAppRestartInProgress()).toBe(true) await act(async () => { await vi.advanceTimersByTimeAsync(RELAUNCH_SETTLE_GRACE_MS + 1) @@ -163,11 +176,14 @@ describe('RecoverableRenderErrorBoundary Restart Orca button', () => { expect(findRestartButton(container!)?.disabled).toBe(false) expect(findRetryButton(container!)?.disabled).toBe(false) expect(container!.textContent).toContain("Restarting didn't complete.") + expect(container!.querySelector('[role="status"]')).not.toBeNull() + expect(isIntentionalAppRestartInProgress()).toBe(false) // The stall cleared the guard, so a second attempt is a fresh request. act(() => findRestartButton(container!)?.click()) expect(relaunch).toHaveBeenCalledTimes(2) expect(container!.textContent).not.toContain("Restarting didn't complete.") + expect(isIntentionalAppRestartInProgress()).toBe(true) }) it('does not leak the stalled-restart notice into a later error after a resetKey reset', async () => { diff --git a/src/renderer/src/components/error-boundaries/StaleChunkRecoveryActions.tsx b/src/renderer/src/components/error-boundaries/StaleChunkRecoveryActions.tsx index 137c3a1efed..fac315b7ab1 100644 --- a/src/renderer/src/components/error-boundaries/StaleChunkRecoveryActions.tsx +++ b/src/renderer/src/components/error-boundaries/StaleChunkRecoveryActions.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactElement } from 'react' import { RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' +import { ORCA_APP_RESTART_ABORTED_EVENT } from '../../../../shared/updater-renderer-events' // How long a clicked relaunch may leave this document alive before the fallback // takes the buttons back. An Electron relaunch tears the window down ~150ms after @@ -52,7 +53,13 @@ export function StaleChunkRecoveryActions({ boundaryId, onRetry }: Props): React clearTimeout(relaunchSettleTimerRef.current) relaunchSettleTimerRef.current = null } + // The preload arms global unload/checkpoint bypasses before invoking main. + // A surviving document must restore them before returning control to the user. + window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) relaunchRequestedRef.current = false + if (unmountedRef.current) { + return + } setRelaunching(false) setRelaunchStalled(true) } @@ -114,7 +121,7 @@ export function StaleChunkRecoveryActions({ boundaryId, onRetry }: Props): React {relaunchStalled ? ( -
+
{translate( 'auto.components.error.boundaries.RecoverableRenderErrorBoundary.restartStalled', "Restarting didn't complete. Try again, or retry this part of Orca."