app-server: fd exhaustion is reported as a missing requirements file at TUI bootstrap; daemon may not inherit the shell's fd limit

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

Codex CLI version

0.147.0 (CLI) / 0.146.0 (running app-server — see "Version skew" below)

Platform

Linux (Ubuntu 24.04, systemd)

What happened

Launching the TUI failed at bootstrap with:

Error: configRequirements/read failed during TUI bootstrap: configRequirements/read failed:
failed to read configuration layers: Failed to read requirements file
/etc/codex/requirements.toml: No file descriptors available (os error 24) (code -32603)

/etc/codex/ does not exist on this machine at all. A healthy run returns ENOENT there and continues without complaint. The message names a file that is not the problem, and reports a startup path that is not where the failure occurred.

The actual cause was the long-lived codex ... app-server --listen unix:// daemon sitting at its file-descriptor ceiling. The TUI does not read requirements.toml itself — it asks the daemon over the control socket. The saturated daemon could not complete open(), received EMFILE, and forwarded it as JSON-RPC -32603.

This is the same mislabeling class as #36755 (skill loader reports EMFILE as "invalid SKILL.md files"), at a different call site. Here it is arguably worse: #36755 at least names files that exist, whereas this points at an absent path and an unrelated subsystem.

Measurements

The daemon had been running 8 days:

Max open files            1024                 1048576              files    (soft/hard)
open fds                  1022
child processes            230

Descriptor accounting closed exactly, at 4 per child (3 stdio pipes + 1 pidfd):

690  pipe          <- 230 children x 3
230  anon_inode:[pidfd]
 80  regular files (state_*.sqlite WAL/shm, thread_history, rollout jsonl, logs)
 13  socket
  9  eventpoll/eventfd/inotify
----
1022 / 1024

The 230 children grouped into 43 identical sets — one set per session started over those 8 days, with ~6 stdio MCP servers per set. None were ever reaped. SIGTERM to the daemon reclaimed all of them.

Because the leaked handle is a pidfd, the children stay valid and never appear as zombies. ps showed one zombie process system-wide. The leak is invisible except as descriptor pressure, which is part of why the surfaced error is so far from the cause.

The leak itself is already reported in #26984 (fd/pipe leak, orphan children) and #30408 (per-thread MCP processes never cleaned up). This report is about two things those do not cover.

Issue 1: the error names the wrong subsystem

EMFILE is a resource-exhaustion condition, not a configuration problem, but the configRequirements/read path reports it as a failure to read a specific requirements file. Anyone hitting this will investigate /etc/codex/requirements.toml — a file that need not exist — instead of the daemon's descriptor use.

Suggested handling, consistent with what #36755 asks for at its own call site:

  • Distinguish EMFILE/ENFILE from ENOENT and from parse failures, and say so: "the app-server has exhausted its file descriptors" rather than naming an optional config path.
  • Do not report an optional, absent config file as a read failure at all when the underlying errno is a resource error.

Issue 2: the daemon may not inherit the launching shell's fd limit

The usual advice for the leak (ulimit -n <higher> before starting Codex) may not reach the daemon at all, which makes the workaround suggested in the existing threads unreliable on Linux.

The app-server detaches into its own login-session scope with PPID 1, so in that
case it takes systemd's DefaultLimitNOFILESoft (built-in default 1024)
rather than anything from the calling shell. Observed directly: launching from a
shell with ulimit -n 262144 produced a daemon with a 1024 soft limit.

This is not fully deterministic — on a later respawn the daemon did land in an
existing session scope and inherited that scope's higher limit. Which limit you
get appears to depend on whether the daemon is started fresh and detached or
forked within an existing session, which makes the ulimit workaround
unreliable rather than simply ineffective. Either way the user has no way to
tell which they got without inspecting /proc/<pid>/limits.

Reproduction:

# a login shell reports a high limit ...
$ bash -lc 'ulimit -Sn'
1048576

# ... but anything systemd starts gets the built-in default
$ systemd-run --user --pipe --wait sh -c 'ulimit -Sn'
1024

# and the daemon can land in the latter category despite the launching shell
$ bash -c 'ulimit -n 262144; codex app-server daemon version >/dev/null'
$ pid=$(pgrep -x codex | while read p; do
      tr '\0' ' ' < /proc/$p/cmdline | grep -q 'app-server --listen' && echo $p
  done | head -1)
$ awk '/Max open files/{print $4}' /proc/$pid/limits
1024

Suggested handling: on startup the app-server could raise its own RLIMIT_NOFILE soft limit toward the hard limit (a standard setrlimit bump — the hard limit here was 1048576, so no privilege is required), and log the effective value. That alone would turn a multi-day outage into a non-event even while the leak persists.

Issue 3 (minor): daemon restart cannot restart an unmanaged daemon

When the daemon was auto-spawned on demand rather than started via the daemon manager:

$ codex app-server daemon restart
Error: app server is running but is not managed by codex app-server daemon

There is no documented path from that state back to a healthy one. SIGTERM to the pid works and the daemon respawns on next use, but the CLI does not suggest it. Either restart should be able to adopt/replace an unmanaged daemon, or the error should say what to do instead.

Version skew

codex app-server daemon version reported:

{"cliVersion":"0.147.0","appServerVersion":"0.146.0"}

