fix(subprocess): stop escalation after tree exit
This commit is contained in:
@@ -384,6 +384,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
|
|
||||||
let graceTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
|
let graceTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
|
||||||
let terminationStarted = false
|
let terminationStarted = false
|
||||||
|
let treeExitObserved = false
|
||||||
|
let treeExitObservation: Promise<void> | undefined
|
||||||
let settled = false
|
let settled = false
|
||||||
|
|
||||||
// Failed spawns use pid -1 so signalling remains a no-op.
|
// Failed spawns use pid -1 so signalling remains a no-op.
|
||||||
@@ -391,6 +393,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
|
|
||||||
/** Whether the detached tree's root (or POSIX group) is still alive. */
|
/** Whether the detached tree's root (or POSIX group) is still alive. */
|
||||||
const treeAlive = (): boolean => {
|
const treeAlive = (): boolean => {
|
||||||
|
/* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
|
||||||
|
the guard is the final defense against probing an id after its tree was confirmed absent. */
|
||||||
|
if (treeExitObserved) return false
|
||||||
if (pid <= 0) return false
|
if (pid <= 0) return false
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
// Windows has no group-liveness probe; the direct child's exit is the
|
// Windows has no group-liveness probe; the direct child's exit is the
|
||||||
@@ -413,13 +418,29 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start or reuse the handle's single whole-tree exit observer. The first
|
||||||
|
* confirmed absence is a permanent no-more-signals boundary: it cancels a
|
||||||
|
* pending escalation before this process-group id can be reused.
|
||||||
|
*/
|
||||||
|
const observeTreeExit = (): Promise<void> => {
|
||||||
|
treeExitObservation ??= (async () => {
|
||||||
|
while (treeAlive()) await sleepTick()
|
||||||
|
treeExitObserved = true
|
||||||
|
terminationStarted = true
|
||||||
|
graceTimer?.cancel()
|
||||||
|
graceTimer = undefined
|
||||||
|
})()
|
||||||
|
return treeExitObservation
|
||||||
|
}
|
||||||
|
|
||||||
// The escalation's tier primitive (not on the handle — terminate() is the
|
// The escalation's tier primitive (not on the handle — terminate() is the
|
||||||
// only consumer-facing termination verb). Guards on TREE liveness, not
|
// only consumer-facing termination verb). Guards on TREE liveness, not
|
||||||
// outcome settlement: a TERM-trapping helper can outlive the settled direct
|
// outcome settlement: a TERM-trapping helper can outlive the settled direct
|
||||||
// child and must stay signalable, while a fully-dead tree (possible pid
|
// child and must stay signalable, while a fully-dead tree (possible pid
|
||||||
// reuse) must not be re-signalled by a later tier.
|
// reuse) must not be re-signalled by a later tier.
|
||||||
const kill = (sig: NodeJS.Signals): void => {
|
const kill = (sig: NodeJS.Signals): void => {
|
||||||
/* v8 ignore next -- a successful consumer wait cancels the ordinary dead-tree timer;
|
/* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
|
||||||
this remains the timer/death race guard and cannot be staged deterministically. */
|
this remains the timer/death race guard and cannot be staged deterministically. */
|
||||||
if (!treeAlive()) return
|
if (!treeAlive()) return
|
||||||
signalTree(platform, pid, sig, child, taskkill)
|
signalTree(platform, pid, sig, child, taskkill)
|
||||||
@@ -428,7 +449,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
const terminate = (): void => {
|
const terminate = (): void => {
|
||||||
if (terminationStarted) return
|
if (terminationStarted) return
|
||||||
terminationStarted = true
|
terminationStarted = true
|
||||||
if (!treeAlive()) return
|
// Observe from the first termination tier onward, even when inherited
|
||||||
|
// pipes delay `done` and no consumer has begun its own teardown wait.
|
||||||
|
void observeTreeExit()
|
||||||
|
if (treeExitObserved) return
|
||||||
kill('SIGTERM')
|
kill('SIGTERM')
|
||||||
// The escalation must survive direct-child settlement — the leader dying
|
// The escalation must survive direct-child settlement — the leader dying
|
||||||
// does not mean the tree died — so settle does not clear this timer, and
|
// does not mean the tree died — so settle does not clear this timer, and
|
||||||
@@ -487,16 +511,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
})
|
})
|
||||||
|
|
||||||
const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
|
const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
|
||||||
while (treeAlive()) {
|
const observed = observeTreeExit()
|
||||||
if (signal?.aborted) return false
|
if (treeExitObserved) return true
|
||||||
await sleepTick()
|
if (signal?.aborted) return false
|
||||||
|
if (signal === undefined) {
|
||||||
|
await observed
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const aborted = Promise.withResolvers<boolean>()
|
||||||
|
const onAbort = (): void => { aborted.resolve(false) }
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
/* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
|
||||||
|
if (signal.aborted) onAbort()
|
||||||
|
try {
|
||||||
|
return await Promise.race([observed.then(() => true), aborted.promise])
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
}
|
}
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -205,6 +205,54 @@ describe('spawnSubprocess', () => {
|
|||||||
await expect(running.waitForExit()).resolves.toBe(true)
|
await expect(running.waitForExit()).resolves.toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => {
|
||||||
|
const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`)
|
||||||
|
const graceMs = 160
|
||||||
|
const childScript = `
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const { writeFileSync } = require('node:fs')
|
||||||
|
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', 1, 2],
|
||||||
|
})
|
||||||
|
writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid))
|
||||||
|
helper.unref()
|
||||||
|
setInterval(() => {}, 1000)
|
||||||
|
`
|
||||||
|
const running = spawnSubprocess({
|
||||||
|
...spec('unused', { graceMs }),
|
||||||
|
argv: [process.execPath, '-e', childScript],
|
||||||
|
})
|
||||||
|
const helper = await waitForPidFile(pidFile)
|
||||||
|
const realKill: typeof process.kill = process.kill.bind(process)
|
||||||
|
let termAt = 0
|
||||||
|
let forceSignals = 0
|
||||||
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||||
|
if (target !== -running.pid) return realKill(target, signal)
|
||||||
|
if (signal === 'SIGTERM') {
|
||||||
|
termAt = Date.now()
|
||||||
|
return realKill(target, signal)
|
||||||
|
}
|
||||||
|
if (signal === 'SIGKILL') {
|
||||||
|
forceSignals += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (signal === 0 && termAt !== 0 && Date.now() - termAt < graceMs / 2) {
|
||||||
|
throw Object.assign(new Error('simulated vanished process group'), { code: 'ESRCH' })
|
||||||
|
}
|
||||||
|
return true // Before TERM the original group is live; later its pgid is reused.
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
running.terminate()
|
||||||
|
await running.done
|
||||||
|
expect(forceSignals).toBe(0)
|
||||||
|
} finally {
|
||||||
|
killSpy.mockRestore()
|
||||||
|
process.kill(helper, 'SIGKILL')
|
||||||
|
await waitGone(helper)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('terminates the whole process group (grandchildren die too)', async () => {
|
it('terminates the whole process group (grandchildren die too)', async () => {
|
||||||
// The subshell writes the sleep's pid then waits on it; terminating the
|
// The subshell writes the sleep's pid then waits on it; terminating the
|
||||||
// group must take the sleep down with bash.
|
// group must take the sleep down with bash.
|
||||||
|
|||||||
Reference in New Issue
Block a user