Stop leaking orphaned child processes — reaped even under SIGKILL, where no cleanup code can run.
npm install github:ettalin/subreapNot on npm yet, so install from GitHub. The import path is still
subreap.
import { spawnManaged } from 'subreap';
const server = spawnManaged('npx', ['-y', 'some-mcp-server'], { stdio: 'pipe' });
// ...an ordinary ChildProcess. Kill this process however you like;
// the server does not survive it.
await server.close();Every cleanup strategy in common use lives inside the parent process: an exit handler,
a signal handler, a finally block. SIGKILL runs none of them. There is no handler
to register and no flag to pass — the kernel removes the process without giving it a
chance to act.
Run the matrix yourself with npm run repro:
| spawned as | cleanup strategy | clean exit | SIGTERM | crash | SIGKILL |
|---|---|---|---|---|---|
| node -> server | no cleanup (control) | orphaned | orphaned | orphaned | orphaned |
| node -> server | child.kill() |
clean | clean | clean | orphaned |
| node -> server | detached + kill(-pid) |
clean | clean | clean | orphaned |
| node -> server | tree-kill walk |
clean | clean | clean | orphaned |
| node -> server | subreap (this repo) |
clean | clean | clean | clean |
| node -> npx shim -> server | no cleanup (control) | orphaned | orphaned | orphaned | orphaned |
| node -> npx shim -> server | child.kill() |
orphaned | orphaned | orphaned | orphaned |
| node -> npx shim -> server | detached + kill(-pid) |
clean | clean | clean | orphaned |
| node -> npx shim -> server | tree-kill walk |
clean | clean | clean | orphaned |
| node -> npx shim -> server | subreap (this repo) |
clean | clean | clean | clean |
| sh -c -> server | no cleanup (control) | orphaned | orphaned | orphaned | orphaned |
| sh -c -> server | child.kill() |
clean | clean | clean | orphaned |
| sh -c -> server | detached + kill(-pid) |
clean | clean | clean | orphaned |
| sh -c -> server | tree-kill walk |
clean | clean | clean | orphaned |
| sh -c -> server | subreap (this repo) |
clean | clean | clean | clean |
9 of 12 cleanup strategies leak under SIGKILL. 12 of 48 scenarios leak overall.
The no cleanup rows are controls; see Trusting the table below.
If the guarantee cannot live in the parent, it has to live somewhere that outlives it.
spawnManaged() puts each child in its own process group and registers it with a small
detached supervisor that holds one end of a pipe. When the parent dies — by any
means, including SIGKILL — the kernel closes that pipe, the supervisor reads EOF and
reaps every group it was watching.
The parent does not have to remember anything at exit, which is the whole point: the kernel does the notifying, so there is no code path that can fail to run.
Detection is EOF, not polling. That matters twice: there is no poll interval to lose a race against, and a recycled PID can never be mistaken for a live parent.
The operating systems each expose a piece of this and none expose all of it:
| platform | primitive | exposed by Node? |
|---|---|---|
| Linux | PR_SET_PDEATHSIG / PR_SET_CHILD_SUBREAPER |
no |
| Windows | Job Objects with KILL_ON_JOB_CLOSE |
no |
| macOS | nothing equivalent exists | n/a |
So subreap builds the guarantee out of a pipe, which every platform does have. No native dependencies on the portable path — a package that needs prebuilt binaries does not get adopted.
The first working version spawned a supervisor per managed child. Measured: 49.6 MB each, so a runtime with twenty MCP servers paid 992 MB — worse than the leak being prevented.
There is now a single shared supervisor per process:
| per-child supervisors | shared supervisor | |
|---|---|---|
| 20 managed children | 992 MB | 47.8 MB |
| amortised per child | 49.6 MB | 2.39 MB |
The shared cost is flat, so it keeps falling as you add servers. Call shutdown() when
your process is exiting and every child is already stopped.
Look at the node -> npx shim -> server rows. child.kill() leaks in all four death
modes, including a completely clean exit.
The PID you spawned is npx; the PID that matters is the node process it spawned. The
shim exits almost immediately, the real server is reparented, and the handle you hold now
refers to something already dead. You kill it successfully and nothing happens.
This is the mechanism behind openai/codex#26984, anthropics/claude-code#40667, anomalyco/opencode#15808 and upstash/context7#2542.
tree-kill is correct for what it does, and this harness confirms it: it handles the shim
case wherever the parent gets to run code. But it is a killer, not a lifetime guarantee —
it has to be called, and under SIGKILL nothing calls it. Its own README documents this.
The table is not a criticism of tree-kill; it is a demonstration that no
call-me-at-exit API can close this gap.
- A residual window exists. For the first managed child the target pid rides in the
supervisor's argv, so it is known at exec time. Later children are registered over the
pipe. Stress test: 12 trials x 4 children, parent
SIGKILLed in the same tick as the last spawn — 0 leaked of 48. The write reaches the kernel before the parent dies, and the supervisor drains buffered input before acting on EOF. Not zero-risk in theory; not reproducible in practice. - If the supervisor is killed on its own, its children are unprotected. There is no re-arm yet.
- Only macOS is verified. Linux runs the identical POSIX code path — process groups,
kill(-pid), stdin EOF — with no Linux-specific branch, so it is expected to behave the same, but it has not been executed there yet. Windows takes a genuinely different route (taskkill /T /F), because Job Objects are the correct primitive and are unreachable without a native addon; that path is unverified. Verifying either is the most valuable contribution this project currently needs. - One extra process per Node process (~48 MB). That is the price. It is flat, not per child.
npm run fdprobe measures descriptor growth across 40 spawn/kill cycles. On Node 24 /
macOS it found ~1 descriptor over 40 cycles — noise, not a leak.
The descriptor-exhaustion half of codex#26984 is a Rust-side problem (process_group(0)
is Rust's std::process API) and does not appear to reproduce in Node. The orphaning
mechanism is language-agnostic and does. Those are different bugs and this project will
not conflate them.
A harness whose detection silently breaks reports everything as "clean" — worse than useless. So the matrix includes control scenarios that perform no cleanup at all. Those rows must always orphan. If any control ever comes back clean, the run aborts and refuses to emit results.
The table above is generated by node harness/run.js --markdown, so it cannot drift from
what the code does, and npm test asserts every claim on this page. Verified
deterministic: 3 consecutive runs, byte-identical.
The harness spawns and kills processes, so it never touches anything it did not create:
- every spawned process carries a unique per-run marker in its command line
- signals only ever go to PIDs whose command line contains that marker
- the harness's own PID and its entire ancestor chain are excluded from every sweep
- liveness requires both that the PID exists and that it still carries the marker, so a recycled PID cannot be misread as a survivor
- cleanup runs on
exit,SIGINT,SIGTERM,SIGHUPanduncaughtException
spawnManaged(command, args?, options?) -> ChildProcessTakes everything child_process.spawn takes, plus:
| option | default | meaning |
|---|---|---|
graceMs |
2000 |
SIGTERM grace period before SIGKILL |
tag |
— | identifying string placed in the supervisor's command line |
Returns an ordinary ChildProcess, plus child.close(opts?) and child.supervisorPid.
shutdown()Stands the shared supervisor down. Call when the process is exiting and every managed child is already stopped.
npm run repro # the table, human readable
npm run repro -- --json # machine readable; stdout is pure JSON
npm run repro -- --markdown # regenerate the table in this README
npm run fdprobe # descriptor growth probe
npm test # assert every claim aboveRequires Node >= 20. Every number on this page was measured on macOS / Node 24. Linux shares the same code path but is not yet verified; Windows takes a different path and is not yet verified.
MIT.