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.
This commit is contained in:
brooklyn! 2026-08-14 02:21:20 -05:00 committed by GitHub
parent bee9ef375b
commit 529ee80ac0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 177 additions and 4 deletions

View File

@ -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

View File

@ -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
// `<script type="module" src>` plus `<link rel="modulepreload" href>`.
const TAG_WITH_URL = /<(?:script|link)\b[^>]*\b(?:src|href)=["']([^"']+)["'][^>]*>/gi
const MODULE_TAG = /\btype=["']module["']|\brel=["']modulepreload["']/i
export function parseModuleAssetRefs(html: string): string[] {
const refs: string[] = []
for (const [tag, href] of String(html ?? '').matchAll(TAG_WITH_URL)) {
// Absolute/CDN URLs aren't part of this bundle's generation.
if (MODULE_TAG.test(tag) && !/^[a-z]+:|^\/\//i.test(href)) {
refs.push(href.replace(/^\.\//, '').split(/[?#]/)[0])
}
}
return refs
}
export interface RendererBundleDeps {
readFileSync?: (file: string, encoding: 'utf8') => string
existsSync?: (file: string) => boolean
}
/**
* The module files `indexPath` declares but that do not exist beside it.
*
* Empty a complete generation (or an index naming nothing checkable the
* caller's own existence gate owns unreadable/missing files). Non-empty torn:
* loading it produces the "Failed to fetch dynamically imported module" crash.
*/
export function missingRendererAssets(indexPath: string, deps: RendererBundleDeps = {}): string[] {
const { readFileSync = fs.readFileSync, existsSync = fs.existsSync } = deps
const dir = path.dirname(indexPath)
let html: string
try {
html = readFileSync(indexPath, 'utf8')
} catch {
return []
}
return parseModuleAssetRefs(html).filter(ref => !existsSync(path.join(dir, ref)))
}

View File

@ -5966,8 +5966,72 @@ def _desktop_stamp_path() -> Path:
return get_hermes_home() / "desktop-build-stamp.json"
def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]:
"""The renderer ``dist`` directory a launch loads, when it is inspectable.
Source mode builds to ``apps/desktop/dist``. A packaged app ships the same
bundle twice inside ``app.asar`` and, because ``asarUnpack`` lists
``dist/**``, beside it in ``app.asar.unpacked``. Only the unpacked copy is
a real directory; that is also the one an interrupted replace tears, so
checking it catches the failure we care about.
"""
if source_mode:
return desktop_dir / "dist"
executable = _desktop_packaged_executable(desktop_dir)
if executable is None:
return None
# macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Contents/Resources
resources = (
executable.parent.parent / "Resources"
if sys.platform == "darwin"
else executable.parent / "resources"
)
return resources / "app.asar.unpacked" / "dist"
# The module files the renderer fetches before any app code runs: Vite emits
# them as `<script type="module" src>` plus `<link rel="modulepreload" href>`.
_HTML_TAG_WITH_URL = re.compile(r"""<(?:script|link)\b[^>]*\b(?:src|href)=["']([^"']+)["'][^>]*>""", re.IGNORECASE)
_MODULE_TAG = re.compile(r"""\btype=["']module["']|\brel=["']modulepreload["']""", re.IGNORECASE)
def _renderer_bundle_torn(dist_dir: Path) -> bool:
"""True when ``index.html`` names hashed module files that aren't there.
``index.html`` and the hashed chunks under ``assets/`` are ONE generation.
An update that replaces the app while its files are locked (antivirus, a
still-running instance, an interrupted Windows replace) can leave the two
behind from different generations. The app then launches and dies on the
first lazy import with ``Failed to fetch dynamically imported module:
/assets/<chunk>-<hash>.js`` and because the content stamp still matches
the intact SOURCE tree, ``hermes desktop`` skips the rebuild that would fix
it, so every relaunch reproduces the crash and reinstalling looks like the
only way out. Detecting the tear turns it into a normal rebuild.
Conservative: an unreadable index, or one naming nothing checkable, is NOT
reported as torn the missing-bundle guards own those cases.
"""
try:
html = (dist_dir / "index.html").read_text(encoding="utf-8", errors="replace")
except OSError:
return False
for match in _HTML_TAG_WITH_URL.finditer(html):
href = match.group(1)
# Absolute/CDN URLs aren't part of this bundle's generation.
if not _MODULE_TAG.search(match.group(0)) or re.match(r"^[a-z]+:|^//", href, re.IGNORECASE):
continue
rel = href.split("?", 1)[0].split("#", 1)[0].lstrip("./")
if rel and not (dist_dir / rel).exists():
return True
return False
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
"""Return True when the desktop build output is stale or missing.
"""Return True when the desktop build output is stale, missing, or torn.
Compares the current content hash against the saved stamp. Also returns
True if the expected build artifact doesn't exist (e.g. first run after
@ -5981,6 +6045,14 @@ def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode:
if _desktop_packaged_executable(desktop_dir) is None:
return True
# A torn renderer bundle is stale no matter what the stamp says: the hash
# describes the SOURCE tree, which is intact, while the built output is the
# half-replaced one that crashes on its first lazy import.
dist_dir = _renderer_bundle_dir(desktop_dir, source_mode=source_mode)
if dist_dir is not None and _renderer_bundle_torn(dist_dir):
print(f" ⚠ A previous update left the desktop bundle incomplete ({dist_dir}); rebuilding it")
return True
stamp_file = _desktop_stamp_path()
if not stamp_file.is_file():
return True