Files
hermes-agent/apps/desktop/electron/pool-stop.ts
brooklyn! 768e5c2af3 fix(desktop): serialize idle reclamation and release slots on real exit
Serve foreground queue promotions and concurrent opens without interrupting backend work. Park retired scopes and keep failed-stop generations fenced until exit.

Co-authored-by: Nikolas de Hor <nikolasdehor79@gmail.com>
Co-authored-by: Mark Sheppard <mark@orchardstreetpress.com>
Co-authored-by: chelsealong <chelsealong@126.com>
2026-09-17 00:34:50 -05:00

117 lines
3.9 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.
*/
import type { WaitableChild } from './backend-child'
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 clear = () => {
if (stops.get(key) === stopping) {
stops.delete(key)
}
}
const stopping = (async () => {
deps.stopChild(entry.process)
await deps.waitForExit(entry.process)
if (deps.afterStop) {
await deps.afterStop(key)
}
})().then(clear, error => {
const child = entry.process as Partial<WaitableChild> | undefined
if (child?.once && child.exitCode === null && child.signalCode === null) {
// Keep rejecting reuse of this profile while the old child is alive.
// The real late exit, not the teardown deadline, releases this fence.
child.once('exit', clear)
} else {
clear()
}
throw error
})
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]))
}
}
}