diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 0eec8ccd319db..3a7b1b0dbf5fe 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -98,6 +98,12 @@ pub fn update_in_progress_marker() -> PathBuf { /// that path), where copying onto ourselves would be a Windows sharing /// violation. Best-effort: a failure here must not fail the install, so the /// caller logs and continues. +/// +/// NOTE: because of that no-op, a user's staged installer is only ever written +/// by a full install/repair. Every later `--update` runs the ORIGINAL binary, +/// so an installer-protocol change can strand the whole installed base on a +/// binary that predates it (see `restage_from_checkout`, which repairs this +/// from the freshly-updated checkout). pub fn copy_self_to_hermes_home() -> std::io::Result<()> { let src = std::env::current_exe()?; let dest = installer_dest(); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 785a1dc96e203..e0d43be1f6231 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -201,7 +201,11 @@ import { sandboxPreflight } from './update-relaunch' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' -import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process' +import { + resolveStagedUpdaterBinary, + spawnUpdaterProcess, + stagedUpdaterSupportsPrewrittenMarker +} from './updater-process' import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { createWakeIndicatorWindowController } from './wake-indicator-window' @@ -3012,8 +3016,20 @@ async function applyUpdates(opts = {}) { // the venv. By writing the marker ourselves the renderer's // waitForUpdateToFinish() gate sees a live update and parks instead. // The updater overwrites this with its own PID later; same format. - if (Number.isInteger(child.pid)) { + // + // SKIPPED for pre-#74782 staged updaters: those have no self-PID + // exclusion, so they read this very marker as a foreign live owner and + // abort with "Another Hermes update is already running (PID )" — + // an unbreakable loop, because the update that would replace the stale + // binary is the one being refused. Losing the anti-respawn hardening is + // strictly better than never updating again, and the updater still writes + // its own marker moments later. + if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) { writeUpdateMarker(HERMES_HOME, child.pid) + } else if (Number.isInteger(child.pid)) { + rememberLog( + `[updates] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim` + ) } rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`) @@ -3100,9 +3116,15 @@ async function handOffWindowsBootstrapRecovery(reason) { // Same marker pre-write as applyUpdates — see comment there. The recovery // hand-off has the same window where the renderer can respawn a backend - // before the updater writes its own marker. - if (Number.isInteger(child.pid)) { + // before the updater writes its own marker, and the same stale-updater + // exclusion: a pre-#74782 binary would refuse its own pre-written claim and + // strand the very recovery meant to heal the install. + if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) { writeUpdateMarker(HERMES_HOME, child.pid) + } else if (Number.isInteger(child.pid)) { + rememberLog( + `[bootstrap] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim` + ) } rememberLog( diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index 96f48eb9ff016..e781fe3efa3ec 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -4,7 +4,65 @@ import path from 'node:path' import { test } from 'vitest' -import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process' +import { + MARKER_SELF_ADOPT_EPOCH_MS, + resolveStagedUpdaterBinary, + spawnUpdaterProcess, + stagedUpdaterSupportsPrewrittenMarker +} from './updater-process' + +const DAY_MS = 24 * 60 * 60 * 1000 + +test('stagedUpdaterSupportsPrewrittenMarker rejects installers predating the self-adopt fix', () => { + // The real-world trap: an installer staged at first install months ago, never + // refreshed because copy_self_to_hermes_home no-ops during --update. + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS + }), + false + ) +}) + +test('stagedUpdaterSupportsPrewrittenMarker accepts installers from the fix onward', () => { + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS + }), + true + ) + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS + 30 * DAY_MS + }), + true + ) +}) + +test('stagedUpdaterSupportsPrewrittenMarker treats an unreadable mtime as unsupported', () => { + // Bias toward the path that can always make progress: a skipped pre-write + // loses anti-respawn hardening, a wedged updater can never update again. + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => null + }), + false + ) +}) + +test('resolveStagedUpdaterBinary still returns a stale staged updater on Windows', () => { + // Staleness gates only the marker PRE-WRITE, never the hand-off itself: + // the stale binary is the only updater these users have, and it works fine + // once it is allowed to write its own claim. + assert.equal( + resolveStagedUpdaterBinary('C:\\Hermes', { + fileExists: () => true, + isWindows: true, + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS + }), + path.join('C:\\Hermes', 'hermes-setup.exe') + ) +}) test('spawnUpdaterProcess hides the updater console and detaches the child on Windows', () => { const calls: Array<{ args: string[]; command: string; options: SpawnOptions }> = [] diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index 56352da3e8534..97b1d9651afa8 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -12,8 +12,20 @@ export interface UpdaterChild { export interface ResolveStagedUpdaterBinaryDeps { isWindows?: boolean fileExists?: (candidate: string) => boolean + stagedMtimeMs?: (candidate: string) => number | null } +/** + * Staged installers older than this have no self-PID exclusion in + * `UpdateMarkerGuard::acquire` and will refuse an update whose marker was + * pre-written on their behalf. + * + * The self-adopt fix landed in #74782 / 160586ff8 (2026-07-30 17:57 +0700). + * We compare against the start of 2026-07-31 UTC so the boundary is + * unambiguous for binaries staged that same day. + */ +export const MARKER_SELF_ADOPT_EPOCH_MS = Date.UTC(2026, 6, 31) + function stagedFileExists(candidate: string): boolean { try { return statSync(candidate).isFile() @@ -22,6 +34,14 @@ function stagedFileExists(candidate: string): boolean { } } +function stagedFileMtimeMs(candidate: string): number | null { + try { + return statSync(candidate).mtimeMs + } catch { + return null + } +} + /** * Decide which staged installer binary — if any — may be handed an update. * @@ -61,6 +81,37 @@ export function resolveStagedUpdaterBinary( return fileExists(candidate) ? candidate : null } +/** + * True when the staged installer is new enough to survive a pre-written marker. + * + * `copy_self_to_hermes_home` deliberately no-ops during `--update` + * (apps/bootstrap-installer/src-tauri/src/paths.rs), so the binary staged by a + * user's ORIGINAL install orchestrates every later update — forever. Installers + * predating #74782 have no self-PID exclusion in `UpdateMarkerGuard::acquire`, + * so when the desktop pre-writes the marker naming that very updater, the + * updater reads its own claim as a foreign live owner and aborts with + * "Another Hermes update is already running (PID , started 1s ago)" — + * the observed infinite "Install didn't finish" loop. Skipping the pre-write + * for those binaries lets them acquire cleanly and run `hermes update`, which + * pulls the permanent fixes. See shouldPrewriteUpdateMarker. + * + * We cannot ask the binary its version without executing it, so use its mtime: + * the installer is written to HERMES_HOME at install/repair time, making mtime + * a faithful stamp of which installer generation produced it. + * + * Unreadable mtime counts as UNSUPPORTED — the pre-write is a best-effort + * hardening, while a wedged updater is unrecoverable, so we bias toward the + * path that can always make progress. + */ +export function stagedUpdaterSupportsPrewrittenMarker( + candidate: string, + deps: ResolveStagedUpdaterBinaryDeps = {} +): boolean { + const mtimeMs = (deps.stagedMtimeMs ?? stagedFileMtimeMs)(candidate) + + return typeof mtimeMs === 'number' && Number.isFinite(mtimeMs) && mtimeMs >= MARKER_SELF_ADOPT_EPOCH_MS +} + export interface SpawnUpdaterProcessDeps { isWindows?: boolean spawnProcess?: (command: string, args: string[], options: SpawnOptions) => UpdaterChild