hermes-agent/apps/desktop/electron/update-gate.ts
Teknium d60a2eb3b4 fix(desktop): gate backend respawns on updateInFlight, not just the update marker (#73822)
On Windows, applyUpdates kills its own backend (releaseBackendLock)
BEFORE the venv-blocker preflight but only writes the on-disk update
marker AFTER the scan. Killing the backend drops the renderer's
WebSocket; the renderer reconnects within ~1s and the marker-only
waitForUpdateToFinish gate happily spawns a fresh 'hermes serve' inside
the update's own critical section. scanVenvBlockers then finds that
brand-new process and aborts with 'another Hermes process is using this
installation' — a different PID on every attempt, so Desktop self-update
can never succeed.

Fix: extract the gate into update-gate.ts (pure, DI-testable) and make
it consult BOTH signals — the on-disk marker AND the in-process
updateInFlight flag. The success path writes the marker before the flag
clears in applyUpdates' finally, so there is no instant where both are
false and a waiter can slip through. Also gate spawnPoolBackend, which
previously had no waitForLocalStart at all — a background profile window
could respawn a pool backend during the same window with the identical
abort.

Tests: update-gate.test.ts covers the open gate, the flag-only window
(the #73822 shape), the flag→marker handoff with no gap, and timeout.
2026-07-28 23:53:25 -07:00

95 lines
3.1 KiB
TypeScript

'use strict'
/**
* update-gate.ts
*
* Pure, dependency-injected gate that parks local backend spawns while an
* in-app update is running (#73822, #50238).
*
* Two independent signals mean "an update owns the venv right now":
*
* - the on-disk marker (`HERMES_HOME/.hermes-update-in-progress`), written
* by the updater — and by the desktop itself just before hand-off — and
* - the in-process `updateInFlight` flag, true for the whole
* `applyUpdates()` critical section.
*
* The marker alone is NOT enough (#73822): `applyUpdates` kills its own
* backend early (`releaseBackendLock`) but only writes the marker AFTER the
* Windows venv-blocker scan. Killing the backend drops the renderer's
* WebSocket, the renderer reconnects within ~1s, and a marker-only gate
* happily spawns a fresh backend inside the update's own critical section —
* which `scanVenvBlockers` then reports as a blocker, aborting every update
* attempt forever. Consulting the flag closes that window. On the success
* path the marker is written BEFORE the flag clears in `applyUpdates`'
* `finally`, so there is no instant where both signals are false and a
* waiter could slip through mid-update.
*/
export type UpdateGateReason = 'marker' | 'update-in-flight' | null
export interface UpdateGateDeps {
/** True when a live on-disk update marker exists (see update-marker.ts). */
hasLiveMarker: () => boolean
/** True while this process is inside applyUpdates()' critical section. */
isUpdateInFlight: () => boolean
}
/** Why the gate is closed right now, or null when it is open. */
export function updateGateReason(deps: UpdateGateDeps): UpdateGateReason {
if (deps.hasLiveMarker()) {
return 'marker'
}
if (deps.isUpdateInFlight()) {
return 'update-in-flight'
}
return null
}
export type UpdateClearanceOutcome = 'clear' | 'finished' | 'timeout'
export interface WaitForUpdateClearanceOptions {
timeoutMs: number
pollMs: number
/** Invoked once per poll while parked (boot progress / logging). */
onWaitTick?: (reason: Exclude<UpdateGateReason, null>) => void | Promise<void>
now?: () => number
sleep?: (ms: number) => Promise<void>
}
/**
* Park until no update signal remains, or the deadline passes.
*
* Returns 'clear' when the gate was already open (no wait happened),
* 'finished' when it opened during the wait, and 'timeout' when the deadline
* expired with the gate still closed (callers proceed anyway — matching the
* long-standing marker-gate behavior, since a wedged updater must not brick
* the app forever).
*/
export async function waitForUpdateClearance(
deps: UpdateGateDeps,
options: WaitForUpdateClearanceOptions
): Promise<UpdateClearanceOutcome> {
const now = options.now || Date.now
const sleep = options.sleep || (ms => new Promise<void>(r => setTimeout(r, ms)))
let reason = updateGateReason(deps)
if (!reason) {
return 'clear'
}
const deadline = now() + options.timeoutMs
while (reason && now() < deadline) {
if (options.onWaitTick) {
await options.onWaitTick(reason)
}
await sleep(options.pollMs)
reason = updateGateReason(deps)
}
return reason ? 'timeout' : 'finished'
}