MCP stdio servers leak pipe fds + orphan child processes → cumulative EMFILE ("Too many open files", os error 24)
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
- Await actual exit on teardown. Make
shutdown()/Dropwaitpid/try_waitthe group after SIGTERM→SIGKILL instead of fire-and-forget, so pipes are closed deterministically. - Terminate on the cancelled-startup arm. In
AsyncManagedClient::shutdown, call the process terminator onErr(Cancelled)rather than relying onDrop. - Reap the whole group reliably. Either spawn the server binary directly (resolve
npm exec/npxto the real entrypoint) orsetsid+ track the session and kill+wait the session, not just the direct child's PID-as-PGID. - (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
23 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
I can reproduce the same EMFILE failure on Codex Desktop on macOS.
Environment:
ulimit -n: 256launchctl limit maxfiles: previously observed as256 unlimitedFailed to create unified exec process: Too many open files (os error 24)Current evidence:
git logandlaunchctl limit maxfiles.lsofentries while the soft limit is 256.chrome-devtools-mcp: 33node_repl: 12SkyComputerUseClient: 13codex app-server --listen stdio: 5codex app-server --listen unix: 2This 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
psoutput 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.
Adding a current Desktop datapoint and linking the broader report: #27601.
Environment:
Current helper counts after some recovery work:
Earlier in the same investigation, helper counts were higher and
turn/startrequests 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 andturn/starttimes out, then hard quitting/reopening Desktop improves it temporarily.I prepared and locally verified a candidate fix for this on a fork branch:
Summary of the fix:
process/closeStdin, so MCP stdio launched through an executor can also receive stdin EOF before terminationRemoteProcess -> ExecServerClient -> process/closeStdin -> server close_stdin_process, which is the path relevant when local Codex controls a remote executor over SSH/exec-serverVerification run locally:
just test -p codex-core unified_execjust test -p codex-rmcp-client --test process_group_cleanup shutdown_closes_remote_executor_stdio_before_terminating_serverjust test -p codex-exec-serverjust test -p codex-rmcp-clientjust fmtjust fix -p codex-exec-serverjust fix -p codex-rmcp-clientI 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.
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_commandis now failing withFailed to create unified exec process: Too many open files (os error 24), including for commands such aspwdorulimit -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.
Additional live datapoint from the same macOS Desktop session:
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 asgit diffandnl. Closing a background subagent reduced pressure enough thatpwdand 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/lsofdetails because they can include local paths or private MCP arguments.Adding a current macOS Desktop/CLI datapoint from a long-running multi-surface setup.
Snapshot time: 2026-06-28 11:26 JST
Environment:
26.623.42026, build45140.142.3codex doctor --json:overallStatus=oklaunchctl limit maxfiles:256 unlimitedulimit -n:1048575Current process / FD pressure:
The important operational detail is that
codex doctoris 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.
Confirming this leak is still present on the current stable
0.144.3, and adding a second reproduction surface: the long-lived Desktop SSHapp-server, not just a TUI /codex resumesession.Reproduction surface: the shared Desktop SSH app-server
Codex Desktop (SSH remote) multiplexes every project window onto a single long-lived parent:
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):
255 anonymous PIPE fds for what should be a handful of live MCP servers. On a default macOS login shell (
RLIMIT_NOFILEsoft = 256) this parent would already be throwingos 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.3contains 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 annpx/npm execgrandchild), and the script explicitly handles clean shutdown:…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-forgetterminate, 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.
Adding a precise multi-agent lifecycle datapoint from stable
codex-cli 0.144.3on macOS arm64. This confirms the same failure and narrows why subagents amplify it.Failure
After a long multi-agent session, even:
failed before spawn with:
The kernel-wide file table was healthy (
8154 / 122880), so this was process-local.Parent FD and child-process snapshot
The Codex parent had 58 direct local MCP helper children:
The correlation is exact:
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
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.
Confirming this leak is still present on
0.144.5(newer than the0.144.3confirmations above), and adding two things I haven't seen upthread:Environment
Failure (app-server stderr, on 0.144.4)
Kernel-wide file table was healthy throughout, so this is process-local, as established upthread:
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).Every
node_replwas still parented to that one app-server, spawning steadily and never exiting: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::terminatebeing 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_managermodel refresh — see #23119This 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):
That string is
CodexErr::Timeoutfromcodex-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
notifyhook — one unreaped child per turnDefault desktop config wires the Computer Use client in as the turn-end notifier:
This host had 26 orphaned
SkyComputerUseClientprocesses, all reparented toinit(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
notifylooks 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 maxfilesonly 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.@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 anArc-lifetime rule, but nothing in the current teardown path actually reaps a stdio child when thatArcis released — so superseded managers keep their MCP processes (and the parent's pipe fds) forever.What #29608 established
Explicit, awaited, refcount-independent.
What
maindoes now#30101 moved publication into
ServiceState::publish_mcp_runtime(core/src/state/service.rs:127), which is synchronous and therefore cannot.awaita shutdown:swap(...) + superseded_manager.shutdown().awaitis gone, and greppingcore/srcthere is now no shutdown of a superseded manager anywhere — the only survivingmanager.shutdown()calls areconnectors.rs:334(the short-livedcodex_apps-only discovery manager) and an unrelatedconversation.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
Arcdoes not reap the childDrop for McpConnectionManager(codex-mcp/src/connection_manager.rs:997) is synchronous, so it can only dropArcs: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 lastArc<StdioServerProcessHandleInner>dies. But there are two live clones of that handle, created at launch:rmcp_client.rs:372—RmcpClient.stdio_processtakestransport.process_handle(), anArcclone;rmcp_client.rs:930— the transport itself (holding the original handle and theTokioChildProcesspipes) is moved intoservice::serve_client(...)and lives on insideClientState::Ready { service: Arc<RunningService<..>> }.So
clients.clear()drops one clone and theArc<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 onDrop— it callsprocess.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:
~18 fd/min, unbounded in session length. Under the
Arcrule 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.rswas inverted by #30101, from "superseded server is shut down" to: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_pidis reaped within some bound — would fail onmaintoday, and would pin the invariant #30101 intended.Possible directions
Arcrule enforceable rather than implicit (closest to #30101's intent). Havepublish_mcp_runtimeswap()instead ofstore()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 onMcpRuntimeSnapshotis more honest than pollingArc::strong_count) and then callsshutdown().await. Preserves #30101's semantics; restores #29608's guarantee.shutdown()is called,LocalProcessTerminator::terminate(stdio_server_launcher.rs:328) is fire-and-forget: SIGTERM the process group, then a detachedstd::threadsleeps 2 s and SIGKILLs, whileshutdown()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.Any progress? I've encountered four container crashes caused by the "too many open files" error this week, which has seriously affected my work.
I also encountered the same problem. Could you please tell me how the repair is progressing.
Reproduced again on
codex-cli 0.145.0-alpha.27on macOS arm64, with a complete per-thread bundle shape that matches the multi-agent analysis above.Sanitized snapshot immediately before recovery:
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.
Confirmed the same symptom is still observable on a newer build:
codex-cli 0.145.01da3f4e0e96028b8a771814293c3033dafd1971f943f6c7e79b0897fe705f590During a delegated design/exploration workload with repeated subagent spawn/completion, I repeatedly observed:
and later the same error from
UserPromptSubmit.I inspected the exact
rust-v0.145.0source at commit25af12f7e61572b0bc18ddb1008be543b91519b0. A plausible contributing lifecycle path is that completed V2 subagent threads remain loaded inThreadManagerStateuntil 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: passedI 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.Additional confirmation that this persists on Codex CLI 0.146.0 on Linux, using
codex app-server --remote-controlwith 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 availablenear the default softnofilelimit.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:
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.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 withToo many open files (os error 24). Roughly 2 pipes leak per session and are never reclaimed until the daemon restarts.Workaround:
ulimit -n 1048576in the daemon's boot path (hard limit is unlimited, no sudo needed). Masks it, doesn't fix it.Adding a current macOS Desktop datapoint that connects this FD leak to the generic UI symptom
Reconnecting x/5/stream disconnected before completion.Environment
User-visible failure
The Desktop UI repeatedly showed:
The standalone CLI continued to work from Terminal.
App-server snapshot
The affected long-lived Desktop app-server was the bundled
codex app-serverusing a localhost WebSocket listener. Sanitized snapshots around the failure: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:
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/responsesfails. 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. Raisinglaunchctl maxfilesor 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.
Linux TUI reproduction on stable 0.147.0: FD exhaustion blocks
thread/forkand transcript persistenceIndependent confirmation of this descriptor leak on Linux, with two additional
failure paths: side-conversation forking and active transcript persistence.
Environment
codex-cli 0.147.0RLIMIT_NOFILE: soft1024, hard1048576Failure
Opening a side conversation failed with:
The same process could no longer persist the active conversation:
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:
The accounting matches retained stdio children:
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:
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
thread/forkbootstrap with multiple configured stdio MCP servers.
lifecycle transition.
RLIMIT_NOFILE.the rollout file is missing or corrupt.
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.
🐧 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:
After approximately 13.5 hours, the app server had inherited an
RLIMIT_NOFILEsoft/hard limit of1024/1048576fromsshd. During inspection:The 998-FD snapshot included:
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:
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:
I also configured
sshd.servicewithLimitNOFILE=65536:1048576so 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.
This appears to still be an issue on Linux. I just ran into it using the Lean MCP.
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
Confirming the leak shape
Steady-state measurement on the live daemon:
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:
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'sStdioServerTransportdoes 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
ulimitalone 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
PATHthan the real binary, since the SSH bootstrap prepends~/.local/bin: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:
Everything comes from a single
ps -axo pid=,ppid=,etime=,comm=snapshot; per-childpscalls were too slow to finish within the interval.Results
Over 24h on a normal working day:
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
killon a whitespace-joined PID list silently does nothing. Inzsh,kill -TERM "$pids"passes one invalid argument; with2>/dev/nullthe error vanishes and the script reports success. Usexargs -n1 kill -TERMand verify each PID afterwards withkill -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 inlsof, 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_replbut would not for a stateful MCP server.Adding a sanitized reproduction from the current stable remote-SSH app-server surface.
Environment:
app-server --listen unix://maxfilessoft limit: 256; kernel per-process maximum: 61,440Failure:
Too many open files (os error 24).Live app-server snapshot before recovery:
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.