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)