mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user