Files
hermes-agent/apps/desktop/electron/ssh-bootstrap-coordinator.ts
kshitijk4poor 9b199246e5 fix(desktop): cancelAndWait resolves only once the composed drain settles
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.
2026-09-14 20:22:49 +05:30

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 }