401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
|
|
import { test, vi } from 'vitest'
|
|
|
|
import {
|
|
backendCommandMatches,
|
|
type BackendIdentity,
|
|
createBackendOwnership,
|
|
createBackendShutdownCoordinator,
|
|
parseBackendOwnership
|
|
} from './backend-ownership'
|
|
|
|
function memoryStore(initial = '') {
|
|
let contents = initial
|
|
|
|
return {
|
|
read: () => contents,
|
|
value: () => contents,
|
|
write: (next: string) => {
|
|
contents = next
|
|
}
|
|
}
|
|
}
|
|
|
|
function identity(overrides: Partial<BackendIdentity> = {}): BackendIdentity {
|
|
return {
|
|
nonce: 'nonce-42',
|
|
pid: 42,
|
|
profile: 'default',
|
|
startMarker: 'os-start-123',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function ownershipEntry(overrides: Partial<BackendIdentity> = {}) {
|
|
return { command: 'hermes serve --port 0', ...identity(overrides) }
|
|
}
|
|
|
|
function stored(entries: object[]): string {
|
|
return JSON.stringify({ backends: entries })
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve!: () => void
|
|
|
|
const promise = new Promise<void>(done => {
|
|
resolve = done
|
|
})
|
|
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function createOwnership(store = memoryStore(), overrides: Partial<Parameters<typeof createBackendOwnership>[0]> = {}) {
|
|
return createBackendOwnership({
|
|
matchesIdentity: async () => true,
|
|
// Unknown parent (no record / legacy) preserves the pre-parent behaviour.
|
|
matchesParent: async () => undefined,
|
|
stop: () => {},
|
|
store,
|
|
...overrides
|
|
})
|
|
}
|
|
|
|
test('claim persists the caller-supplied exact identity before resolving', async () => {
|
|
const store = memoryStore()
|
|
const ownership = createOwnership(store)
|
|
const claim = ownershipEntry()
|
|
|
|
assert.deepEqual(await ownership.claim(claim), claim)
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [claim])
|
|
})
|
|
|
|
test('incomplete claims and persisted records are rejected', async () => {
|
|
const store = memoryStore(
|
|
stored([
|
|
ownershipEntry(),
|
|
{ ...ownershipEntry({ pid: 43 }), startMarker: '' },
|
|
{ ...ownershipEntry({ pid: 44 }), nonce: undefined },
|
|
{ ...ownershipEntry({ pid: 45 }), profile: undefined }
|
|
])
|
|
)
|
|
|
|
const ownership = createOwnership(store)
|
|
|
|
await assert.rejects(ownership.claim({ ...ownershipEntry(), startMarker: '' }), /complete process identity/)
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [ownershipEntry()])
|
|
})
|
|
|
|
test('failed persistence awaits asynchronous cleanup of the exact identity', async () => {
|
|
const cleanup = deferred()
|
|
const stop = vi.fn(() => cleanup.promise)
|
|
const expected = new Error('disk full')
|
|
const claim = ownershipEntry({ pid: 43 })
|
|
|
|
const ownership = createOwnership(memoryStore(), {
|
|
stop,
|
|
store: {
|
|
read: () => null,
|
|
write: () => {
|
|
throw expected
|
|
}
|
|
}
|
|
})
|
|
|
|
let rejected = false
|
|
|
|
const result = ownership.claim(claim).catch(error => {
|
|
rejected = true
|
|
throw error
|
|
})
|
|
|
|
await Promise.resolve()
|
|
assert.equal(rejected, false)
|
|
assert.deepEqual(stop.mock.calls, [[claim]])
|
|
|
|
cleanup.resolve()
|
|
await assert.rejects(result, expected)
|
|
assert.equal(rejected, true)
|
|
})
|
|
|
|
test('startup reap drops a confirmed PID reuse mismatch without stopping it', async () => {
|
|
const entry = ownershipEntry()
|
|
const store = memoryStore(stored([entry]))
|
|
const matchesIdentity = vi.fn(async () => false)
|
|
const stop = vi.fn()
|
|
const ownership = createOwnership(store, { matchesIdentity, stop })
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.deepEqual(matchesIdentity.mock.calls, [[entry]])
|
|
assert.equal(stop.mock.calls.length, 0)
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [])
|
|
})
|
|
|
|
test('startup reap preserves records when exact identity probing is uncertain or fails', async () => {
|
|
const uncertain = ownershipEntry({ pid: 50, nonce: 'uncertain' })
|
|
const failed = ownershipEntry({ pid: 51, nonce: 'failed' })
|
|
const store = memoryStore(stored([uncertain, failed]))
|
|
const stop = vi.fn()
|
|
|
|
const ownership = createOwnership(store, {
|
|
matchesIdentity: async entry => {
|
|
if (entry.pid === failed.pid) {
|
|
throw new Error('process table unavailable')
|
|
}
|
|
|
|
return undefined
|
|
},
|
|
stop
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.equal(stop.mock.calls.length, 0)
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [uncertain, failed])
|
|
})
|
|
|
|
test('startup reap passes the full confirmed identity to stop', async () => {
|
|
const entry = ownershipEntry({ pid: 52 })
|
|
const store = memoryStore(stored([entry]))
|
|
const stop = vi.fn()
|
|
const ownership = createOwnership(store, { stop })
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [52])
|
|
assert.deepEqual(stop.mock.calls, [[entry]])
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [])
|
|
})
|
|
|
|
test('startup reap preserves failed stops for the next launch', async () => {
|
|
const entry = ownershipEntry({ pid: 53 })
|
|
const store = memoryStore(stored([entry]))
|
|
|
|
const ownership = createOwnership(store, {
|
|
stop: () => {
|
|
throw new Error('permission denied')
|
|
}
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
|
})
|
|
|
|
test('startup reap stops at the deadline and preserves the unprocessed records', async () => {
|
|
const first = ownershipEntry({ pid: 60 })
|
|
const second = ownershipEntry({ pid: 61 })
|
|
const store = memoryStore(stored([first, second]))
|
|
const stop = vi.fn()
|
|
|
|
const ownership = createOwnership(store, {
|
|
// Each probe is slow enough to blow a 1ms budget after the first entry.
|
|
matchesIdentity: async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
return false
|
|
},
|
|
stop,
|
|
reapDeadlineMs: 1
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
// The first entry was processed (dropped); the second was preserved for the
|
|
// next launch instead of stalling boot on a slow identity probe.
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [second])
|
|
})
|
|
|
|
test('startup reap preserves would-be-reaped records when the budget runs out', async () => {
|
|
const first = ownershipEntry({ pid: 62 })
|
|
const second = ownershipEntry({ pid: 63 })
|
|
const store = memoryStore(stored([first, second]))
|
|
const stop = vi.fn()
|
|
|
|
const ownership = createOwnership(store, {
|
|
matchesIdentity: async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
return true
|
|
},
|
|
stop,
|
|
reapDeadlineMs: 1
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [62])
|
|
// The second would have been reaped too, but the budget ran out — it is
|
|
// preserved so a later launch retries it.
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [second])
|
|
})
|
|
|
|
test('startup reap never stops a backend whose parent Electron is still alive', async () => {
|
|
const entry = { ...ownershipEntry({ pid: 54 }), parentPid: 100, parentStartMarker: 'os-start-parent' }
|
|
const store = memoryStore(stored([entry]))
|
|
const stop = vi.fn()
|
|
|
|
const ownership = createOwnership(store, {
|
|
matchesParent: async () => true,
|
|
stop
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.equal(stop.mock.calls.length, 0)
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
|
})
|
|
|
|
test('startup reap still reaps a backend whose parent is gone or reused', async () => {
|
|
const gone = { ...ownershipEntry({ pid: 55 }), parentPid: 200, parentStartMarker: 'os-start-dead' }
|
|
const reused = { ...ownershipEntry({ pid: 56 }), parentPid: 201, parentStartMarker: 'os-start-old' }
|
|
const store = memoryStore(stored([gone, reused]))
|
|
const stop = vi.fn()
|
|
|
|
const ownership = createOwnership(store, {
|
|
matchesParent: async entry => (entry.parentPid === 201 ? true : false),
|
|
stop
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [55])
|
|
assert.deepEqual(stop.mock.calls, [[gone]])
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [reused])
|
|
})
|
|
|
|
test('startup reap preserves a record when parent liveness probing fails', async () => {
|
|
const entry = { ...ownershipEntry({ pid: 57 }), parentPid: 300, parentStartMarker: 'os-start-parent' }
|
|
const store = memoryStore(stored([entry]))
|
|
const stop = vi.fn()
|
|
|
|
const ownership = createOwnership(store, {
|
|
matchesParent: async () => {
|
|
throw new Error('process table unavailable')
|
|
},
|
|
stop
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.equal(stop.mock.calls.length, 0)
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
|
})
|
|
|
|
test('claim persists the parent identity so a later reap can see it', async () => {
|
|
const store = memoryStore()
|
|
const ownership = createOwnership(store)
|
|
const claim = { ...ownershipEntry(), parentPid: 42, parentStartMarker: 'os-start-parent' }
|
|
|
|
const entry = await ownership.claim(claim)
|
|
|
|
assert.equal(entry.parentPid, 42)
|
|
assert.equal(entry.parentStartMarker, 'os-start-parent')
|
|
assert.deepEqual(parseBackendOwnership(store.value())[0].parentPid, 42)
|
|
assert.deepEqual(parseBackendOwnership(store.value())[0].parentStartMarker, 'os-start-parent')
|
|
})
|
|
|
|
test('release removes only the exact identity rather than every record for its PID', () => {
|
|
const oldProcess = ownershipEntry({ nonce: 'old', startMarker: 'start-old' })
|
|
const reusedPid = ownershipEntry({ nonce: 'new', startMarker: 'start-new' })
|
|
const store = memoryStore(stored([oldProcess, reusedPid]))
|
|
const ownership = createOwnership(store)
|
|
|
|
ownership.release(oldProcess)
|
|
|
|
assert.deepEqual(parseBackendOwnership(store.value()), [reusedPid])
|
|
})
|
|
|
|
test('backend identity check matches only serve and dashboard invocation shapes', () => {
|
|
assert.equal(backendCommandMatches('/venv/bin/hermes serve --port 0'), true)
|
|
assert.equal(backendCommandMatches('python -m hermes_cli.main dashboard --no-open'), true)
|
|
assert.equal(backendCommandMatches('/venv/bin/hermes --profile work serve --port 0'), true)
|
|
assert.equal(backendCommandMatches('"C:\\Hermes Runtime\\hermes.exe" dashboard --no-open'), true)
|
|
assert.equal(backendCommandMatches('hermes chat --query serve'), false)
|
|
assert.equal(backendCommandMatches('unrelated dashboard'), false)
|
|
})
|
|
|
|
test('shutdown coordinator returns one promise and awaits teardown exactly once', async () => {
|
|
const completion = deferred()
|
|
const teardown = vi.fn(() => completion.promise)
|
|
const coordinator = createBackendShutdownCoordinator(teardown)
|
|
|
|
const first = coordinator.run()
|
|
const second = coordinator.run()
|
|
|
|
assert.equal(first, second)
|
|
assert.equal(coordinator.hasStarted(), true)
|
|
await Promise.resolve()
|
|
assert.equal(teardown.mock.calls.length, 1)
|
|
|
|
let finished = false
|
|
first.then(() => {
|
|
finished = true
|
|
})
|
|
await Promise.resolve()
|
|
assert.equal(finished, false)
|
|
|
|
completion.resolve()
|
|
await second
|
|
assert.equal(finished, true)
|
|
assert.equal(coordinator.run(), first)
|
|
})
|
|
|
|
// #89298: a corrupt ownership file must never be silently rewritten as [] —
|
|
// that permanently erases the only record of still-running backends. The reap
|
|
// sweep quarantines the file and skips; a later healthy write recreates it.
|
|
test('reapOrphans on a corrupt file quarantines and does not rewrite', async () => {
|
|
let contents = '{ this is not json'
|
|
let quarantined = 0
|
|
const writes: string[] = []
|
|
const stopped: number[] = []
|
|
|
|
const store = {
|
|
read: () => contents,
|
|
value: () => contents,
|
|
write: (next: string) => {
|
|
writes.push(next)
|
|
contents = next
|
|
},
|
|
quarantine: () => {
|
|
quarantined += 1
|
|
}
|
|
}
|
|
|
|
const ownership = createOwnership(store, {
|
|
stop: identityArg => {
|
|
stopped.push(identityArg.pid)
|
|
}
|
|
})
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.equal(quarantined, 1)
|
|
assert.deepEqual(writes, [])
|
|
assert.deepEqual(stopped, [])
|
|
})
|
|
|
|
test('reapOrphans on a corrupt file without a quarantine hook still skips the rewrite', async () => {
|
|
const writes: string[] = []
|
|
|
|
const store = {
|
|
read: () => 'garbage{{{',
|
|
value: () => 'garbage{{{',
|
|
write: (next: string) => {
|
|
writes.push(next)
|
|
}
|
|
}
|
|
|
|
const ownership = createOwnership(store)
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
assert.deepEqual(writes, [])
|
|
})
|
|
|
|
test('an empty or missing ownership file is NOT corrupt — reap sweeps normally', async () => {
|
|
const writes: string[] = []
|
|
|
|
const store = {
|
|
read: () => '',
|
|
value: () => '',
|
|
write: (next: string) => {
|
|
writes.push(next)
|
|
},
|
|
quarantine: () => assert.fail('empty file must not be quarantined')
|
|
}
|
|
|
|
const ownership = createOwnership(store)
|
|
|
|
assert.deepEqual(await ownership.reapOrphans(), [])
|
|
// Empty roster: rewriting [] is harmless and keeps the legacy behavior.
|
|
assert.equal(writes.length, 1)
|
|
})
|