stdio MCP servers spawned via launcher commands (bun run, npx) leave orphaned grandchildren after CLI exit — consider killing the process group

Status Open
Reported on v2.1.206
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 10, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Environment

  • Claude Code 2.1.206, macOS 26.5 (Apple Silicon), bun 1.3.11
  • Headless -p sessions (stream-json) spawned/recycled by an external supervisor; plugin MCP servers from the claude-plugins-official marketplace

Summary

When an MCP server's command is a launcher rather than the server binary itself — e.g. the telegram plugin's

{ "command": "bun", "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"] }

— the CLI's shutdown only reaps its direct child (the launcher). The real server is a grandchild (bun runbun server.ts); on CLI exit (SIGTERM, crash, supervisor recycle) it reparents to PID 1 and lives on. Well-behaved servers can self-terminate on stdin EOF, but (a) many don't, and (b) even then the launcher process itself is often left behind idling.

Observed at fleet scale

On a host that recycles sessions frequently (context-compaction respawns, idle eviction, supervisor restarts) we accumulated 72 orphaned MCP pairs (144 processes, ~6.9 GB RSS) in a single day, all reparented to PID 1. Some grandchildren also ignored SIGTERM (plugin-side bug, reported separately), so only SIGKILL reclaimed them.

Expected

MCP subprocess lifetime should not depend on every plugin author getting orphan self-detection right. Suggestions, in increasing strength:

  1. Spawn each stdio MCP server in its own process group (detached/setsid) and signal the group (kill(-pgid, SIGTERM), escalate to SIGKILL after a grace period) during CLI shutdown.
  2. Do the same reaping on abnormal paths (uncaught exit, SIGTERM/SIGINT of the CLI itself).
  3. Optionally document that launcher-style commands (npx, bun run, uv run, shell wrappers) are orphan-prone if 1) is not implemented.

npx/bun run-style commands are the dominant idiom in MCP configs across the ecosystem, so the grandchild-escape pattern is common, not exotic.

Repro

  1. Register any stdio MCP whose command is a launcher that execs a child (e.g. bun run ... start, npx some-mcp)
  2. Start claude -p, confirm the process tree: claude → launcher → server
  3. kill -TERM <claude pid>
  4. ps -o pid,ppid,command — launcher and/or server remain with PPID 1

View original on GitHub ↗

3 Comments

DarkCloudCZ · 1 month ago

Reproduced on Linux — orphans survived 2 weeks, 119% CPU on a 1-core VPS

Confirming this on Linux (Debian, 1 vCPU / 3.9 GB, bun 1.3.x), so it isn't macOS-specific. The platform:macos label on this and #73814 should probably widen.

Why the plugin's own watchdog can't catch it

external_plugins/telegram/server.ts has an orphan watchdog that self-terminates on reparenting:

const bootPpid = process.ppid
setInterval(() => {
  const orphaned =
    (process.platform !== 'win32' && process.ppid !== bootPpid) ||
    process.stdin.destroyed || process.stdin.readableEnded
  if (orphaned) shutdown()
}, 5000).unref()

This never fires with the launcher indirection described in this issue. The chain is claude → bun run wrapper → bun server.ts. When claude dies, the wrapper is the process that gets reparented to PID 1 — and it stays alive. The server's own ppid therefore still equals bootPpid (the wrapper), so it concludes it is healthy and polls forever. The watchdog is watching the wrong link in the chain. Killing the process group, as this issue proposes, fixes it; a grandparent-liveness check would too.

Observed damage

Six orphaned bun server.ts processes accumulated, the oldest running continuously for 13 days. Combined: ~119% CPU and ~31% of RAM. Load average on the single core was 13.1, with 50% CPU steal as the hypervisor throttled the VM. After killing them: load 0.19, steal 0%, ~1.1 GB RAM returned.

The CPU burn is a second-order effect worth calling out, because it makes this leak much more expensive than a plain memory leak. Telegram allows only one getUpdates consumer per bot token, so every orphan past the first gets HTTP 409 Conflict and hot-loops on retry. The plugin's startup path kills the one stale poller recorded in bot.pid, but any earlier orphan that already lost the pid-file race is never reaped and spins indefinitely.

They also ignore SIGTERM

All six survived kill -TERM despite server.ts registering a SIGTERM handler with a 2s force-exit; only kill -9 cleared them, which suggests the event loop is wedged in the retry loop. So a supervisor-side process-group kill needs to escalate to SIGKILL rather than assume graceful shutdown.

Stale .in_use/<pid> lock files for long-dead Claude PIDs also accumulate in the plugin cache dir and are never reaped.

DanielGarzaB · 1 month ago

Confirming this on Windows 11 with measured numbers, plus a pointer to a fix that already landed elsewhere for the exact same root cause.

Observed

Single machine, Windows 11, multiple concurrent Claude Code sessions (~27 open over a working day). Snapshot taken ~5h after boot:

| | count | private RAM |
|---|---|---|
| node.exe (total) | 296 | 29.0 GB |
| node.exe from one single MCP server (chrome-devtools-mcp@1.6.0 via npx) | 99 | 11.4 GB |
| cmd.exe | 224 | 0.9 GB |

Of those 99, 68 are independent root launches — verified by parentage, not guessed: each has its own cmd.exe parent, and the chain is exactly the launcher pattern described in this issue:

cmd.exe -> node.exe (npx-cli.js) -> node.exe (server)

Walking up the ancestor chain, 66 of the 68 roots resolve to a live claude.exe.

Growth rate: ~60 new processes per hour, monotonic. Nothing reaps them.

They are not doing work

This is what makes it clearly a leak rather than legitimate usage:

  • Accumulated CPU per process: 38 of them under 1 second total, the rest between 1–10 seconds. They have been idle for hours.
  • Zero browser processes attached (checked for --remote-debugging/--headless).
  • Process ages cluster in discrete waves matching session start times, not a steady trickle — i.e. each new session adds its own set and the previous sets stay resident.

Why the spec-prescribed shutdown isn't enough on Windows

The MCP stdio lifecycle prescribes: close stdin → wait → SIGTERMSIGKILL. That is a POSIX model and it terminates one PID. Windows has no POSIX signal semantics and no automatic parent→descendant propagation: TerminateProcess() affects only the target. Since npx on Windows resolves through a .cmd shim, the process the client spawned and controls (cmd.exe) is not the process doing the work (node.exe), so killing the top-level PID orphans the actual server.

The fix already exists upstream

The official MCP Python SDK hit this exact bug and fixed it in modelcontextprotocol/python-sdk#850. Root cause quoted in that PR:

the main process does not propagate the SIGTERM to the child process which leads to a failed kill signal and the stdin & stdout of the root process getting mangled

Their fix: start_new_session=True on POSIX (kill the process group) plus tree termination via taskkill /T on Windows.

Same mechanism confirmed independently in:

  • github/copilot-sdk#1804 — identical launcher.cmd → cmd.exe → node.exe chain: "On Windows, terminating the launcher does not cascade to its descendants... these are left orphaned and alive after termination." Suggests Windows Job Objects with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE as the race-free alternative to taskkill.
  • pnpm/pnpm#12406 — same problem, same class of fix, applied at the spawn layer.
  • node-tree-kill — the de-facto community solution: taskkill /pid PID /T /F on Windows, process-group kill on POSIX.

So the "consider killing the process group" in this issue's title is exactly right, and there is a validated reference implementation in the MCP org's own SDK.

Note on related closed issues

#11502 (Windows, 60+ zombie node.exe) is essentially this report from November 2025 — closed as duplicate without a linked fix. #66280 was closed not planned. The two lazy-load requests (#42220, #23410) were also closed as duplicates, so there is currently no way to avoid paying the full MCP server set per session either.

Right now there is no official cleanup command and no documented way to share servers between sessions, so the only mitigation available to users is killing process trees manually — which does not scale when the count grows by ~60/hour.

bcherny collaborator · 14 days ago

Confirmed — reproduced on 2.1.233 on Linux. This is a real bug, and long-standing rather than a recent regression.

What I ran: an MCP config whose command is a launcher (sh -c 'node server.js; :') pointing at a minimal stdio MCP server that ignores stdin EOF, then claude -p --mcp-config cfg.json --strict-mcp-config "list your tools". The server connected and its tool was listed.

Observed after exit:

  • Normal exit: the launcher was reaped, but the grandchild server survived, reparented to PID 1.
  • SIGTERM mid-session: same — grandchild orphaned.
  • SIGKILL mid-session: both the launcher and the server were left running.
  • Control (server as the direct command, no launcher): cleaned up correctly in every case, even though the server ignores stdin EOF.

So shutdown currently terminates only the direct child; anything the launcher spawned is never signaled. Your process-group suggestion is the right shape for the clean-exit and SIGTERM cases (SIGKILL inherently also needs the supervisor to kill the group, or a post-hoc sweep). We're tracking this as a bug.

🤖 Generated with Claude Code