MCP stdio servers leak pipe fds + orphan child processes → cumulative EMFILE ("Too many open files", os error 24)

Open 💬 23 comments Opened Jun 8, 2026 by jacobcxdev
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

MCP stdio servers leak pipe fds + orphan child processes → cumulative EMFILE ("Too many open files", os error 24)

What version of Codex CLI is running?

codex-cli 0.137.0 (also reproduced symptom on long-running 0.12x sessions)

What subscription do you have?

Pro

Which model were you using?

n/a (process/transport bug, model-independent)

What platform is your computer?

Darwin 25.5.0 (macOS 26.5.1) arm64

What issue are you seeing?

Long-running sessions that use stdio MCP servers slowly accumulate open file descriptors in the codex parent process until they hit the per-process soft limit (256 on a default macOS login shell), at which point every subsequent open()/spawn() fails with Too many open files (os error 24).

This is almost certainly the underlying fd leak behind the now-closed openai/codex#17806 ("Repeat Os { code: 24 } crashes"). That issue was closed via [#12216](<https://github.com/openai/codex/issues/12216>), which only hardened the panic in AbsolutePathBuf::parent() so EMFILE no longer crashes the TUI — it did not address why fds are exhausted. The leak is still present.

Evidence (live system)

A single long-lived TUI session (codex resume, soft limit 256) sitting 3 fds under the ceiling:

$ lsof -p <codex_pid> | awk 'NR>1{print $5}' | sort | uniq -c | sort -rn
 186 PIPE        <-- the leak
  38 REG         (state_*.sqlite WAL/shm, rollout jsonl, logs_*.sqlite)
   8 KQUEUE
   7 unix
   5 IPv4
   ...
 253 total       (soft NOFILE = 256 -> next open() = EMFILE)

186 anonymous PIPE fds for a session with \~4–5 configured MCP servers (expected ≈3 pipes/server = \~15). The excess maps almost 1:1 to orphaned MCP child processes that the codex parent still holds read pipes to.

System-wide, orphaned stdio MCP servers far outnumber the live codex sessions that should own them:

$ pgrep -f slack-mcp-server  | wc -l   # 30
$ pgrep -f xcodebuildmcp     | wc -l   # 18
$ pgrep -f "codex app-server"| wc -l   #  9

These are spawned as npm exec <server> / npx, i.e. the actual server (node) is a grandchild under an npm wrapper.

Root-cause analysis (code, codex-rs @ 8f1aad5)

Cleanup of stdio MCP servers has three gaps that compound under the MCP refresh/rebuild churn.

1\. Teardown is fire-and-forget — never awaits process exit

rmcp-client/src/stdio_server_launcher.rs LocalProcessTerminator::terminate (unix):

let should_escalate = terminate_process_group(pgid) /* killpg SIGTERM */ ...;
if should_escalate {
    spawn(move || {                 // detached std::thread
        sleep(PROCESS_GROUP_TERM_GRACE_PERIOD /* 2s */);
        kill_process_group(pgid);   // killpg SIGKILL
    });
}

RmcpClient::shutdown (rmcp-client/src/rmcp_client.rs:678) calls this and returns immediately — the SIGKILL lands ≥2 s later on a detached thread. Nothing awaits actual child exit or pipe EOF. If the runtime/parent is torn down (or the next refresh races) within that window, the SIGKILL thread can be lost, leaving the group alive.

2\. Cancelled-startup path skips explicit terminate entirely

On MCP refresh, core/src/session/mcp.rs::refresh_mcp_servers_inner:

// :341  cancel old startup token
guard.cancel();
// :344  build a brand-new manager — spawns the ENTIRE MCP server set again
let (refreshed_manager, cancel_token) = McpConnectionManager::new(...).await;
// :377  swap in
let mut old_manager = std::mem::replace(&mut *manager, refreshed_manager);
// :379  shut down the old one
old_manager.shutdown().await;

For any old client still mid-handshake, AsyncManagedClient::shutdown (codex-mcp/src/rmcp_client.rs:240) resolves the shared startup future to Err(Cancelled):

match self.client().await {
    Ok(client) => client.client.shutdown().await,
    Err(StartupOutcomeError::Cancelled) => {}   // <-- terminate() never called
    Err(error) => warn!(...),
}

