Skip to content

npm test leaks an orphaned tsx app.ts process group on every run — proc.kill() kills npx, not its children #36

Description

@andrevanzuydam

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions