fix(subagent): bound forced child termination

Observe signal errors and bound the final forced-exit edge with disposeGraceMs so a refused or ineffective SIGKILL cannot leave disposal pending forever.

Apply the confirmation bound on POSIX and Windows, remove listeners and timers on every outcome, and update the ACP consumer contract plus the generated config catalog.

Cover emitted signal errors, synchronous kill exceptions, refused termination, and accepted termination that never reports exit.
This commit is contained in:
Tianyi Cui
2026-07-19 12:19:33 +08:00
parent 1b496854c2
commit 4625195968
7 changed files with 118 additions and 23 deletions
@@ -51,16 +51,6 @@ export function spawnFailure(child: ChildProcess): Promise<Error> {
})
}
/**
* Resolve once the child process exits (any code/signal); immediate if it is
* already gone.
* @param child - the child process to await.
*/
function waitForExit(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
}
/**
* Race the child's exit against a timer. Neither outcome leaves anything
* behind on the child: the exit listener is removed on timeout and the timer
@@ -104,10 +94,49 @@ export interface DisposeLadderGraces {
* headroom.
*/
disposeEofGraceMs: number
/** POSIX tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */
/**
* Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after
* `SIGKILL`; Windows applies it after the direct forced termination.
*/
disposeGraceMs: number
}
/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
let accepted = false
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
child.off('exit', onExit)
child.off('error', onError)
}
const settle = (complete: () => void): void => {
if (settled) return
settled = true
cleanup()
complete()
}
const onExit = (): void => { settle(resolve) }
const onError = (error: Error): void => { settle(() => { reject(error) }) }
child.once('exit', onExit)
child.once('error', onError)
const timer = setTimeout(() => {
const disposition = accepted ? 'accepted' : 'refused'
settle(() => {
reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
})
}, ms).unref()
try {
accepted = child.kill('SIGKILL')
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
} catch (error: unknown) {
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
}
})
}
/**
* Tear a child process down to quiescence, resolving only after exit: close stdin and allow
* cooperative flush, then use the host's graceful and forced termination semantics. POSIX
@@ -117,6 +146,8 @@ export interface DisposeLadderGraces {
* @param child - the child process to tear down.
* @param graces - the two grace periods, from the consuming plugin's Config.
* @param platform - the host platform, injectable for unit coverage.
* @throws When forced termination errors or the child does not report exit within
* `disposeGraceMs`.
*/
export async function disposeChildProcess(
child: ChildProcess,
@@ -133,9 +164,8 @@ export async function disposeChildProcess(
child.kill('SIGTERM')
if (await exitsWithin(child, graces.disposeGraceMs)) return
}
// 3. Force-kill and await the (now-certain) exit.
child.kill('SIGKILL')
await waitForExit(child)
// 3. Force-kill and await a bounded exit edge.
await forceTerminateWithin(child, graces.disposeGraceMs)
}
/**