hermes-agent/apps/desktop/electron/update-gate.test.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

144 lines
4.2 KiB
TypeScript

/**
* Tests for electron/update-gate.ts — the update mutual-exclusion gate that
* parks local backend spawns while an in-app update is running.
*
* The regression this guards (#73822): applyUpdates kills its own backend
* BEFORE the Windows venv-blocker scan but writes the on-disk marker AFTER
* it. A marker-only gate therefore let the renderer's reconnect spawn a
* fresh backend inside the update's own critical section, which the scan
* reported as a blocker — aborting every Desktop update attempt on Windows.
* The gate must consult the in-process updateInFlight flag as well.
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { updateGateReason, waitForUpdateClearance } from './update-gate'
function deps(marker: boolean, inFlight: boolean) {
return {
hasLiveMarker: () => marker,
isUpdateInFlight: () => inFlight
}
}
// ---------------------------------------------------------------------------
// updateGateReason
// ---------------------------------------------------------------------------
test('gate open when neither marker nor flag is set', () => {
assert.equal(updateGateReason(deps(false, false)), null)
})
test('marker alone closes the gate', () => {
assert.equal(updateGateReason(deps(true, false)), 'marker')
})
test('updateInFlight alone closes the gate (#73822 — the pre-marker window)', () => {
assert.equal(updateGateReason(deps(false, true)), 'update-in-flight')
})
test('marker wins as the reported reason when both are set', () => {
assert.equal(updateGateReason(deps(true, true)), 'marker')
})
// ---------------------------------------------------------------------------
// waitForUpdateClearance
// ---------------------------------------------------------------------------
test('returns clear immediately without sleeping when the gate is open', async () => {
let slept = 0
const outcome = await waitForUpdateClearance(deps(false, false), {
pollMs: 10,
sleep: async () => {
slept += 1
},
timeoutMs: 1000
})
assert.equal(outcome, 'clear')
assert.equal(slept, 0)
})
test('parks on the in-flight flag and finishes when it clears', async () => {
// Simulates the #73822 sequence: the reconnect arrives while updateInFlight
// is true and no marker exists yet; the flag clears (abort path finally)
// and the waiter proceeds.
let inFlight = true
let ticks = 0
const outcome = await waitForUpdateClearance(
{ hasLiveMarker: () => false, isUpdateInFlight: () => inFlight },
{
onWaitTick: reason => {
ticks += 1
assert.equal(reason, 'update-in-flight')
if (ticks >= 3) {
inFlight = false
}
},
pollMs: 1,
sleep: async () => {},
timeoutMs: 10_000
}
)
assert.equal(outcome, 'finished')
assert.equal(ticks, 3)
})
test('parks across the flag→marker handoff without a gap', async () => {
// Success path: the marker is written (main.ts:2936) BEFORE applyUpdates'
// finally clears the flag, so a waiter that arrived during the scan stays
// parked through the transition instead of slipping through.
let inFlight = true
let marker = false
let ticks = 0
const reasons: string[] = []
const outcome = await waitForUpdateClearance(
{ hasLiveMarker: () => marker, isUpdateInFlight: () => inFlight },
{
onWaitTick: reason => {
ticks += 1
reasons.push(reason)
if (ticks === 2) {
marker = true // updater hand-off: marker written first…
}
if (ticks === 3) {
inFlight = false // …then the flag clears; marker still holds the gate
}
if (ticks === 5) {
marker = false // updater finished
}
},
pollMs: 1,
sleep: async () => {},
timeoutMs: 10_000
}
)
assert.equal(outcome, 'finished')
assert.deepEqual(reasons, ['update-in-flight', 'update-in-flight', 'marker', 'marker', 'marker'])
})
test('returns timeout when the gate never opens', async () => {
let clock = 0
const outcome = await waitForUpdateClearance(deps(true, false), {
now: () => clock,
pollMs: 10,
sleep: async ms => {
clock += ms
},
timeoutMs: 50
})
assert.equal(outcome, 'timeout')
})