stdio MCP servers accumulate under a live app-server on 26.810.52044, after the #18881 / #19753 shutdown fix
Filing separately from the closed #18881 because the shutdown path that PR #19753
fixed now works on this build, while accumulation during a live session does
not. Cross-referencing #12491 (open, GUI), #20349, #25015, #26984.
Environment
| | |
|---|---|
| Codex (ChatGPT.app) | 26.810.52044, bundle 6662 |
| codex-cli | 0.148.0-alpha.9 |
| macOS | 26.6.1 (25G76), arm64 |
| Surface | Codex desktop app, codex ... app-server |
Two stdio MCP servers configured in ~/.codex/config.toml, both launched via a
wrapper script that execs (so the wrapper leaves no process of its own):
[mcp_servers.fin]
command = "/Users/<user>/.ai_ops/bin/mcp-launch.sh"
args = ["fin"] # execs a python stdio server
[mcp_servers.email]
command = "/Users/<user>/.ai_ops/bin/mcp-launch.sh"
args = ["email"] # execs `npx -y @codefuturist/email-mcp stdio`
What happens
A long-lived app-server spawns a fresh stdio MCP server roughly every three
minutes and never closes the previous one. The old servers stay parented to the
app-server with their stdio pipes still held open (lsof shows fds 0/1/2 as
PIPE), at 0.0% CPU and ~1s of accumulated CPU time. They are idle but retained.
This is the same shape as #18881, but that issue was closed by PR #19753
(merged 2026-04-28) and this build postdates it.
What PR #19753 did fix, verified here
Shutdown draining works. When the app-server exits, its MCP children go with it.
Observed directly: app-server PID 43065 was restarted, and all ~33 of its
accumulated MCP children terminated along with it, requiring no manual cleanup.
So this is not a regression of the shutdown path. It is the in-session path,
where servers are replaced but the superseded ones are never shut down.
Measurements
Two independent windows, one app-server each, machine otherwise idle:
| | |
|---|---|
| Rate | ~1 new stdio server per 3 minutes, sustained |
| Single app-server (PID 14353), 71 min uptime | 24 fin_mcp children |
| Across both configured servers, ~1 hour after a manual clear from 11 processes | 137 processes |
| RSS at that point | ~5.4 GB |
| Free system memory at that point | 141 MB |
| After killing the superseded children | 4480 MB free |
Reproduced twice, hours apart, across an app-server restart in between.
Reproduction
- Configure one or more stdio MCP servers in
~/.codex/config.toml. - Start the Codex desktop app and leave a project session open.
- Watch the children of the
app-serverprocess:
APPSRV=$(pgrep -f "Resources/codex .*app-server" | head -1)
watch -n 30 "pgrep -P $APPSRV -f mcp | wc -l"
The count climbs monotonically and never decreases while the app-server lives.
Expected
When a stdio MCP server is replaced, the superseded one is shut down and its
process reaped, so the count tracks the number of configured servers rather than
session age.
Notes
Each server is cheap alone (~28 to 90 MB here) and the fault is only visible over
hours. With two servers configured it took roughly one hour to consume several GB
and drive the machine to 141 MB free. #12491 reports the same end state at much
larger scale (1319 processes, 37 GB), which suggests the ceiling is however long
the app stays open.
The three fix directions proposed in #12491 (process groups, startup reaping of
stale trees, heartbeat self-termination) would each cover this case too. The
narrowest fix specific to what is seen here is shutting down the superseded
server at the point of replacement, rather than only at session shutdown, which
is what #19753 addressed.
8 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Traced the in-session path on
main@ 1f41cc5d92, and the code structure matches your observation precisely: the shutdown that #19753 added exists only on the session-end path; the in-session replacement path has no shutdown at all, and its Drop-based fallback is a no-op for exactly the processes you're seeing accumulate.1. Replacement never shuts superseded servers down. A refresh goes through
McpRuntime::replace→publish, which builds a newMcpConnectionSet(reusing previous connections only when their identity matches) and then just swaps the published pointer:https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/codex-mcp/src/runtime.rs#L183-L231
The proper cleanup —
McpConnectionSet::shutdown, which terminates each stdio child (connection_manager.rs#L813-L828) — is invoked from exactly one place:McpRuntime::shutdown(runtime.rs#L441), i.e. the path #19753 fixed. Superseded connections on the replace path are simply dropped.2. The Drop fallback cannot kill a started server.
Drop for McpServerConnectiononly cancels the startup token:https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/codex-mcp/src/connection_manager.rs#L148-L152
and that token only guards the startup future (
.or_cancel(...)in rmcp_client.rs#L370). Oncestartup_completeis set, cancelling it does nothing. Actual child termination happens inStdioServerProcessHandleInner::drop/terminate()(stdio_server_launcher.rs#L497+), which requires the lastArc<RmcpClient>clone to go away — and clones live inside theSharedstartup future, cached bindings, and tool-catalog plumbing. Yourlsofevidence (superseded children with all three stdio pipes still open in the live app-server) demonstrates that some clone does survive in practice, so the children are never terminated until process exit. This is why the leak is monotonic while the app-server lives yet fully drains on restart.3. Why a fresh server spawns at all (the ~3-minute cadence). Reuse requires the new
McpServerConnectionIdentityto equal the old one, and the identity includes volatile inputs — notably the raw auth token (runtime_auth_token: Option<String>) and the resolved values of referenced environment variables:https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/codex-mcp/src/server.rs#L95-L110
Meanwhile the session's MCP prewarm worker marks the runtime dirty and re-publishes on every auth-change tick of the auth manager's watch channel (
start_mcp_prewarm_workerincore/src/session/mcp_prewarm.rs). So: periodic token refresh → watch tick → refresh → identity mismatch (token string changed) → brand-new child, old one orphaned by (1)+(2). A testable prediction for your setup:stat -f %m ~/.codex/auth.jsonshould advance at the same ~3-minute cadence as newfin_mcpchildren appear. If it does, that's the trigger confirmed end-to-end.Fix outline.
publish, diff the previous set against the new one and explicitlyshutdown()every previous connection that wasn't carried over — the in-session analog of #19753, ideally spawned as a detached task like set-level shutdown already does so an interrupt can't cancel cleanup.Drop for McpServerConnectionto schedule a fullshutdown()for startup-complete clients instead of only cancelling the startup token, so no future refactor can silently reintroduce the leak.info!line would have surfaced it immediately.Windows 11 repro, Codex desktop 26.810.7004.0 (ChatGPT.exe hosting codex.exe), 8 stdio servers, ChatGPT auth.
Fresh boot, one Codex thread, no authentication rotation or config changes. Codex started at 11:12:04. By 11:25:46, the same codex.exe had 70 MCP descendant processes using 1,924 MB. It retained three chains each for Azure DevOps, ServiceNow, and Power BI, plus six Snowflake chains for two configured Snowflake servers. This indicates startup created and retained three complete MCP server sets for a single thread.
Earlier the same day, over a 35-minute watched run of MCP tool calls: auth.json mtime never changed (Aug 10 17:42 throughout), a fourth complete set spawned at the next turn boundary after a config.toml edit was reverted, and one superseded set stayed alive beside the replacement for the rest of the run. So on this platform the leak reproduces without any auth change; each publish leaves the previous set alive, matching point (1). Point (3) alone would not prevent it.
macOS最新版でも同じlive app-server内のMCPランタイム蓄積を再現しました。
環境:
観測結果(2026-08-19 JST):
codex ... app-server --analytics-default-enabledはRSS最大約5.1GB、physical footprint peak 4.7GB。node_repl、gitnexus、Slack、Context7、Pencil、mem0などの同じstdio MCP群が複数世代存在した。/Volumes/RAID/cache/codex_sessionsのrollout JSONLを複数開いており、2026-07-23開始のファイルが約2.03GiBまで増加し、調査中も追記されていた。補足:
rustc/tokioの痕跡があり、Rust化は根本対策にならない。再現時のcredential・会話本文は共有していません。
(codex-lead レーンによる報告です)
追加のアプリ層比較です(同一macOSホスト上の読み取り専用プロセス集計)。
この比較から、個別MCPの実装だけではChatGPT.appだけで肥大する差を説明できません。ChatGPT.appのアプリ層が、複数threadのruntimeとMCP stdioプロセスを単一の長寿命app-serverへ保持する設計・ライフサイクルが増幅要因です。
期待する修正境界は、GUIのrenderer最適化ではなく、app-serverのthread unload / unsubscribe / MCP runtime ownershipです。論理threadのresume用状態を保持しても、MCP runtimeとstdio子プロセスは切り離して、必要時にlazy再生成できるべきです。
(codex-lead レーンによる追加監査です)
Cross-reference from #37453: I reproduced the restart/resume amplification on macOS and posted a tested lifecycle design and before/after PID evidence here: https://github.com/openai/codex/issues/37453#issuecomment-5353125274
The overlap with this issue is the retention of superseded or idle local stdio connection generations. In the prototype, turn completion publishes a dormant replacement first, allows in-flight bindings to retain the old connection set, and shuts down only host-local stdio transports after those references drain. HTTP MCPs, remote transports, Codex Apps, and active calls are preserved.
I am keeping the detailed discussion in #37453 to avoid duplicating the same analysis across both issues.
Ran a full forensic pass on this on macOS 26.5.2, desktop 26.814.41407 / CLI 0.148.0, after a month of recurring
Too many open files (os error 24)outages (11 distinct days). Findings that may help triage:codex app-server. Its fd numbers capped at exactly 255 (lsof) while holding 230–234 descriptors — i.e. it filled a 256 soft-limit table. 126 of 234 were pipes; 13node_replhelpers were alive for 3 in-progress threads, ages spread over 74 minutes — per-turn spawn without reap, exactly as described here.rust-v0.148.0andrust-v0.149.0-alpha.7touches fd limits or MCP reaping (commit sweep), and there is noRLIMIT_NOFILEhandling anywhere in the codebase.Workaround (installed today; immediate verification passed — raised limit confirmed end to end, zero errors since restart — but no long-term soak yet): a launcher wrapper at
~/.local/bin/codexdoingulimit -S -n 32768before exec'ing the real binary (the SSH-workspace bootstrap resolves codex via PATH, so the app-server inherits it), plus a small watcher for when an update rewrites the symlink. Write-up, verification method, and scripts: https://github.com/lazforprez/codex-fd-exhaustion-fixTwo asks for maintainers: reap superseded MCP helpers, and raise the soft limit at app-server startup (
kern.maxfilesperprocis 61440 by default — 256 is the launchd floor, not a real constraint).Another surface for the same in-session accumulation: the standalone
codex app-serverlaunched by the Claude Code companion plugin (openai/codex-plugin-cc), i.e. no ChatGPT.app involved.Environment: codex-cli 0.147.0 (npm), macOS 26.6.2 (25G83) arm64, 36 GB RAM, 8 stdio MCP servers in
~/.codex/config.toml.The plugin keeps a broker (
app-server-broker.mjs, orphaned under launchd after the originating session ended) that holds one long-livedcodex app-server. Observed after ~26 h of uptime:Confirming the shutdown-path observation from the OP on this surface too: killing the app-server took the whole accumulated tree down with it; swap dropped from 42.4 GB to 3.7 GB immediately, free memory went from 38 % to 65 %.
One triage note: the fd-limit wrapper from lazforprez/codex-fd-exhaustion-fix does not help this manifestation — memory, not descriptors, was the binding constraint here, so raising the fd ceiling only extends how long the accumulation can run before the OOM dialog appears.