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.
This commit is contained in:
Jinwoo-H
2026-08-08 05:23:12 -04:00
parent 982570648a
commit 9ae5eee645
2 changed files with 154 additions and 1 deletions
+20 -1
View File
@@ -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<T extends AnyComponent>(
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 (
@@ -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<string, unknown> }
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()
})
})