The daemon kept running its old binaries across a CLI upgrade, carrying the accumulated leak with it. Worth considering whether the CLI should signal (or handle) a daemon older than itself, since a long-lived daemon is exactly the process most likely to have accumulated problems that a restart would clear.

Related

  • #26984 — MCP stdio servers leak pipe fds + orphan child processes (same leak, macOS)
  • #30408 — MCP server processes leak: per-thread processes never cleaned up (same leak, Desktop)
  • #36755 — skill loader mislabels EMFILE as invalid SKILL.md (same mislabeling class, different call site)

View original on GitHub ↗

5 Comments

github-actions[bot] contributor · 17 days ago

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

  • #36755

Powered by Codex Action

boombx403-byte · 9 days ago

Hi @jeanmonet, the frozen history/resume behavior you described is one of the persistence patterns I've been studying. Codex Rescue Alpha5 includes a read-only projection consistency check that compares canonical rollout progress against stored projection cursors without touching SQLite.

If you still have the affected pre-repair session locally, you can test it with:

pip install codex-rescue==0.1.0a5
codex-rescue doctor --latest --json

(or point doctor directly to the rollout file). Please sanitize/redact any sensitive paths or names before sharing diagnostic output; raw session files are never needed.

jeanmonet · 9 days ago

Follow-up: I hit silently truncated resumes again on a machine where the descriptor limits from this report were already fixed (soft and hard nofile both 1048576, verified). The cause turned out to be unrelated to fd exhaustion, so I'm recording it here in case it helps anyone landing on this issue with the same symptom.

Symptom. codex resume <id> on a 3-day session opened showing only the first turn. The rollout .jsonl was fully intact: 9356 records, no malformed lines, contiguous ordinals, correct final record.

Cause. Resume renders the projection in ~/.codex/thread_history_1.sqlite, not the journal. That projection had stalled:

thread_history_projection_state.next_rollout_ordinal = 19   (of 9356)
thread_turns  = 1     (expected 52)
thread_items  = 3     (expected 2510)

It froze roughly one minute into the session and never advanced again. An intact rollout file is therefore not evidence that resume will work.

Scale. 59 of 79 threads with history_mode='paginated' were stalled the same way. Threads with history_mode='legacy' were unaffected. backfill_state.status is complete with a watermark months older than the affected threads, so nothing revisits them — this does not self-heal.

Fix that works. Delete the thread's projection rows and let it rebuild from the journal on the next resume:

DELETE FROM thread_items                    WHERE thread_id='<id>';
DELETE FROM thread_turns                    WHERE thread_id='<id>';
DELETE FROM thread_history_projection_state WHERE thread_id='<id>';

Verified: next_rollout_ordinal went from 19/9356 to 9356/9356, 52 turns and 2510 items, and resume opens normally.

Possible second bug. My first attempt was to set history_mode='legacy', since the large majority of threads already use it and resume fine. That makes things worse — a thread converted to legacy fails outright:

thread/resume failed during TUI bootstrap:
thread/resume failed: list_turns is not supported yet (code -32601)

A thread created legacy resumes normally; a converted one does not, and clearing its projection rows first does not help. If converting is unsupported, it may be better to reject that state than to fail at TUI bootstrap.

Threads were created on 0.146.0 and observed on 0.147.0.

Useful detail for anyone reproducing: codex refuses a non-tty stdin, so a resume attempt can be scripted with timeout 30 script -qec "codex resume <id>" /dev/null, which turns a manual TUI retry into a quick check.

jdcodes1 · 9 days ago

Verified the mislabeling site on main (1f41cc5d92): the loader special-cases only ErrorKind::NotFound — every other errno, EMFILE included, gets wrapped as Failed to read requirements file <path>: … (https://github.com/openai/codex/blob/1f41cc5d92/codex-rs/config/src/loader/mod.rs#L699-L711). So your Issue 1 fix is one match arm: treat ErrorKind for resource exhaustion (Rust maps EMFILE/ENFILE to Uncategorized/OutOfMemory-adjacent kinds, so matching on raw_os_error() == Some(libc::EMFILE|ENFILE) is the reliable check) and report "app-server has exhausted its file descriptors" without naming the optional path.

On Issue 2, one addition to your systemd analysis: the daemon can make the ulimit question moot by raising its own soft limit to the hard limit at startup — setrlimit(RLIMIT_NOFILE, {hard, hard}) is a standard daemon move (your measurements show hard = 1048576, so the 1024 soft cap is pure default inheritance). That's a few lines in app-server startup, removes the "which scope did systemd put me in" nondeterminism entirely, and buys enough headroom that the underlying child-reaping leak (#26984/#30408) takes years instead of days to bite. Both fixes are independent of actually fixing the leak, and both are cheap.

jeanmonet · 9 days ago

That's the call site — the Err(e) arm at 1f41cc5d92 matches what I saw from the outside.

On the soft→hard bump (same as Issue 2's suggested handling above): the pattern already exists in the tree but doesn't reach this process. codex-rs/process-hardening does a pre-main setrlimit(RLIMIT_CORE, 0) on Linux, but only linux-sandbox and responses-api-proxy depend on it — app-server, cli and core don't, so it needs its own call rather than just adopting that crate.

Worth checking alongside it: on macOS close_inherited_fds_except (codex-rs/utils/pty/src/pty.rs) falls back to looping STDERR_FILENO + 1..rlim_cur, which scales with the raised soft limit. Linux enumerates /dev/fd, so my own measurements wouldn't have caught it.