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.
144 lines
4.2 KiB
TypeScript
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')
|
|
})
|