🩹 Codex app-server leaks MCP process stacks for subagents, causing linear memory growth on Linux
Summary
Codex app-server appears to start additional stdio MCP process stacks when spawning subagents. After the subagents complete and close_agent is called, some MCP child process trees remain alive under the long-lived codex app-server.
This causes process count and memory usage to grow roughly linearly with subagent usage. On my machine this previously caused heavy swap pressure and an OOM/desktop session collapse.
I also created a Linux-only diagnostic/workaround tool that helped confirm the source of the growth:
https://github.com/LostFrxks/codex-mcp-clean
Environment
- Codex CLI:
codex-cli 0.128.0 - OS: Ubuntu 24.04.4 LTS
- Kernel: Linux 6.17.0-29-generic x86_64
- Machine: 16 GB RAM, 16 GB swap, 8 CPU cores
- Surface: Codex app-server / IDE integration
- Reduced MCP set during repro:
- Serena
- Telegram
- YouTrack via
mcp-remote
Heavy MCP servers such as Playwright, Context7, and Slack were disabled for the reduced repro.
Reproduction Pattern
- Start Codex app-server with stdio MCP servers enabled.
- Take a baseline process/memory snapshot of the
codex app-serverchild tree. - Spawn subagents with trivial tasks that do not use MCP tools.
- Wait for the subagents to complete.
- Call
close_agentfor each subagent. - Wait briefly for Codex cleanup.
- Check the
codex app-serverchild process tree again. - Optionally repeat with larger subagent batches.
Small Live Repro: 3 Subagents
Before spawning 3 trivial subagents, the selected app-server had:
23descendants6MCP launcher roots- RSS around
203 MiB
After spawning 3 subagents:
74descendants24MCP launcher roots- RSS around
3.7 GiB - Snapshot report showed
18new MCP launcher roots /50new processes
After all 3 subagents completed and close_agent was called for each one, Codex cleaned up part of the process tree, but the snapshot still showed:
9MCP launcher roots33processes
A dry-run with codex-mcp-clean cleanup --root-pid <PID> identified those remaining MCP subtrees. Running the same command with --kill terminated them:
TERM sent to 33 processes; SIGKILL fallback attempted for 0
After cleanup:
- snapshot report returned to
launcher_roots=0,processes_total=0 - app-server returned to about the previous baseline MCP footprint
- available memory increased back from about
4.7 GiBto about6.8 GiB
Larger Repro: 10 + 10 Subagents
In an earlier larger run, before the batch:
- app-server descendants:
46 - total RSS under app-server: about
2.9 GiB - total PSS under app-server: about
1.6 GiB - MCP bundles: about
4
After spawning and closing 10 subagents:
- app-server descendants:
156 - total RSS: about
7.9 GiB - total PSS: about
3.6 GiB - MCP bundles: about
14
After spawning and closing another 10 subagents:
- app-server descendants:
266 - total RSS: about
11.7 GiB - swap usage grew to about
6.4 GiB - MCP bundles: about
24
The growth matched the number of spawned subagents.
Expected Behavior
After a subagent finishes and is closed:
- MCP processes created for that subagent should be terminated, or
- subagents should reuse a shared project/app-server MCP process pool, or
- Codex should allow subagents to start without inheriting all parent-session MCP servers.
Repeated subagent usage should return to a bounded baseline instead of accumulating MCP process stacks.
Actual Behavior
Subagent creation starts additional MCP process stacks, even when the subagent task does not call MCP tools.
After subagent completion and close_agent, some MCP child process trees remain alive under the app-server, causing process and memory growth.
Diagnostic / Workaround Tool
I wrote a small Linux-only diagnostic/workaround tool:
https://github.com/LostFrxks/codex-mcp-clean
It snapshots a selected Codex app-server PID and later reports or kills only MCP launcher subtrees created after that snapshot.
This is not a proposed upstream fix, but it helped confirm that stale MCP child trees are the source of the memory growth. It also avoids touching other Codex app-server PIDs.
Related Issues
This looks related to the same defect class as:
- #17574
- #17832
- #20883
- #21984
- #24347
I am opening this separately because this is a Linux app-server reproduction with a reduced MCP set and controlled before/after measurements.
Happy to help with more testing or open a PR if the Codex team wants an external contribution for this fix.
9 Comments
The failure mode here looks less like one memory bug and more like missing admission control for helper lifetimes.
A practical split that has worked better for us is:
owning_thread,owning_subagent,last_tool_call_at, andidle_timeout_atThat matters because operators usually need two different answers:
Without those receipts, every quiet helper stack looks like a leak, even when some are just over-scoped lifecycle decisions. We ran into the same boundary in MartinLoop, and making helper admission explicit reduced both real leaks and false-positive debugging.
Confirming the same leak on macOS — the mechanism described here is not Linux-specific. Evidence below from the ChatGPT desktop app's bundled app-server.
Environment
26.707.62119, runningcodex -c features.code_mode_host=true app-serverchrome-devtools-mcp,@upstash/context7-mcp,xcodebuildmcp(via build-ios-apps plugin), all spawned asnpx <pkg>@latestObservation (app-server pid 55230, uptime ~47 min at capture)
Six complete generations of MCP server sets were alive simultaneously as children of the app-server — apparently one per thread/subagent, never reaped after completion. Three sets were spawned within 7 seconds of each other (parallel subagents):
Each set = 3
npm execwrapper processes + 3 actual server processes ≈ 600–900 MB RSS. At capture the machine had 52 node processes totalling ~5 GB RSS, the majority of them these leaked sets; swap was at 3.2/4 GB.Related datapoint: a standalone
@openai/codexCLI process (npm install, v24.13.1 node) had been orphaned headless (PPID 1, no TTY, idle) for 9.7 days, holding its full MCP server set alive the entire time — consistent with #28794 and the accumulation reported for macOS in #25744.Impact beyond memory: because each set is spawned via
npx <pkg>@latest, every leaked-then-respawned generation also performs a fresh npm registry resolution, adding CPU burst and network churn on every thread start.Workarounds we verified: quitting the ChatGPT app reaps all leaked sets; pinning MCP commands to globally-installed binaries (instead of
npx @latest) reduces per-set cost and spawn time but does not stop the leak itself.Deterministic root-cause closure on current macOS Desktop build
I reproduced and source-traced this on the current macOS Desktop package. The visible symptom contains two separate lifecycle failures:
killpg(SIGTERM)can returnEPERM; the current local-process terminator then suppresses escalation and permanently marks the handle terminated, so that MCP group may survive.This is a resource/lifecycle leak (unbounded retention), not evidence of a single allocator leak inside one Node process.
Environment
26.5.2(25F84), Apple silicon / arm6426.715.21425, build5488codex-cli 0.145.0-alpha.18rust-v0.145.0-alpha.18, commitf84f9a6406cc55b210395f71b4c6aed236fc7ebbSource-level causal chain
A. Every thread/session eagerly starts its own MCP stack
Sessionowns a session-local MCP manager. The source even contains aTODOto extract session state for sharing between subagents:session.rsL1035-L1100.During session construction, Codex calls
McpConnectionManager::new(...)before any model turn or MCP tool call:session.rsL1211-L1263. The manager iterates all enabled servers and starts them eagerly:connection_manager.rsL128-L225.Therefore the cost is:
loaded root sessions + resident subagent sessions×all enabled stdio MCP serversThis happens even when an agent never invokes an MCP tool.
B. V1 completion does not end the child session
The V1 completion watcher waits for a final status and notifies the parent, but it does not call
shutdown_live_agent,remove_thread, or release the registry entry:control.rsL431-L518.Actual V1
close_agentis a different path and explicitly sendsOp::Shutdown, waits, removes the thread, forgets residency, and releases the registry entry:legacy.rsL3-L25.So “agent completed” and “agent session/MCP runtime shut down” are not the same event.
C. V2 intentionally keeps completed agents resident
V2 stores resident subagents in an
AgentControl-scopedVecDeque. It shuts down an unloadable completed/errored/interrupted agent only when a later reservation finds that this root session's residency capacity is full and LRU eviction runs:residency.rsL79-L149,residency.rsL216-L230.The cap is derived from
max_concurrent_threads_per_session, not a global Desktop/app-server process or memory budget:config/mod.rsL1462-L1475.This behavior was intentionally introduced by PR #26632. PR #26994 then renamed the V2 model-facing
close_agentoperation tointerrupt_agentspecifically so the agent remains registered and reusable.The design is coherent for durable logical identity, but it accidentally couples logical durability to a heavyweight live MCP runtime. With many root tasks, each root can remain under its per-session cap while the app-server's global process/RSS total grows without a bound.
D. Desktop unsubscribe also retains the whole session for 30 minutes
The app-server contract explicitly says that
thread/unsubscribekeeps the thread loaded for 30 minutes after the last subscriber/activity:app-server READMEL162.Keeping lightweight thread state resident may be reasonable; keeping every session's eager stdio MCP processes resident for the same window is the expensive coupling.
Isolated black-box reproduction
I used a separate temporary
CODEX_HOMEand a separate app-server. I did not touch the user's live app-server or tasks.Configuration:
Observed direct children of the isolated app-server:
| Step | Loaded threads | MCP child processes | MCP RSS |
|---|---:|---:|---:|
| Baseline | 0 | 0 | 0 |
| Start one thread | 1 | 1 | ~35 MiB |
| Start five threads | 5 | 5 | ~170-174 MiB |
| Unsubscribe all five; wait and re-list | 5 | 5 | ~170 MiB |
All five
thread/unsubscribecalls returnedstatus: "unsubscribed". A subsequentthread/loaded/liststill returned all five IDs, and all five MCP PIDs remained alive.Control test: I started one additional persisted thread, producing a sixth MCP child.
thread/deletechanged that thread tonotLoadedand immediately reaped exactly its corresponding MCP PID; the five unsubscribed ephemeral sessions and their five MCP children remained. Finally exiting only the isolated app-server reaped all five remaining diagnostic children.This separates the questions cleanly:
The explicit shutdown implementation does call the MCP manager's shutdown path:
handlers.rsL597-L615,connection_manager.rsL461-L472.Field corroboration (sanitized)
In one live app-server snapshot I counted 81 repeated, session-shaped helper bundles. Seventy-six shared one workload cwd. Each complete bundle had the same shape: one Node REPL plus four plugin MCP servers. The local spawn graph contained 7,847 closed and 1,113 open edges; one high-fanout workload had 6,414 closed and 120 open edges across five root parents.
I am not treating an open graph edge as proof that a process is live. The live-process conclusion comes from the OS process tree; the graph counts only corroborate the exposure pattern and show why a per-root cap cannot bound a long-lived desktop globally.
Secondary cleanup defect:
EPERMbecomes a silent terminal stateLocal stdio servers are launched in their own process group:
stdio_server_launcher.rsL263-L280.On shutdown, if
terminate_process_group()errors, the implementation logs a warning and setsshould_escalate = false, so no delayed SIGKILL is attempted:stdio_server_launcher.rsL327-L344. The outer handle has already set itsterminatedatomic and returnsOk(())for the local path:stdio_server_launcher.rsL385-L404.On this exact build I found four warnings with the exact shape:
This is not needed to explain the primary accumulation above, but it can explain survivors after a genuine
close_agent/shutdown attempt. The origin of the intermittent macOSEPERMis not yet isolated; the confirmed bug is that the error is swallowed, prevents fallback, and makes the handle non-retryable.Why PR #19753 did not fully solve this
PR #19753 correctly added explicit MCP shutdown and process handles. The current explicit deletion test proves that path can work. The remaining primary gap is when shutdown is invoked: ordinary agent completion, V2 durable residency, and app-server unsubscribe intentionally retain the session, so the new shutdown method is never reached.
Version attribution
This is not introduced by Desktop build 5488.
I downloaded the official build 5484 package and compared it with the installed 5488 package:
codex-cli 0.145.0-alpha.18arm64;codexbinaries are byte-for-byte identical through the complete executable payload up to theLC_CODE_SIGNATUREoffset (262,827,104); only the signature region differs;thread/loaded/list,thread/unsubscribe,thread/read, andthread/listdiffers in only 138 bytes, all of which are generated import/source-map filename hashes; the lifecycle logic is identical;The same defect family is documented on much older versions in #17832 and this issue. The recent amplification point is the June 8 V2 durable-residency/interrupt design in PRs #26632 and #26994, combined with larger plugin MCP sets and many simultaneously loaded/restored root tasks. It is a long-standing lifecycle defect made more expensive and easier to trigger, not a one-day 5488 regression.
As of current
maincommit315195492c80fdade38e917c18f9584efd599304, the relevant completion, residency, manager-shutdown, and local-process-terminator files retain the same behavior.Suggested fix boundary
Session::new().killpgfailure is surfaced and retried/fallback-cleaned rather than reported as success.Evidence boundary
The primary mechanism is deterministically confirmed by exact release source, a minimal isolated reproduction, version-to-version binary/chunk comparison, and the live process-tree shape. The intermittent cause of macOS
EPERMis the only part I am leaving as an explicitly separate unresolved question; its error-handling defect is confirmed in source.this is the fan-out-inherits-everything pattern. each subagent forks the parent's full stdio MCP set at spawn, so process count tracks how wide you fan out, not whether the task ever calls a tool. the leak is a second bug stacked on that: close_agent reaps the agent but the child stdio trees hang off a different lifecycle, so they outlive it. the shared per-project pool in your expected-behavior list kills both, spawn the servers once, hand subagents handles, nothing to reap on close. linear RSS growth is the tell that spawn and teardown aren't symmetric.
Confirming the same failure mode on Windows 11 Codex Desktop. This appears to be cross-platform session/MCP-runtime retention rather than a Linux-only problem.
Environment
10.0.26100OpenAI.Codex 26.721.11231.0Read-only field snapshot
Snapshot taken 2026-07-30 10:33 AWST while active tasks were left untouched:
codex app-servertree (child ofChatGPT.exe): approximately 345 processes, 17.22 GiB private bytes, and 4.70 GiB working set175×node.exe128×cmd.exe16×node_repl.exe21× PowerShell /pwsh16Context7 instances16Playwright instances16Sentry MCP instances16Node REPL instances48Node command lines matchingchrome-devtools-mcp(the npm wrapper/launcher/server layers produce multiple matching processes per set)No processes were killed and the app was not restarted because long-running tasks were active.
Field reproduction pattern
This matches the lifecycle explanation in the macOS evidence above: logical thread/subagent residency remains coupled to an eagerly started heavyweight MCP runtime. On Windows, npm-backed MCPs also add substantial
cmd.exewrapper fan-out.Expected behavior would be for inactive/completed sessions to release or detach their MCP runtimes, for MCPs to start lazily, or for the app-server to enforce a global process/private-byte/idle-time budget. A Windows-safe diagnostic/cleanup mechanism would also help until the lifecycle is fixed.
I can gather a controlled before/after Windows reproduction later, but I did not disturb the live tasks for this field capture.
Additional high-scale macOS field confirmation on a newer Desktop build (credential-redacted).
Environment
26.727.51351(build6119)codex-cli 0.146.0-alpha.9.226.5.2(25F84), arm64multi_agentandmulti_agent_v2enabledObserved impact and cleanup
Lifecycle correlation
node_replexecution contexts were alive in the same Codex app-server process tree.spawn_agent, 31exec, and 7 wait/message/follow-up events. 71 were also within three seconds of rollout-file creation.Config reload behavior
Disabling one stdio server changed most later context initializations but did not terminate already-running copies. Config disablement therefore is not a cleanup mechanism.
This field dataset matches the session/MCP-runtime retention mechanism already source-traced above and confirms that the defect remains observable in the newer bundled
0.146.0-alpha.9.2build. I have not independently source-traced that exact build's callback path, so the callback-level conclusion here is intentionally limited to the process and timestamp evidence.I can provide credential-redacted PID, PPID, RSS, start-time inventories, and timestamp-correlation tables if useful. No endpoints or credentials are included.
Current-build reproduction (2026-08-14)
macOS, Codex Desktop app
26.803.61601(build6396), CLI0.147.0-alpha.6.5. The mainapp-serverhad been alive ~22.5 h at peak. Parallel workflows / sub-agent threads each spin up their own stdio MCP runtime (opencode-codebase-index-mcp --host codex, launched as annpm execwrapper plus itsnodeserver child). At peak: 218 matching processes (~109 npm+node pairs), all descendants of the live app-server, zero with PPID 1. Live snapshot at time of writing (machine in active use, after the cleanup below): 19npm execwrappers + 19nodeservers, all still direct children of the same long-lived app-server.New causal evidence
Read-only aggregates from the local Codex state database (
thread_spawn_edgesjoined withthreads): 4,555 spawn edges (3,680 open, 875 closed); 5,016 threads withthread_source = 'subagent'(1,785 not archived); 158 child threads created in the last 6 hours; 222 parent threads with 5 or more children. Bursts of child threads are born within the same second/minute during parallel workflows, and each thread carries a full MCP runtime that outlives the sub-agent.Controlled cleanup
Selection based on start-time + cwd correlation with thread metadata (created ±10 min, same cwd, thread not archived, activity <60 min to preserve active ones): 142 stale runtimes stopped, 74 preserved, only 2 respawned afterwards for genuinely active threads. Age alone is a dangerous criterion: one runtime ~14 h 48 old was still serving an active conversation.
Interpretation
This is retention of MCP runtimes per thread/sub-agent under a live app-server, not an EOF-propagation defect of the MCP server and not primarily a watcher storm (see #30408 for the general per-thread angle). Individual processes are mostly idle, but their count and their ~29 OS threads each grow without bound.
Expected invariant
After a sub-agent terminates/closes/is unloaded, the number of MCP runtimes should return toward the number of genuinely active threads; runtimes retained for resume must be detached or lazily recreated.
Evidence availability
Happy to provide a sanitized PID/PPID/start/cwd table (paths hashed) and the aggregation SQL; I won't share the state database or rollouts.
Current stable Linux Desktop confirmation (2026-08-19)
The same session-shaped MCP multiplication is still observable on a newer Linux Desktop release.
Environment
6.14.0-37-generic255.4-1ubuntu8.1726.814.41957codex-cli 0.147.0launched ascodex -c features.code_mode_host=true app-server --analytics-default-enabledcodex-cli 0.148.0-alpha.15, but this app-server was configured to use the local 0.147.0 binary.codex mcp list --json:node_replandclaude_collabLive process census
At a fixed capture after approximately 54 minutes of app-server uptime:
node_repl: 47 direct children, 375.2 MiB aggregate RSS, 705 OS threadsclaude_collabstdio server: 37 direct children, 1,845.0 MiB aggregate RSS, 259 OS threadsThe tree also contained nine deliberately long-lived project helper servers (~372 MiB RSS); I excluded them from the MCP totals above. There were no direct children in uninterruptible
Dstate at capture.This field snapshot does not prove that every one of the 84 instances was stale. It does show that, on the current stable Linux Desktop build, a long-lived app-server can retain dozens of complete per-session MCP runtimes and more than 1,000 associated OS threads in under an hour, despite having only two configured MCP families. The direct PPID relationship and repeated start-time pairs match the per-loaded-session/session-retention mechanism already source-traced above.
Operational impact observed on this machine
During a separate high-pressure incident,
user@1000.servicereached 29.0 GiB memory peak and 7.9 GiB swap peak. Ubuntu's defaultsystemd-oomdrecorded user-slice pressure at 80.66% > 50% for over 20 seconds, then killed the user'sinit.scope, collapsing the GNOME session. The candidate report included a 21.6 GiB high-pressure application scope (PSI avg10 79.93) and live Codex/node_replprocesses.There were also multiple concurrent Rust compile/link jobs in that incident, so I am not attributing the entire 21.6 GiB scope or logout solely to MCP retention. The narrower conclusion is that retained per-session MCP runtimes materially consume memory, swap, PID, and thread headroom, amplifying otherwise legitimate parallel workloads.
Diagnostics that would make future reports easier to action
It would help if app-server diagnostics exposed:
The latest OpenAI documentation describes MCP configuration consistently across Codex surfaces, but currently there is no user-visible mapping from a spawned local MCP PID back to the task/session retaining it. That ownership mapping would also make a safe temporary cleanup tool possible without age-based guessing.
Additional field report from Linux. This is provided as a diagnostic snapshot only; I am not able to perform additional reproduction work or respond to follow-up QA requests.
Environment:
codex-cli 0.149.0codex doctor:0.147.05.15.0-187-genericx86_64Observed behavior:
goplsand telemetry descendants.Resource impact at the snapshot:
goplsprocess.The process topology is consistent with this issue: new MCP generations are added beneath a live app-server, while previous generations are not reaped. Because all generations share the same long-lived parent and session scope, the OS cannot distinguish completed thread/session ownership and will not reclaim them automatically.
Expected behavior: when a thread/subagent/session is completed, cancelled, disconnected, or reinitialized, Codex should terminate and wait for the owned MCP process tree, with a grace period and descendant-kill fallback. Per-generation ownership metadata or process-group/cgroup isolation would also make cleanup deterministic.
Again, this comment is an evidence contribution only. No additional reproduction or follow-up QA support is available from this reporter.