fix(renderer): restore safeguards after stalled relaunch

This commit is contained in:
Brennan Benson
2026-08-16 18:32:02 -07:00
parent 13e14eb80d
commit 2eb95d4f33
4 changed files with 57 additions and 25 deletions
+16 -4
View File
@@ -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<void>((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)
})
+16 -19
View File
@@ -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<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 () => {
@@ -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<typeof vi.spyOn>
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<void>>().mockResolvedValue(undefined)
const relaunch = vi.fn<() => Promise<void>>().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 () => {
@@ -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
</Button>
</div>
{relaunchStalled ? (
<div className="max-w-md text-xs">
<div role="status" className="max-w-md text-xs">
{translate(
'auto.components.error.boundaries.RecoverableRenderErrorBoundary.restartStalled',
"Restarting didn't complete. Try again, or retry this part of Orca."