mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
fix(renderer): restore safeguards after stalled relaunch
This commit is contained in:
@@ -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
@@ -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 () => {
|
||||
|
||||
+17
-1
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user