Skill loader mislabels transient EMFILE ("too many open files") as "invalid SKILL.md files", with no retry
Codex CLI version
0.146.0
Platform
macOS
What happened
On startup, the TUI reported:
⚠ Skipped loading 4 skill(s) due to invalid SKILL.md files.
...for 4 skills out of roughly 100 in a cloud-synced skills folder ($HOME/.agents/skills). The affected skills were not actually invalid — their SKILL.md files parse and validate fine. Re-launching Codex skipped a different set of skills each time, which is not consistent with a content problem. The pattern points to a resource-exhaustion race, not a validation failure, but the UI presents it as the latter with no path to recovery.
Why (source walk)
Skill discovery fans out file reads with two layers of concurrency:
MAX_CONCURRENT_ROOT_SCANS = 8—codex-rs/core-skills/src/loader.rs:62MAX_CONCURRENT_SKILL_LOADS = 64—codex-rs/core-skills/src/loader/discovery.rs:18, applied via.buffered(MAX_CONCURRENT_SKILL_LOADS)atloader.rs:626andloader.rs:672
Multiplied together, that's up to ~512 concurrent file opens across roots. Per skill, loader.rs:714 additionally does a tokio::join! that reads two files at once (the SKILL.md body and its metadata file), roughly doubling the peak fd count for that window.
macOS gives terminal-launched processes a default soft limit of 256 open file descriptors (ulimit -n). With ~512+ concurrent opens possible against a 256-fd ceiling, some fraction of reads hit EMFILE (os error 24) — a transient, load-dependent failure, not a property of the file's content.
That EMFILE is then wrapped as a generic parse error:
// loader.rs:170
Read(std::io::Error),
...
// loader.rs:180
SkillParseError::Read(e) => write!(f, "failed to read file: {e}"),
...
// loader.rs:718
let contents = contents.map_err(SkillParseError::Read)?;
and surfaced in the TUI as an "invalid SKILL.md" warning with no distinction from an actual YAML/schema error:
// codex-rs/tui/src/app/startup_prompts.rs:56
"Skipped loading {error_count} skill(s) due to invalid SKILL.md files."
There's no retry anywhere in this path — a skill that loses the fd race is simply dropped for that session.
Observed signature matching this theory: with ~98 SKILL.md files scanned in alphabetical order, the 4 failures landed at ranks 79, 80, 81, and 83 — with rank 82 succeeding in between. A content-validity bug would fail the same skill(s) every time; a transient fd-exhaustion window failing an arbitrary contiguous-ish band, with different survivors run to run, is the signature of a resource race, not a parsing bug.
Repro sketch
- Populate a skills directory with on the order of 100
SKILL.mdfiles (all individually valid). - Lower the fd soft limit to make the race reliable:
ulimit -n 256 - Launch the Codex TUI and observe which skills, if any, get reported as "invalid."
- Repeat the launch a few times — if different skills fail (or none do) on different runs despite unchanged content, that's the EMFILE race, not a content bug.
Suggested fixes (offered, not prescriptive)
- Distinguish
EMFILE/ENFILEfrom genuine parse/validation errors inSkillParseErrorand surface a distinct, honest message (e.g. "N skill(s) skipped: too many open files — try again" vs. "N skill(s) invalid: <reason>"). - Retry on
EMFILE(with backoff) before giving up on a given skill file. - Bound global concurrent file opens with a single semaphore shared across root scans and skill loads, rather than multiplying two independent per-layer limits (8 × 64 ≈ 512).
- Optionally, attempt to raise
RLIMIT_NOFILEtoward the hard limit at startup (many CLIs do this defensively on macOS given its low default soft limit).
Workaround
Raise the fd limit before launching Codex:
ulimit -n 65536