diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 78cd0127df..e4b83749ff 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -306,6 +306,7 @@ import { createRemoteWsHeaderStore } from './remote-ws-headers' import { missingRendererAssets } from './renderer-bundle' +import { loadRendererLoadErrorPage } from './renderer-load-error-page' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' import { classifyStoredSecret, @@ -13189,11 +13190,30 @@ function createWindow() { } catch (err) { rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`) } + }, + // #95575: a renderer that repeatedly fails to load (torn bundle after + // an update, file locked by AV, missing index.html) used to sit on a + // white screen with only a desktop.log line. Once the bounded reload + // budget is exhausted, put the VISIBLE error page in the window so the + // user sees what is wrong and how to repair it. + onFailedLoadBudgetExhausted: details => { + rememberLog( + `[renderer:main] load-failure budget exhausted; loading visible error page` + + `${details?.errorCode === undefined ? '' : ` code=${String(details.errorCode)}`}` + ) + void loadRendererLoadErrorPage(mainWindow, { + errorCode: details?.errorCode, + url: details?.url, + errorDescription: 'The desktop renderer failed to load repeatedly after the update.', + repairHint: 'hermes desktop --force-build', + reloadUrl: DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString() + }) } }, reloadWindowMs: RENDERER_RELOAD_WINDOW_MS, reloadMax: RENDERER_RELOAD_MAX, - recentReloadTimesRef: rendererReloadTimesRef + recentReloadTimesRef: rendererReloadTimesRef, + reloadOnFailedLoad: true }) // Electron always passes the event first. The canonical (Electron 36+) shape @@ -13203,7 +13223,34 @@ function createWindow() { // quick-entry windows used to vanish without a trace). attachRendererConsoleCapture(mainWindow, 'main', rememberLog) - loadWindowUrl(mainWindow, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Renderer') + // #95575: a torn renderer bundle (update replaced the app while its files + // were locked) loads fine and then dies on the first lazy import — a white + // screen with no error surface. resolveRendererIndex already logs the torn + // copies; here we refuse to load one into the PRIMARY window and put the + // visible repair page in it instead. The Reload button re-attempts the + // bundle in case the file lock cleared since boot. + const rendererIndex = DEV_SERVER ? null : resolveRendererIndex() + const tornAssets = rendererIndex ? missingRendererAssets(rendererIndex) : [] + + if (!DEV_SERVER && rendererIndex && tornAssets.length > 0) { + rememberLog( + `[renderer] primary window: chosen renderer bundle ${rendererIndex} is incomplete ` + + `(${tornAssets.length} missing asset(s)); loading visible repair page instead of a white screen` + ) + void loadRendererLoadErrorPage(mainWindow, { + errorCode: 'ERR_FILE_NOT_FOUND', + errorDescription: `The desktop renderer bundle is incomplete after the last update (${tornAssets.length} missing file(s)).`, + missingAssets: tornAssets, + repairHint: 'hermes desktop --force-build', + reloadUrl: pathToFileURL(rendererIndex).toString() + }) + } else { + loadWindowUrl( + mainWindow, + DEV_SERVER || pathToFileURL(rendererIndex || resolveRendererIndex()).toString(), + 'Renderer' + ) + } // Start the Python backend NOW, in parallel with the renderer load — not on // did-finish-load. The backend cold boot (spawn → port announce → /api/status) diff --git a/apps/desktop/electron/renderer-load-error-page.test.ts b/apps/desktop/electron/renderer-load-error-page.test.ts new file mode 100644 index 0000000000..986ef10e20 --- /dev/null +++ b/apps/desktop/electron/renderer-load-error-page.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { buildRendererLoadErrorPage, loadRendererLoadErrorPage } from './renderer-load-error-page' + +test('error page names the failure and carries a Reload button', () => { + const html = buildRendererLoadErrorPage({ + errorCode: -6, + errorDescription: 'The desktop renderer bundle is incomplete after the last update (2 missing file(s)).', + missingAssets: ['assets/app-C0ffee.js', 'assets/shiki-block-DeadBeef.js'], + repairHint: 'hermes desktop --force-build' + }) + + assert.match(html, /Hermes couldn.t start the desktop UI/) + assert.match(html, /incomplete after the last update \(2 missing file\(s\)\)/) + assert.match(html, /-6/) + assert.match(html, /assets\/app-C0ffee\.js/) + assert.match(html, /assets\/shiki-block-DeadBeef\.js/) + assert.match(html, /hermes desktop --force-build/) + assert.match(html, /Reload/) + assert.match(html, /location\.reload\(\)/) +}) + +test('error page reload button targets the real renderer URL when provided', () => { + const html = buildRendererLoadErrorPage({ + errorDescription: 'load failed', + reloadUrl: 'file:///C:/Hermes%20Agent/dist/index.html' + }) + + // A data: page cannot recover with location.reload() (it would re-render + // the error page) — the button must navigate back to the app URL. + assert.match(html, /location\.replace\("file:\/\/\/C:\/Hermes%20Agent\/dist\/index\.html"\)/) + assert.doesNotMatch(html, /location\.reload\(\)/) +}) + +test('error page escapes HTML in failure details', () => { + const html = buildRendererLoadErrorPage({ + errorDescription: '', + url: 'file:///C:/x/index.html?', + missingAssets: ['assets/.js'] + }) + + assert.doesNotMatch(html, /` + ) +} + +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function missingAssetsList(missingAssets?: string[]): string { + const assets = (missingAssets ?? []).slice(0, 5) + + if (assets.length === 0) { + return '' + } + + const items = assets.map(asset => `
  • ${escapeHtml(asset)}
  • `).join('') + + return ( + `

    The renderer bundle is missing ${missingAssets!.length} module file(s) ` + + `(first ${assets.length} shown) — the last update replaced the app while ` + + `its files were locked.

    ` + ) +} + +/** + * Build the self-contained error page. Deliberately dependency-free: no + * stylesheets, no images, no fetch — a data: URL must render from a blank + * origin with zero network access. + */ +export function buildRendererLoadErrorPage(details: RendererLoadErrorDetails = {}): string { + const code = + details.errorCode === undefined || details.errorCode === null ? '' : ` (${escapeHtml(details.errorCode)})` + const title = 'Hermes couldn\u2019t start the desktop UI' + const description = escapeHtml(details.errorDescription || 'The desktop renderer failed to load.') + const url = details.url ? `

    ${escapeHtml(details.url)}

    ` : '' + const repair = details.repairHint ? `

    Repair with: hermes desktop --force-build

    ` : '' + + return ` + + + +${title} + + + +
    +

    ${title}

    +

    ${description}${code}

    + ${url} + ${missingAssetsList(details.missingAssets)} + ${repair} +

    If this keeps happening, check logs/desktop.log and try + hermes desktop --force-build, then restart the app.

    + ${reloadButtonJs(details)} +
    + +` +} + +/** Minimal structural surface of BrowserWindow used here. */ +export interface LoadErrorWindowLike { + loadURL: (url: string) => Promise +} + +const DATA_URL_PREFIX = 'data:text/html;charset=utf-8,' + +/** + * Load the visible error page into a window, replacing the white screen. + * Always resolves — loadURL is the one call that could reject, and a + * rejection must not be allowed to turn the recovery surface itself blank. + */ +export async function loadRendererLoadErrorPage( + win: LoadErrorWindowLike, + details: RendererLoadErrorDetails = {} +): Promise { + const url = `${DATA_URL_PREFIX}${encodeURIComponent(buildRendererLoadErrorPage(details))}` + + try { + await win.loadURL(url) + } catch { + // The white screen is strictly better than an unhandled rejection here; + // the log line from the caller still tells the story. + } +} diff --git a/apps/desktop/electron/window-renderer-lifecycle.test.ts b/apps/desktop/electron/window-renderer-lifecycle.test.ts index 61442b9672..85a7b93b5e 100644 --- a/apps/desktop/electron/window-renderer-lifecycle.test.ts +++ b/apps/desktop/electron/window-renderer-lifecycle.test.ts @@ -7,6 +7,7 @@ import { installWindowRendererLifecycle, pruneReloadTimes, pushReloadTime, + shouldReloadAfterFailedLoad, shouldReloadAfterRendererGone } from './window-renderer-lifecycle' @@ -400,3 +401,144 @@ test('describeRendererLifecycleEvent sanitizes unknown fields', () => { '[renderer:main] render-process-gone reason=killed exitCode=1 (expected teardown)' ) }) + +// --- #95575: white-screen recovery for main-frame load failures ------------- +// A torn renderer bundle (update replaced the app while its files were +// locked) or a missing index.html used to leave the primary window blank with +// only a desktop.log line. The policy below turns that into bounded +// auto-reload (transient failures self-heal) and, once the budget is +// exhausted, a VISIBLE error page instead of a silent white screen. + +test('shouldReloadAfterFailedLoad reloads a real main-frame failure', () => { + assert.deepEqual(shouldReloadAfterFailedLoad({ errorCode: -6, isMainFrame: true, recentReloadTimes: [] }), { + reload: true + }) + assert.deepEqual(shouldReloadAfterFailedLoad({ errorCode: -2, isMainFrame: true, recentReloadTimes: [] }), { + reload: true + }) +}) + +test('shouldReloadAfterFailedLoad never reloads sub-frames or ERR_ABORTED', () => { + // Sub-frame failures are page-internal noise. + assert.deepEqual(shouldReloadAfterFailedLoad({ errorCode: -6, isMainFrame: false, recentReloadTimes: [] }), { + reload: false, + suppressedReason: 'unrecoverable-reason' + }) + // -3 = ERR_ABORTED: the load was superseded (navigation/redirect), expected. + assert.deepEqual(shouldReloadAfterFailedLoad({ errorCode: -3, isMainFrame: true, recentReloadTimes: [] }), { + reload: false, + suppressedReason: 'expected-teardown' + }) +}) + +test('shouldReloadAfterFailedLoad surfaces a visible error once the budget is exhausted', () => { + const decision = shouldReloadAfterFailedLoad({ + errorCode: -6, + isMainFrame: true, + recentReloadTimes: [100, 50, 10], + reloadWindowMs: 60_000, + reloadMax: 3, + now: () => 200 + }) + + assert.deepEqual(decision, { reload: false, suppressedReason: 'crash-loop', surfaceError: true }) +}) + +test('installWindowRendererLifecycle auto-reloads main-frame load failures when enabled', async () => { + const win = makeFakeWindow() + const { logs, options } = makeOptions(win, 'main', { + reloadOnFailedLoad: true, + reloadWindowMs: 60_000, + reloadMax: 3, + now: () => 1000 + }) + + installWindowRendererLifecycle(win, options) + win.webContents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///dist/index.html', true) + await flushDeferred() + + assert.equal(win.reloadCalls.length, 1) + assert.match(logs[0], /\[renderer:main\] did-fail-load code=-6 url=file:\/\/\/dist\/index\.html/) +}) + +test('installWindowRendererLifecycle surfaces the error page after the reload budget trips', async () => { + const win = makeFakeWindow() + const surfaced: Array<{ errorCode?: number | string; url?: string }> = [] + + const { logs, options } = makeOptions(win, 'main', { + reloadOnFailedLoad: true, + reloadWindowMs: 60_000, + reloadMax: 1, + now: () => 1000, + callbacks: { + log: (message: string) => { + logs.push(message) + }, + reload: () => { + win.webContents.reload() + }, + onFailedLoadBudgetExhausted: details => { + surfaced.push({ errorCode: details?.errorCode, url: details?.url }) + } + } + }) + + installWindowRendererLifecycle(win, options) + + // First failure reloads (budget = 1). + win.webContents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///dist/index.html', true) + await flushDeferred() + assert.equal(win.reloadCalls.length, 1) + assert.equal(surfaced.length, 0) + + // Second failure within the window trips the budget → visible error. + win.webContents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///dist/index.html', true) + await flushDeferred() + + assert.equal(win.reloadCalls.length, 1) + assert.equal(surfaced.length, 1) + assert.deepEqual(surfaced[0], { errorCode: -6, url: 'file:///dist/index.html' }) + assert.match(logs[logs.length - 1], /surfacing visible error instead of a blank window/) +}) + +test('load-failure reloads share the crash-loop budget with render-process-gone', async () => { + const win = makeFakeWindow() + const { options } = makeOptions(win, 'main', { + reloadOnFailedLoad: true, + reloadWindowMs: 60_000, + reloadMax: 1, + now: () => 1000 + }) + + installWindowRendererLifecycle(win, options) + + // A crash spends the single budget slot… + win.webContents.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 3 }) + await flushDeferred() + assert.equal(win.reloadCalls.length, 1) + + // …so the load failure that follows must NOT reload — it surfaces instead. + win.webContents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///dist/index.html', true) + await flushDeferred() + assert.equal(win.reloadCalls.length, 1) +}) + +test('ERR_ABORTED does not consume the load-failure budget', async () => { + const win = makeFakeWindow() + const { options } = makeOptions(win, 'main', { + reloadOnFailedLoad: true, + reloadWindowMs: 60_000, + reloadMax: 1, + now: () => 1000 + }) + + installWindowRendererLifecycle(win, options) + win.webContents.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'file:///dist/index.html', true) + await flushDeferred() + assert.equal(win.reloadCalls.length, 0) + + // A real failure still has its full budget. + win.webContents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///dist/index.html', true) + await flushDeferred() + assert.equal(win.reloadCalls.length, 1) +}) diff --git a/apps/desktop/electron/window-renderer-lifecycle.ts b/apps/desktop/electron/window-renderer-lifecycle.ts index 937e8f72cd..d536538fcf 100644 --- a/apps/desktop/electron/window-renderer-lifecycle.ts +++ b/apps/desktop/electron/window-renderer-lifecycle.ts @@ -19,9 +19,15 @@ // - `unresponsive` → log only (no reload; Chromium usually follows with // render-process-gone, and forcing a reload while the main thread is wedged // can make things worse). -// - `did-fail-load` on the MAIN frame → log only (no blind reload: a repeatable -// startup failure would boot-loop; the backend startup path already surfaces -// the actionable error). +// - `did-fail-load` on the MAIN frame → log only by default. With +// `reloadOnFailedLoad` enabled (primary window), a main-frame failure with a +// real error code (e.g. a torn renderer bundle after an update, ERR_FILE_NOT_FOUND) +// gets a BOUNDED auto-reload through the same shared rolling budget — the +// white screen self-heals when the failure was transient (file lock, AV +// scan) — and once the budget is exhausted the window surfaces a VISIBLE +// error page via `onFailedLoadBudgetExhausted` instead of staying blank +// forever. `ERR_ABORTED` (-3) is expected (a navigation superseded by +// another) and never reloads. // // Console-message capture is deliberately NOT here: renderer-log.ts owns it // (per-window labels, boundary-report formatting). Keeping one owner avoids @@ -53,6 +59,21 @@ export interface ReloadPolicyDecision { reload: boolean /** Why reload was refused, when it was. */ suppressedReason?: 'crash-loop' | 'expected-teardown' | 'unrecoverable-reason' + /** + * The reload budget is exhausted and the window must not stay blank: the + * caller should surface a visible error (load the renderer error page) + * instead of silently doing nothing. Only ever true when `reload` is false. + */ + surfaceError?: boolean +} + +export interface FailedLoadDetails { + /** Electron `did-fail-load` errorCode (negative Chromium codes, e.g. -3 = ERR_ABORTED). */ + errorCode?: number | string | undefined + /** did-fail-load: only main-frame failures are meaningful (issue point 4). */ + isMainFrame?: boolean + /** did-fail-load: the URL that failed. */ + url?: string } export interface WindowRendererLifecycleOptions { @@ -66,6 +87,10 @@ export interface WindowRendererLifecycleOptions { /** Called when the shared crash-loop budget suppresses a reload — the * primary window uses it for the #38216 Windows sandbox relaunch check. */ onCrashLoopSuppressed?: (details?: RendererLifecycleDetails) => void + /** Called when a main-frame load failure has exhausted the reload budget + * (`reloadOnFailedLoad`): the window would otherwise stay blank, so the + * caller should load a visible error page in its place. */ + onFailedLoadBudgetExhausted?: (details?: FailedLoadDetails) => void } /** Rolling crash-loop window, ms. Defaults to 60_000 (RENDERER_RELOAD_WINDOW_MS). */ reloadWindowMs?: number @@ -73,6 +98,10 @@ export interface WindowRendererLifecycleOptions { reloadMax?: number /** Shared per-process reload budget. Omitted → per-window budget (tests). */ recentReloadTimesRef?: { current: number[] } + /** Enable bounded auto-reload + visible-error surfacing for main-frame + * `did-fail-load` (primary content windows; off by default so OAuth/portal + * windows loading remote URLs never auto-reload into a loop). */ + reloadOnFailedLoad?: boolean now?: () => number } @@ -149,6 +178,53 @@ export function shouldReloadAfterRendererGone(details: { return { reload: true } } +/** + * Decide whether a main-frame `did-fail-load` should reload its window. + * + * The primary window's old policy was log-only: a repeatable startup failure + * would boot-loop. That left a torn renderer bundle (a post-update state: + * index.html and its chunks from different generations) as a permanent white + * screen with nothing but a desktop.log line. This policy instead: + * + * - never reloads sub-frame failures (page-internal assets fail all the time); + * - never reloads ERR_ABORTED (-3) — a navigation superseded by another load + * is expected, not a failure; + * - reloads other main-frame failures with the SAME bounded rolling budget as + * render-process-gone, so a transient failure (AV lock, busy file) heals + * itself while a repeatable one stops after `reloadMax` attempts; + * - when the budget is exhausted, sets `surfaceError` so the caller can put a + * visible error page in the window instead of leaving it blank. + */ +export function shouldReloadAfterFailedLoad(details: { + errorCode?: number | string | undefined + isMainFrame?: boolean + recentReloadTimes: number[] + reloadWindowMs?: number + reloadMax?: number + now?: () => number +}): ReloadPolicyDecision { + if (details.isMainFrame !== true) { + return { reload: false, suppressedReason: 'unrecoverable-reason' } + } + + // -3 = ERR_ABORTED: the load was superseded (navigation, redirect, stop + // button). Never a reason to reload. + if (String(details.errorCode) === '-3') { + return { reload: false, suppressedReason: 'expected-teardown' } + } + + const windowMs = details.reloadWindowMs ?? DEFAULT_RELOAD_WINDOW_MS + const max = details.reloadMax ?? DEFAULT_RELOAD_MAX + const now = safeNow(details.now) + const recent = pruneReloadTimes(details.recentReloadTimes, now, windowMs) + + if (recent.length >= max) { + return { reload: false, suppressedReason: 'crash-loop', surfaceError: true } + } + + return { reload: true } +} + /** * One log line per renderer lifecycle event, e.g. * [renderer:secondary] render-process-gone reason=crashed exitCode=3 @@ -258,16 +334,75 @@ export function installWindowRendererLifecycle( validatedURL: unknown, isMainFrame?: unknown ) => { - if (isMainFrame === true) { - log( - describeRendererLifecycleEvent({ - kind, - event: 'did-fail-load', - errorCode: typeof errorCode === 'number' ? errorCode : String(errorCode ?? ''), + if (isMainFrame !== true) { + return + } + + const code = typeof errorCode === 'number' ? errorCode : String(errorCode ?? '') + + log( + describeRendererLifecycleEvent({ + kind, + event: 'did-fail-load', + errorCode: code, + url: String(validatedURL ?? '') + }) + ) + + // Default policy is log-only (helper windows, remote OAuth pages). Only + // windows that opt in get bounded auto-reload + visible-error surfacing. + if (!options.reloadOnFailedLoad) { + return + } + + const nowMs = safeNow(now) + const recent = pruneReloadTimes(budgetRef.current, nowMs, reloadWindowMs) + + budgetRef.current.length = 0 + budgetRef.current.push(...recent) + + const decision = shouldReloadAfterFailedLoad({ + errorCode: code, + isMainFrame: true, + recentReloadTimes: budgetRef.current, + reloadWindowMs, + reloadMax, + now: () => nowMs + }) + + if (!decision.reload) { + if (decision.surfaceError) { + log( + `[renderer:${kind}] suppressing reload: ${budgetRef.current.length} failed loads within ${reloadWindowMs}ms; surfacing visible error instead of a blank window` + ) + options.callbacks.onFailedLoadBudgetExhausted?.({ + errorCode: code, + isMainFrame: true, url: String(validatedURL ?? '') }) - ) + } + + return } + + if (typeof reload !== 'function') { + return + } + + pushReloadTime(budgetRef.current, nowMs) + + // Deferred: never reload from inside the event handler (see above). + setImmediate(() => { + if (win.isDestroyed()) { + return + } + + try { + reload() + } catch (error) { + log(`[renderer:${kind}] reload after failed load: ${error instanceof Error ? error.message : String(error)}`) + } + }) } contents.on('render-process-gone', onRendererGone)