/** * In-app update mutual-exclusion marker (#50238). * * The Tauri updater writes HERMES_HOME/.hermes-update-in-progress for the whole * duration of an `--update` run (see apps/bootstrap-installer/src-tauri/src/ * update.rs `UpdateMarkerGuard`). The marker body is two lines: the updater's * pid and the unix-seconds it started. * * Why: if the user relaunches the desktop mid-update — the window vanished with * no progress and looks crashed — a fresh instance must NOT spawn its own local * backend. That backend re-locks the venv shim, the updater's straggler cleanup * (`force_kill_other_hermes`, taskkill /IM hermes.exe) kills it, the launch * fails with the 45s "backend didn't come up" timeout, and the user relaunches * into the same trap — an infinite respawn/kill loop. The desktop gates local * backend startup on this marker and parks until the update finishes. * * This module holds the PURE, side-effect-light logic (path, pid liveness, * parse + staleness) so it is unit-testable without booting Electron. The * polling/boot-progress wrapper lives in main.ts where the boot-progress and * log sinks are. */ import fs from 'fs' import path from 'path' // Even with a live-looking PID, never treat a marker older than this as a live // update. A full update (git pull + pip + desktop rebuild) is minutes, not tens // of minutes; past this the marker is almost certainly stale (e.g. the OS // recycled the pid onto an unrelated process), so the gate self-heals. export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 export function markerPath(hermesHome) { return path.join(hermesHome, '.hermes-update-in-progress') } // True only if a host process with this pid is currently alive. Signal 0 does // not deliver a signal — it just probes existence/permission. ESRCH => dead; // EPERM => alive but owned by another user (still "alive" for our purposes). // Injectable `kill` keeps it unit-testable. export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { if (!Number.isInteger(pid) || pid <= 0) { return false } try { kill(pid, 0) return true } catch (err) { return Boolean(err && err.code === 'EPERM') } } /** * Read + interpret the marker. * * Returns `{ pid, ageMs }` only when an update is GENUINELY still running * (parseable pid that is alive, within the age ceiling). Returns `null` for * every "no live update" case — absent, unreadable, malformed, dead pid, or * past the ceiling — and, when a stale marker file exists, deletes it so it * cannot strand future launches. * * Pure-ish: file I/O against the given path, plus an injectable pid probe and * clock for tests. */ export function readLiveUpdateMarker( hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS }: { now?: () => number maxAgeMs?: number kill?: typeof process.kill } = {} ) { const file = markerPath(hermesHome) let raw try { raw = fs.readFileSync(file, 'utf8') } catch { return null // absent or unreadable => no live update } const [pidLine, startedLine] = String(raw).split('\n') const pid = Number.parseInt((pidLine || '').trim(), 10) const startedAt = Number.parseInt((startedLine || '').trim(), 10) const ageMs = Number.isFinite(startedAt) ? now() - startedAt * 1000 : Infinity const alive = Number.isInteger(pid) && isPidAlive(pid, kill) if (!alive || ageMs > maxAgeMs) { try { fs.unlinkSync(file) } catch { void 0 } return null } return { pid, ageMs } } /** * Write the update-in-progress marker *from the desktop* before handing off * to the detached updater. * * The Tauri-based hermes-setup.exe takes several seconds to initialise its * window and reach the Rust `run_update` entry point where it writes the * marker itself. During that gap the desktop's `app.quit()` teardown kills * the backend child, the renderer's WebSocket drops, and the renderer * immediately calls `ensureBackend()` → `waitForUpdateToFinish()`. Because * the updater hasn't written the marker yet, the gate sees no live update * and spawns a *new* backend — which re-locks `.pyd` files in the venv. * When the updater finally reaches the venv-rebuild stage it finds those * files locked and the update bricks. * * Fix: the desktop writes the marker itself, using the spawned updater's * PID, immediately after `spawn()`. The updater's `UpdateMarkerGuard` will * later adopt it or another hand-off stage may replace the PID. A live * holder's original timestamp is preserved across those transfers so retries * cannot keep resetting the 20-minute stale ceiling. When the updater finishes * it deletes the marker as before. * If the updater never starts (spawn failure) the marker still contains a * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. */ export function writeUpdateMarker( hermesHome, pid, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS, startedAt }: { now?: () => number maxAgeMs?: number kill?: typeof process.kill startedAt?: number } = {} ) { const file = markerPath(hermesHome) const nowMs = now() const owner = readLiveUpdateMarker(hermesHome, { kill, maxAgeMs, now: () => nowMs }) const acquiredAt = typeof startedAt === 'number' && Number.isInteger(startedAt) ? startedAt : owner ? Math.floor((nowMs - owner.ageMs) / 1000) : Math.floor(nowMs / 1000) try { fs.writeFileSync(file, `${pid}\n${acquiredAt}\n`, 'utf8') } catch { // Best-effort: if we can't write the marker, proceed anyway. The // updater will write its own when it reaches run_update. } } /** * Whether a NEW updater hand-off must be refused because a different, * already-alive updater currently owns the marker (#75778). * * `writeUpdateMarker` unconditionally overwrites the marker file. Called * before every hand-off with no conflict check, a user who clicks "Update" * again while a prior updater is still parked mid-run (e.g. "waiting for * Hermes to exit…") clobbers that still-running updater's claim: the * retry's pre-write now names the NEW child, so the OLD process — alive * and mutating the checkout — is no longer recorded as the owner. A second * live updater can then run over the same tree unrecorded, the exact * two-updaters-at-once hazard `UpdateMarkerGuard` in the Rust updater * exists to prevent (apps/bootstrap-installer/src-tauri/src/update.rs). * * Returns the live foreign owner (with a ready-to-show message) when the * hand-off must be refused, or `null` when it's safe to spawn — no marker, * or the existing one is stale/dead and self-heals via * `readLiveUpdateMarker`. */ export function updateHandoffConflict( hermesHome, opts: { now?: () => number maxAgeMs?: number kill?: typeof process.kill } = {} ) { const owner = readLiveUpdateMarker(hermesHome, opts) if (!owner) { return null } const mins = Math.floor(owner.ageMs / 60_000) const secs = Math.floor((owner.ageMs % 60_000) / 1000) const elapsed = mins > 0 ? `${mins}m ${secs}s` : `${secs}s` return { pid: owner.pid, ageMs: owner.ageMs, message: `An update is already running (PID ${owner.pid}, started ${elapsed} ago). Wait for it to finish, then try again.` } }