diff --git a/src/preload/index.ts b/src/preload/index.ts index e2ca5656d00..63817ee6b12 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts new file mode 100644 index 00000000000..7fa0e8b5aed --- /dev/null +++ b/src/preload/renderer-restart-wiring.test.ts @@ -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 => {') + 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*\}/ + ) + }) +}) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 8e3ac1e0b5b..3fd78ba01e9 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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' }) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx index 7c69952fa9e..ff19cc89915 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx @@ -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 { }) } -// 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()) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index e6a5376f31f..71f0f9bba79 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx @@ -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() +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 { 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 }) } diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts new file mode 100644 index 00000000000..fc89ab66fac --- /dev/null +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts @@ -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> { + return new Promise((resolve) => { + let graceTimer: ReturnType | undefined + const settle = ( + outcome: Exclude + ): 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 { + 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 +} diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts index 1f8faceb099..a5b086c6b19 100644 --- a/src/renderer/src/lib/lazy-with-retry.test.ts +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -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).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 => { + 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)) + }) }) diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 415d72a25c2..dba4c9c6d57 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -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 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 => 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 { - return new Promise((_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( } } + 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 diff --git a/src/preload/renderer-restart-preparation.test.ts b/src/shared/renderer-restart-preparation.test.ts similarity index 54% rename from src/preload/renderer-restart-preparation.test.ts rename to src/shared/renderer-restart-preparation.test.ts index c216ef93f35..92799676523 100644 --- a/src/preload/renderer-restart-preparation.test.ts +++ b/src/shared/renderer-restart-preparation.test.ts @@ -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 => {') - 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*\}/ - ) - }) -}) diff --git a/src/preload/renderer-restart-preparation.ts b/src/shared/renderer-restart-preparation.ts similarity index 96% rename from src/preload/renderer-restart-preparation.ts rename to src/shared/renderer-restart-preparation.ts index 4f526844bd8..78560dd761e 100644 --- a/src/preload/renderer-restart-preparation.ts +++ b/src/shared/renderer-restart-preparation.ts @@ -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