Cleanup then depends entirely on Drop for StdioServerProcessHandleInner, which only fires once the last Arc clone is dropped — and which itself uses the same fire-and-forget terminate from gap [#1](<https://github.com/openai/codex/issues/1>).

The same full-set spawn-then-shutdown churn also runs in the ephemeral connector-discovery manager (core/src/connectors.rs:273 … :359).

3\. Group signalling misses npm/npx grandchildren

Spawn uses bare command.process_group(0) (stdio_server_launcher.rs:263) and signals pgid == direct-child PID. The direct child is the npm/npx wrapper; the real server is node. When the wrapper exits (or the server reparents) the original group no longer covers node, so killpg(SIGTERM/SIGKILL) misses it → orphaned node (the 30/18 above). Note utils/pty/src/process_group.rs already has detach_from_tty/setsid-style helpers, but the stdio MCP launcher does not use a wait-and-verify reap.

Net effect

Each refresh/rebuild cycle spawns a fresh server set and tears down the old one without awaiting death and without reliably reaping the npm grandchild. Every orphan keeps its stdout/stderr write ends open, so the codex-side read pipe + the detached stderr-reader task (stdio_server_launcher.rs:274) never reach EOF and never close. Pipe fds in the codex parent grow monotonically until EMFILE.

Suggested fixes

  1. Await actual exit on teardown. Make shutdown()/Drop waitpid/try_wait the group after SIGTERM→SIGKILL instead of fire-and-forget, so pipes are closed deterministically.
  2. Terminate on the cancelled-startup arm. In AsyncManagedClient::shutdown, call the process terminator on Err(Cancelled) rather than relying on Drop.
  3. Reap the whole group reliably. Either spawn the server binary directly (resolve npm exec/npx to the real entrypoint) or setsid + track the session and kill+wait the session, not just the direct child's PID-as-PGID.
  4. (Hardening) Bound the stderr-reader task to transport lifetime / close the read half on terminate so a lingering grandchild can't pin the pipe.

Workaround for users

Raise the per-process limit before launching (ulimit -n 65536, and/or sudo launchctl limit maxfiles 65536 524288 for GUI-launched servers) and periodically kill orphaned *-mcp-server / npx processes. This only delays the leak.

Related

  • openai/codex#17806 (closed) — same symptom; closed via panic-hardening only, leak not addressed
  • [#12216](<https://github.com/openai/codex/issues/12216>) (closed) — EMFILE panic hardening in AbsolutePathBuf::parent()
  • openai/codex#25243 (open) — distinct root cause (Codex.app re-exec storm vs syspolicyd), not MCP

View original on GitHub ↗

23 Comments

github-actions[bot] contributor · 2 months ago

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

  • #25744
  • #25742
  • #25697
  • #26773

Powered by Codex Action

mochafreddo · 2 months ago

I can reproduce the same EMFILE failure on Codex Desktop on macOS.

Environment:

  • Codex Desktop is still running when the failure occurs.
  • ulimit -n: 256
  • launchctl limit maxfiles: previously observed as 256 unlimited
  • Failure observed from Codex tool execution:

Failed to create unified exec process: Too many open files (os error 24)

Current evidence:

  • Even trivial process creation can fail intermittently, including commands like git log and launchctl limit maxfiles.
  • Codex GUI main process had ~259 lsof entries while the soft limit is 256.
  • Related child process counts observed:
  • chrome-devtools-mcp: 33
  • node_repl: 12
  • SkyComputerUseClient: 13
  • codex app-server --listen stdio: 5
  • codex app-server --listen unix: 2

This matches the hypothesis in this issue: long-lived Codex Desktop sessions with MCP/tooling accumulate child processes and file descriptors until the parent process reaches the macOS soft NOFILE limit. At that point, new open()/spawn() calls start failing with EMFILE.

I did not paste full ps output because it contains local workspace paths and prompt content, but I can provide more sanitized diagnostics if useful.

Impact:
This blocks normal development because once the parent process reaches the FD limit, Codex cannot reliably run even simple shell commands. Restarting Codex temporarily recovers the session, but the counts grow again in long-running sessions.

twentyOne2x · 2 months ago

Adding a current Desktop datapoint and linking the broader report: #27601.

Environment:

Codex Desktop 26.608.12217 / build 3722
Bundled CLI 0.138.0-alpha.7
macOS 26.3 / Darwin 25.3.0 arm64
launchctl maxfiles soft limit: 256

Current helper counts after some recovery work:

codex app-server --listen stdio:// helpers: 40
node_repl helpers: 40

Earlier in the same investigation, helper counts were higher and turn/start requests timed out with high pending counts. This lines up with a cumulative helper/stdio lifecycle pressure class: Desktop eventually gets into a state where thread loads take minutes and turn/start times out, then hard quitting/reopening Desktop improves it temporarily.

mochafreddo · 2 months ago

I prepared and locally verified a candidate fix for this on a fork branch:

Summary of the fix:

  • local MCP stdio shutdown now closes stdin first, waits briefly for EOF-driven clean exit, then terminates only if needed
  • local stdio child/process-group cleanup now waits/reaps the child and escalates to kill after the grace period
  • executor process API now has process/closeStdin, so MCP stdio launched through an executor can also receive stdin EOF before termination
  • remote coverage exercises the websocket exec-server path: RemoteProcess -> ExecServerClient -> process/closeStdin -> server close_stdin_process, which is the path relevant when local Codex controls a remote executor over SSH/exec-server
  • added regression coverage for local stdio, executor stdio, remote executor stdio, wrapper/grandchild cleanup, and initialized shutdown with an in-flight operation

Verification run locally:

  • just test -p codex-core unified_exec
  • just test -p codex-rmcp-client --test process_group_cleanup shutdown_closes_remote_executor_stdio_before_terminating_server
  • just test -p codex-exec-server
  • just test -p codex-rmcp-client
  • just fmt
  • just fix -p codex-exec-server
  • just fix -p codex-rmcp-client

I cannot open an upstream PR from this account because the repository currently limits PR creation to collaborators. If this approach aligns with the intended direction, please invite/enable a PR from this branch or feel free to cherry-pick the branch.

Nicolas0315 · 2 months ago

Additional live data point: in a long-running macOS Codex Desktop session, the failure can progress to the point where Codex cannot spawn even trivial diagnostics from the session. exec_command is now failing with Failed to create unified exec process: Too many open files (os error 24), including for commands such as pwd or ulimit -n.

That matches the EMFILE behavior described here, but the practical impact is that once the fd ceiling is reached, the affected session cannot collect normal follow-up evidence or run local mitigation commands through Codex itself. I’m avoiding raw process/log output here because it may include local paths or private MCP details.

Nicolas0315 · 2 months ago

Additional live datapoint from the same macOS Desktop session:

ulimit -n: 256
launchctl maxfiles: 256 unlimited
Codex GUI process: 1
codex helper processes: 6
node processes: 37
node_repl processes: 6
chrome-devtools-mcp processes: 13
Codex GUI lsof entries: 233

Immediately before this capture, the session hit Failed to create unified exec process: Too many open files (os error 24) while trying to run read-only diagnostics such as git diff and nl. Closing a background subagent reduced pressure enough that pwd and the sanitized count commands started working again.

This seems consistent with a soft-limit pressure pattern rather than a single permanently wedged command path: the parent can be below the 256 soft limit after recovery, but transient helper/subagent/MCP activity can still push it over the edge and make process creation fail. I’m still avoiding raw ps/lsof details because they can include local paths or private MCP arguments.

Nicolas0315 · 2 months ago

Adding a current macOS Desktop/CLI datapoint from a long-running multi-surface setup.

Snapshot time: 2026-06-28 11:26 JST

Environment:

  • Codex Desktop: 26.623.42026, build 4514
  • Codex CLI / app-server: 0.142.3
  • Platform: macOS arm64
  • codex doctor --json: overallStatus=ok
  • MCP config: 13 configured servers, 4 disabled, 7 stdio, 6 streamable HTTP
  • launchctl limit maxfiles: 256 unlimited
  • shell ulimit -n: 1048575

Current process / FD pressure:

standalone codex app-server --listen unix:// lsof entries: 258
chrome_devtools_mcp processes: 59
playwright_mcp processes: 30
computer_use_mcp processes: 41
computer_use_event_stream processes: 22
node_repl processes: 22
xcodebuildmcp processes: 40
codex_app_server matched processes: 20

The important operational detail is that codex doctor is green while the long-lived app-server/helper surface is still over the launchd soft FD limit. In practice, once this state is reached, the session can feel slow or fail at ordinary process/tool startup even though the basic health checks pass.

I am keeping this sanitized and omitting raw process args, full local paths, and private MCP/project details. Happy to collect a narrower maintainer-requested probe if there is a preferred diagnostic shape.

CountofGlamorgan · 1 month ago

Confirming this leak is still present on the current stable 0.144.3, and adding a second reproduction surface: the long-lived Desktop SSH app-server, not just a TUI / codex resume session.

Reproduction surface: the shared Desktop SSH app-server

Codex Desktop (SSH remote) multiplexes every project window onto a single long-lived parent:

codex -c features.code_mode_host=true app-server --listen unix://

That one process holds the read pipes to the stdio MCP child of every session it has ever served. Live snapshot from a ~27h-old app-server (0.144.1):

# orphaned stdio MCP proxy processes still parented to the app-server
$ ps -axo pid,ppid,command | grep '[m]cp-proxy' | awk '$2==<app_server_pid>' | wc -l
31

# fds held by the app-server parent
$ lsof -p <app_server_pid> | awk 'NR>1{print $5}' | sort | uniq -c | sort -rn
 255 PIPE     <-- the leak
  39 REG
   5 unix
   3 KQUEUE
   ...

255 anonymous PIPE fds for what should be a handful of live MCP servers. On a default macOS login shell (RLIMIT_NOFILE soft = 256) this parent would already be throwing os error 24; it only survives because we pre-raised the soft limit to 65536. Peak before a manual sweep was 96 orphaned proxy processes machine-wide (35 under the app-server), ~5 GB RSS.

Because all Desktop projects share this one app-server, when it finally hits EMFILE every project's Codex dies simultaneously — spawn, openpty, and DNS resolution all start failing at once.

Still leaking on 0.144.3

A freshly-started 0.144.3 app-server accumulated 3 orphaned proxies within ~2h of uptime. This is consistent with the changelog: rust-v0.144.1..rust-v0.144.3 contains only a Guardian auto-review revert (#32672) plus a "version-only" 0.144.3 with no merged changes — nothing touches MCP transport or process teardown.

Extra data point for the root-cause (stdin is never closed)

The stdio MCP here is a plain node proxy.mjs (not an npx/npm exec grandchild), and the script explicitly handles clean shutdown:

process.stdin.on('end', () => process.exit(0));

…but that 'end' never fires, because the app-server never closes the child's stdin write end (nor awaits its exit) on session teardown. The child blocks forever on a stdin that will never EOF. So beyond the "fire-and-forget terminate, never awaits exit" gap already noted here: even a well-behaved child that self-exits on stdin EOF cannot clean itself up, because the parent keeps the pipe's write end open. That narrows it away from any npm/npx-wrapper theory — a single-process node child leaks the same way.

Env: macOS 26 (Darwin 25.x) arm64 · codex-cli 0.144.1 → 0.144.3 · Desktop SSH remote host.

MQ-EL · 1 month ago

Adding a precise multi-agent lifecycle datapoint from stable codex-cli 0.144.3 on macOS arm64. This confirms the same failure and narrows why subagents amplify it.

Failure

After a long multi-agent session, even:

pwd

failed before spawn with:

Failed to create unified exec process: Too many open files (os error 24)

The kernel-wide file table was healthy (8154 / 122880), so this was process-local.

Parent FD and child-process snapshot

numeric FDs: 254
highest FD: 254
PIPE: 174
REG: 43
soft maxfiles limit: 256

The Codex parent had 58 direct local MCP helper children:

node_repl: 15
codebase-memory-mcp: 15
chrome-devtools-mcp: 14
mcp/server.mjs: 14

The correlation is exact:

58 live stdio MCP children × 3 parent-side stdio pipe FDs = 174 PIPE FDs

Why subagents multiply MCP processes

These subagents are logical Codex threads inside one Codex OS process, not one OS process per subagent. The control plane is shared across the agent tree, but each spawned thread creates its own session and MCP connection manager. Consequently, every enabled local stdio MCP entry normally starts a separate helper process for every resident logical thread. (Remote HTTP/SSE MCPs do not create local helper processes.)

A current source checkout also shows an important lifecycle detail: V2 residency treats completed, errored, and interrupted threads as unloadable, but LRU unloading is attempted only when the configured logical-agent capacity is reached. An interrupt only cancels the current turn; it is not a shutdown, so I am not counting lack of cleanup on interrupt itself as the bug.

In this run, the agent capacity exposed to the session was 200, while four local stdio MCPs cost roughly 12 parent pipe FDs per resident thread. The process therefore hit the macOS soft FD limit long before the logical-agent residency limit could trigger eviction.

Trigger evidence

  • The logical tree had 15 non-root agents, including agents already completed and therefore eligible for unload.
  • Their MCP helpers remained live and sleeping so the resource count accumulated across the session.
  • The first additional read-only agent started a complete four-helper MCP bundle.
  • The second additional agent started only two of four helpers before the parent reached 254 FDs.
  • The root and both new agents then failed to spawn even a trivial command.
  • A second independent long-lived Codex process showed the same signature: 252 numeric FDs, 189 PIPE FDs, and 63 direct MCP children.

Interpretation and suggested regression boundary

Dedicated per-thread MCP runtimes may be intentional for cwd, permissions, cancellation, event routing, and stateful tools. The failure is the resource-budget mismatch: residency is bounded by logical thread count, but does not account for per-thread stdio process/FD cost or the process RLIMIT_NOFILE. Partial MCP startup also needs atomic rollback.

A robust fix could keep persisted agent metadata while unloading eligible thread runtimes before the FD low-water mark, and should close/await all helper processes plus parent pipe handles. A regression test should spawn and complete N agents with K local stdio MCPs, then assert that helper-process and parent-FD counts remain bounded and return to baseline after eviction/shutdown; it should also force failure partway through an MCP bundle and verify rollback.

Turtle-Hwan · 1 month ago

Confirming this leak is still present on 0.144.5 (newer than the 0.144.3 confirmations above), and adding two things I haven't seen upthread:

  1. an accumulation rate datapoint — the fd fingerprint others reach in ~27h, this host reached in 12 minutes; and
  2. two timer/turn-driven spawn paths that leak independently of session or MCP-refresh churn, one of which has its own open issue (#23119) where the fd angle has never been raised.

Environment

codex-cli 0.144.5
macOS arm64 (Darwin 25.5.0)
launchctl limit maxfiles: 256 unlimited
shell ulimit -n: 1048576
surface: codex -c features.code_mode_host=true app-server --listen unix://

Failure (app-server stderr, on 0.144.4)

ERROR codex_core::tools::router: error=exec_command failed for `/bin/zsh -lc '<gradle test cmd>'`:
  CreateProcess { message: "Rejected(\"Failed to create unified exec process: Too many open files (os error 24)\")" }
ERROR codex_core::tools::router: error=exec_command failed for `/bin/zsh -lc '<node -e cmd>'`:
  CreateProcess { message: "Rejected(\"Failed to create unified exec process: Too many open files (os error 24)\")" }

Kernel-wide file table was healthy throughout, so this is process-local, as established upthread:

kern.num_files: 7625 / kern.maxfiles: 122880   (6%)
kern.maxfilesperproc: 61440

Live snapshot — a 12-minute-old app-server

Process uptime at capture: 12m17s. One stdio MCP server of interest (node_repl, the OpenAI-bundled one shipped inside the desktop app).

$ lsof -p <app_server_pid> | awk 'NR>1{print $5}' | sort | uniq -c | sort -rn
 142 PIPE        <-- the leak
  49 REG
  11 IPv4
   6 unix
   3 KQUEUE
   ...
 216 total       (soft NOFILE = 256)

Every node_repl was still parented to that one app-server, spawning steadily and never exiting:

$ ps -eo ppid,command | grep [n]ode_repl | awk '{print $1}' | sort | uniq -c
   9 <app_server_pid>

spawn times (all children of that single app-server):
  14:34:36  14:34:48  14:34:59  14:35:02  14:38:07
  14:40:21  14:42:51  14:43:28  14:43:56

216 fds / 142 PIPE / 9 orphaned MCP children in ~12 minutes → 84% of the 256 ceiling, i.e. ~18 fd/min. At that rate this app-server would have hit EMFILE in under half an hour. That is far faster than the long-session reports upthread, which points at a driver that is not proportional to session count or user activity.

---

Two spawn paths that leak on a timer / per turn, not per session

The OP pins teardown on LocalProcessTerminator::terminate being fire-and-forget — nothing awaits child exit or pipe EOF. I want to point at two callers that hit that path repeatedly and unattended, which I think explains the rate above and the "it leaks even while idle" reports.

(a) codex_models_manager model refresh — see #23119

This host logged 63 occurrences of the following over ~43h, on a base period of ~183s (measured intervals cluster hard at 185s and 366s ≈ 2×183, i.e. a ~3-minute timer with skips):

ERROR codex_models_manager::manager: failed to refresh available models: timeout waiting for child process to exit

That string is CodexErr::Timeout from codex-rs/protocol/src/error.rs — it means the parent gave up waiting for the child to exit. That is definitionally a child the parent stopped reaping, on a timer, forever, for the entire life of the app-server.

Both EMFILE failures above were immediately preceded by a run of this exact error.

This is filed separately as #23119 ("...every 15-45s", open since May, zero comments), where it is treated purely as stderr noise — the word "fd" does not appear in it. I think the two issues are the same bug seen from opposite ends: #23119 is the symptom log line, this issue is the resource consequence. Worth cross-linking, and #23119's reported 15-45s cadence is ~4-12× faster than this host's 183s, which would make it a much more aggressive leak driver than anything measured in this thread so far.

(b) The notify hook — one unreaped child per turn

Default desktop config wires the Computer Use client in as the turn-end notifier:

notify = ["<...>/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient", "turn-ended"]

This host had 26 orphaned SkyComputerUseClient processes, all reparented to init (ppid=1), spawned across two days 19 days before capture and still resident — they survived every desktop app restart in that window and had to be reaped by hand. Their spawn timestamps are scattered a few minutes apart across two long evening sessions, which is exactly the shape of per-turn notify invocations rather than anything periodic.

So notify looks like a third path onto the same unreaped-child problem, and a nastier one: these orphans outlive the app entirely, so a user who "fixes" EMFILE by quitting and relaunching Codex still carries them.

Why the mitigations discussed upthread don't hold

Raising launchctl limit maxfiles only moves the ceiling — with a ~3-minute timer and a per-turn notify both leaking unconditionally, growth stays monotonic and unbounded in session length. Same for quitting/relaunching Desktop: it resets the app-server's fd table but leaves the (b) orphans behind.

Turtle-Hwan · 1 month ago

@jif-oai — following up on my datapoint above with a code-level root cause, since this sits directly on top of #29608 and #30101.

Short version: the guarantee #29608 added no longer holds on main. #30101 replaced the explicit shutdown of a superseded manager with an Arc-lifetime rule, but nothing in the current teardown path actually reaps a stdio child when that Arc is released — so superseded managers keep their MCP processes (and the parent's pipe fds) forever.

What #29608 established

// core/src/session/mcp.rs, PR #29608
-        self.services.mcp_connection_manager.store(Arc::new(refreshed_manager));
+        let superseded_manager = self.services.mcp_connection_manager.swap(Arc::new(refreshed_manager));
+        superseded_manager.shutdown().await;

Explicit, awaited, refcount-independent.

What main does now

#30101 moved publication into ServiceState::publish_mcp_runtime (core/src/state/service.rs:127), which is synchronous and therefore cannot .await a shutdown:

pub(crate) fn publish_mcp_runtime(&self, ..., manager: McpConnectionManager) -> Arc<McpRuntimeSnapshot> {
    let manager = Arc::new(manager);
    self.mcp_connection_manager.store(Arc::clone(&manager));   // store, not swap
    ...
    self.mcp_runtime.store(Some(Arc::clone(&runtime)));
    runtime
}

swap(...) + superseded_manager.shutdown().await is gone, and grepping core/src there is now no shutdown of a superseded manager anywhere — the only surviving manager.shutdown() calls are connectors.rs:334 (the short-lived codex_apps-only discovery manager) and an unrelated conversation.shutdown().

That is deliberate: #30101 states the intended rule as "Lets an old runtime live only while an in-flight step or request still holds its Arc." The intent is sound. The problem is the second half of that sentence never happens.

Why releasing the last Arc does not reap the child

Drop for McpConnectionManager (codex-mcp/src/connection_manager.rs:997) is synchronous, so it can only drop Arcs:

fn drop(&mut self) {
    self.startup_cancellation_token.cancel();
    self.clients.clear();
}

The only drop path that actually signals a local child is Drop for StdioServerProcessHandleInner (rmcp-client/src/stdio_server_launcher.rs:407), which fires when the last Arc<StdioServerProcessHandleInner> dies. But there are two live clones of that handle, created at launch:

  • rmcp_client.rs:372 — RmcpClient.stdio_process takes transport.process_handle(), an Arc clone;
  • rmcp_client.rs:930 — the transport itself (holding the original handle and the TokioChildProcess pipes) is moved into service::serve_client(...) and lives on inside ClientState::Ready { service: Arc<RunningService<..>> }.

So clients.clear() drops one clone and the Arc<RunningService> handle; it does not stop rmcp's service, and the handle's refcount does not reach zero, so nothing SIGTERMs the child and the transport's pipe fds stay open in the parent.

The asymmetry is telling: RmcpClient::shutdown() (rmcp_client.rs:737) does not rely on Drop — it calls process.terminate() explicitly and only then drops the previous state. That async path is the only teardown that reliably works, and after #30101 nothing calls it for a superseded manager.

This matches the runtime behavior exactly

The orphans I snapshotted are live processes, not zombies, all still parented to the app-server, with the parent holding their pipes — precisely what "handle refcount never hits zero, so no SIGTERM, and the transport is never dropped" predicts:

codex-cli 0.144.5, app-server uptime 12m17s, soft NOFILE = 256

  216 total fds   (142 PIPE)
    9 node_repl children, all ppid = <app_server_pid>, none exiting

~18 fd/min, unbounded in session length. Under the Arc rule as written, a refresh-heavy session leaks one stdio child per superseded manager, permanently.

The regression test encodes only half the invariant

core/tests/suite/mcp_refresh_cleanup.rs was inverted by #30101, from "superseded server is shut down" to:

async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() {
    ...
    assert!(process_is_alive(&superseded_pid)?);
    assert!(process_is_alive(&replacement_pid)?);
}

It asserts the keep-alive half and never asserts the eventually-reaped half, so the leak is invisible to CI. A complementary assertion — after the in-flight call completes and the step ends, superseded_pid is reaped within some bound — would fail on main today, and would pin the invariant #30101 intended.

Possible directions

  1. Make the Arc rule enforceable rather than implicit (closest to #30101's intent). Have publish_mcp_runtime swap() instead of store() and hand the superseded manager to an owner that can .await — e.g. spawn a cleanup task that waits for in-flight holders to drain (an explicit in-flight guard on McpRuntimeSnapshot is more honest than polling Arc::strong_count) and then calls shutdown().await. Preserves #30101's semantics; restores #29608's guarantee.
  2. Drain superseded managers at a step/turn boundary — keep a session-level registry of superseded runtimes and shut them down once no step holds them. Simpler to reason about than lifetime-driven cleanup, at the cost of an explicit list.
  3. Independently, make teardown itself sound. Even when shutdown() is called, LocalProcessTerminator::terminate (stdio_server_launcher.rs:328) is fire-and-forget: SIGTERM the process group, then a detached std::thread sleeps 2 s and SIGKILLs, while shutdown() returns immediately and nothing awaits child exit or pipe EOF. Closing stdin first and awaiting exit before escalating (as @mochafreddo prototyped upthread) would make the reap deterministic. This is orthogonal to (1)/(2) — it fixes teardown quality, not the fact that teardown is never invoked.

I'd treat (1) or (2) as the actual fix here and (3) as hardening.

Caveat on rigor: the fd/process numbers above are measured on this host, but the ownership analysis is read from main — I have not built a patched binary to confirm, and I've since disabled the offending server locally, so I no longer have a live repro to bisect against.

dchjmichael · 1 month ago

Any progress? I've encountered four container crashes caused by the "too many open files" error this week, which has seriously affected my work.

ZhangJinH · 1 month ago

I also encountered the same problem. Could you please tell me how the repair is progressing.

tarrific · 1 month ago

Reproduced again on codex-cli 0.145.0-alpha.27 on macOS arm64, with a complete per-thread bundle shape that matches the multi-agent analysis above.

Sanitized snapshot immediately before recovery:

numeric FDs: 256 (soft limit: 256)
PIPE FDs: 195
direct helper children: 65
bundle shape: 13 groups x 5 helpers
helpers per group: 2 node + 1 node_repl + 1 npm + 1 python3
failure: Failed to create unified exec process: Too many open files (os error 24)

Terminating those 65 direct children reduced the parent from 256 to 61 FDs. In a later verification run, this same still-open session intermittently hit EMFILE again during parallel process creation, then recovered enough to spawn serial checks. That reinforces that this is process-local FD pressure tied to resident helper bundles, not a kernel-wide file-table problem.

The regression boundary I would add is: spawn/complete enough logical agents to create N complete local stdio bundles, release/evict their runtimes, and assert both (a) helper process count and parent PIPE FDs return near baseline and (b) a partially-started bundle is rolled back atomically. Raising RLIMIT_NOFILE delays the failure but does not fix lifecycle cleanup.

fusengine · 1 month ago

Confirmed the same symptom is still observable on a newer build:

  • macOS arm64
  • codex-cli 0.145.0
  • Multi-Agent V2 enabled
  • multiple global stdio MCP servers
  • installed binary SHA-256: 1da3f4e0e96028b8a771814293c3033dafd1971f943f6c7e79b0897fe705f590

During a delegated design/exploration workload with repeated subagent spawn/completion, I repeatedly observed:

PostToolUse hook (failed)
error: Too many open files (os error 24)

and later the same error from UserPromptSubmit.

I inspected the exact rust-v0.145.0 source at commit 25af12f7e61572b0bc18ddb1008be543b91519b0. A plausible contributing lifecycle path is that completed V2 subagent threads remain loaded in ThreadManagerState until residency capacity forces eviction. While loaded, the session retains its MCP runtime/connection manager and transports. This is source-supported lifecycle evidence, not yet a controlled proof that this retention alone caused the observed EMFILE.

I built an experimental proactive-eviction change to test that path:

  • cargo check -p codex-core --tests: passed
  • focused residency tests: 4/4 passed
  • unload/reload regression: 1/1 passed with the repository's 8 MiB test stack

I am not publishing that patch as a verified fix: an existing handler regression around captured_ops() still fails, and a termination-versus-direct-enqueue interleaving remains unproven. The useful new data point here is that the EMFILE symptom persists in 0.145.0 and is readily triggered by a Multi-Agent V2 workload with stdio MCP servers.

angelrancelantigravity · 29 days ago

Additional confirmation that this persists on Codex CLI 0.146.0 on Linux, using codex app-server --remote-control with several stdio MCP servers.

After roughly nine hours of remote/subagent activity, the long-lived app-server had approximately 540 open descriptors: about 213 sockets and 206 pipes. Its direct children included repeated cohorts of the configured stdio MCP servers, with several cohorts having start times consistent with earlier completed work. Native spawning eventually surfaced No file descriptors available near the default soft nofile limit.

Raising the live app-server soft limit from 1024 to 8192 restored headroom without a restart, but the descriptor count and repeated cohorts remained. This is mitigation only, not cleanup.

I did not use PID/name-based cleanup: parent PID, argv, cwd, or process group alone could not prove which cohort belonged to a completed versus still-live remote task. A safe product-level fix/diagnostic would expose:

  • stable remote task/thread/session → MCP cohort ownership;
  • terminal lifecycle and stream-EOF/closure evidence;
  • MCP pooling/reuse disposition;
  • a scoped graceful shutdown primitive with an ACK that the exact cohort exited.

That would allow cleanup to be generation/identity-bound instead of relying on broad pkill, PPID, or process-group heuristics. No user paths, session identifiers, credentials, or command payloads are included in this report.

mdliss · 18 days ago

Still present on codex-cli 0.147.0, macOS arm64.

Long-lived codex app-server --listen unix:// daemon, ~4 days of TUI use: 153 of ~250 fds are leaked pipes (lsof -p <pid> | grep -c PIPE), right at the 256 soft limit macOS gives non-terminal processes. Every TUI start then fails with Too many open files (os error 24). Roughly 2 pipes leak per session and are never reclaimed until the daemon restarts.

Workaround: ulimit -n 1048576 in the daemon's boot path (hard limit is unlimited, no sudo needed). Masks it, doesn't fix it.

w00ing · 16 days ago

Adding a current macOS Desktop datapoint that connects this FD leak to the generic UI symptom Reconnecting x/5 / stream disconnected before completion.

Environment

Codex Desktop / ChatGPT macOS bundle: 26.803.61601 (build 6396)
Bundled CLI: codex-cli 0.147.0-alpha.6.5
macOS: 26.5.1 (Darwin 25F80), Apple Silicon
launchctl maxfiles: 256 unlimited
terminal shell ulimit -n: 1048575
agents.max_concurrent_threads_per_session: 6

User-visible failure

The Desktop UI repeatedly showed:

Reconnecting 5/5
stream disconnected before completion: error sending request for url
(https://chatgpt.com/backend-api/codex/responses)

The standalone CLI continued to work from Terminal.

App-server snapshot

The affected long-lived Desktop app-server was the bundled codex app-server using a localhost WebSocket listener. Sanitized snapshots around the failure:

failure snapshot:
  numeric FDs: 252
  PIPE FDs: 160
  direct helper children: 53

follow-up snapshot:
  numeric FDs: 248
  PIPE FDs: 166
  direct helper children: 55
    MCP server helpers: 20
    node_repl: 19
    node: 15
    code-mode-host: 1

The kernel-wide file table was healthy; pressure was local to the Desktop app-server process.

Evidence linking EMFILE to the reconnect error

macOS unified logging recorded 571 messages for that app-server between 2026-08-12 03:33:56 and 04:25:24 local time:

dnssd_clientstub deliver_request: socketpair failed 24 (Too many open files)

The visible reconnect/stream error occurred inside that interval. This suggests the accumulated pipe FDs exhaust the app-server's per-process limit, after which DNS-SD cannot create a socketpair and the request to chatgpt.com/backend-api/codex/responses fails. Network/DNS/TLS checks from a separate Terminal process were normal, which also explains why the standalone CLI still worked.

I found and terminated three additional reparented Codex JS kernels (PPID 1, roughly two days old), but the Desktop app-server itself still retained the repeated direct helper cohorts and remained close to its 256-FD ceiling. Raising launchctl maxfiles or reducing agent concurrency only delays/mitigates the failure; deterministic helper shutdown and pipe closure appear to be the required fix.

No local paths, session IDs, account data, or listener ports are included here.

vcheckk · 15 days ago

Linux TUI reproduction on stable 0.147.0: FD exhaustion blocks thread/fork and transcript persistence

Independent confirmation of this descriptor leak on Linux, with two additional
failure paths: side-conversation forking and active transcript persistence.

Environment

  • codex-cli 0.147.0
  • Linux x86_64
  • Long-running interactive Codex TUI
  • Process uptime: approximately three days
  • RLIMIT_NOFILE: soft 1024, hard 1048576

Failure

Opening a side conversation failed with:

Failed to start side conversation: thread/fork failed during TUI bootstrap:
thread/fork failed: failed to read thread: thread-store internal error:
failed to read session metadata <CODEX_HOME>/sessions/.../<rollout>.jsonl:
No file descriptors available (os error 24) (code -32603)

The same process could no longer persist the active conversation:

Failed to save the conversation transcript; Codex will continue retrying.
Error: thread-store internal error: failed to read session metadata ...:
No file descriptors available (os error 24)

The rollout file existed and had normal ownership and permissions. It was
already open by the affected TUI, so this was not a missing-file or permission
failure.

Process-level evidence

Snapshot immediately before the affected process exited:

open descriptors:       1013 / 1024
pipe descriptors:        645
pidfd descriptors:       214
regular files:             81
sockets:                   53
other descriptors:         20
direct child processes:   215

The accounting matches retained stdio children:

215 children × 3 stdio pipes = 645 pipe descriptors

The process contained repeated cohorts of stdio helper children. This confirms
lifecycle retention, although this observation alone does not attribute every
retained cohort specifically to the final fork attempt.

The system-wide file table remained healthy; exhaustion was local to the Codex
process. After that process exited, its descriptors and child processes were
released. A fresh TUI started near its normal descriptor baseline.

Impact

At the descriptor ceiling, Codex cannot:

  • fork or open a side conversation;
  • read thread metadata through the thread store; or
  • persist the active transcript.

The transcript failure introduces a risk of losing the most recent conversation
state, beyond the tool-spawn failures already documented in this issue.

Suggested regression coverage

  1. Repeatedly start, close, cancel, and fail side-conversation or thread/fork

bootstrap with multiple configured stdio MCP servers.

  1. Assert that child, pipe, and pidfd counts return close to baseline after each

lifecycle transition.

  1. Cover cancellation and partially initialized helper cohorts.
  2. Test under a deliberately low soft RLIMIT_NOFILE.
  3. Ensure descriptor exhaustion is surfaced explicitly without implying that

the rollout file is missing or corrupt.

  1. Ensure transcript persistence either retains sufficient descriptor headroom

or performs controlled recovery instead of retrying indefinitely.

This appears to share the underlying cause tracked in #26984 and #30408, with a
TUI-bootstrap failure surface related to #37971.

servrox · 14 days ago

🐧 WSL2 persistent app-server reproduction on stable 0.147.0

Adding a WSL2 datapoint for the persistent app-server path. This occurred with NixOS on WSL2 and Codex Desktop connecting to an app server launched through SSH:

codex -c features.code_mode_host=true app-server --listen unix://

After approximately 13.5 hours, the app server had inherited an RLIMIT_NOFILE soft/hard limit of 1024/1048576 from sshd. During inspection:

open FDs:             998 -> 1018 / 1024
direct child processes: 198 -> 201

The 998-FD snapshot included:

pipe:          597
anon_inode:    210
regular file:  158
socket:         29

Repeated direct-child cohorts included next-devtools, shadcn, CodeGraph, Playwright MCP, Chrome DevTools MCP, node_repl, and other stdio MCP servers.

At the ceiling, a fresh TUI failed during bootstrap:

configRequirements/read failed: failed to read configuration layers:
No file descriptors available (os error 24)

thread/start failed: failed to load configuration:
No file descriptors available (os error 24)

$HOME/.codex/plugins/.../vercel/0.21.4/skills/workflow/SKILL.md:
failed to read file: No file descriptors available (os error 24)

The referenced skill file existed with normal permissions and was readable from another process, so it was another open() victim rather than a malformed or missing file.

As an immediate mitigation, this raised the limit on the already-running app server without restarting it:

prlimit --pid <app-server-pid> --nofile=65536:1048576

I also configured sshd.service with LimitNOFILE=65536:1048576 so future app-server processes inherit a higher soft limit. This only adds headroom: the retained child processes and descriptors remain, so it does not address the underlying lifecycle leak.

The WSL2 app-server behavior appears consistent with the same MCP/helper cleanup problem already documented here.

phulin · 14 days ago

This appears to still be an issue on Linux. I just ran into it using the Lean MCP.

daubas · 8 days ago

Independent confirmation on macOS, plus a mitigation that goes one step beyond raising ulimit — periodic reaping of idle MCP children. Sharing it because every workaround in this thread so far only enlarges the budget, and enlarging the budget alone did not hold for us.

Environment

codex-cli 0.146.1
macOS 26.6.1 (Darwin 25.6.0), arm64 (M1 Mac mini, headless, always-on)
launchctl limit maxfiles: 256 unlimited
codex app-server --listen unix://   (SSH-bootstrapped, long-lived: current uptime 1d 20h)
Clients: desktop + iOS + iPadOS Codex apps connecting to the same app-server

Confirming the leak shape

Steady-state measurement on the live daemon:

node_repl children of app-server : 19
PIPE fds held by app-server      : 59
ratio                            : 3.10

Three pipes per child (stdin/stdout/stderr), matching the ~2/session figure others reported here. The children are /Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node_repl, spawned per session and never reaped.

One observation that may help narrow the root cause

We found children that had been idle for four days still alive, with the app-server still holding their pipe fds. Two consequences follow:

  1. The parent never closes its side of the pipes. So the child's stdin never sees EOF.
  2. Therefore any shutdown path in the MCP server that relies on stdin EOF cannot fire.

Point 2 is not theoretical for us. We maintain an in-house stdio MCP server and first tried to fix our own leak by handling stdin 'end'/'close' — it never triggered. (Incidentally, @modelcontextprotocol/sdk's StdioServerTransport does not listen for those events either, but that turned out to be moot: even a correct implementation would never be woken up.) Only an internal idle timeout worked, because it is the one mechanism that does not depend on the parent doing anything.

If that generalises, it means MCP server authors cannot fix this from their side via the transport, and the fix has to be in the parent.

Mitigation: raise the limit and reap idle children

Raising ulimit alone was not enough here. We measured bursts of +74 fds in a single minute, so a larger budget just moves the wall.

Layer 1 — raise the soft limit. A wrapper earlier on PATH than the real binary, since the SSH bootstrap prepends ~/.local/bin:

#!/bin/sh
ulimit -S -n 1024 2>/dev/null || true
exec /opt/homebrew/bin/codex "$@"

We deliberately stopped at 1024 rather than 65536. The fd limit is also the only brake on process accumulation; with a very high limit the failure mode changes from "EMFILE" to "hundreds of node processes until memory pressure", which is harder to diagnose and worse to recover from.

Layer 2 — periodic reaping. A launchd agent runs every 60s, samples the app-server's fd usage, and when it crosses a threshold terminates idle children. Selection criteria matter more than the killing:

  • direct children of the app-server only
  • no descendants of their own — a child with descendants may be hosting live work
  • idle for at least 300s — anything younger may belong to an active session

Everything comes from a single ps -axo pid=,ppid=,etime=,comm= snapshot; per-child ps calls were too slow to finish within the interval.

Results

Over 24h on a normal working day:

15 reaping runs, 308 children reclaimed
fd_used: mean 83.5, peak 218 (limit 1024)
EMFILE occurrences: 0

Typical run: fd 200 → 128. Before this, the daemon reached the 256 wall in roughly a day and needed a manual restart, dropping every connected client.

Two things that cost us time

kill on a whitespace-joined PID list silently does nothing. In zsh, kill -TERM "$pids" passes one invalid argument; with 2>/dev/null the error vanishes and the script reports success. Use xargs -n1 kill -TERM and verify each PID afterwards with kill -0.

Detecting whether the raised limit is in effect is harder than it looks. macOS won't let you read another process's RLIMIT_NOFILE. We infer it from the highest fd number seen in lsof, since the kernel allocates the lowest free descriptor — an fd number above 256 proves the limit is above 256. Using fd count instead deadlocks: reaping keeps the count below the threshold, so the evidence never appears.

Caveat

This is containment, not a fix. Children are still spawned per session and still never reaped by the daemon; we are just deleting them on a timer. The mitigation also assumes idle children are safe to kill, which holds for node_repl but would not for a stateful MCP server.

garyjai97-DHMO · 4 days ago

Adding a sanitized reproduction from the current stable remote-SSH app-server surface.

Environment:

  • Codex app-server: 0.147.0, x86_64 macOS
  • Surface: shared Desktop SSH app-server using app-server --listen unix://
  • App-server uptime at capture: about 2 days 4 hours
  • Login-session maxfiles soft limit: 256; kernel per-process maximum: 61,440

Failure:

  • Unified exec, thread persistence, rollout recording, shell snapshots, and model-cache access all began failing with Too many open files (os error 24).
  • The failure was intermittent near the ceiling: trivial commands briefly worked when transient descriptors closed, then failed again.

Live app-server snapshot before recovery:

open descriptors: 239 / 256 soft limit
descriptor types: 109 REG, 107 PIPE, 11 IPv4, 6 unix, 3 KQUEUE, misc 3
system-wide files: 5,765 / 122,880
direct retained MCP children:
  18 codebase-memory-mcp processes, about 54 MB aggregate RSS
  16 Node stdio MCP processes, about 449 MB aggregate RSS

The 16 Node children were Dispatcher MCP servers. Their ages ranged from minutes to more than six hours, and every retained MCP process was a direct child of the same long-lived app-server. The healthy system-wide file table plus 107 pipes in the app-server isolates this to the same per-thread stdio MCP lifecycle leak described here, not machine-wide file pressure.

Restarting/reconnecting only the Desktop UI did not clear the remote app-server; the two-day-old remote process and children remained. A remote app-server subtree restart is required for complete reclamation.

Raw logs, task IDs, local project paths, and process arguments are intentionally omitted. A local pre-restart log was preserved for a narrower maintainer-requested probe.