From 529ee80ac08d65f39fc14c127a714de700cd93e5 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 14 Aug 2026 02:21:20 -0500 Subject: [PATCH] Recover from a half-replaced desktop bundle instead of requiring a reinstall (#85887) * fix(desktop): load the intact renderer bundle when an update tears one copy index.html and the hashed chunks it names are one generation. A packaged app ships that bundle twice (inside app.asar and, via asarUnpack, beside it in app.asar.unpacked), so an update that replaces the app while its files are locked can leave the two copies from different generations. resolveRendererIndex took the first index.html that existed, so it could pick the torn one and the window died on its first lazy import with "Failed to fetch dynamically imported module" -- with no way out, because every relaunch reloaded the same copy. Check each candidate's declared modules and prefer a complete generation; when both are torn, log which files are missing and how to repair instead of leaving the crash unexplained. * fix(cli): rebuild the desktop app when its renderer bundle is half-replaced The content stamp hashes the SOURCE tree, which an interrupted update leaves intact, so `hermes desktop` reported "up to date" and skipped the rebuild that would repair a torn bundle -- the app relaunched into the same crash and reinstalling looked like the only option. Treat a bundle whose index.html names missing chunks as stale regardless of the stamp, and say so on the way into the rebuild. --- apps/desktop/electron/main.ts | 36 +++++++++++- apps/desktop/electron/renderer-bundle.ts | 71 +++++++++++++++++++++++ hermes_cli/main.py | 74 +++++++++++++++++++++++- 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/electron/renderer-bundle.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9cd3a13a17921..6e162a006b800 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -184,6 +184,7 @@ import { revalidatePooledRemoteBackends, revalidateRemoteConnection } from './remote-liveness' +import { missingRendererAssets } from './renderer-bundle' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' import { buildSessionWindowUrl, @@ -3566,10 +3567,39 @@ function resolveWebDist() { function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] - const found = candidates.find(fileExists) + const present = candidates.filter(fileExists) - if (found) { - return found + // index.html and the hashed chunks it names are one generation. An update + // that replaces only one of the two shipped copies (app.asar vs + // app.asar.unpacked) leaves a TORN copy: the window loads, then dies on the + // first lazy import with "Failed to fetch dynamically imported module" and + // every restart reloads the same torn copy. Prefer a copy whose modules are + // all present, so the intact generation heals the boot by itself. + for (const candidate of present) { + const missing = missingRendererAssets(candidate) + + if (missing.length === 0) { + return candidate + } + + rememberLog( + `[renderer] skipping torn renderer bundle at ${candidate}: ` + + `${missing.length} module file(s) named by index.html are missing ` + + `(${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''})` + ) + } + + if (present.length > 0) { + // Every copy is torn. Load the first one anyway — the boundary's error is + // still better than a blank window — but say what is wrong and how to fix + // it, because no amount of restarting repairs a torn bundle. + rememberLog( + `[renderer] every renderer bundle is incomplete (${present.join(', ')}). ` + + `The last update replaced the app while its files were locked. ` + + `Repair with: hermes desktop --force-build` + ) + + return present[0] } // Nothing on disk. A packaged build with no renderer bundle blank-pages with diff --git a/apps/desktop/electron/renderer-bundle.ts b/apps/desktop/electron/renderer-bundle.ts new file mode 100644 index 0000000000000..f977729e8db39 --- /dev/null +++ b/apps/desktop/electron/renderer-bundle.ts @@ -0,0 +1,71 @@ +/** + * Renderer bundle generation check. + * + * `index.html` and the hashed chunks under `dist/assets/` are ONE generation: + * every `lazy()` route resolves to a filename baked into that generation's + * module graph. A self-update that replaces the package while its files are + * locked (antivirus, a still-running instance, an interrupted Windows replace) + * can leave the two copies electron-builder ships — inside `app.asar` and, + * because `asarUnpack` lists `dist/**`, beside it in `app.asar.unpacked` — + * from DIFFERENT generations. The window then loads an `index.html` whose + * chunks are gone and dies on the first lazy import: + * + * Failed to fetch dynamically imported module: + * …/app.asar/dist/assets/shiki-block-COiz1pEN.js + * + * The app looks permanently broken (every relaunch reloads the same torn copy), + * yet the OTHER copy is usually intact. This makes that checkable, so the + * loader can prefer a complete generation and only report a repair when both + * are torn. + * + * Pure + injectable so it is testable without booting Electron. `fs` here is + * Electron's asar-aware fs: paths inside `app.asar` read like real files. + */ + +import fs from 'node:fs' +import path from 'node:path' + +// The modules the browser fetches before any app code runs: Vite emits them as +// `