Every caller tears the SSH connection down right after `await cancelAndWait(scope)`, so resolving on this call's own barrier alone let a connection-apply teardown overlap the pool stop's still-running afterStop teardown for the same key. Wait for the composed barrier instead; chain the barriers (drain promises never reject, so this equals allSettled). The test now waits a macrotask and asserts that neither the apply nor the new bootstrap runs before the first teardown completes.
309 lines
8.8 KiB
TypeScript
309 lines
8.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
|
|
|
function deferred() {
|
|
let resolve
|
|
let reject
|
|
|
|
const promise = new Promise((ok, fail) => {
|
|
resolve = ok
|
|
reject = fail
|
|
})
|
|
|
|
return { promise, reject, resolve }
|
|
}
|
|
|
|
const config = { host: 'box', user: 'alice', port: 22, keyPath: '/key', remoteHermesPath: '/hermes' }
|
|
|
|
test('sshConfigFingerprint covers scope and every connection field', () => {
|
|
const base = sshConfigFingerprint('', config)
|
|
assert.equal(base, sshConfigFingerprint('', { ...config }))
|
|
|
|
for (const [field, value] of Object.entries({
|
|
host: 'other',
|
|
user: 'bob',
|
|
port: 2222,
|
|
keyPath: '/other',
|
|
remoteHermesPath: '/other-hermes',
|
|
remoteProfile: 'default',
|
|
effectiveConfigFingerprint: 'changed-config'
|
|
})) {
|
|
assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value }))
|
|
}
|
|
|
|
assert.notEqual(base, sshConfigFingerprint('profile', config))
|
|
})
|
|
|
|
test('same scope and fingerprint share one bootstrap', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const gate = deferred()
|
|
let runs = 0
|
|
|
|
const first = coordinator.start('', 'same', async () => {
|
|
runs++
|
|
|
|
return gate.promise
|
|
})
|
|
|
|
const second = coordinator.start('', 'same', async () => {
|
|
runs++
|
|
|
|
return 'wrong'
|
|
})
|
|
|
|
assert.equal(first, second)
|
|
gate.resolve('done')
|
|
assert.equal(await second, 'done')
|
|
assert.equal(runs, 1)
|
|
})
|
|
|
|
test('changed fingerprint waits for old rollback before starting', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const gate = deferred()
|
|
const events: string[] = []
|
|
let oldLease
|
|
|
|
const oldPromise = coordinator.start('', 'old', async lease => {
|
|
oldLease = lease
|
|
events.push('old-start')
|
|
await gate.promise
|
|
events.push('old-rollback')
|
|
lease.assertCurrent()
|
|
})
|
|
|
|
await Promise.resolve()
|
|
|
|
const newPromise = coordinator.start('', 'new', async lease => {
|
|
events.push('new-start')
|
|
lease.assertCurrent()
|
|
|
|
return 'new'
|
|
})
|
|
|
|
assert.equal(oldLease.signal.aborted, true)
|
|
await Promise.resolve()
|
|
assert.deepEqual(events, ['old-start'])
|
|
gate.resolve()
|
|
await assert.rejects(oldPromise, (error: any) => error.kind === 'superseded')
|
|
assert.equal(await newPromise, 'new')
|
|
assert.deepEqual(events, ['old-start', 'old-rollback', 'new-start'])
|
|
})
|
|
|
|
test('forceCleanupAll runs registered pending resource cleanup', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const gate = deferred()
|
|
let cleaned = 0
|
|
|
|
const promise = coordinator.start('', 'x', async lease => {
|
|
lease.onForceCleanup(async () => {
|
|
cleaned++
|
|
})
|
|
await gate.promise
|
|
})
|
|
|
|
await Promise.resolve()
|
|
await coordinator.forceCleanupAll()
|
|
assert.equal(cleaned, 1)
|
|
gate.resolve()
|
|
await promise
|
|
})
|
|
|
|
test('shutdown cancels active bootstraps and permanently rejects respawn attempts', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const gate = deferred()
|
|
|
|
const active = coordinator.start('primary', 'old', async lease => {
|
|
await gate.promise
|
|
lease.assertCurrent()
|
|
})
|
|
|
|
coordinator.shutdown()
|
|
gate.resolve()
|
|
|
|
await assert.rejects(active, (error: any) => error.kind === 'superseded')
|
|
let started = 0
|
|
await assert.rejects(
|
|
coordinator.start('primary', 'new', async () => {
|
|
started += 1
|
|
}),
|
|
(error: any) => error.kind === 'superseded'
|
|
)
|
|
assert.equal(started, 0)
|
|
})
|
|
|
|
test('cancelAll invalidates every pending scope and exposes promises for quit', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const gates = [deferred(), deferred()]
|
|
|
|
const promises = gates.map((gate, index) =>
|
|
coordinator.start(String(index), 'x', async lease => {
|
|
await gate.promise
|
|
lease.assertCurrent()
|
|
})
|
|
)
|
|
|
|
assert.equal(coordinator.promises().length, 2)
|
|
coordinator.cancelAll()
|
|
gates.forEach(gate => gate.resolve())
|
|
const results = await Promise.allSettled(promises)
|
|
assert.ok(results.every(result => result.status === 'rejected' && (result.reason as any).kind === 'superseded'))
|
|
})
|
|
|
|
test('cancelAndWait drains only the requested scope', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const firstGate = deferred()
|
|
const secondGate = deferred()
|
|
|
|
const first = coordinator.start('first', 'x', async lease => {
|
|
await firstGate.promise
|
|
lease.assertCurrent()
|
|
})
|
|
|
|
const second = coordinator.start('second', 'x', async lease => {
|
|
await secondGate.promise
|
|
lease.assertCurrent()
|
|
|
|
return 'second'
|
|
})
|
|
|
|
await Promise.resolve()
|
|
let drained = false
|
|
|
|
const drain = coordinator.cancelAndWait('first').then(() => {
|
|
drained = true
|
|
})
|
|
|
|
await Promise.resolve()
|
|
assert.equal(drained, false)
|
|
firstGate.resolve()
|
|
await drain
|
|
await assert.rejects(first, (error: any) => error.kind === 'superseded')
|
|
assert.equal(coordinator.pending.has('second'), true)
|
|
secondGate.resolve()
|
|
assert.equal(await second, 'second')
|
|
})
|
|
|
|
test('cancelAndWait force-cleans pending resources before awaiting rollback', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const gate = deferred()
|
|
let cleaned = 0
|
|
|
|
const pending = coordinator.start('scope', 'x', async lease => {
|
|
lease.onForceCleanup(async () => {
|
|
cleaned++
|
|
})
|
|
await gate.promise
|
|
lease.assertCurrent()
|
|
})
|
|
|
|
await Promise.resolve()
|
|
const drain = coordinator.cancelAndWait('scope')
|
|
await Promise.resolve()
|
|
gate.resolve()
|
|
await drain
|
|
await assert.rejects(pending, (error: any) => error.kind === 'superseded')
|
|
assert.equal(cleaned, 1)
|
|
})
|
|
|
|
test('cancelAndWait keeps the drain up through afterCancel teardown', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const events: string[] = []
|
|
let releaseAfter: (() => void) | undefined
|
|
const afterGate = new Promise<void>(resolve => {
|
|
releaseAfter = resolve
|
|
})
|
|
let teardownStarted: (() => void) | undefined
|
|
const started = new Promise<void>(resolve => {
|
|
teardownStarted = resolve
|
|
})
|
|
|
|
const drain = coordinator.cancelAndWait('scope', async () => {
|
|
events.push('teardown-start')
|
|
teardownStarted?.()
|
|
await afterGate
|
|
events.push('teardown-done')
|
|
})
|
|
|
|
await started
|
|
const next = coordinator.start('scope', 'new', async () => {
|
|
events.push('new-start')
|
|
|
|
return 'new'
|
|
})
|
|
|
|
await Promise.resolve()
|
|
assert.deepEqual(events, ['teardown-start'])
|
|
releaseAfter?.()
|
|
await drain
|
|
assert.equal(await next, 'new')
|
|
assert.deepEqual(events, ['teardown-start', 'teardown-done', 'new-start'])
|
|
})
|
|
|
|
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
|
|
const coordinator = createBootstrapCoordinator()
|
|
const oldGate = deferred()
|
|
const events: string[] = []
|
|
|
|
const old = coordinator.start('scope', 'old', async lease => {
|
|
events.push('old-start')
|
|
await oldGate.promise
|
|
lease.assertCurrent()
|
|
})
|
|
|
|
await Promise.resolve()
|
|
const drain = coordinator.cancelAndWait('scope')
|
|
|
|
const next = coordinator.start('scope', 'new', async () => {
|
|
events.push('new-start')
|
|
|
|
return 'new'
|
|
})
|
|
|
|
await Promise.resolve()
|
|
assert.deepEqual(events, ['old-start'])
|
|
oldGate.resolve()
|
|
await drain
|
|
await assert.rejects(old, (error: any) => error.kind === 'superseded')
|
|
assert.equal(await next, 'new')
|
|
assert.deepEqual(events, ['old-start', 'new-start'])
|
|
})
|
|
|
|
test('a second cancelAndWait on the same scope composes with the teardown still in flight', async () => {
|
|
// Pool stop is blocked in SSH teardown; a connection apply cancels the same
|
|
// scope with no bootstrap left to drain. The apply's drain must not replace
|
|
// and clear the barrier, or start() runs before the first teardown finishes.
|
|
const coordinator = createBootstrapCoordinator()
|
|
const events: string[] = []
|
|
const teardownGate = deferred()
|
|
const teardownStarted = deferred()
|
|
|
|
const poolStop = coordinator.cancelAndWait('scope', async () => {
|
|
events.push('teardown-start')
|
|
teardownStarted.resolve()
|
|
await teardownGate.promise
|
|
events.push('teardown-done')
|
|
})
|
|
|
|
await teardownStarted.promise
|
|
const apply = coordinator.cancelAndWait('scope').then(() => events.push('apply-drained'))
|
|
const next = coordinator.start('scope', 'new', async () => {
|
|
events.push('new-start')
|
|
|
|
return 'new'
|
|
})
|
|
|
|
// A macrotask, not a microtask tick: nothing may run before the first teardown finishes.
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
assert.deepEqual(events, ['teardown-start'])
|
|
teardownGate.resolve()
|
|
await Promise.all([poolStop, apply])
|
|
assert.equal(await next, 'new')
|
|
// Both the apply and the new bootstrap wake on the same drained barrier; only
|
|
// their position after teardown-done is the contract.
|
|
assert.deepEqual(events.slice(0, 2), ['teardown-start', 'teardown-done'])
|
|
assert.deepEqual(events.slice(2).sort(), ['apply-drained', 'new-start'])
|
|
})
|