mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(editor): route lazy-chunk recovery reload through the intentional-restart path
Crash b860def2's recovery reload was requested and never landed: Terminal's beforeunload handler preventDefault()s while any editor tab is dirty and Electron cancels the navigation with no dialog, so chunk recovery could never run in the common case. Take the updater's path instead — hot-exit backup, one synchronous session checkpoint, restart latch — then reload. - Reject on ORCA_RENDERER_UNLOAD_PREVENTED_EVENT instead of a blind, never-cleared 10s timer; keep the timer only as a backstop. - Record a lazy_chunk_reload_vetoed breadcrumb in the same tick as the report it now files, so the 30-entry ring cannot evict the evidence. - Drop this document's own stale guard after a refused reload (capped in memory) so saving the blocking tab does not forfeit recovery for the session. - Carry reloadKey on LazyChunkLoadError and the degraded breadcrumb. - Move renderer-restart-preparation to src/shared: it is now a renderer/preload contract, and the composite web project cannot import preload runtime code. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -278,7 +278,7 @@ import { readRendererHeapStatistics } from './renderer-heap-statistics-reader'
|
||||
import {
|
||||
createUpdaterQuitAbortRelay,
|
||||
prepareRendererForAppRestart
|
||||
} from './renderer-restart-preparation'
|
||||
} from '../shared/renderer-restart-preparation'
|
||||
|
||||
type NativeFileDropCallback = (data: NativeFileDropPayload) => void
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('preload restart wiring', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/preload/index.ts'), 'utf8')
|
||||
|
||||
it('relays prevented unload and async updater failure IPC into renderer lifecycle events', () => {
|
||||
expect(source).toContain("ipcRenderer.on('updater:status'")
|
||||
expect(source).toContain('updaterQuitAbortRelay.handleStatus(status)')
|
||||
expect(source).toContain("ipcRenderer.on('window:unload-prevented'")
|
||||
expect(source).toContain(
|
||||
'window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks updater preparation before invoking main and aborts it on immediate IPC failure', () => {
|
||||
const start = source.indexOf('quitAndInstall: async (): Promise<void> => {')
|
||||
const end = source.indexOf('onStatus: (callback) => {', start)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const block = source.slice(start, end)
|
||||
const prepare = block.indexOf('await prepareRendererForAppRestart(window, {')
|
||||
const markPrepared = block.indexOf('updaterQuitAbortRelay.markPrepared()')
|
||||
const invoke = block.indexOf("ipcRenderer.invoke('updater:quitAndInstall')")
|
||||
const abort = block.indexOf('updaterQuitAbortRelay.abort()')
|
||||
|
||||
expect(prepare).toBeGreaterThanOrEqual(0)
|
||||
expect(markPrepared).toBeGreaterThan(prepare)
|
||||
expect(invoke).toBeGreaterThan(markPrepared)
|
||||
expect(abort).toBeGreaterThan(invoke)
|
||||
expect(block).toMatch(
|
||||
/try \{\s*return await ipcRenderer\.invoke\('updater:quitAndInstall'\)\s*\} catch \(error\) \{\s*updaterQuitAbortRelay\.abort\(\)\s*throw error\s*\}/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -33,8 +33,9 @@ import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
|
||||
const MonacoEditor = lazy(() => import('./MonacoEditor'))
|
||||
const DiffViewer = lazy(() => import('./DiffViewer'))
|
||||
const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
|
||||
// Why keyed: the reload breadcrumb is the only pre-crash evidence, and an unkeyed
|
||||
// site logs `reloadKey: "unknown"` (see crash b860def2).
|
||||
// Why keyed: `reloadKey` rides on LazyChunkLoadError and on the vetoed-reload
|
||||
// breadcrumb, so the report names this chunk instead of one of the eight lazy sites
|
||||
// below. b860def2's own pre-reload crumb had already been evicted from the ring.
|
||||
const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), {
|
||||
reloadKey: 'rich-markdown-editor'
|
||||
})
|
||||
|
||||
@@ -7,7 +7,10 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
|
||||
import {
|
||||
clearLazyChunkBreadcrumbDedupeForTest,
|
||||
RichMarkdownErrorBoundary
|
||||
} from './RichMarkdownErrorBoundary'
|
||||
|
||||
const reportCrashMock = vi.hoisted(() => vi.fn())
|
||||
const recordBreadcrumbMock = vi.hoisted(() => vi.fn())
|
||||
@@ -57,8 +60,10 @@ async function flushReactWork(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// The breadcrumb dedupe is module-scoped (it has to outlive boundary remounts), so
|
||||
// every test below must use a distinct parse-error message to start from a clean slate.
|
||||
// The parse error a corrupt rich-editor chunk rejects with; reused across tests to
|
||||
// prove the module-scoped dedupe is reset rather than dodged by unique messages.
|
||||
const CORRUPT_CHUNK_PARSE_ERROR = "Unexpected token ':'"
|
||||
|
||||
describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
@@ -67,6 +72,8 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
|
||||
beforeEach(() => {
|
||||
reportCrashMock.mockReset()
|
||||
recordBreadcrumbMock.mockReset()
|
||||
// Dedupe is module-scoped so it can outlive boundary remounts; reset it per test.
|
||||
clearLazyChunkBreadcrumbDedupeForTest()
|
||||
window.sessionStorage.clear()
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
@@ -88,8 +95,8 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
|
||||
it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => {
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
|
||||
const LazyRejectingImport = lazyWithRetry(
|
||||
() => Promise.reject(new SyntaxError("Unexpected token ':'")),
|
||||
{ retries: 0 }
|
||||
() => Promise.reject(new SyntaxError(CORRUPT_CHUNK_PARSE_ERROR)),
|
||||
{ retries: 0, reloadKey: 'rich-markdown-editor' }
|
||||
)
|
||||
;({ container, root } = createContainer())
|
||||
|
||||
@@ -108,16 +115,18 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
|
||||
// The suppressed report was the only artifact naming this surface; keep evidence.
|
||||
expect(recordBreadcrumbMock).toHaveBeenCalledWith('lazy_chunk_boundary_degraded', {
|
||||
boundaryId: 'editor.rich-markdown',
|
||||
cause: "SyntaxError: Unexpected token ':'"
|
||||
reloadKey: 'rich-markdown-editor',
|
||||
cause: `SyntaxError: ${CORRUPT_CHUNK_PARSE_ERROR}`
|
||||
})
|
||||
})
|
||||
|
||||
// The breadcrumb ring holds 30 entries and does not coalesce this name, so an
|
||||
// impatient Retry streak on a permanently rejected chunk would erase the trail.
|
||||
// Reuses the previous test's error to pin that the dedupe set is actually cleared.
|
||||
it('records the degraded breadcrumb once across repeated Retry clicks', async () => {
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
|
||||
const LazyRejectingImport = lazyWithRetry(
|
||||
() => Promise.reject(new SyntaxError("Unexpected token '}'")),
|
||||
() => Promise.reject(new SyntaxError(CORRUPT_CHUNK_PARSE_ERROR)),
|
||||
{ retries: 0 }
|
||||
)
|
||||
;({ container, root } = createContainer())
|
||||
|
||||
@@ -16,10 +16,16 @@ type State = {
|
||||
|
||||
// Module-scoped, not per instance: EditorContent re-keys this boundary per open file,
|
||||
// so an instance field would re-record on every markdown tab switch and flush the
|
||||
// 30-entry breadcrumb ring that this very evidence lives in. Bounded by the number of
|
||||
// distinct chunk failures a document can see (effectively one).
|
||||
// 30-entry breadcrumb ring that this very evidence lives in. The key is the cause's
|
||||
// name+message, which embeds per-chunk URLs for fetch failures, so a session that
|
||||
// exhausts recovery across many lazy sites can accumulate dozens of entries — small
|
||||
// and bounded by distinct failures, not by one.
|
||||
const recordedLazyChunkCauses = new Set<string>()
|
||||
|
||||
export function clearLazyChunkBreadcrumbDedupeForTest(): void {
|
||||
recordedLazyChunkCauses.clear()
|
||||
}
|
||||
|
||||
function describeLazyChunkCause(error: Error): string {
|
||||
const cause = (error as { cause?: unknown }).cause
|
||||
if (cause instanceof Error) {
|
||||
@@ -63,6 +69,9 @@ export class RichMarkdownErrorBoundary extends React.Component<Props, State> {
|
||||
recordedLazyChunkCauses.add(cause)
|
||||
recordRendererCrashBreadcrumb('lazy_chunk_boundary_degraded', {
|
||||
boundaryId: 'editor.rich-markdown',
|
||||
// Carried on the error itself: the pre-reload crumb naming the chunk may
|
||||
// already have been evicted from the 30-entry ring (crash b860def2).
|
||||
reloadKey: error.reloadKey,
|
||||
cause
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prepareRendererForAppRestart } from '../../../shared/renderer-restart-preparation'
|
||||
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
|
||||
import {
|
||||
ORCA_APP_RESTART_ABORTED_EVENT,
|
||||
ORCA_APP_RESTART_STARTED_EVENT
|
||||
} from '../../../shared/updater-renderer-events'
|
||||
|
||||
/**
|
||||
* Why: `window.location.reload()` alone cannot recover a corrupt lazy chunk in an
|
||||
* editor app. Terminal's beforeunload handler preventDefault()s whenever any open
|
||||
* file is dirty, and Electron's will-prevent-unload cancels the navigation with no
|
||||
* dialog — so the recovery reload for crash b860def2 was requested and silently
|
||||
* dropped, leaving the pane dead for 44 minutes. The updater already solved this:
|
||||
* back up hot-exit buffers, take the one synchronous session checkpoint, and latch
|
||||
* `isIntentionalAppRestartInProgress()` so the dirty-tab guard stands down. This
|
||||
* runs that same sequence for chunk recovery.
|
||||
*/
|
||||
|
||||
export type LazyChunkRecoveryReloadOutcome =
|
||||
/** Hot-exit backup or the session checkpoint refused; unsaved work stays put. */
|
||||
| 'checkpoint-refused'
|
||||
/** Something still vetoed beforeunload after the restart latch was armed. */
|
||||
| 'unload-vetoed'
|
||||
/** No veto signal, but the document outlived the grace window. */
|
||||
| 'never-landed'
|
||||
|
||||
// Backstop only. The veto event is authoritative, but a headless/paired-web host may
|
||||
// cancel navigation without one, so never suspend the boundary indefinitely.
|
||||
const RELOAD_SETTLE_GRACE_MS = 10_000
|
||||
|
||||
function waitForRefusedNavigation(
|
||||
win: Window
|
||||
): Promise<Exclude<LazyChunkRecoveryReloadOutcome, 'checkpoint-refused'>> {
|
||||
return new Promise((resolve) => {
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const settle = (
|
||||
outcome: Exclude<LazyChunkRecoveryReloadOutcome, 'checkpoint-refused'>
|
||||
): void => {
|
||||
if (graceTimer !== undefined) {
|
||||
clearTimeout(graceTimer)
|
||||
}
|
||||
win.removeEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, onUnloadPrevented)
|
||||
resolve(outcome)
|
||||
}
|
||||
const onUnloadPrevented = (): void => settle('unload-vetoed')
|
||||
|
||||
win.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, onUnloadPrevented)
|
||||
graceTimer = setTimeout(() => settle('never-landed'), RELOAD_SETTLE_GRACE_MS)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the one recovery reload. Resolves ONLY when the navigation was refused —
|
||||
* a landed reload tears the document down instead, so the caller stays suspended.
|
||||
*/
|
||||
export async function requestLazyChunkRecoveryReload(
|
||||
win: Window
|
||||
): Promise<LazyChunkRecoveryReloadOutcome> {
|
||||
try {
|
||||
await prepareRendererForAppRestart(win, {
|
||||
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
|
||||
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT
|
||||
})
|
||||
} catch {
|
||||
// prepareRendererForAppRestart already dispatched the abort; never reload over
|
||||
// editor buffers that could not be backed up.
|
||||
return 'checkpoint-refused'
|
||||
}
|
||||
|
||||
const refused = waitForRefusedNavigation(win)
|
||||
win.location.reload()
|
||||
const outcome = await refused
|
||||
// Why: the latch suppresses the unsaved-changes prompt, so a document that stayed
|
||||
// alive must never keep it armed.
|
||||
win.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT))
|
||||
return outcome
|
||||
}
|
||||
@@ -2,7 +2,22 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
import { isLazyChunkLoadError, loadLazyWithRetry } from './lazy-with-retry'
|
||||
import {
|
||||
isLazyChunkLoadError,
|
||||
loadLazyWithRetry,
|
||||
resetLazyChunkReloadRequestsForTest
|
||||
} from './lazy-with-retry'
|
||||
import { preventUnloadAndScheduleShutdownCheckpointReset } from './shutdown-checkpoint-guard'
|
||||
import {
|
||||
isIntentionalAppRestartInProgress,
|
||||
registerUpdaterBeforeUnloadBypass
|
||||
} from './updater-beforeunload'
|
||||
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
|
||||
import { ORCA_APP_RESTART_ABORTED_EVENT } from '../../../shared/updater-renderer-events'
|
||||
import {
|
||||
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
|
||||
type EditorPrepareHotExitDetail
|
||||
} from '../../../shared/editor-save-events'
|
||||
|
||||
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
|
||||
// A guard holding a *different* document's token is the only proof the reload
|
||||
@@ -44,6 +59,8 @@ function makeSessionStorageThrow(): void {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
window.sessionStorage.clear()
|
||||
// The per-document reload cap is module state; a fresh document per test.
|
||||
resetLazyChunkReloadRequestsForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -181,6 +198,33 @@ describe('loadLazyWithRetry', () => {
|
||||
expect(isLazyChunkLoadError(caught)).toBe(false)
|
||||
})
|
||||
|
||||
// b860def2's only reload evidence was a 19:05 breadcrumb, evicted from the 30-entry
|
||||
// ring long before the 19:49 report. This path now files the report, so it must leave
|
||||
// its own proof in the same tick.
|
||||
it('records a lazy_chunk_reload_vetoed breadcrumb in the tick that reports the crash', async () => {
|
||||
spyOnReload()
|
||||
const recordBreadcrumb = stubCrashReportsBreadcrumb()
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, String(performance.timeOrigin))
|
||||
const error = chunkParseError()
|
||||
|
||||
const loaded = loadLazyWithRetry(() => Promise.reject(error), {
|
||||
retries: 0,
|
||||
reloadKey: 'rich-markdown-editor'
|
||||
})
|
||||
const assertion = expect(loaded).rejects.toBe(error)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await assertion
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith({
|
||||
name: 'lazy_chunk_reload_vetoed',
|
||||
data: {
|
||||
reloadKey: 'rich-markdown-editor',
|
||||
message: "Unexpected token ']'",
|
||||
outcome: 'guard-not-landed'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the original error when the guarded failure is not a dynamic import failure', async () => {
|
||||
const reload = spyOnReload()
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
|
||||
@@ -313,4 +357,232 @@ describe('loadLazyWithRetry', () => {
|
||||
expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe(LANDED_RELOAD_GUARD_VALUE)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('carries the reloadKey on LazyChunkLoadError so the crash names the call site', async () => {
|
||||
spyOnReload()
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
|
||||
const factory = vi.fn(() => Promise.reject(chunkParseError()))
|
||||
|
||||
const loaded = loadLazyWithRetry(factory, { retries: 0, reloadKey: 'rich-markdown-editor' })
|
||||
const settled = loaded.then(
|
||||
() => null,
|
||||
(rejection: unknown) => rejection
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(await settled).toMatchObject({ reloadKey: 'rich-markdown-editor' })
|
||||
})
|
||||
})
|
||||
|
||||
// Crash b860def2: `window.location.reload()` was requested at 19:05:00.984Z and never
|
||||
// landed — Terminal's beforeunload handler preventDefault()s whenever any open editor
|
||||
// file is dirty and Electron's will-prevent-unload cancels the navigation with no
|
||||
// dialog. In an editor app with one unsaved tab, chunk recovery could never run.
|
||||
describe('loadLazyWithRetry recovery reload vs the dirty-editor-tab unload veto', () => {
|
||||
type Harness = {
|
||||
navigations: string[]
|
||||
hotExitBackups: number
|
||||
restartLatchAtNavigation: boolean
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
// Mirrors production: Terminal.tsx's dirty-tab guard + Electron's will-prevent-unload
|
||||
// relay (preload dispatches ORCA_RENDERER_UNLOAD_PREVENTED_EVENT on cancel).
|
||||
function installDirtyEditorTab(options: { hotExitBackupFails?: boolean } = {}): Harness {
|
||||
const harness: Harness = {
|
||||
navigations: [],
|
||||
hotExitBackups: 0,
|
||||
restartLatchAtNavigation: false,
|
||||
cleanup: () => {}
|
||||
}
|
||||
const cleanupBypass = registerUpdaterBeforeUnloadBypass()
|
||||
|
||||
const dirtyTabGuard = (event: Event): void => {
|
||||
if (isIntentionalAppRestartInProgress()) {
|
||||
return
|
||||
}
|
||||
preventUnloadAndScheduleShutdownCheckpointReset(event, window)
|
||||
}
|
||||
const hotExitBackup = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<EditorPrepareHotExitDetail>).detail
|
||||
detail.claim()
|
||||
harness.hotExitBackups += 1
|
||||
if (options.hotExitBackupFails === true) {
|
||||
detail.reject('Some unsaved editor changes cannot be backed up before restart.')
|
||||
return
|
||||
}
|
||||
detail.resolve()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', dirtyTabGuard)
|
||||
window.addEventListener(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, hotExitBackup)
|
||||
vi.spyOn(window.location, 'reload').mockImplementation(() => {
|
||||
harness.restartLatchAtNavigation = isIntentionalAppRestartInProgress()
|
||||
const accepted = window.dispatchEvent(new Event('beforeunload', { cancelable: true }))
|
||||
harness.navigations.push(accepted ? 'landed' : 'cancelled')
|
||||
if (!accepted) {
|
||||
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
|
||||
}
|
||||
})
|
||||
|
||||
harness.cleanup = () => {
|
||||
window.removeEventListener('beforeunload', dirtyTabGuard)
|
||||
window.removeEventListener(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, hotExitBackup)
|
||||
cleanupBypass()
|
||||
}
|
||||
return harness
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
window.sessionStorage.clear()
|
||||
resetLazyChunkReloadRequestsForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
delete (window as unknown as { api?: unknown }).api
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('lands the recovery reload despite an unsaved editor tab', async () => {
|
||||
const harness = installDirtyEditorTab()
|
||||
const error = chunkParseError()
|
||||
|
||||
const loaded = loadLazyWithRetry(() => Promise.reject(error), { retries: 0 })
|
||||
let settled: unknown = 'pending'
|
||||
void loaded.then(
|
||||
(value) => {
|
||||
settled = value
|
||||
},
|
||||
(rejection: unknown) => {
|
||||
settled = rejection
|
||||
}
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(harness.navigations).toEqual(['landed'])
|
||||
expect(harness.restartLatchAtNavigation).toBe(true)
|
||||
// Unsaved buffers are backed up before the document is thrown away.
|
||||
expect(harness.hotExitBackups).toBe(1)
|
||||
// A landed navigation kills the document, so the boundary never sees the error.
|
||||
expect(settled).toBe('pending')
|
||||
harness.cleanup()
|
||||
})
|
||||
|
||||
it('refuses to reload when unsaved buffers cannot be backed up', async () => {
|
||||
const harness = installDirtyEditorTab({ hotExitBackupFails: true })
|
||||
const recordBreadcrumb = stubCrashReportsBreadcrumb()
|
||||
const restartAborted = vi.fn()
|
||||
window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted)
|
||||
const error = chunkParseError()
|
||||
|
||||
const loaded = loadLazyWithRetry(() => Promise.reject(error), {
|
||||
retries: 0,
|
||||
reloadKey: 'rich-markdown-editor'
|
||||
})
|
||||
let settled: unknown = 'pending'
|
||||
void loaded.then(
|
||||
(value) => {
|
||||
settled = value
|
||||
},
|
||||
(rejection: unknown) => {
|
||||
settled = rejection
|
||||
}
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(harness.navigations).toEqual([])
|
||||
expect(settled).toBe(error)
|
||||
// The latch suppresses the unsaved-changes prompt; never leave it armed.
|
||||
expect(restartAborted).toHaveBeenCalled()
|
||||
expect(isIntentionalAppRestartInProgress()).toBe(false)
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith({
|
||||
name: 'lazy_chunk_reload_vetoed',
|
||||
data: {
|
||||
reloadKey: 'rich-markdown-editor',
|
||||
message: "Unexpected token ']'",
|
||||
outcome: 'checkpoint-refused'
|
||||
}
|
||||
})
|
||||
window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted)
|
||||
harness.cleanup()
|
||||
})
|
||||
|
||||
it('settles on the unload-prevented signal instead of waiting out the blind grace window', async () => {
|
||||
// Chromium throttles background-tab timers, so a 10s blind wait can stretch far past
|
||||
// a real veto — and the old timer was never cleared for a navigation that did land.
|
||||
vi.spyOn(window.location, 'reload').mockImplementation(() => {
|
||||
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
|
||||
})
|
||||
const error = chunkParseError()
|
||||
|
||||
const loaded = loadLazyWithRetry(() => Promise.reject(error), { retries: 0 })
|
||||
let settled: unknown = 'pending'
|
||||
void loaded.then(
|
||||
(value) => {
|
||||
settled = value
|
||||
},
|
||||
(rejection: unknown) => {
|
||||
settled = rejection
|
||||
}
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(settled).toBe(error)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('drops the stale guard after a vetoed reload but caps re-arming per document', async () => {
|
||||
const reload = vi.fn(() => {
|
||||
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
|
||||
})
|
||||
vi.spyOn(window.location, 'reload').mockImplementation(reload)
|
||||
const error = chunkParseError()
|
||||
const attempt = async (): Promise<unknown> => {
|
||||
let settled: unknown = 'pending'
|
||||
void loadLazyWithRetry(() => Promise.reject(error), { retries: 0 }).then(
|
||||
(value) => {
|
||||
settled = value
|
||||
},
|
||||
(rejection: unknown) => {
|
||||
settled = rejection
|
||||
}
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
return settled
|
||||
}
|
||||
|
||||
expect(await attempt()).toBe(error)
|
||||
// A guard left by a reload that never happened denies this document recovery for
|
||||
// the rest of the session, even once the user saves the tab that blocked it.
|
||||
expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBeNull()
|
||||
|
||||
expect(await attempt()).toBe(error)
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
|
||||
// ...but clearing the guard must not let a permanently vetoing window loop.
|
||||
expect(await attempt()).toBe(error)
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the guard while a reload is in flight so a sibling chunk cannot re-arm it', async () => {
|
||||
spyOnReload() // no veto signal: the navigation stays in flight for the grace window
|
||||
const error = chunkParseError()
|
||||
|
||||
void loadLazyWithRetry(() => Promise.reject(error), { retries: 0 }).then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
// A second lazy site fails while the document is already tearing down.
|
||||
void loadLazyWithRetry(() => Promise.reject(error), { retries: 0 }).then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBe(String(performance.timeOrigin))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { lazy, type ComponentType, type LazyExoticComponent } from 'react'
|
||||
|
||||
import {
|
||||
requestLazyChunkRecoveryReload,
|
||||
type LazyChunkRecoveryReloadOutcome
|
||||
} from './lazy-chunk-recovery-reload'
|
||||
|
||||
/**
|
||||
* Resilient replacement for React.lazy.
|
||||
*
|
||||
@@ -8,9 +13,10 @@ import { lazy, type ComponentType, type LazyExoticComponent } from 'react'
|
||||
* ']'"). React.lazy permanently caches that rejection, so the error boundary's
|
||||
* "Retry" — which just re-renders the same Lazy — can never recover it; the
|
||||
* surface stays dead and reports a react-error-boundary crash. This wrapper first
|
||||
* retries transient fetch failures, then performs ONE guarded full reload to
|
||||
* refetch fresh chunk bytes and rebuild the ES module map, before finally falling
|
||||
* through to the error boundary.
|
||||
* retries transient fetch failures, then performs ONE guarded full reload — routed
|
||||
* through the intentional-restart path so Orca's dirty-tab beforeunload guard cannot
|
||||
* silently veto it — to refetch fresh chunk bytes and rebuild the ES module map,
|
||||
* before finally falling through to the error boundary.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror React.lazy's own ComponentType<any> constraint so every existing call site type-checks unchanged.
|
||||
@@ -23,14 +29,18 @@ type ReloadGuardState = 'not-attempted' | 'reload-landed' | 'reload-not-landed'
|
||||
export type LazyWithRetryOptions = {
|
||||
retries?: number
|
||||
baseDelayMs?: number
|
||||
/** Label surfaced in the reload breadcrumb for triage; not used for control flow. */
|
||||
/** Names the call site on the reload breadcrumbs and on LazyChunkLoadError; never control flow. */
|
||||
reloadKey?: string
|
||||
}
|
||||
|
||||
export class LazyChunkLoadError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
/** Which lazy call site failed; carried on the error so reports name it without a breadcrumb. */
|
||||
readonly reloadKey: string
|
||||
|
||||
constructor(cause: unknown, reloadKey = 'unknown') {
|
||||
super('Lazy chunk load failed after reload recovery was exhausted')
|
||||
this.name = 'LazyChunkLoadError'
|
||||
this.reloadKey = reloadKey
|
||||
;(this as { cause?: unknown }).cause = cause
|
||||
}
|
||||
}
|
||||
@@ -42,9 +52,9 @@ export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadErro
|
||||
// One recovery reload per session. The guard survives the reload itself (so we
|
||||
// never loop) but resets when the window/app closes, so a later launch — e.g.
|
||||
// after an update ships fresh chunks — can earn another reload. sessionStorage
|
||||
// (not localStorage) gives exactly that lifetime; it is never cleared mid-session,
|
||||
// otherwise a sibling chunk's healthy load would re-arm the reload and an
|
||||
// auto-mounted corrupt chunk would loop.
|
||||
// (not localStorage) gives exactly that lifetime; a healthy sibling load never
|
||||
// clears it, otherwise an auto-mounted corrupt chunk would loop. The one exception
|
||||
// is a reload this document proved never landed (see clearChunkReloadGuard).
|
||||
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
|
||||
// Stand-in document identity for hosts without a usable performance.timeOrigin; a
|
||||
// reload re-evaluates this module, so a fresh value still means the reload landed.
|
||||
@@ -88,13 +98,47 @@ function markChunkReloadAttempted(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function recordReloadBreadcrumb(reloadKey: string, message: string): void {
|
||||
// Why: a token this document wrote itself proves no reload happened, so leaving it in
|
||||
// place would deny the surface recovery for the whole session even after whatever
|
||||
// vetoed the navigation is gone (e.g. the user saved the dirty tab).
|
||||
function clearChunkReloadGuard(): void {
|
||||
try {
|
||||
window.sessionStorage.removeItem(RELOAD_GUARD_KEY)
|
||||
} catch {
|
||||
// Best effort: a document that cannot clear the guard just forfeits its retry.
|
||||
}
|
||||
}
|
||||
|
||||
// sessionStorage cannot bound a document that keeps re-arming its own guard, so cap
|
||||
// recovery requests in memory; the counter dies with the document a reload replaces.
|
||||
const MAX_RELOAD_REQUESTS_PER_DOCUMENT = 2
|
||||
let reloadRequestsThisDocument = 0
|
||||
// A sibling chunk failing mid-navigation reads the same 'reload-not-landed' guard;
|
||||
// clearing it there would re-arm a document that is already tearing down.
|
||||
let reloadRequestInFlight = false
|
||||
|
||||
export function resetLazyChunkReloadRequestsForTest(): void {
|
||||
reloadRequestsThisDocument = 0
|
||||
reloadRequestInFlight = false
|
||||
}
|
||||
|
||||
type ReloadBreadcrumbName = 'lazy_chunk_reload' | 'lazy_chunk_reload_vetoed'
|
||||
|
||||
function recordReloadBreadcrumb(
|
||||
name: ReloadBreadcrumbName,
|
||||
reloadKey: string,
|
||||
message: string,
|
||||
outcome?: string
|
||||
): void {
|
||||
// Inlined rather than importing crash-diagnostics so this low-level recovery
|
||||
// primitive stays free of the renderer/webview module graph (keeps it SSR- and
|
||||
// unit-test-friendly). Mirrors crash-diagnostics' best-effort breadcrumb call.
|
||||
try {
|
||||
const api = (window as Window & { api?: Window['api'] }).api
|
||||
api?.crashReports.recordBreadcrumb({ name: 'lazy_chunk_reload', data: { reloadKey, message } })
|
||||
api?.crashReports.recordBreadcrumb({
|
||||
name,
|
||||
data: { reloadKey, message, ...(outcome === undefined ? {} : { outcome }) }
|
||||
})
|
||||
} catch {
|
||||
// Crash evidence is best-effort and must never mask the original failure.
|
||||
}
|
||||
@@ -102,20 +146,6 @@ function recordReloadBreadcrumb(reloadKey: string, message: string): void {
|
||||
|
||||
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Grace window for reload() to tear the document down. Long enough that the error
|
||||
// fallback never flashes ahead of a real navigation.
|
||||
const RELOAD_SETTLE_GRACE_MS = 10_000
|
||||
|
||||
// Suspends the boundary while reload() tears the page down. Suspending forever would
|
||||
// hang the pane on a spinner when the reload is vetoed (Orca preventDefault()s
|
||||
// beforeunload for dirty editor tabs), so surface the real failure once the grace
|
||||
// window proves the navigation never happened.
|
||||
function suspendUntilReloadLands(loadError: unknown): Promise<never> {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject(loadError), RELOAD_SETTLE_GRACE_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function isKnownDynamicImportFailure(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
@@ -166,21 +196,47 @@ export async function loadLazyWithRetry<T extends AnyComponent>(
|
||||
}
|
||||
}
|
||||
|
||||
const reloadKey = options.reloadKey ?? 'unknown'
|
||||
const failureMessage = lastError instanceof Error ? lastError.message : String(lastError)
|
||||
const reloadGuardState = readChunkReloadGuardState()
|
||||
if (typeof window !== 'undefined' && reloadGuardState === 'not-attempted') {
|
||||
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
reloadGuardState === 'not-attempted' &&
|
||||
reloadRequestsThisDocument < MAX_RELOAD_REQUESTS_PER_DOCUMENT
|
||||
) {
|
||||
if (!markChunkReloadAttempted()) {
|
||||
throw lastError
|
||||
}
|
||||
recordReloadBreadcrumb(
|
||||
options.reloadKey ?? 'unknown',
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
)
|
||||
window.location.reload()
|
||||
return suspendUntilReloadLands(lastError)
|
||||
reloadRequestsThisDocument += 1
|
||||
reloadRequestInFlight = true
|
||||
recordReloadBreadcrumb('lazy_chunk_reload', reloadKey, failureMessage)
|
||||
// Resolves only if the navigation was refused; a landed reload kills this document.
|
||||
const outcome: LazyChunkRecoveryReloadOutcome = await requestLazyChunkRecoveryReload(window)
|
||||
reloadRequestInFlight = false
|
||||
clearChunkReloadGuard()
|
||||
recordReloadBreadcrumb('lazy_chunk_reload_vetoed', reloadKey, failureMessage, outcome)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (reloadGuardState === 'reload-landed' && isKnownDynamicImportFailure(lastError)) {
|
||||
throw new LazyChunkLoadError(lastError)
|
||||
throw new LazyChunkLoadError(lastError, reloadKey)
|
||||
}
|
||||
|
||||
if (
|
||||
reloadGuardState === 'reload-not-landed' &&
|
||||
!reloadRequestInFlight &&
|
||||
isKnownDynamicImportFailure(lastError)
|
||||
) {
|
||||
// Why here and not only at request time: this crumb lands in the same tick as the
|
||||
// crash report, so the 30-entry ring cannot evict it the way it evicted b860def2's.
|
||||
clearChunkReloadGuard()
|
||||
recordReloadBreadcrumb(
|
||||
'lazy_chunk_reload_vetoed',
|
||||
reloadKey,
|
||||
failureMessage,
|
||||
'guard-not-landed'
|
||||
)
|
||||
}
|
||||
|
||||
// No proven reload (SSR / node / blocked storage / a reload this document asked
|
||||
|
||||
+1
-36
@@ -1,7 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { UpdateStatus } from '../shared/types'
|
||||
import type { UpdateStatus } from './types'
|
||||
import {
|
||||
createUpdaterQuitAbortRelay,
|
||||
prepareRendererForAppRestart
|
||||
@@ -55,36 +53,3 @@ describe('createUpdaterQuitAbortRelay', () => {
|
||||
expect(aborted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preload restart wiring', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/preload/index.ts'), 'utf8')
|
||||
|
||||
it('relays prevented unload and async updater failure IPC into renderer lifecycle events', () => {
|
||||
expect(source).toContain("ipcRenderer.on('updater:status'")
|
||||
expect(source).toContain('updaterQuitAbortRelay.handleStatus(status)')
|
||||
expect(source).toContain("ipcRenderer.on('window:unload-prevented'")
|
||||
expect(source).toContain(
|
||||
'window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks updater preparation before invoking main and aborts it on immediate IPC failure', () => {
|
||||
const start = source.indexOf('quitAndInstall: async (): Promise<void> => {')
|
||||
const end = source.indexOf('onStatus: (callback) => {', start)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const block = source.slice(start, end)
|
||||
const prepare = block.indexOf('await prepareRendererForAppRestart(window, {')
|
||||
const markPrepared = block.indexOf('updaterQuitAbortRelay.markPrepared()')
|
||||
const invoke = block.indexOf("ipcRenderer.invoke('updater:quitAndInstall')")
|
||||
const abort = block.indexOf('updaterQuitAbortRelay.abort()')
|
||||
|
||||
expect(prepare).toBeGreaterThanOrEqual(0)
|
||||
expect(markPrepared).toBeGreaterThan(prepare)
|
||||
expect(invoke).toBeGreaterThan(markPrepared)
|
||||
expect(abort).toBeGreaterThan(invoke)
|
||||
expect(block).toMatch(
|
||||
/try \{\s*return await ipcRenderer\.invoke\('updater:quitAndInstall'\)\s*\} catch \(error\) \{\s*updaterQuitAbortRelay\.abort\(\)\s*throw error\s*\}/
|
||||
)
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
|
||||
type EditorPrepareHotExitDetail
|
||||
} from '../shared/editor-save-events'
|
||||
import type { UpdateStatus } from '../shared/types'
|
||||
} from './editor-save-events'
|
||||
import type { UpdateStatus } from './types'
|
||||
|
||||
export type AppRestartPrepOptions = {
|
||||
startedEventName: string
|
||||
Reference in New Issue
Block a user