Files
hermes-agent/apps/desktop/electron/update-gate.test.ts
Teknium 2f31792e82 fix: a failed update's live-looking v1 marker no longer parks boot 20 min
a94916f0313 dropped the failed-receipt fast exit (#122206) because a retry
still reads `failed` while it runs. At this head `hermes update` still writes
v1 markers (pid + started, no `ct:`), which read live on a bare alive pid for
up to V1_MAX_AGE_S, so a recycled pid on Windows parked boot ~20 min again
if this landed before the lock PR.

The gate now treats a live v1 marker (no `ct:`, no delegate) as over when
latest.json records `failed` with a finished_at after the marker's start
second: that update already ended. A retry's marker starts after the old
receipt and a v2 owner's creation time tells a reused pid apart, so both
still wait. The marker is left in place. Also drops the update-gate test
that set the removed `hasFailedReceipt` dep, which nothing read (review
K132345-v1-before-lock, K132345 P3 dead-failed-receipt-test).
2026-10-06 05:17:45 -07:00

204 lines
5.6 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 stops its own backend
* before committing the hand-off. A marker-only gate lets the renderer's
* reconnect spawn a fresh backend on the runtime being replaced.
* The gate must consult the in-process updateInFlight flag and the successful
* detached hand-off state as well.
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { updateGateReason, waitForUpdateClearance } from './update-gate'
function deps(marker: boolean, inFlight: boolean, handoffActive = false) {
return {
hasLiveMarker: () => marker,
isUpdateInFlight: () => inFlight,
isHandoffActive: () => handoffActive
}
}
// ---------------------------------------------------------------------------
// updateGateReason
// ---------------------------------------------------------------------------
test('gate open when neither marker nor flag is set', async () => {
assert.equal(await updateGateReason(deps(false, false)), null)
})
test('marker alone closes the gate', async () => {
assert.equal(await updateGateReason(deps(true, false)), 'marker')
})
test('updateInFlight alone closes the gate (#73822 — the pre-marker window)', async () => {
assert.equal(await updateGateReason(deps(false, true)), 'update-in-flight')
})
test('marker wins as the reported reason when both are set', async () => {
assert.equal(await updateGateReason(deps(true, true)), 'marker')
})
test('handoff remains closed after the detached wrapper exits', async () => {
let handoffActive = true
let ticks = 0
const outcome = await waitForUpdateClearance(
{
hasLiveMarker: () => false,
isUpdateInFlight: () => false,
isHandoffActive: () => handoffActive
},
{
onWaitTick: reason => {
ticks += 1
assert.equal(reason, 'handoff')
if (ticks === 2) {
handoffActive = false
}
},
pollMs: 1,
sleep: async () => {},
timeoutMs: 10_000
}
)
assert.equal(outcome, 'finished')
assert.equal(ticks, 2)
})
// ---------------------------------------------------------------------------
// 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, isHandoffActive: () => false },
{
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, isHandoffActive: () => false },
{
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('an in-process signal times out when it never clears', async () => {
let clock = 0
const outcome = await waitForUpdateClearance(deps(false, true), {
now: () => clock,
pollMs: 10,
sleep: async ms => {
clock += ms
},
timeoutMs: 50
})
assert.equal(outcome, 'timeout')
})
// A live marker owner is waited out, never aged out (C1 rule 3, desktop V3):
// the old 20-minute deadline booted a backend into a half-replaced runtime.
test('a live marker keeps parking past the deadline until its owner finishes', async () => {
let clock = 0
let marker = true
let ticks = 0
const outcome = await waitForUpdateClearance(
{ hasLiveMarker: async () => marker, isUpdateInFlight: () => false, isHandoffActive: () => false },
{
now: () => clock,
onWaitTick: () => {
ticks += 1
if (ticks === 50) {
marker = false
}
},
pollMs: 10,
sleep: async ms => {
clock += ms
},
timeoutMs: 50
}
)
assert.equal(outcome, 'finished')
assert.equal(ticks, 50, 'parked ten times past the deadline')
})