mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
95 lines
3.1 KiB
TypeScript
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'
|
|
}
|