MCP child processes leak when McpConnectionManager is replaced
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-indexcodebase-memory-mcpgithub-mcp-server --toolsets actions,issues,pull_requests,repos stdioast-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:
- Session MCP refresh, via
refresh_mcp_servers_innerincodex-rs/core/src/session/mcp.rs:177. - Accessible-connectors refresh, via
compute_accessible_connectorsincodex-rs/core/src/connectors.rs(reached throughlist_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 ProcessGroupGuardcallingterminate_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 RmcpClientcodex-rs/rmcp-client/src/rmcp_client.rs:1014-1049,run_service_operationclones theArc<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_serverspawns viatokio::process::Commanddirectly 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)
9 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
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:
None of them identify the specific code defect. This issue does:
McpConnectionManagerhas noDropimpl (codex-rs/codex-mcp/src/mcp_connection_manager.rs:657).refresh_mcp_servers_innerreplacement andcompute_accessible_connectorsscope-exit rely onArc<RunningService>refcount reaching zero for teardown.AsyncManagedClientholds the client as a cloneableShared<BoxFuture<...>>, and everyrun_service_operation_onceclones theArc. 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::newor clones the innerSharedorArchits the same leak.Adding a Linux data point: observed on kernel 6.17 with
mcp-codebase-index,codebase-memory-mcp,github-mcp-server, andast-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.
Related:
https://github.com/openai/codex/issues/17806#issuecomment-4277332062
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.rsthat:McpConnectionManagerclient to reach readiness,Command:
Current result on
main: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
McpConnectionManagerown the lifetime of the stdio child processes strongly enough that replacing/dropping the manager terminates them even if completedRmcpClient/RunningServiceclones still exist?If yes, would the team prefer:
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.
I can reproduce what looks like the same underlying issue on native Windows in the VS Code extension.
Environment:
openai.chatgpt-26.417.40842codex-cli 0.122.0-alpha.13Microsoft Windows NT 10.0.26200 x64Observed symptoms:
codex.exe app-server --analytics-default-enabledprocesses remain alive after older VS Code sessionsapp-serverparents keep or respawn MCP child processesapp-servercan spawn the same configured MCP server more than onceExample from one run:
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.Additional evidence that #18881 is not limited to external stdio MCP wrappers: the same lifecycle leak affects the bundled
computer-useclient on macOS desktop.Observed on 2026-04-22 under a single long-lived
codex app-server(PID 46990):50direct childSkyComputerUseClient mcpprocesses661.4 MB0descendantsStale classification used:
SETIME > 60mUsing that rule, the split was:
44processes,559.0 MB RSS6processes,102.5 MB RSSRepresentative
pssnapshot:I compared one stale PID (
15349) and one fresh PID (96515) withlsof. Their FD layout was effectively identical:cwdunder~/.codex/plugins/cache/openai-bundled/computer-use/1.0.755txt= bundledSkyComputerUseClientbinary0/1/2= stdio pipes~/Library/Group Containers/2DC432GLL2.com.openai.sky.CUAService/Library/Application Support/Software/Analytics.dbnetsrccontrol handleRepresentative sanitized
lsofshape:I then terminated only the stale set (
44direct children) and left the fresh/current-session set untouched.Before / after:
SkyComputerUseClient mcpcount:50 -> 6661.4 MB -> 102.5 MB4444exited onSIGTERMWhy this matters for #18881:
This broadens the impact of the root cause in #18881: it is not just leaking external stdio MCP wrappers, but also bundled
computer-usehelper 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-bundledis treated as a bundled plugin incodex-rs/core/src/plugins/discoverable.rscomputer-useis treated as an MCP-backed tool surface incodex-rs/core/src/tools/handlers/tool_search.rscodex-rs/core/src/session/mcp.rshttps://github.com/openai/codex/commit/fa4708189cced9ece561160cc10faf17170ad71e
I have created a fix. This fix makes sure the MCP servers children are cleared on conversation close.
Confirmed on Codex Desktop 26.422.30944 with
claude mcp serveSame 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
/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.):
Parent process:
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 servechildren were never reaped. They persist indefinitely.Memory impact
~1.9 GB of RSS consumed by 147 stale
claude mcp serveprocesses, each using 8–32 MB.Time span
Orphaned processes date back over 7 hours:
Thread count
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
Frees ~1.9 GB immediately. Codex will re-spawn MCP processes on demand.
Summary
This reproduces the exact pattern described in this issue:
McpConnectionManagerreplacement without proper teardown →Arc<RunningService>clones keep child processes alive → unbounded accumulation. The fact that it happens with a completely different MCP binary (claudeCLI via Homebrew) confirms this is a Codex-side lifecycle management defect, not an MCP server implementation issue.Update: broader MCP process leak confirmed across all stdio MCP server types
After the initial cleanup of 147
claude mcp serveprocesses, 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:
/Applications/Codex.app/Contents/Resources/codex app-server), orThis confirms the leak originates from
McpConnectionManagerreplacement incodex-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
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
ps aux | grep mcpWorkaround
Frees ~6.8 GB immediately. Codex re-spawns only what it actually needs.