Files
hermes-agent/apps/desktop/electron/local-backend-lifecycle.test.ts
T
brooklyn! 8607d6a52a fix(desktop): retain and cancel owned backend lifecycle work
Track pending starts, pre-claim children, and teardown removed from routing. Bound cleanup and cancel setup/update waits while preserving settled remote descriptors.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>

Co-authored-by: ChanPark03 <parkchan0302@gmail.com>
2026-09-11 07:33:17 -05:00

181 lines
5.1 KiB
TypeScript

import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { test, vi } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { createLocalBackendLifecycle } from './local-backend-lifecycle'
import { runPrimaryBackendStartup } from './primary-backend-startup'
import { createQuitTeardownCoordinator } from './quit-teardown'
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
test('shutdown joins a removed start and prevents its late spawn', async () => {
const resume = deferred()
const spawn = vi.fn(() => ({}))
const lifecycle = createLocalBackendLifecycle({
stopChild: () => {},
waitForExit: async () => {},
cancelSetup: () => {}
})
const start = lifecycle.start(async () => {
await resume.promise
lifecycle.spawn(spawn)
})
void start.catch(() => {})
await Promise.resolve()
assert.equal(lifecycle.hasPending(), true)
const shutdown = lifecycle.shutdown()
resume.resolve()
await shutdown
await assert.rejects(start)
assert.equal(spawn.mock.calls.length, 0)
await assert.rejects(lifecycle.start(async () => {}))
})
test('shutdown retains a child before asynchronous claim and joins an earlier stop', async () => {
const exit = deferred()
const stopChild = vi.fn()
const lifecycle = createLocalBackendLifecycle({ stopChild, waitForExit: () => exit.promise, cancelSetup: () => {} })
const child = lifecycle.spawn(() => ({}))
const stopping = lifecycle.stop(child)
let done = false
const shutdown = lifecycle.shutdown().then(() => {
done = true
})
await Promise.resolve()
assert.equal(done, false)
assert.equal(stopChild.mock.calls.length, 1)
exit.resolve()
await Promise.all([shutdown, stopping])
})
test('shutdown is bounded even if startup ignores cancellation', async () => {
vi.useFakeTimers()
try {
const lifecycle = createLocalBackendLifecycle({
stopChild: () => {},
waitForExit: async () => {},
cancelSetup: () => {},
timeoutMs: 20
})
void lifecycle.start(() => new Promise(() => {}))
await Promise.resolve()
const shutdown = lifecycle.shutdown()
await vi.advanceTimersByTimeAsync(20)
await shutdown
assert.throws(() => lifecycle.spawn(() => ({})))
assert.equal(vi.getTimerCount(), 0)
} finally {
vi.useRealTimers()
}
})
test('the quit barrier cancels real first-run startup and waits for both local and SSH drains', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const localExit = deferred()
const sshExit = deferred()
const finalQuit = vi.fn()
const install = vi.fn(async () => ({}))
const lifecycle = createLocalBackendLifecycle({
stopChild: () => {},
waitForExit: () => localExit.promise,
cancelSetup: () => gate.resetForRetry()
})
lifecycle.spawn(() => ({}))
const startup = lifecycle.start(() =>
runPrimaryBackendStartup({
signal: lifecycle.signal,
resolveRemote: async () => null,
connectRemote: async () => ({}),
waitForLocalStart: async () => {},
prepareLocalBackend: () => ({ kind: 'bootstrap-needed' }),
waitForDecision: backend => gate.wait(backend),
ensureLocalRuntime: install
})
)
void startup.catch(() => {})
for (let i = 0; i < 30; i++) {
await Promise.resolve()
}
assert.equal(gate.hasWaiter(), true)
const quit = createQuitTeardownCoordinator(finalQuit)
assert.equal(
quit.begin([
{ run: () => lifecycle.shutdown(), waitForCompletion: lifecycle.hasPending() },
{ run: () => sshExit.promise, waitForCompletion: true }
]),
true
)
await assert.rejects(startup)
assert.equal(gate.hasWaiter(), false)
assert.equal(install.mock.calls.length, 0)
localExit.resolve()
await lifecycle.shutdown()
assert.equal(quit.begin([]), true)
assert.equal(finalQuit.mock.calls.length, 0)
sshExit.resolve()
for (let i = 0; i < 10; i++) {
await Promise.resolve()
}
assert.equal(finalQuit.mock.calls.length, 1)
assert.equal(quit.begin([]), false)
})
test('a child spawned before claim remains owned after the routing entry disappears', async () => {
const lifecycle = createLocalBackendLifecycle<ReturnType<typeof spawn>>({
stopChild: child => {
child.kill()
},
waitForExit: async child => {
if (child.exitCode === null && child.signalCode === null) {
await once(child, 'exit')
}
},
cancelSetup: () => {}
})
const child = lifecycle.spawn(() =>
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
)
child.once('exit', () => lifecycle.release(child))
try {
await once(child, 'spawn')
// No connection-state attachment or persisted claim has happened yet.
await lifecycle.shutdown()
assert.ok(child.exitCode !== null || child.signalCode !== null)
assert.equal(lifecycle.hasPending(), false)
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
await once(child, 'exit')
}
}
})