fix(subagent): close terminal teardown races

This commit is contained in:
pku-xht
2026-08-04 20:26:45 +08:00
parent f96acad438
commit 451c2929ed
6 changed files with 135 additions and 30 deletions

View File

@@ -383,6 +383,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
let graceTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
let terminationStarted = false
let settled = false
// Failed spawns use pid -1 so signalling remains a no-op.
@@ -418,14 +419,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// child and must stay signalable, while a fully-dead tree (possible pid
// reuse) must not be re-signalled by a later tier.
const kill = (sig: NodeJS.Signals): void => {
/* v8 ignore next -- the exit monitor cancels the ordinary dead-tree timer;
/* v8 ignore next -- a successful consumer wait cancels the ordinary dead-tree timer;
this remains the timer/death race guard and cannot be staged deterministically. */
if (!treeAlive()) return
signalTree(platform, pid, sig, child, taskkill)
}
const terminate = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
if (terminationStarted) return
terminationStarted = true
if (!treeAlive()) return
kill('SIGTERM')
// The escalation must survive direct-child settlement — the leader dying
@@ -433,15 +435,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// kill() re-probes tree liveness before force-killing. It stays ref'd:
// the pending SIGKILL is a commitment, and a parent exiting before it
// fires would orphan a trapped survivor. Self-bounds at graceMs.
const timer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') })
graceTimer = timer
// A very large configured grace must not pin the parent after TERM already
// removed the whole tree. Keep the escalation armed only while its target
// remains alive; direct-child settlement alone is not sufficient.
void waitForExit().then(() => {
timer.cancel()
graceTimer = undefined
})
graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') })
}
// The caller owns timeout classification; this layer only reacts to abort.
@@ -497,6 +491,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
if (signal?.aborted) return false
await sleepTick()
}
// Successful observation is the permanent no-more-signals boundary. It
// also cancels an escalation whose TERM tier already removed the tree.
terminationStarted = true
graceTimer?.cancel()
graceTimer = undefined
return true
}

View File

@@ -668,7 +668,6 @@ describe('coverage seams', () => {
it('terminate() after the tree died delivers no termination signal', async () => {
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
running.terminate()
@@ -677,6 +676,21 @@ describe('coverage seams', () => {
} finally {
spy.mockRestore()
}
await running.waitForExit()
})
it('repeated terminate after exit never probes or signals a reused process group', async () => {
const running = spawnSubprocess(spec('sleep 60'))
running.terminate()
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
running.terminate()
expect(spy).not.toHaveBeenCalled()
} finally {
spy.mockRestore()
}
})
it('waitForExit on a failed spawn reports exited immediately', async () => {