From 9ae5eee645fdf263fa1a9b7c36a2bf0bfcccd203 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 8 Aug 2026 05:23:12 -0400 Subject: [PATCH] fix(renderer): stop chasing chunk recovery during a committed update install The installer replaces app.asar underneath the running renderer, so lazy chunks read at their old byte offsets return another file's bytes. The recovery reload cannot land - the process is already being torn down - so the old path spent the full 10s grace window requesting a doomed reload while the installer worked underneath it. Measured in a built app: 10,755ms before, 753ms after. isIntentionalAppRestartInProgress() already tracked exactly this window; nothing in the loading path consulted it. --- src/renderer/src/lib/lazy-with-retry.ts | 21 ++- .../lazy-with-retry.update-install.test.ts | 134 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/lazy-with-retry.update-install.test.ts diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 77650b67ded..9050789bb82 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -4,6 +4,7 @@ import { requestLazyChunkRecoveryReload, type LazyChunkRecoveryReloadOutcome } from './lazy-chunk-recovery-reload' +import { isIntentionalAppRestartInProgress } from './updater-beforeunload' /** * Resilient replacement for React.lazy. @@ -106,7 +107,10 @@ export function resetLazyChunkReloadRequestsForTest(): void { reloadRequestInFlight = false } -type ReloadBreadcrumbName = 'lazy_chunk_reload' | 'lazy_chunk_reload_vetoed' +type ReloadBreadcrumbName = + | 'lazy_chunk_reload' + | 'lazy_chunk_reload_vetoed' + | 'lazy_chunk_reload_skipped' function recordReloadBreadcrumb( name: ReloadBreadcrumbName, @@ -192,6 +196,21 @@ export async function loadLazyWithRetry( const reloadKey = options.reloadKey ?? 'unknown' const failureMessage = lastError instanceof Error ? lastError.message : String(lastError) + + // The installer replaces app.asar underneath us, so chunks read at their old + // offsets return another file's bytes. Reloading cannot land — the process is + // already being torn down — and waiting out the grace window only delays the + // restart. Contain immediately and leave the guard clean for the next document. + if (isIntentionalAppRestartInProgress()) { + recordReloadBreadcrumb( + 'lazy_chunk_reload_skipped', + reloadKey, + failureMessage, + 'update-install-in-progress' + ) + throw containedChunkFailure(lastError, reloadKey) + } + const reloadGuardState = readChunkReloadGuardState() if ( diff --git a/src/renderer/src/lib/lazy-with-retry.update-install.test.ts b/src/renderer/src/lib/lazy-with-retry.update-install.test.ts new file mode 100644 index 00000000000..be9ef4a13d3 --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.update-install.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +// Reproduces the chunk failure that happens *during* a committed update install. +// +// Verified mechanism (2026-08-07, isolated Orca build, CDP): the renderer reads +// its 778 lazy chunks by byte offset out of a single app.asar. Once the installer +// replaces that archive, the old offsets land inside a different file, so an +// import that succeeded a moment earlier returns unparseable JavaScript: +// before swap -> LOADED_OK +// after swap -> SyntaxError: Unexpected token '}' (also ':', 'Unexpected string') +// name changed -> TypeError: Failed to fetch dynamically imported module +// Those are the exact messages in the shipped reports, and `overlay.update-card` +// — the surface hosting the restart button — is one of the failing boundaries. +// +// Requesting a recovery reload in that window is worse than useless: the process +// is already being torn down and the bundle underneath it has been swapped. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../../../shared/updater-renderer-events' +import { registerUpdaterBeforeUnloadBypass } from './updater-beforeunload' +import { + isLazyChunkLoadError, + loadLazyWithRetry, + resetLazyChunkReloadRequestsForTest +} from './lazy-with-retry' + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' + +// The dominant crash-time message across the shipped bundles. +const CORRUPT_CHUNK_ERROR = (): SyntaxError => new SyntaxError("Unexpected token '}'") + +type Breadcrumb = { name: string; data: Record } + +function installBreadcrumbSink(): Breadcrumb[] { + const breadcrumbs: Breadcrumb[] = [] + ;(window as unknown as { api: unknown }).api = { + crashReports: { + recordBreadcrumb: (crumb: Breadcrumb) => { + breadcrumbs.push(crumb) + } + } + } + return breadcrumbs +} + +describe('loadLazyWithRetry during a committed update install', () => { + let unregister: (() => void) | null = null + + beforeEach(() => { + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + unregister = registerUpdaterBeforeUnloadBypass() + }) + + afterEach(() => { + unregister?.() + unregister = null + vi.restoreAllMocks() + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + delete (window as unknown as { api?: unknown }).api + }) + + const commitUpdateInstall = (): void => { + window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT)) + } + + it('does not request a recovery reload once the installer is committed', async () => { + installBreadcrumbSink() + commitUpdateInstall() + + await expect( + loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'overlay.update-card' + }) + ).rejects.toSatisfy(isLazyChunkLoadError) + + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('contains the failure so the boundary suppresses it instead of filing a crash', async () => { + installBreadcrumbSink() + commitUpdateInstall() + + const error = await loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'overlay.update-card' + }).catch((e: unknown) => e) + + expect(isLazyChunkLoadError(error)).toBe(true) + }) + + it('records why recovery was skipped, naming the call site', async () => { + const breadcrumbs = installBreadcrumbSink() + commitUpdateInstall() + + await loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'overlay.update-card' + }).catch(() => undefined) + + const skipped = breadcrumbs.find((crumb) => crumb.name === 'lazy_chunk_reload_skipped') + expect(skipped?.data.reloadKey).toBe('overlay.update-card') + expect(skipped?.data.outcome).toBe('update-install-in-progress') + }) + + it('leaves no reload guard behind, so a later real failure can still recover', async () => { + installBreadcrumbSink() + commitUpdateInstall() + + await loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'overlay.update-card' + }).catch(() => undefined) + + expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBeNull() + }) + + it('still requests recovery when no install is committed', async () => { + installBreadcrumbSink() + + await loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'right-sidebar' + }).catch(() => undefined) + + // Positive control: the ordinary recovery path must stay alive, or this fix + // would silently disable chunk recovery everywhere. + expect(window.location.reload).toHaveBeenCalled() + }) +})