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.
169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
import crypto from 'node:crypto'
|
|
|
|
function sshConfigFingerprint(scope, config) {
|
|
const parts = [
|
|
scope,
|
|
config.host,
|
|
config.user,
|
|
config.port,
|
|
config.keyPath,
|
|
config.remoteHermesPath,
|
|
config.remoteProfile,
|
|
config.effectiveConfigFingerprint
|
|
]
|
|
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(JSON.stringify(parts.map(value => value ?? '')))
|
|
.digest('hex')
|
|
}
|
|
|
|
function createBootstrapCoordinator() {
|
|
const active = new Set<any>()
|
|
const pending = new Map<string, any>()
|
|
const generations = new Map<string, number>()
|
|
const drains = new Map<string, Promise<void>>()
|
|
let shutdownRequested = false
|
|
|
|
function start(scope, fingerprint, run, metadata = null) {
|
|
if (shutdownRequested) {
|
|
const error: any = new Error('SSH bootstrap was cancelled because Desktop is quitting.')
|
|
error.kind = 'superseded'
|
|
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
const current = pending.get(scope)
|
|
|
|
if (current?.fingerprint === fingerprint) {
|
|
return current.promise
|
|
}
|
|
|
|
current?.controller.abort()
|
|
|
|
const generation = (generations.get(scope) || 0) + 1
|
|
generations.set(scope, generation)
|
|
const controller = new AbortController()
|
|
const forceCleanups = new Set<() => any>()
|
|
|
|
const lease = {
|
|
signal: controller.signal,
|
|
onForceCleanup(cleanup) {
|
|
forceCleanups.add(cleanup)
|
|
|
|
return () => forceCleanups.delete(cleanup)
|
|
},
|
|
isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation,
|
|
assertCurrent() {
|
|
if (!this.isCurrent()) {
|
|
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
|
error.kind = 'superseded'
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
const drain = drains.get(scope) || Promise.resolve()
|
|
const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain
|
|
const entry: any = { controller, fingerprint, forceCleanups, generation, metadata, promise: null, scope }
|
|
|
|
const promise = predecessor
|
|
.then(() => {
|
|
lease.assertCurrent()
|
|
|
|
return run(lease)
|
|
})
|
|
.finally(() => {
|
|
forceCleanups.clear()
|
|
active.delete(entry)
|
|
|
|
if (pending.get(scope)?.generation === generation) {
|
|
pending.delete(scope)
|
|
}
|
|
})
|
|
|
|
entry.promise = promise
|
|
active.add(entry)
|
|
pending.set(scope, entry)
|
|
|
|
return promise
|
|
}
|
|
|
|
function cancel(scope) {
|
|
pending.get(scope)?.controller.abort()
|
|
}
|
|
|
|
async function cancelAndWait(scope, afterCancel?: () => Promise<void>) {
|
|
let release
|
|
|
|
const own = new Promise<void>(resolve => {
|
|
release = resolve
|
|
})
|
|
// Compose with any drain already in flight for this scope (a pool stop
|
|
// still tearing down SSH while a connection apply cancels the same scope):
|
|
// start() must wait for every active teardown, and the map entry is
|
|
// cleared only once the composed barrier settles.
|
|
const prior = drains.get(scope)
|
|
// Drain barriers never reject, so chaining is equivalent to allSettled.
|
|
const barrier = prior ? prior.then(() => own) : own
|
|
|
|
drains.set(scope, barrier)
|
|
void barrier.finally(() => {
|
|
if (drains.get(scope) === barrier) {
|
|
drains.delete(scope)
|
|
}
|
|
})
|
|
const entries = [...active].filter(entry => entry.scope === scope)
|
|
|
|
for (const entry of entries) {
|
|
entry.controller.abort()
|
|
}
|
|
|
|
try {
|
|
// Cancellation alone only invalidates the lease; it does not interrupt a
|
|
// child process currently blocked in SSH connect/config resolution. Close
|
|
// registered resources first so rollback can settle promptly while the
|
|
// drain barrier still prevents stale resurrection.
|
|
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
|
|
await Promise.allSettled(entries.map(entry => entry.promise))
|
|
// Keep the drain up through caller teardown (SSH keepalive / tunnel)
|
|
// so a replacement start() cannot publish before the old scope is gone.
|
|
if (afterCancel) {
|
|
await afterCancel()
|
|
}
|
|
} finally {
|
|
release()
|
|
}
|
|
|
|
// "Cancel and wait" means the scope is drained: callers tear down SSH right
|
|
// after this returns, so wait for the composed barrier, not just our own.
|
|
await barrier
|
|
}
|
|
|
|
function cancelAll() {
|
|
for (const entry of active) {
|
|
entry.controller.abort()
|
|
}
|
|
}
|
|
|
|
function shutdown() {
|
|
// Terminal: reconnect callbacks during a prevented first quit must not
|
|
// spawn a replacement serve --isolated for an app that is already leaving.
|
|
shutdownRequested = true
|
|
cancelAll()
|
|
}
|
|
|
|
async function forceCleanupAll() {
|
|
const cleanups = [...active].flatMap(entry => [...entry.forceCleanups])
|
|
await Promise.allSettled(cleanups.map(cleanup => cleanup()))
|
|
}
|
|
|
|
function promises() {
|
|
return [...active].map(entry => entry.promise)
|
|
}
|
|
|
|
return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, shutdown, start }
|
|
}
|
|
|
|
export { createBootstrapCoordinator, sshConfigFingerprint }
|