[Bug] File descriptor leak causes ENOENT on subprocess spawn, "directory no longer exists" on Bash cwd
Claude Code — Bug Report
Environment
- Product: Claude Code CLI
- Model: claude-opus-4-6 (1M context)
- OS: macOS (Darwin 25.4.0)
- Shell: zsh
- Repo: Next.js + Firebase Cloud Functions monorepo (path:
~/Documents/GitHub/Acme) - Session type: interactive, plan-mode → execute; resumed via
claude --resume <session>
TL;DR — one root cause, three surfaced symptoms
Claude Code exhausted the process's file-descriptor limit (macOS default soft limit: 256) during a long interactive session. The fd exhaustion surfaced as three different misleading errors across the session, none of which pointed at the actual cause until a session resume finally produced:
error: An unknown error occurred, possibly due to low max file descriptors (Unexpected)
Current limit: 256
To fix this, try running:
ulimit -n 2147483646
A machine restart cleared the leaked fds and everything worked normally again. The three symptoms below are all consistent with running out of fds inside the same process; filing them as one report because they should be fixed together.
---
Symptom 1 — Grep tool fails with ENOENT: posix_spawn 'rg'
What I called
Grep(pattern="prepCodeIds:\\s*skel", path=".../ticketProductionTrigger.ts", output_mode="content")
Actual result
ENOENT: no such file or directory, posix_spawn 'rg'
The same error returned for every Grep invocation, not just this one.
Expected result
Matching lines from the file (ripgrep-equivalent behavior).
Root cause (revised after fd-exhaustion diagnosis)
Almost certainly not a missing binary. posix_spawn requires several new file descriptors (pipes for stdin/stdout/stderr plus internal bookkeeping). When the process is already near its soft fd limit, posix_spawn can fail for lack of fds and the syscall's failure mode gets reported by the tool runtime as a generic ENOENT. The error surface string ("no such file or directory") is the default libc message for multiple errno values in this codepath and is very misleading.
Evidence:
rgfailures appeared ~30 minutes into the session, not at session start- Other Bash subprocess spawns (
grep,npx tsc,npm test) continued to work — suggests the fd pool was marginal, not fully empty, andrghappened to need one more fd than was available at that moment - Hours later the session produced the explicit "low max file descriptors" message, confirming the process had been near its ceiling
Impact
Every structured code search required me to substitute a Bash call with grep -n .... Not a blocker, but:
- Tool-use budget wasted on retries
- The
Greptool's advertised features (multiline, type filters, context lines) were unavailable even though systemgrepwould have covered most of them - The error text does not suggest the workaround or mention a missing dependency — it just surfaces a raw syscall error
Suggested fix
- Detect missing
rgat tool-call time and return a clearer error ("ripgrep not found on PATH; please install ripgrep or use Bash grep as a fallback"). - Optionally fall back to the system
grepfor basicoutput_mode: "content" | "files_with_matches"cases so the tool degrades gracefully. - At minimum, document the ripgrep dependency in the Grep tool description so callers know to install it.
---
Symptom 2 — Persistent Bash shell state claims cwd "no longer exists"
This one was more disruptive.
What I called
After running several commands with cd /../../../../functions && <cmd> successfully, the next Bash call — a plain npx tsc --noEmit — failed:
Working directory "/../../../../../functions" no longer exists. Please restart Claude from an existing directory.
What I did to confirm the directory still existed
- Called
cd /../../../../Acme && pwd— same error. - Called
pwdalone — same error. - The user opened their own bash prompt and ran
cd ..— same error (Working directory "/../../../../../functions" no longer exists). - After the session was interrupted and resumed, I called
cd /../../../../../functions && npx tsc --noEmit→ it worked, exit 0. The directory clearly existed the whole time.
What triggered it
I cannot reproduce deterministically. It happened after:
- ~8 minutes of continuous work in that cwd
- Many back-to-back Edit / Write / Bash calls
- One
Writecall that created a new file (functions/scripts/auditUnivAssemblies.ts) immediately before the first failure
No external process touched the filesystem (no rm, no editor, no VCS ops, no emulator restart).
Observation
The persistent Bash shell state is the thing that got stuck. The directory on disk was fine. Once I stopped relying on the session's tracked cwd and prefixed every command with cd /absolute/path && <cmd>, the tool started working again — but only because each command was effectively spawning fresh shell state. Any call that needed to execute in the sticky cwd still failed with the same error.
Root cause (revised after fd-exhaustion diagnosis)
The Bash tool's persistent shell tracks cwd via an open file descriptor (standard pattern — openat-style tracking so cwd semantics survive renames). When the process hits its fd ceiling:
- Any syscall that needs a new fd (stat, open, readdir, even re-opening the cwd fd) fails
- The shell wrapper treats that failure as "directory vanished" and surfaces
"no longer exists" - The directory on disk was never affected — only the shell's ability to re-open or verify it
This explains why every Bash call failed once fd exhaustion tipped over, and why the issue persisted across absolute-path cd attempts (new commands still needed fds that weren't available).
Impact
Worse than Bug 1. I lost the ability to run any Bash command that relied on sticky state. The error message actively misleads the user — telling them to restart Claude when the directory is demonstrably still there. Running rm -rf or any recovery attempt based on that error text would be dangerous.
Suggested fix
- Do not tell the user to restart Claude or that a directory "no longer exists" without first distinguishing an
ENOENTfrom anEMFILE/ENFILEat the syscall level. On macOS the errnos are distinct; surface them accurately. - If fd pressure is detected (via
getrlimitor repeatedEMFILE), surface the actual diagnosis immediately — not hours later on session resume. - Add a one-line recovery hint:
"try prefixing your command with 'cd /absolute/path &&' to bypass the sticky cwd"— but only as a stopgap; the real fix is to not leak fds.
---
Symptom 3 — On claude --resume, "low max file descriptors" error
After the interactive session was interrupted, attempting to resume produced:
error: An unknown error occurred, possibly due to low max file descriptors (Unexpected)
Current limit: 256
To fix this, try running:
ulimit -n 2147483646
If that still doesn't work, you may need to run:
sudo launchctl limit maxfiles 2147483646
Issues with this error
- The suggested
ulimit -n 2147483646is wrong on macOS. macOS caps per-process fds well below 2^31. Realistic hard limits on macOS areOPEN_MAX(~10240 or configurable vialaunchctl limit maxfiles) — asking users to run a value that large will either fail or silently clamp. The suggested command is misleading. - The message conflates two different limits.
ulimit -nadjusts the shell's soft limit for child processes; it does not raise the limit for an already-running process (which is what was actually exhausted). For a resumedclaudeprocess the advice cannot fix the issue without a restart. - This is the first time in the session the user got an accurate diagnosis. The same underlying condition had been surfacing as "ripgrep not found" and "directory no longer exists" for the preceding ~30 minutes.
The user ultimately restarted the entire machine and everything returned to normal — which is consistent with a per-process fd leak that doesn't clear until the process dies.
Suggested fix
- Check
getrlimit(RLIMIT_NOFILE)at session start and warn if the soft limit is at macOS default (256) before heavy tool use begins. - Suggest realistic values:
ulimit -n 10240on macOS, andsudo launchctl limit maxfiles 65536 200000for system-wide. The literal2147483646is not a helpful recommendation. - Audit Claude Code for fd leaks — persistent Bash shells, long-running watchers, MCP server connections, and any file-descriptor-based caches are all candidates. A ~45-minute session with ~100 tool calls should not approach 256 fds unless something is leaking.
- Consider periodically logging fd count in debug builds so support can correlate the leak with specific tool types.
---
Combined pattern
All three symptoms trace to a single root cause: the Claude Code process is leaking file descriptors during long interactive sessions, and the tool runtime surfaces the exhaustion as three different misleading errors (ENOENT on subprocess spawn, "directory no longer exists" on cwd access, "unknown error" on session resume) before it eventually points at the actual problem. In a subagent or long autonomous run, any of these would cause the agent to spiral or give up on a task that is otherwise completable.
The most important fixes, in priority order:
- Find and fix the fd leak. 256 fds should be plenty for a tool-using agent session; hitting the ceiling means something isn't closing.
- Surface the right error. If
EMFILE/ENFILEoccurs, say so — don't route it through error codepaths that printENOENT-style text. - Fix the remediation advice.
ulimit -n 2147483646on macOS is not correct.
Session metadata (if useful to support)
- Plan file:
~/.claude/plans/sprightly-painting-willow.md - Project CLAUDE.md references Vitest, targeted Firebase deploys,
functions/src/shared/sync behavior - All three symptoms reproduced during implementation of Sub-Plans 1–4 from that plan
- Session duration before first fd-related symptom: ~30 minutes
- Session duration before "low max file descriptors" message: ~45–60 minutes
- User remediation: full machine restart (reopening the terminal or restarting
claudealone was not sufficient to clear the condition) - Final outcome (after restart): all edits landed, 100/100 vitest tests pass, tsc clean on both
functions/andsite/
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