Skip to content

process: halt during exec acquisition orphans the child — spawn precedes the kill-ensure registration #236

Description

@taras

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions