Problem
A scope halted while exec is acquiring its child process can leave the child — and its whole process tree — running with no owner. The kill-teardown is never registered, so nothing ever terminates it; the orphan runs to completion.
This is the complement of #228: there teardown waits too long on the close-settled join; here teardown never arms at all.
Root cause
In createPosixProcess (src/exec/posix.ts, 0.8.1), the OS process is created first and the ensure that signals it is registered last, with several suspension points in between:
spawnProcess(...) // child exists from here on
yield* fromReadable(stdout) // ← a halt landing on any of
yield* fromReadable(stderr) // these suspension points
yield* spawn(stdout pump) // discards the remaining
yield* spawn(stderr pump) // instructions...
yield* spawn(trapError)
yield* spawn(close watcher)
yield* ensure(kill + join) // ← ...so this never registers
A halt delivers generator.return() at the pending suspension, so everything after it — including the ensure registration — is discarded. The window is narrow (a few scheduler ticks), which is why it only surfaces under load.
It also cannot be guarded from the caller's side: the window is inside the single yield* exec(...), so when the halt lands there the Process value never returns and the caller has no pid to clean up with.
PR #230 does not change this — the child is still spawned early and the ensure still registers at the end of the eval body (with one more spawn, the exit watcher, ahead of it).
Observed impact
taras/executable.md#417: under a concurrent verification battery, a halted deno bundle invocation was orphaned mid-acquisition; the orphan (with its esbuild service child) ran to completion and wrote its output file after the caller's earlier-registered cleanup had already removed it, failing a leaves-nothing-behind test. A 60-iteration CPU-saturated soak sweeping halt delays 0–29ms reproduces the leak class within a few iterations; an ensure-first spawn stays clean across all 60 (taras/executable.md#423 has the details and the downstream workaround, scripts/lib/contained-run.ts).
Suggested fix
Register the termination teardown before the process exists and create the process with no suspension point in between — e.g. a slot the ensure reads:
let child: ChildProcess | undefined;
yield* ensure(function* () {
if (!child?.pid) return;
// signal the group, then join
});
child = spawnProcess(command, args, { ... }); // same synchronous continuation
A halt then lands either before the spawn (slot empty, nothing to clean up) or after the teardown is armed — never between. The stream/pump wiring can keep its current position; only the spawn/ensure pair needs to be inseparable.
Related observations in the same teardown
- The
catch (_e) {} around process.kill(-pid, "SIGTERM") also swallows the stdio-EOF join, so a failed kill returns teardown while the tree may still be winding down.
- On macOS,
killpg on a process group whose members are all zombies fails with EPERM, not ESRCH (verified on Darwin 25.5.0) — worth knowing for any errno-based "already dead" classification.
Problem
A scope halted while
execis acquiring its child process can leave the child — and its whole process tree — running with no owner. The kill-teardown is never registered, so nothing ever terminates it; the orphan runs to completion.This is the complement of #228: there teardown waits too long on the close-settled join; here teardown never arms at all.
Root cause
In
createPosixProcess(src/exec/posix.ts, 0.8.1), the OS process is created first and theensurethat signals it is registered last, with several suspension points in between:A halt delivers
generator.return()at the pending suspension, so everything after it — including theensureregistration — is discarded. The window is narrow (a few scheduler ticks), which is why it only surfaces under load.It also cannot be guarded from the caller's side: the window is inside the single
yield* exec(...), so when the halt lands there theProcessvalue never returns and the caller has no pid to clean up with.PR #230 does not change this — the child is still spawned early and the
ensurestill registers at the end of the eval body (with one morespawn, theexitwatcher, ahead of it).Observed impact
taras/executable.md#417: under a concurrent verification battery, a halted
deno bundleinvocation was orphaned mid-acquisition; the orphan (with its esbuild service child) ran to completion and wrote its output file after the caller's earlier-registered cleanup had already removed it, failing a leaves-nothing-behind test. A 60-iteration CPU-saturated soak sweeping halt delays 0–29ms reproduces the leak class within a few iterations; an ensure-first spawn stays clean across all 60 (taras/executable.md#423 has the details and the downstream workaround,scripts/lib/contained-run.ts).Suggested fix
Register the termination teardown before the process exists and create the process with no suspension point in between — e.g. a slot the
ensurereads:A halt then lands either before the spawn (slot empty, nothing to clean up) or after the teardown is armed — never between. The stream/pump wiring can keep its current position; only the spawn/ensure pair needs to be inseparable.
Related observations in the same teardown
catch (_e) {}aroundprocess.kill(-pid, "SIGTERM")also swallows the stdio-EOF join, so a failed kill returns teardown while the tree may still be winding down.killpgon a process group whose members are all zombies fails withEPERM, notESRCH(verified on Darwin 25.5.0) — worth knowing for any errno-based "already dead" classification.