fix(desktop): replace white screen after update with visible error + auto-reload (#95575)
A torn renderer bundle (update replaced the app while its files were locked, e.g. antivirus or a still-running instance) loads index.html fine and then dies on the first lazy import — a white screen with only a desktop.log line. A main-frame load failure (missing index.html, blocked file) was likewise log-only. - resolveRendererIndex() already detects torn bundles; the primary window now refuses to load one and shows a visible repair page (error code, missing assets, 'hermes desktop --force-build', Reload) instead of a blank window. - did-fail-load on the main frame now gets bounded auto-reload through the shared rolling reload budget (transient failures self-heal) and, once the budget is exhausted, surfaces the visible error page. ERR_ABORTED and sub-frame failures stay log-only, and helper windows (OAuth/portal) keep their log-only policy (opt-in via reloadOnFailedLoad). Regression tests cover the policy decisions (reload / abort / budget-exhausted surface), budget sharing with render-process-gone, and the error page content + data: URL loading.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: '<script>alert("pwned")</script>',
|
||||
url: 'file:///C:/x/index.html?<b>',
|
||||
missingAssets: ['assets/<img src=x onerror=alert(1)>.js']
|
||||
})
|
||||
|
||||
assert.doesNotMatch(html, /<script>alert\("pwned"\)<\/script>/)
|
||||
assert.match(html, /<script>alert/)
|
||||
})
|
||||
|
||||
test('error page renders without any details', () => {
|
||||
const html = buildRendererLoadErrorPage()
|
||||
|
||||
assert.match(html, /The desktop renderer failed to load\./)
|
||||
assert.match(html, /Reload/)
|
||||
})
|
||||
|
||||
test('loadRendererLoadErrorPage loads a data: URL and swallows loadURL rejections', async () => {
|
||||
const loads: string[] = []
|
||||
const win = {
|
||||
loadURL: async (url: string) => {
|
||||
loads.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
await loadRendererLoadErrorPage(win, { errorCode: -6, errorDescription: 'torn bundle' })
|
||||
|
||||
assert.equal(loads.length, 1)
|
||||
assert.ok(loads[0].startsWith('data:text/html;charset=utf-8,'))
|
||||
assert.ok(decodeURIComponent(loads[0]).includes('torn bundle'))
|
||||
|
||||
// A rejected loadURL must not become an unhandled rejection — the white
|
||||
// screen is strictly better than a crashed recovery path.
|
||||
const rejectingWin = {
|
||||
loadURL: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}
|
||||
|
||||
await assert.doesNotReject(() => loadRendererLoadErrorPage(rejectingWin))
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Visible renderer-load error page.
|
||||
*
|
||||
* The white-screen failure modes this exists for:
|
||||
*
|
||||
* 1. TORN BUNDLE after an update (#95575): `hermes update` replaces the app
|
||||
* while its files are locked (antivirus, a still-running instance, an
|
||||
* interrupted Windows replace), leaving index.html and its hashed chunks
|
||||
* from DIFFERENT generations. The window loads, then dies on the first
|
||||
* lazy import ("Failed to fetch dynamically imported module") — a white
|
||||
* screen that no amount of restarting fixes.
|
||||
* 2. LOAD FAILURE: a missing/blocked index.html surfaces only as a bare
|
||||
* ERR_FILE_NOT_FOUND window (see #39484) with a log line nobody sees.
|
||||
*
|
||||
* Both used to leave the user staring at a blank window with the only
|
||||
* explanation in `logs/desktop.log`. This module renders the failure INTO
|
||||
* the window — error code, what is missing, how to repair — with a Reload
|
||||
* button, so the white screen becomes a diagnosable, actionable surface.
|
||||
*
|
||||
* Pure + injectable so it is testable without booting Electron: the page is
|
||||
* a self-contained data: URL (no network, no file access), so `loadURL` can
|
||||
* never itself fail on a torn install.
|
||||
*/
|
||||
|
||||
export interface RendererLoadErrorDetails {
|
||||
/** Chromium error code, e.g. -6 (ERR_FILE_NOT_FOUND) or its name. */
|
||||
errorCode?: number | string | undefined
|
||||
/** Human description of the failure, e.g. the renderer bundle is torn. */
|
||||
errorDescription?: string
|
||||
/** The URL that failed to load, when known. */
|
||||
url?: string
|
||||
/** Module files index.html declares but that are missing on disk. */
|
||||
missingAssets?: string[]
|
||||
/** Repair command hint, e.g. `hermes desktop --force-build`. */
|
||||
repairHint?: string
|
||||
/**
|
||||
* URL to navigate to when the user clicks Reload. On a data: page
|
||||
* `location.reload()` would just re-render the error page, so recovery
|
||||
* must target the real renderer URL. Omitted → the button reloads in
|
||||
* place (harmless: the caller's load-failure policy re-surfaces).
|
||||
*/
|
||||
reloadUrl?: string
|
||||
}
|
||||
|
||||
function reloadButtonJs(details: RendererLoadErrorDetails): string {
|
||||
const target = details.reloadUrl ? `location.replace(${JSON.stringify(details.reloadUrl)})` : 'location.reload()'
|
||||
|
||||
return (
|
||||
'<button id="reload" type="button">Reload</button>\n' +
|
||||
` <script>document.getElementById("reload").addEventListener("click", () => ${target})</script>`
|
||||
)
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.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 => `<li><code>${escapeHtml(asset)}</code></li>`).join('')
|
||||
|
||||
return (
|
||||
`<p>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.</p><ul>${items}</ul>`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? `<p><code>${escapeHtml(details.url)}</code></p>` : ''
|
||||
const repair = details.repairHint ? `<p>Repair with: <code>hermes desktop --force-build</code></p>` : ''
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0b0e14;
|
||||
color: #e6e6e6;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
max-width: 560px;
|
||||
padding: 32px;
|
||||
border: 1px solid #2b2f3a;
|
||||
border-radius: 12px;
|
||||
background: #11151d;
|
||||
}
|
||||
h1 { font-size: 18px; margin: 0 0 12px; }
|
||||
p { font-size: 14px; line-height: 1.5; margin: 8px 0; }
|
||||
code {
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #1a1f2a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
ul { margin: 8px 0; padding-left: 20px; font-size: 13px; }
|
||||
button {
|
||||
margin-top: 16px;
|
||||
padding: 8px 18px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #4f7cff;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #6b90ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${title}</h1>
|
||||
<p>${description}${code}</p>
|
||||
${url}
|
||||
${missingAssetsList(details.missingAssets)}
|
||||
${repair}
|
||||
<p>If this keeps happening, check <code>logs/desktop.log</code> and try
|
||||
<code>hermes desktop --force-build</code>, then restart the app.</p>
|
||||
${reloadButtonJs(details)}
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/** Minimal structural surface of BrowserWindow used here. */
|
||||
export interface LoadErrorWindowLike {
|
||||
loadURL: (url: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user