prctl-subreaper
v0.1.1
Published
Make any Linux Node.js or Bun process a subreaper for its descendants. Calls prctl(PR_SET_CHILD_SUBREAPER, 1) plus runs a polling waitpid reaper for zombies whose ppid is the host process. Closes the libuv #1911 close-before-exit gap and the orphan-repare
Maintainers
Readme
prctl-subreaper
Make any Linux Node.js or Bun process a subreaper for its descendants — without
tini, without a wrapper, without changing your application code.
npm install prctl-subreaper
NODE_OPTIONS="--require prctl-subreaper" node your-app.jsThat's it.
What this fixes
Three classes of zombie processes that accumulate under any long-lived Node.js host:
| Path | Cause | Caught by tini-as-PID-1? | Caught by prctl-subreaper? |
|---|---|---|---|
| A. Orphan reparenting | Intermediate parent dies; child reparents to PID 1. | ✓ | ✓ |
| B. libuv #1911 close-before-exit | Application calls uv_close() (or its Node equivalent) on a process handle before the spawned child exits. libuv unregisters its waitpid for that pid and never reaps. Child becomes a zombie attached to node directly — parent alive. | ✗ (parent's alive, not orphaned) | ✓ |
| C. child.kill() leak | child.kill() sends SIGTERM but doesn't always result in waitpid being called, particularly under load. Same shape as B from the process tree. | ✗ if parent still alive, ✓ once parent dies | ✓ |
Path B is the under-discussed one. libuv's own current documentation reads, verbatim:
"On Unix, if the process has not yet exited when you call
uv_close, you will create a zombie that libuv cannot reap. You are responsible for callingwaitpidlater."
The libuv source (src/unix/process.c, uv__wait_children) carries this comment:
"The child died, and we missed it. This probably means someone else stole the
waitpidfrom us. Handle this by not handling it at all."
Path B is closed-not-fixed at the libuv layer. Tini-as-PID-1 — the canonical Docker fix for Node-in-containers — does not catch Path B because the zombie's parent is alive (it's node itself). This package catches all three paths in one place.
How it works
┌─────────────────────────────────────────────────────────────────────┐
│ on require: │
│ prctl(PR_SET_CHILD_SUBREAPER, 1) │
│ → orphans of any descendant now reparent to *us*, not PID 1. │
│ spawn polling thread: │
│ every 1 s, walk /proc/[pid]/status │
│ for each pid where State == Z and PPid == our pid │
│ and (now - starttime) >= 5 s │
│ call waitpid(pid, &status, WNOHANG). │
└─────────────────────────────────────────────────────────────────────┘The 5-second minimum age is the libuv co-existence trick. libuv reaps its tracked children (those it spawned via uv_spawn and still tracks) within milliseconds of SIGCHLD. By waiting 5 seconds before reaping, we let libuv reap-its-own first; we only catch the leaked tail. Configurable via PRCTL_SUBREAPER_MIN_AGE_MS.
We also use waitpid(specific_pid, WNOHANG), never waitpid(-1, ...). This eliminates the worst-case race: even if our reaper and libuv's handler fire simultaneously on the same pid, exactly one wins; the loser gets ECHILD and (per the comment above) "handles this by not handling it at all." If we lose, libuv handles it. If libuv loses, we handle it. Either way the zombie is reaped.
Loading the package
Auto-load via NODE_OPTIONS
For an existing application you don't want to modify:
NODE_OPTIONS="--require prctl-subreaper" node app.jsIn a systemd unit file
[Service]
Environment="NODE_OPTIONS=--require prctl-subreaper"
Environment="NODE_PATH=/usr/local/lib/node_modules"
ExecStart=/usr/local/bin/node /path/to/app.jsProgrammatically
require('prctl-subreaper'); // auto-init on require
// optional:
const sub = require('prctl-subreaper');
console.log(sub.stats()); // { running: true, pid, intervalMs, minAgeMs, reapedCount }Bun
BUN_OPTIONS="--preload prctl-subreaper" bun run app.ts(Bun's --preload is the analog of Node's --require. Bun is also dynamically linked and the addon's prctl call works the same way under Bun's runtime.)
Configuration
All via environment variables. Read once at init() time.
| Variable | Default | Range | Purpose |
|---|---|---|---|
| PRCTL_SUBREAPER_INTERVAL_MS | 1000 | 50 – 600000 | Polling interval for the reaper thread. Lower = faster reaping, higher CPU. 1 s is fine for most workloads. |
| PRCTL_SUBREAPER_MIN_AGE_MS | 5000 | 0 – 600000 | Zombies younger than this are skipped — gives libuv time to reap its own tracked children first. Set to 0 to reap immediately (only safe if you don't care about libuv child.on('exit') callbacks). |
| PRCTL_SUBREAPER_SILENT | unset | 0/1 | If 1, suppress the warning log when the native addon fails to load. |
API
// Auto-called on require. Idempotent. Returns true on success.
function init(): boolean;
// Stop the polling thread (does NOT clear PR_SET_CHILD_SUBREAPER — there is
// no API to clear it). Mostly for tests.
function stop(): boolean;
// Snapshot of runtime state.
function stats(): {
running: boolean;
pid: number;
intervalMs: number;
minAgeMs: number;
reapedCount: bigint;
};
// True iff this build supports prctl(PR_SET_CHILD_SUBREAPER) — i.e., Linux
// with the native addon successfully loaded.
function isSupported(): boolean;Verifying it works
The most direct test is to spawn-and-leak a process, then count zombies before and after:
// without prctl-subreaper:
const { spawn } = require('child_process');
const c = spawn('sleep', ['0.1']);
c.unref();
// ... if uv_close runs before child exits, zombie persists.
// with prctl-subreaper loaded:
require('prctl-subreaper');
// same code → zombie is reaped within PRCTL_SUBREAPER_MIN_AGE_MS + interval.Inspect zombie state with:
ps axo pid,ppid,stat,comm | awk '$3 ~ /Z/'After 5–6 seconds (default min age + one polling cycle) the row should disappear.
Platform support
| Platform | Status |
|---|---|
| Linux x64 / arm64 | Supported (native addon). Kernel 3.4+ for PR_SET_CHILD_SUBREAPER. |
| macOS | No-op. isSupported() returns false; init does nothing. Won't crash your app. |
| Windows | No-op. Same as macOS. |
| Linux musl (Alpine) | Supported. N-API ABI is libc-independent. |
Bun status (verified 2026-05-05): The same .node binary built against Node 22.22.2's N-API loads cleanly under Bun 1.3.13 via Bun's N-API compatibility layer. Both bun -e "require('prctl-subreaper')" and bun --preload prctl-subreaper work — stats() returns { running: true, supported: true } and the reaper thread is alive in the Bun process.
The package's os field in package.json is ["linux"] so npm warns on non-Linux installs, but the JS layer also short-circuits at runtime so cross-platform projects keep working.
Race semantics with libuv (in detail)
Read this if you maintain a Node application that relies on child_process exit callbacks and want to know whether this package can break them.
libuv installs a process-wide SIGCHLD handler. When SIGCHLD arrives, libuv iterates its internal queue of tracked children (handles spawned via uv_spawn and not yet uv_close'd) and calls waitpid(specific_pid, WNOHANG) on each. When a known child has exited, libuv's exit callback fires the user's child.on('exit', ...) event.
prctl-subreaper runs on a separate kernel thread. Every PRCTL_SUBREAPER_INTERVAL_MS, it walks /proc and reaps qualifying zombies via waitpid(specific_pid, WNOHANG). Three race outcomes are possible:
libuv reaps first: by the time our reaper visits the zombie's pid, the kernel has already cleaned it up. Our
waitpidreturns-1witherrno=ECHILD. We continue. libuv has fired the user'son('exit')correctly. No bug.Our reaper reaps first AND libuv was tracking: we steal libuv's
waitpid. When libuv'sSIGCHLDhandler runs, libuv'swaitpid(specific_pid)returns-1/ECHILD, libuv treats it as "handled by not handling it at all." The user'son('exit')callback does not fire. This is the failure mode we work to avoid. The 5-second minimum-age threshold (PRCTL_SUBREAPER_MIN_AGE_MS) is the avoidance mechanism: libuv reaps its own children within milliseconds ofSIGCHLD. By waiting 5 s, we let libuv finish first.Our reaper reaps first AND libuv was NOT tracking (the libuv #1911 case, or post-
uv_close): libuv has no tracking entry for this pid; itsSIGCHLDhandler ignores the dead child. Ourwaitpidsucceeds. No callback was scheduled — there's nothing to break. This is the case we exist to handle.
If your application has a hard requirement that every child.on('exit') callback fire and you cannot tolerate the failure mode in case 2, raise PRCTL_SUBREAPER_MIN_AGE_MS higher (e.g., 30 s) until you observe no missing-exit-callback regressions in your test suite.
Why we don't use waitpid(-1, WNOHANG)
A naive subreaper would use waitpid(-1, WNOHANG) in a SIGCHLD handler — which is the textbook approach for an init process. We deliberately do not, because installing a process-wide SIGCHLD handler would replace libuv's, breaking all of Node's child-process tracking. Polling /proc and using per-pid waitpid is slower but coexists with libuv cleanly.
Comparison with alternatives
| Tool | Approach | Pros | Cons |
|---|---|---|---|
| tini (PID 1 in Docker) | Tiny init, replaces PID 1 of the container. | Battle-tested. ~30 KB binary. | Requires a wrapper; doesn't catch Path B (libuv close-before-exit) zombies. |
| dumb-init (PID 1 in Docker) | Same as tini, different impl. | Same as tini. | Same as tini. |
| Shopify/child_subreaper (Ruby) | prctl(PR_SET_CHILD_SUBREAPER, 1) in Ruby. | Same idea as this package. ~10 lines. | Ruby-only. |
| rootmos/dont-fear-the-reaper (Rust) | prctl + walk /proc + send SIGTERM (terminates orphans). | Standalone CLI. | Terminates rather than reaps; experimental (4 stars). |
| prctl-subreaper (this) | prctl + polling waitpid in a separate thread, libuv-aware. | Single-package fix; no wrapper; auto-init via --require; works in Node and Bun. | Linux only. |
Prior art and references
- libuv issue #1911 — child process stuck in defunct state if uv_close is called before child exited
- PR_SET_CHILD_SUBREAPER man page (man7.org)
- Don't Fear the Subreaper (William La Martin, 2020)
- Shopify/child_subreaper (Ruby gem, 2017) — the closest prior art
- krallin/tini — the canonical tiny init for containers
- Linux kernel commit adding PR_SET_CHILD_SUBREAPER
License
MIT. See LICENSE.
Contributing
PRs welcome. The C++ source is a single file (src/addon.cc); the JS layer is a single file (lib/index.js). Open an issue first if you want to add a feature that changes the public API.
This package's design assumes Linux. Maintainers will not accept PRs that add platform-specific implementations for non-Linux subreaper equivalents — that scope belongs in a separate package.
