Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

subreap

Stop leaking orphaned child processes — reaped even under SIGKILL, where no cleanup code can run.

npm install github:ettalin/subreap

Not 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();

The problem

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.

Why the fix works

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.

One supervisor, not one per child

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.

The shim case

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.

What about tree-kill?

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.

Limits, stated plainly

  • 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.

An honest negative result

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.

Trusting the table

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.

Safety

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, SIGHUP and uncaughtException

API

spawnManaged(command, args?, options?) -> ChildProcess

Takes 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.

Running it

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 above

Requires 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.

About

Stop leaking orphaned child processes — reaped even under SIGKILL, where no cleanup code can run. Zero dependencies.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages