Fix MCP server processes leaking as zombies - #54793
Merged
Merged
Conversation
Two bugs caused MCP server child processes (e.g. npm/node for mcp-remote) to accumulate as zombie processes: 1. stop_server() only called ContextServer::stop() for servers in Running state. If a server completed initialization but was still in Starting state when stopped (a race between the init task completing and maintain_servers restarting), the client/transport was never released, keeping the child process alive. Fix: call stop() unconditionally. It is a safe no-op when the client has not been initialized. 2. StdioTransport used a raw smol::process::Child with kill_on_drop, which only sends SIGKILL to the direct child process (the shell or npm wrapper). The actual MCP server (e.g. node mcp-remote) runs as a grandchild and survives, reparented to launchd. Fix: use util::process::Child, which already exists for this purpose. It calls setsid() via pre_exec to make the child a process group leader, and uses killpg() to terminate the entire process tree on kill.
rtfeldman
force-pushed
the
SEC-267/fix-zombie-mcp-processes
branch
from
April 24, 2026 14:46
46c8e50 to
981e695
Compare
rtfeldman
force-pushed
the
SEC-267/fix-zombie-mcp-processes
branch
from
April 24, 2026 15:06
e5d2c48 to
927f68b
Compare
rtfeldman
marked this pull request as ready for review
April 24, 2026 15:18
bennetbo
reviewed
May 4, 2026
bennetbo
approved these changes
May 4, 2026
rtfeldman
commented
May 4, 2026
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
rtfeldman
enabled auto-merge
May 4, 2026 15:39
3 tasks
This was referenced May 13, 2026
This was referenced May 14, 2026
ubunatic
pushed a commit
to ubunatic/zed
that referenced
this pull request
May 16, 2026
Two bugs caused MCP server child processes (e.g. `npm`/`node` for `mcp-remote`) to accumulate as zombie processes that were never cleaned up: **Bug 1: `stop_server()` only called `stop()` for `Running` servers** If a server completed initialization but was still in `Starting` state when `stop_server()` was called (a race between the init task completing and `maintain_servers` restarting), the client/transport/process were never released. The `Arc<ContextServer>` was moved into a `Stopped` state with its inner client still holding the transport and child process handle. Fix: call `stop()` unconditionally in `stop_server()`. It is a safe no-op when the client has not been initialized (`None`). **Bug 2: `kill_on_drop` only killed the direct child, not the process tree** `StdioTransport` used a raw `smol::process::Child` with `kill_on_drop(true)`, which sends SIGKILL only to the direct child process (the shell/`npm` wrapper). The actual MCP server (e.g. `node mcp-remote`) runs as a grandchild and survives the kill, getting reparented to launchd. Fix: use `util::process::Child`, which already exists in the codebase for exactly this purpose. It calls `setsid()` via `pre_exec` to make the child a process group leader, and uses `killpg()` to terminate the entire process tree on kill. This requires passing a `std::process::Command` (via `build_std_command`) instead of a `smol::process::Command` (via `build_smol_command`), because that is what `util::process::Child::spawn` accepts — it needs to call `pre_exec` on the `std::process::Command` before internally converting it to `smol::process::Command` for async I/O. Release Notes: - Fixed zombie MCP server processes accumulating over time
jonx
pushed a commit
to jonx/zed-aros
that referenced
this pull request
Jul 17, 2026
Two bugs caused MCP server child processes (e.g. `npm`/`node` for `mcp-remote`) to accumulate as zombie processes that were never cleaned up: **Bug 1: `stop_server()` only called `stop()` for `Running` servers** If a server completed initialization but was still in `Starting` state when `stop_server()` was called (a race between the init task completing and `maintain_servers` restarting), the client/transport/process were never released. The `Arc<ContextServer>` was moved into a `Stopped` state with its inner client still holding the transport and child process handle. Fix: call `stop()` unconditionally in `stop_server()`. It is a safe no-op when the client has not been initialized (`None`). **Bug 2: `kill_on_drop` only killed the direct child, not the process tree** `StdioTransport` used a raw `smol::process::Child` with `kill_on_drop(true)`, which sends SIGKILL only to the direct child process (the shell/`npm` wrapper). The actual MCP server (e.g. `node mcp-remote`) runs as a grandchild and survives the kill, getting reparented to launchd. Fix: use `util::process::Child`, which already exists in the codebase for exactly this purpose. It calls `setsid()` via `pre_exec` to make the child a process group leader, and uses `killpg()` to terminate the entire process tree on kill. This requires passing a `std::process::Command` (via `build_std_command`) instead of a `smol::process::Command` (via `build_smol_command`), because that is what `util::process::Child::spawn` accepts — it needs to call `pre_exec` on the `std::process::Command` before internally converting it to `smol::process::Command` for async I/O. Release Notes: - Fixed zombie MCP server processes accumulating over time
jolutz
pushed a commit
to jolutz/zed
that referenced
this pull request
Aug 8, 2026
Two bugs caused MCP server child processes (e.g. `npm`/`node` for `mcp-remote`) to accumulate as zombie processes that were never cleaned up: **Bug 1: `stop_server()` only called `stop()` for `Running` servers** If a server completed initialization but was still in `Starting` state when `stop_server()` was called (a race between the init task completing and `maintain_servers` restarting), the client/transport/process were never released. The `Arc<ContextServer>` was moved into a `Stopped` state with its inner client still holding the transport and child process handle. Fix: call `stop()` unconditionally in `stop_server()`. It is a safe no-op when the client has not been initialized (`None`). **Bug 2: `kill_on_drop` only killed the direct child, not the process tree** `StdioTransport` used a raw `smol::process::Child` with `kill_on_drop(true)`, which sends SIGKILL only to the direct child process (the shell/`npm` wrapper). The actual MCP server (e.g. `node mcp-remote`) runs as a grandchild and survives the kill, getting reparented to launchd. Fix: use `util::process::Child`, which already exists in the codebase for exactly this purpose. It calls `setsid()` via `pre_exec` to make the child a process group leader, and uses `killpg()` to terminate the entire process tree on kill. This requires passing a `std::process::Command` (via `build_std_command`) instead of a `smol::process::Command` (via `build_smol_command`), because that is what `util::process::Child::spawn` accepts — it needs to call `pre_exec` on the `std::process::Command` before internally converting it to `smol::process::Command` for async I/O. Release Notes: - Fixed zombie MCP server processes accumulating over time
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two bugs caused MCP server child processes (e.g.
npm/nodeformcp-remote) to accumulate as zombie processes that were never cleaned up:Bug 1:
stop_server()only calledstop()forRunningserversIf a server completed initialization but was still in
Startingstate whenstop_server()was called (a race between the init task completing andmaintain_serversrestarting), the client/transport/process were never released. TheArc<ContextServer>was moved into aStoppedstate with its inner client still holding the transport and child process handle.Fix: call
stop()unconditionally instop_server(). It is a safe no-op when the client has not been initialized (None).Bug 2:
kill_on_droponly killed the direct child, not the process treeStdioTransportused a rawsmol::process::Childwithkill_on_drop(true), which sends SIGKILL only to the direct child process (the shell/npmwrapper). The actual MCP server (e.g.node mcp-remote) runs as a grandchild and survives the kill, getting reparented to launchd.Fix: use
util::process::Child, which already exists in the codebase for exactly this purpose. It callssetsid()viapre_execto make the child a process group leader, and useskillpg()to terminate the entire process tree on kill. This requires passing astd::process::Command(viabuild_std_command) instead of asmol::process::Command(viabuild_smol_command), because that is whatutil::process::Child::spawnaccepts — it needs to callpre_execon thestd::process::Commandbefore internally converting it tosmol::process::Commandfor async I/O.Release Notes: