MCP child processes leak when McpConnectionManager is replaced

Resolved 💬 9 comments Opened Apr 21, 2026 by 7etsuo Closed Apr 28, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What issue are you seeing?

A long-running codex --dangerously-bypass-approvals-and-sandbox daemon leaks stdio MCP child processes over time. A single daemon with roughly 15 hours of uptime accumulated 492 orphaned MCP children, 123 of each of 4 configured stdio MCP servers, all direct children (PPID) of the daemon process. Each leaked child was busy-looping at roughly 35% CPU, and cumulative RSS of the leaked set was about 82 GB. The spawn/leak rate was approximately one full cycle per 7 minutes.

Configured stdio MCP servers in the affected daemon:

  • mcp-codebase-index
  • codebase-memory-mcp
  • github-mcp-server --toolsets actions,issues,pull_requests,repos stdio
  • ast-grep-server

Confirmed via ps:

  • 492 processes with PPID equal to the daemon PID
  • exactly 123 instances per configured server (123 × 4 = 492)
  • killing the daemon PID reaped all 492 children immediately, ruling out a non-codex parent

What steps can reproduce the bug?

I do not have a minimal isolated repro yet. Observed on a long-lived daemon with four configured stdio MCP servers. Based on code inspection the leak should reproduce by driving either of these paths repeatedly:

  1. Session MCP refresh, via refresh_mcp_servers_inner in codex-rs/core/src/session/mcp.rs:177.
  2. Accessible-connectors refresh, via compute_accessible_connectors in codex-rs/core/src/connectors.rs (reached through list_accessible_connectors_from_mcp_tools*).

Both paths construct a fresh McpConnectionManager (which spawns all configured MCP server processes), perform tool listing, then release the manager. Field leak rate (about one cycle per 7 minutes across 15 hours) matches "one refresh cycle per interval" rather than "one per daemon start".

A targeted integration test would configure a stub stdio MCP server that records its own PID at startup, drive the refresh path N times, and assert that previous PIDs are no longer alive by the time the N-th refresh completes.

What is the expected behavior?

When an McpConnectionManager is replaced or dropped, every MCP child process it spawned should be terminated (SIGTERM, escalating to SIGKILL after a bounded grace period) during that drop. Teardown should not depend on Arc<RunningService> refcount reaching zero, because cloned Arcs legitimately escape the manager's ownership boundary during in-flight operations.

Additional information

Root cause

McpConnectionManager (codex-rs/codex-mcp/src/mcp_connection_manager.rs:657) has no Drop impl. Child-process cleanup is entirely downstream of Arc<RunningService> refcount reaching zero.

StdioServerTransport owns both a TokioChildProcess with kill_on_drop(true) and a ProcessGroupGuard that SIGTERMs the child's process group on drop:

  • codex-rs/rmcp-client/src/stdio_server_launcher.rs:211, command.kill_on_drop(true)
  • codex-rs/rmcp-client/src/stdio_server_launcher.rs:218, command.process_group(0)
  • codex-rs/rmcp-client/src/stdio_server_launcher.rs:290-296, impl Drop for ProcessGroupGuard calling terminate_process_group

But StdioServerTransport is held inside RunningService, which is held inside Arc<RunningService<...>> inside ClientState::Ready, which is held inside RmcpClient, which is wrapped by AsyncManagedClient as a cloneable shared future:

  • codex-rs/codex-mcp/src/mcp_connection_manager.rs:478-483, AsyncManagedClient { client: Shared<BoxFuture<..., Result<ManagedClient, _>>>, ... }
  • codex-rs/rmcp-client/src/rmcp_client.rs:494, pub struct RmcpClient
  • codex-rs/rmcp-client/src/rmcp_client.rs:1014-1049, run_service_operation clones the Arc<RunningService> into every in-flight operation

Every in-flight run_service_operation_once clones the Arc. Dropping the manager drops only its reference; any concurrent or detached task holding an Arc clone keeps the transport, and therefore the child PID, alive indefinitely. The Shared<BoxFuture<...>> at AsyncManagedClient.client additionally retains the completed ManagedClient for replay; any other clone of that Shared extends client lifetime past manager drop.

Why the observed symptoms match this mechanism

  • PPID equals daemon: LocalStdioServerLauncher::launch_server spawns via tokio::process::Command directly in the daemon (stdio_server_launcher.rs:209-225). No subagent intermediary.
  • 123 of each of 4 servers: each McpConnectionManager::new(...) call spawns one process per configured server, so multiples of 4 are expected.
  • Roughly 35% CPU per leaked child: consistent with the child's stdio reader loop spinning after the parent dropped its pipe end without delivering EOF or SIGTERM.
  • Scales with uptime, not startup: the daemon boots once; refresh paths fire repeatedly.

Replacement sites with no teardown

codex-rs/core/src/session/mcp.rs:200-229:

let (refreshed_manager, cancel_token) = McpConnectionManager::new(...).await;
...
let mut manager = self.services.mcp_connection_manager.write().await;
*manager = refreshed_manager;        // old manager dropped here, no shutdown()

codex-rs/core/src/connectors.rs:238-320 (compute_accessible_connectors):

let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(...).await;
...
// function returns; manager falls out of scope, no shutdown()

Proposed fix direction

A. Explicit async shutdown. Add pub async fn shutdown(&mut self) on McpConnectionManager that drives each AsyncManagedClient to readiness and calls a new RmcpClient::shutdown() which closes the transport and kills the process group. Invoke shutdown() at both the replacement and the scope-exit sites.

B. Manager-level Drop plus hoisted PGID. Hoist the child's process group ID out of the StdioServerTransport and Arc<RunningService> chain and store it at the AsyncManagedClient level, outside the Arc. Add impl Drop for McpConnectionManager that iterates self.clients and calls terminate_process_group on each stored PGID synchronously (the existing ProcessGroupGuard::drop is already sync via libc kill, so no async is required). This handles panic-driven drops and requires no caller changes.

Option B more closely matches the invariant that the manager owns these processes' lifetime. Per docs/contributing.md external PRs are invitation-only. Happy to prepare one if helpful.

Operational workaround

Restart the daemon to reap leaked children.

Environment

  • Platform: Linux, kernel 6.17.0-20-generic
  • Codex: codex-cli 0.122.0
  • MCP transports: 4 configured stdio servers (listed above)

View original on GitHub ↗

9 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #17832
  • #17574
  • #17115
  • #18333

Powered by Codex Action

7etsuo · 3 months ago

Thanks for the auto-triage. All four flagged issues describe the same underlying bug from different angles, but they are symptom reports rather than root-cause analyses.

The flagged issues:

  • #17115, #17574, #17832, #18333 all report stdio MCP child processes accumulating in Codex Desktop or CLI over time. Different MCP servers (Playwright, GitHub wrapper, xcodebuildmcp, chrome-devtools-mcp), same shape: children spawned per session/subagent/refresh and never reaped. All macOS.
  • #16895 was closed on 2026-04-09 as fixed, then #17832 reported the regression a few days later on codex-cli 0.120.0.

None of them identify the specific code defect. This issue does:

  • McpConnectionManager has no Drop impl (codex-rs/codex-mcp/src/mcp_connection_manager.rs:657).
  • refresh_mcp_servers_inner replacement and compute_accessible_connectors scope-exit rely on Arc<RunningService> refcount reaching zero for teardown.
  • That refcount does not reach zero in practice: AsyncManagedClient holds the client as a cloneable Shared<BoxFuture<...>>, and every run_service_operation_once clones the Arc. Any in-flight or detached task keeps the transport alive, which keeps the child PID alive.

This explains the regression pattern. Patching a specific spawn or refresh path does not remove the lifecycle dependency on refcount, so any new path that calls McpConnectionManager::new or clones the inner Shared or Arc hits the same leak.

Adding a Linux data point: observed on kernel 6.17 with mcp-codebase-index, codebase-memory-mcp, github-mcp-server, and ast-grep-server. Not platform-specific.

Happy to post the root-cause pointer on #17832 instead or as well, if maintainers would rather consolidate discussion there.

justinpbarnett · 3 months ago

I was able to reduce this to a local repro without needing a long-running daemon.

I added a temporary regression probe in codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs that:

  • starts a stub stdio MCP server which writes its PID to a temp file,
  • waits for the McpConnectionManager client to reach readiness,
  • keeps one completed client clone alive outside the manager,
  • drops the manager,
  • then asserts the child process exits.

Command:

cargo test -p codex-mcp dropping_manager_terminates_stdio_server_even_if_completed_client_clone_escapes -- --nocapture

Current result on main:

Error: process <pid> still running after timeout

After the test unwinds and the held client clone is dropped, the child process exits, so this looks consistent with the lifetime chain described above: manager drop releases only its own reference, while the completed/shared client path can keep the stdio transport and child process alive past manager replacement.

Before proposing code, I want to align on the intended ownership invariant. Should McpConnectionManager own the lifetime of the stdio child processes strongly enough that replacing/dropping the manager terminates them even if completed RmcpClient / RunningService clones still exist?

If yes, would the team prefer:

  1. explicit async shutdown at the manager replacement/scope-exit sites, or
  2. manager-owned process-group teardown that is independent of the Arc<RunningService> lifetime?

Per the contributing guide, I’m keeping this as issue analysis rather than opening an unsolicited PR. If a narrow PR would be useful, I’m happy to prepare one with the failing regression test and the minimal fix in the direction you prefer.

huandao-gonglu · 2 months ago

I can reproduce what looks like the same underlying issue on native Windows in the VS Code extension.

Environment:

  • VS Code extension: openai.chatgpt-26.417.40842
  • Bundled Codex binary: codex-cli 0.122.0-alpha.13
  • OS: Microsoft Windows NT 10.0.26200 x64
  • One configured local stdio MCP server

Observed symptoms:

  • stale codex.exe app-server --analytics-default-enabled processes remain alive after older VS Code sessions
  • those stale app-server parents keep or respawn MCP child processes
  • one fresh app-server can spawn the same configured MCP server more than once

Example from one run:

19236 codex.exe app-server --analytics-default-enabled
├─ 38708 "E:\WORK\myMcp\.venv\Scripts\python.exe" E:\WORK\myMcp\server.py
│  └─ 44412 "D:\ProgramData\miniconda3\python.exe" E:\WORK\myMcp\server.py
└─ 23156 "E:\WORK\myMcp\.venv\Scripts\python.exe" E:\WORK\myMcp\server.py
   └─ 45180 "D:\ProgramData\miniconda3\python.exe" E:\WORK\myMcp\server.py

So this looks consistent with #18881, but with a Windows / VS Code extension manifestation rather than the Linux daemon path described in the root issue.

Filed separately at #18965, but I am happy to treat that as a duplicate if you want to consolidate discussion here.

itselavia · 2 months ago

Additional evidence that #18881 is not limited to external stdio MCP wrappers: the same lifecycle leak affects the bundled computer-use client on macOS desktop.

Observed on 2026-04-22 under a single long-lived codex app-server (PID 46990):

  • 50 direct child SkyComputerUseClient mcp processes
  • total family RSS: 661.4 MB
  • every client had 0 descendants
  • all leaked clients were direct children of the app-server, not an unrelated daemon

Stale classification used:

  • process state starts with S
  • ETIME > 60m

Using that rule, the split was:

  • stale: 44 processes, 559.0 MB RSS
  • fresh/current-session: 6 processes, 102.5 MB RSS

Representative ps snapshot:

15349 46990 15.8MB 01-04:23:04 S ./Codex Computer Use.app/.../SkyComputerUseClient mcp
3140  46990 15.3MB 01-17:04:31 S ./Codex Computer Use.app/.../SkyComputerUseClient mcp
60747 46990 19.2MB    13:08:31 S ./Codex Computer Use.app/.../SkyComputerUseClient mcp
94605 46990 16.4MB       22:00 S ./Codex Computer Use.app/.../SkyComputerUseClient mcp
95016 46990 16.8MB       21:42 S ./Codex Computer Use.app/.../SkyComputerUseClient mcp
96515 46990 16.8MB       21:20 S ./Codex Computer Use.app/.../SkyComputerUseClient mcp

I compared one stale PID (15349) and one fresh PID (96515) with lsof. Their FD layout was effectively identical:

  • cwd under ~/.codex/plugins/cache/openai-bundled/computer-use/1.0.755
  • txt = bundled SkyComputerUseClient binary
  • 0/1/2 = stdio pipes
  • one handle to ~/Library/Group Containers/2DC432GLL2.com.openai.sky.CUAService/Library/Application Support/Software/Analytics.db
  • one netsrc control handle
  • no child processes
  • no obvious extra active sockets

Representative sanitized lsof shape:

cwd  ... ~/.codex/plugins/cache/openai-bundled/computer-use/1.0.755
txt  ... SkyComputerUseClient
0    PIPE ...
1    PIPE ...
2    PIPE ...
3u   REG  ... ~/Library/Group Containers/.../Analytics.db
37u  systm [ctl com.apple.netsrc id 7 unit 37]

I then terminated only the stale set (44 direct children) and left the fresh/current-session set untouched.

Before / after:

  • SkyComputerUseClient mcp count: 50 -> 6
  • family RSS: 661.4 MB -> 102.5 MB
  • stale targets removed: 44
  • all 44 exited on SIGTERM

Why this matters for #18881:

  • these leaked processes are owned directly by the app-server
  • they behave like per-session helper clients, not a singleton shared background service
  • stale and fresh instances are operationally indistinguishable except for age
  • stale-only cleanup collapses the family cleanly back to the active floor

This broadens the impact of the root cause in #18881: it is not just leaking external stdio MCP wrappers, but also bundled computer-use helper clients created through the same session/app-server lifecycle.

Repo context that makes this look like the same class of bug rather than an unrelated desktop-only issue:

  • computer-use@openai-bundled is treated as a bundled plugin in codex-rs/core/src/plugins/discoverable.rs
  • computer-use is treated as an MCP-backed tool surface in codex-rs/core/src/tools/handlers/tool_search.rs
  • session MCP refresh replaces the session-owned manager in codex-rs/core/src/session/mcp.rs
luohoa97 · 2 months ago

https://github.com/openai/codex/commit/fa4708189cced9ece561160cc10faf17170ad71e
I have created a fix. This fix makes sure the MCP servers children are cleared on conversation close.

hdot123 · 2 months ago

Confirmed on Codex Desktop 26.422.30944 with claude mcp serve

Same root cause, different MCP server type. This confirms the leak is not specific to any particular MCP server implementation — it affects all stdio MCP connections.

Environment

  • Codex Desktop: 26.422.30944 (codex-cli 0.125.0-alpha.3)
  • Platform: macOS Darwin arm64
  • Configured MCP: /opt/homebrew/bin/claude mcp serve (Claude Code CLI as MCP provider)

Evidence

After several hours of normal usage (opening/switching threads, spawning sub-agents, etc.):

$ ps -eo pid,ppid,command | grep "claude mcp serve" | wc -l
     147

$ ps -eo pid,ppid,command | grep "claude mcp serve" | awk '{print $2}' | sort | uniq -c | sort -rn
  80 63553       # children of Codex app-server
  65 1           # ORPHANS (parent already exited)
   1 9509        # child of VS Code ChatGPT extension
   1 84970

Parent process:

PID 63553 → /Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled

65 orphaned processes with PPID=1 — their parent (a previous Codex app-server instance or a previous session) has already exited, but the claude mcp serve children were never reaped. They persist indefinitely.

Memory impact

$ ps -eo rss,command | grep "claude mcp serve" | awk '{sum+=$1} END {printf "%.0f MB\n", sum/1024}'
1856 MB

~1.9 GB of RSS consumed by 147 stale claude mcp serve processes, each using 8–32 MB.

Time span

Orphaned processes date back over 7 hours:

PID  2699 (PPID=1) — started 02:22
PID 3438 (PPID=1) — started 02:22
...
PID 78326 (PPID=63553) — started 09:08

Thread count

$ sqlite3 ~/.codex/state_5.sqlite "SELECT count(*) FROM threads WHERE archived = 0;"
1100

1100 active threads but only 147 leaked processes so far — the leak rate correlates with session/MCP refresh frequency, not thread count. This matches the analysis in the original report: one refresh cycle per interval triggers the leak.

Workaround

pkill -f "claude mcp serve"

Frees ~1.9 GB immediately. Codex will re-spawn MCP processes on demand.

Summary

This reproduces the exact pattern described in this issue: McpConnectionManager replacement without proper teardown → Arc<RunningService> clones keep child processes alive → unbounded accumulation. The fact that it happens with a completely different MCP binary (claude CLI via Homebrew) confirms this is a Codex-side lifecycle management defect, not an MCP server implementation issue.

hdot123 · 2 months ago

Update: broader MCP process leak confirmed across all stdio MCP server types

After the initial cleanup of 147 claude mcp serve processes, I continued investigating and found the same leak pattern affecting every configured stdio MCP server — not just Claude. The total leaked process count and memory impact is far larger than initially reported.

Full leak inventory

| MCP Server | Leaked processes | Memory |
|---|---|---|
| claude mcp serve | 147 | ~1.9 GB |
| crawl4ai_local_mcp.py + Playwright driver | 121 | ~871 MB |
| chrome-devtools-mcp (npm) | 192 | ~1.3 GB |
| notebooklm-mcp-server (npm) | 130 | ~1.9 GB |
| mcp-server-supabase (npm) | 2 | ~10 MB |
| zai-mcp-server (npm) | 1 | ~5 MB |
| SkyComputerUseClient (Computer Use plugin) | 84 | ~650 MB |
| node_repl (Codex internal) | 35 | ~160 MB |
| Total | ~712 | ~6.8 GB |

On a 16 GB MacBook, a single Codex Desktop session consumed 42% of system RAM in leaked child processes over ~9 hours of uptime.

All processes traced to PID 63553 (Codex app-server)

Every leaked process was either:

  • A direct child of PID 63553 (/Applications/Codex.app/Contents/Resources/codex app-server), or
  • An orphan (PPID=1) whose parent was a previous Codex app-server instance that already exited

This confirms the leak originates from McpConnectionManager replacement in codex-rs/core/src/session/mcp.rs, not from any specific MCP server implementation. Any stdio MCP server configured in Codex will accumulate leaked processes at the same rate.

System impact

$ memory_pressure
System-wide memory free percentage: 57%

Swap I/O:
  Swapins:  12,571,342
  Swapouts: 16,323,304

The leak was severe enough to trigger heavy swap usage (16M swapouts), degrading overall system performance. After cleanup, free memory jumped from 43% to 57%.

Repro summary

  1. Configure multiple stdio MCP servers (Claude, Playwright, chrome-devtools, notebooklm, crawl4ai, supabase, etc.)
  2. Use Codex Desktop normally for several hours — open/switch threads, spawn sub-agents
  3. Observe unbounded process growth via ps aux | grep mcp
  4. Each MCP refresh cycle leaks one instance of every configured server
  5. Memory grows linearly with uptime, not with active thread count (1,100 threads, ~712 leaked processes)

Workaround

# Kill all leaked MCP child processes
pkill -f "claude mcp serve"
pkill -f "crawl4ai_local_mcp"
pkill -f "chrome-devtools-mcp"
pkill -f "notebooklm-mcp-server"
pkill -f "SkyComputerUseClient"
pkill -f "node_repl"

Frees ~6.8 GB immediately. Codex re-spawns only what it actually needs.