Files
hermes-agent/apps/desktop/electron/update-gate.ts
Teknium 74b5854ebd fix(desktop): a held update blocks boot behind a recoverable screen, never a timer
Review R8 D3. A verified `held` verdict (the update exited, a process it
started still holds the checkout lock) used to stop blocking after 7200 s and
let the local backend start while that process could still be writing the
install. The gate now never opens on its own while an update hold lasts.

- update-marker-gate.ts: no held ceiling. Each blocking state carries a stable
  hold id (sha256 of the marker body) and `blocking` (held/busy/error, not a
  live pid). `allowStartOverHold(id)` is the only way through while the hold
  lasts, scoped to that exact marker body and process-wide (pool waits honour
  it; a new body blocks again). `requestHoldRecheck()` makes every probe
  re-ask the script helper on its next poll. The marker is never touched.
- main.ts: past a 5 s grace the boot shows the blocked screen (boot progress
  `updateHold`): what holds the install, since when, last check. IPC from the
  primary window only: recheck, quit, and start-anyway, which needs
  `confirmed: true` and the hold id on screen, logs a USER OVERRIDE line and
  flushes desktop.log. The non-blocking "started anyway" dialog is gone. Pool
  waits log the hold once and wait for it to end or the primary's override.
- Renderer: UpdateHoldOverlay with Check again, Quit Hermes, Open logs and a
  two-step Start anyway (confirmation copy says what can break). en/de/fr/es.

Tests: the ceiling cells are replaced by two invariants (held stays closed 10x
past the old ceiling and Retry re-asks at once; a confirmed Start anyway opens
only its hold, for every wait, and a new body blocks again). Both RED on
8855353024e, GREEN here. A real-process probe (SIGKILLed UpdateLock updater,
custody child holding the real checkout flock, real posix.sh) with the clock
jumped 3 h: the old gate opens, this one stays closed until Start anyway.

(cherry picked from commit 324a489259ab90e1f7158dcc2fa7d1c8a2da20f8)
2026-10-06 05:17:45 -07:00

158 lines
5.3 KiB
TypeScript

'use strict'
import { runBackendStartStep } from './backend-start-cancellation'
/**
* update-gate.ts
*
* Pure, dependency-injected gate that parks local backend spawns while an
* in-app update is running (#73822, #50238).
*
* Three independent signals mean "an update owns the local runtime 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, and
* - the successful detached hand-off state, which remains true while this
* Desktop is waiting to quit after the wrapper has handed control away.
*
* The marker alone is NOT enough (#73822): `applyUpdates` stops its backend
* early (`releaseBackendLock`) before committing the hand-off. The renderer
* reconnects after the WebSocket closes; a marker-only gate can spawn a new
* backend on the runtime being replaced. Consulting the flag closes that
* window. On success 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.
*
* A finished, failed update (`logs/update_receipts/latest.json` says `failed`)
* never outranks a live marker: `latest.json` is written only at finalize, so a
* retry after a failed update still reads `failed` while the new update runs
* (desktop V2). The marker's owner liveness (pid + creation time, see
* update-marker.ts) is the only signal for "an update is running"; a crashed
* updater's marker reads dead and opens the gate by itself.
*/
export type UpdateGateReason = 'marker' | 'update-in-flight' | 'handoff' | null
export interface UpdateGateDeps {
/** True when a live on-disk update marker exists (see update-marker.ts). */
hasLiveMarker: () => boolean | Promise<boolean>
/** True while this process is inside applyUpdates()' critical section. */
isUpdateInFlight: () => boolean
/** True after a detached updater hand-off is viable and this Desktop will quit. */
isHandoffActive: () => boolean
}
/** Why the gate is closed right now, or null when it is open. */
export async function updateGateReason(deps: UpdateGateDeps): Promise<UpdateGateReason> {
if (await deps.hasLiveMarker()) {
return 'marker'
}
if (deps.isUpdateInFlight()) {
return 'update-in-flight'
}
if (deps.isHandoffActive()) {
return 'handoff'
}
return null
}
export type UpdateClearanceOutcome = 'clear' | 'finished' | 'timeout' | 'cancelled'
export interface WaitForUpdateClearanceOptions {
signal?: AbortSignal
isCancelled?: () => boolean
timeoutMs: number
pollMs: number
/** Invoked once per poll while parked (boot progress / logging). */
onWaitTick?: (reason: Exclude<UpdateGateReason, null>, waitedMs: number) => void | Promise<void>
now?: () => number
sleep?: (ms: number) => Promise<void>
}
/**
* Park until no update signal remains.
*
* 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 an in-process signal (in-flight / hand-off) still set. A LIVE
* marker has no deadline here: owner liveness decides when an update is over
* (C1 rule 3) — booting into a half-replaced runtime at minute 21 of a slow
* Windows update is exactly the failure the gate exists for. The marker reader
* still bounds the wait: an owner whose creation time cannot be read stops
* reading live at the 20-minute ceiling (A1), so a reused pid never parks
* boot forever. A dead marker whose checkout a leftover process still holds
* (`held`) never ages out: the caller shows a blocked boot screen whose only
* way through while the hold lasts is a confirmed, logged Start anyway
* (update-marker-gate.ts, review R8 D3).
*/
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)))
const isCancelled = () => options.signal?.aborted || options.isCancelled?.()
if (isCancelled()) {
return 'cancelled'
}
let reason = await updateGateReason(deps)
if (!reason) {
return 'clear'
}
const startedAt = now()
const deadline = startedAt + options.timeoutMs
while (reason && (reason === 'marker' || now() < deadline)) {
if (isCancelled()) {
return 'cancelled'
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
if (options.onWaitTick) {
const tickReason = reason
await runBackendStartStep(options.signal, () => options.onWaitTick!(tickReason, now() - startedAt))
}
if (isCancelled()) {
return 'cancelled'
}
await runBackendStartStep(options.signal, () =>
options.sleep
? sleep(options.pollMs)
: new Promise<void>(resolve => {
timer = setTimeout(resolve, options.pollMs)
})
)
} catch (error) {
if (isCancelled()) {
return 'cancelled'
}
throw error
} finally {
clearTimeout(timer)
}
if (isCancelled()) {
return 'cancelled'
}
reason = await updateGateReason(deps)
}
return reason ? 'timeout' : 'finished'
}