Files
hermes-agent/apps/desktop/electron/bundle-swap.ts
T
Brooklyn Nicholson c19537fb03 fix(desktop): relaunch into the swapped bundle instead of booting a torn renderer
A user who reopens Hermes while an update is running lands on the boot gate,
which is what it is for. But the updater swaps the packaged bundle on disk
after `hermes update` exits, and its `open` leg only focuses this already-
running process, so nothing ever loads the new build. The parked instance then
passes the gate and boots the new runtime under the old renderer — the "App
build out of date" banner immediately after a fully successful update, over an
Updates card that says "You're on the latest version" and so offers no remedy.

Compare the install stamp this process loaded at boot with the one on disk when
the gate clears. On positive proof of a swap — different commit, or a different
builtAt at the same commit — relaunch instead of starting a backend. Detection
fails quiet like bundle-skew, so a swap that never happened (the Windows
locked-binary case) is unchanged. A one-shot argv flag makes a relaunch loop
impossible and a 15s failsafe falls back to the old behavior.

Co-authored-by: tk-pkm111 <133480534+tk-pkm111@users.noreply.github.com>
Co-authored-by: aeonsong <aeonsong@users.noreply.github.com>
2026-09-01 22:33:29 -05:00

62 lines
2.4 KiB
TypeScript

/**
* Swapped-bundle detection.
*
* The detached updater (scripts/desktop-update/posix.sh mac_swap /
* windows.ps1) rebuilds and swaps the packaged app on disk AFTER
* `hermes update` exits. An instance that was launched from the PRE-swap
* bundle — the user reopened Hermes mid-update, the #50238 gesture the boot
* gate exists for — would otherwise proceed to run the NEW runtime under the
* OLD renderer. The updater's own `open`/relaunch leg cannot rescue it: the
* single-instance lock turns that into a focus of the parked process, so no
* process ever loads the new build.
*
* That is the stale-renderer tail of a FULLY SUCCESSFUL update: the "App
* build out of date" banner appears right after the update, while the Updates
* card says "You're on the latest version" and so offers nothing that would
* clear it.
*
* Detection: compare the install stamp this process loaded at boot with the
* one on disk now. A different commit — or a different builtAt at the same
* commit (a dirty-tree or content-hash rebuild) — means the bundle under our
* feet is not the one we are running, and a plain relaunch loads it.
*
* Fail-quiet like bundle-skew: a missing stamp on either side (dev runs,
* unreadable resources) or a fallback all-zero commit reports "not swapped".
* This must never false-positive — a positive triggers an automatic relaunch.
*
* Pure so it is testable without booting Electron.
*/
import { isFallbackCommit } from './bundle-skew'
export interface BundleSwapStamp {
/** write-build-stamp.mjs build timestamp — differs on every rebuild. */
builtAt?: null | string
commit: string
/** write-build-stamp.mjs source tag — 'fallback' means the commit is fake. */
source?: null | string
}
/** True only on positive proof that the bundle on disk is not the running one. */
export function detectBundleSwap(running: BundleSwapStamp | null, onDisk: BundleSwapStamp | null): boolean {
if (!running?.commit || !onDisk?.commit) {
return false
}
if (running.source === 'fallback' || isFallbackCommit(running.commit)) {
return false
}
if (onDisk.source === 'fallback' || isFallbackCommit(onDisk.commit)) {
return false
}
if (running.commit !== onDisk.commit) {
return true
}
// Same commit: only a builtAt PRESENT ON BOTH sides can prove a rebuild —
// a missing timestamp (older stamp schema) proves nothing.
return Boolean(running.builtAt && onDisk.builtAt && running.builtAt !== onDisk.builtAt)
}