Files
hermes-agent/apps/desktop/electron/pool-stop.ts
T
kshitijk4poor 39ef1c1f3d refactor(desktop): keepalive redials with capped backoff; staleness derived, not counted
A dead tunnel was redialled every 2 s until the scope was torn down;
delays now double from the base to a 30 s cap and reset on open. The
generation counter duplicated what the entry map and entry.socket
already say (stop() removes the entry, connect() replaces the socket),
so abandonIfStale reads those instead. afterStop's unused entry
parameter is dropped.
2026-09-14 19:56:15 +05:30

100 lines
3.4 KiB
TypeScript

/**
* pool-stop.ts
*
* Bounded, deduplicated teardown for pooled profile backends.
*
* Idle reaping, LRU eviction, profile deletion, and app quit can all ask to
* stop the same pooled backend, and historically each caller SIGTERM'd the
* child and immediately deleted the pool entry. A child that did not exit
* promptly lost its only handle and survived detached under PID 1 — a live
* installation accumulated 41 orphaned profile backends this way.
*
* createPoolStopper() gives every caller the same contract instead:
* - the pool entry is evicted immediately (no router hands out a dying
* backend), but the stop promise keeps the process handle until the
* bounded SIGTERM -> SIGKILL escalation in waitForExit resolves;
* - concurrent stop requests for one key share the single in-flight stop;
* - spawn paths can await inFlight(key) so a fresh child never overlaps a
* dying one on the same HERMES_HOME.
*
* Extracted into a dependency-free module (same pattern as backend-child.ts /
* pool-eviction.ts) so the dedup and handle-retention semantics are asserted
* directly instead of grepping main.ts source text.
*/
export interface PoolStopEntry {
process?: unknown
}
export interface PoolStopperDeps {
/** The live backend pool. Entries are evicted synchronously on stop. */
pool: Map<string, PoolStopEntry>
/** Signal the child (tree/group kill per platform). Synchronous. */
stopChild: (child: unknown) => void
/** Bounded wait: resolves when the child exits, escalating to SIGKILL. */
waitForExit: (child: unknown) => Promise<void>
/**
* Extra per-key work that must finish before a replacement may spawn.
* Held on the same in-flight promise as child exit (SSH teardown, etc.).
*/
afterStop?: (key: string) => Promise<void>
}
export interface PoolStopper {
/** The in-flight stop for a key, if any — await before respawning it. */
inFlight: (key: string) => Promise<void> | undefined
/** Whether the pool has a local child or an already-evicted stop in flight. */
hasPending: () => boolean
/** Stop one pooled backend; concurrent calls share the same promise. */
stop: (key: string) => Promise<void>
/** Stop every pooled backend and join stops already in flight. */
stopAll: () => Promise<void>
}
export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
const stops = new Map<string, Promise<void>>()
function stop(key: string): Promise<void> {
const inFlight = stops.get(key)
if (inFlight) {
return inFlight
}
const entry = deps.pool.get(key)
if (!entry) {
return Promise.resolve()
}
// Evict now: routing must not hand out a dying backend. The stop promise
// below retains the process handle until the bounded exit completes.
deps.pool.delete(key)
const stopping = (async () => {
deps.stopChild(entry.process)
await deps.waitForExit(entry.process)
if (deps.afterStop) {
await deps.afterStop(key)
}
})().finally(() => {
stops.delete(key)
})
stops.set(key, stopping)
return stopping
}
return {
inFlight: key => stops.get(key),
hasPending: () => stops.size > 0 || [...deps.pool.values()].some(entry => entry.process != null),
stop,
stopAll: async () => {
const currentStops = [...deps.pool.keys()].map(stop)
await Promise.all(new Set([...stops.values(), ...currentStops]))
}
}
}