npm test leaks an orphaned tsx app.ts process group on every run — proc.kill() kills npx, not its children.
Summary
Every npm test run leaves a detached server process group behind. On this machine 19 orphaned node .../bin/tsx app.ts processes (PPID 1) were found, aged 5 hours to 2 days 6 hours — one per test run, going back days. Each leak is actually three processes: the tsx shim, the node --require preflight.cjs … app.ts server, and an esbuild --service worker.
They hold no listening port (the test allocates an ephemeral one), so they are easy to miss — an idle node process at 0% CPU is invisible until you go looking. But they never exit on their own.
Root cause
test/devReloadWs.test.ts:157 spawns the dev server through npx:
const proc = spawn("npx", ["tsx", "app.ts"], {
cwd: proj,
env: { ...process.env, NODE_PATH: join(REPO_ROOT, "node_modules"), PORT: String(port) },
});
and the finally block at test/devReloadWs.test.ts:252 reaps only the direct child:
} finally {
proc.kill("SIGKILL");
try { rmSync(proj, { recursive: true, force: true }); } catch { /* best effort */ }
}
proc is npx, not the server. The real chain is:
npx → node .../bin/tsx app.ts → node --require preflight.cjs … app.ts
→ esbuild --service
proc.kill("SIGKILL") terminates npx only. Its children are re-parented to PID 1 and keep running forever. This matches the observed evidence exactly — every orphan has ppid=1, and they occur in same-etime triplets:
PID PPID ELAPSED COMMAND
1410 1 02-04:37:43 node …/node_modules/.bin/tsx app.ts
1411 1 02-04:37:43 node --require …/tsx/dist/preflight.cjs … app.ts
1413 1411 02-04:37:43 …/@esbuild/darwin-arm64/bin/esbuild --service=0.27.4 --ping
This is the exact failure mode the maintainer skill already names — "an explicit kill of the process GROUP (pkill -P / killpg) for a shell you launched" — the test kills the child instead of the group.
Reproduce
ps -eo pid,ppid,command | grep '[b]in/tsx app.ts' | awk '$2==1' | wc -l # note the count
npm test
ps -eo pid,ppid,command | grep '[b]in/tsx app.ts' | awk '$2==1' | wc -l # +1
Proposed fix
Put the child in its own process group and kill the group, with a safety net so a crash or Ctrl-C mid-test still reaps:
const proc = spawn("npx", ["tsx", "app.ts"], {
cwd: proj,
env: { ...process.env, NODE_PATH: join(REPO_ROOT, "node_modules"), PORT: String(port) },
detached: true, // child becomes a process-group leader
});
const reap = () => {
try { process.kill(-proc.pid!, "SIGKILL"); } catch { /* already gone */ }
};
process.once("exit", reap); // survives a throw before `finally`, and Ctrl-C
} finally {
reap(); // negative pid = whole group, not just npx
process.off("exit", reap);
try { rmSync(proj, { recursive: true, force: true }); } catch { /* best effort */ }
}
Worth a look as a suite-wide guard too: test/run-all.ts could assert that no new PPID-1 tsx process survives a run, so a future leak fails the build instead of accumulating silently for days.
Note that dropping npx and spawning tsx directly would not be sufficient on its own — tsx still forks the node server and the esbuild worker, so the group kill is the actual fix.
Impact
Test-harness hygiene only; no shipped framework code is affected. But it accumulates: on a dev machine or a long-lived CI runner, every npm test adds a permanent idle node process group holding memory and an esbuild worker.
Environment
- macOS 25.5.0 (arm64), Node 24.9.0
tina4-nodejs v3 @ 61d82d5
- Suite green otherwise: 5770 passed, 0 failed across 186 files
npm testleaks an orphanedtsx app.tsprocess group on every run —proc.kill()killsnpx, not its children.Summary
Every
npm testrun leaves a detached server process group behind. On this machine 19 orphanednode .../bin/tsx app.tsprocesses (PPID 1) were found, aged 5 hours to 2 days 6 hours — one per test run, going back days. Each leak is actually three processes: thetsxshim, thenode --require preflight.cjs … app.tsserver, and anesbuild --serviceworker.They hold no listening port (the test allocates an ephemeral one), so they are easy to miss — an idle node process at 0% CPU is invisible until you go looking. But they never exit on their own.
Root cause
test/devReloadWs.test.ts:157spawns the dev server throughnpx:and the
finallyblock attest/devReloadWs.test.ts:252reaps only the direct child:procisnpx, not the server. The real chain is:proc.kill("SIGKILL")terminatesnpxonly. Its children are re-parented to PID 1 and keep running forever. This matches the observed evidence exactly — every orphan hasppid=1, and they occur in same-etimetriplets:This is the exact failure mode the maintainer skill already names — "an explicit kill of the process GROUP (
pkill -P/killpg) for a shell you launched" — the test kills the child instead of the group.Reproduce
Proposed fix
Put the child in its own process group and kill the group, with a safety net so a crash or
Ctrl-Cmid-test still reaps:Worth a look as a suite-wide guard too:
test/run-all.tscould assert that no new PPID-1tsxprocess survives a run, so a future leak fails the build instead of accumulating silently for days.Note that dropping
npxand spawningtsxdirectly would not be sufficient on its own —tsxstill forks the node server and the esbuild worker, so the group kill is the actual fix.Impact
Test-harness hygiene only; no shipped framework code is affected. But it accumulates: on a dev machine or a long-lived CI runner, every
npm testadds a permanent idle node process group holding memory and an esbuild worker.Environment
tina4-nodejsv3@61d82d5