[BUG] [PERF] Per-turn in-process directory walk (35k getdents64) stalls every tool call ~30s in a workspace with many nested repos

Status Open
Reported on v2.1.218
Maintainer reply None cached
Activity 1 comment · opened Jul 31, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

In a workspace that contains many independent git checkouts under one parent directory (~83 repos, ~2.3M files total, ~1.27M of them inside node_modules), every turn pays a fixed ~30–40s of local, non-API time. It is most visible on Bash tool calls, because each one ends a turn and begins another, so the cost is paid before the command runs. Trivial commands like echo hello take 30–40s wall-clock. The TUI is unresponsive during the stall.

The time is spent in a recursive directory enumeration performed in-process by the main thread, not in any subprocess.

Root-privileged strace of the live process across two real turn boundaries:

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 34.99   25.266835     1263341        20           epoll_pwait2      <- idle wait between turns
 34.51   24.926163      461595        54         4 futex             <- main thread waiting on workers
 12.43    8.978032     1122254         8         7 restart_syscall
  8.88    6.411148         190     33688           getdents64        <- directory enumeration
  4.46    3.224586         189     16975       204 openat
  4.28    3.092091         184     16771           close

Per-PID attribution shows 35,780 of 37,234 getdents64 and 18,026 openat were issued by the main Claude Code PID — not by a child process. Scattered counts of 16–56 belong to transient children.

perf record -F 99 -g over the same window:

   Children      Self  Shared Object
    100.00%     0.18%  libc.so.6
     99.93%     0.68%  <claude-code-binary>
     99.20%     0.00%  [anon:JSJITCode]        <- JIT-compiled JS drives the stack
     99.14%    99.14%  [kernel.kallsyms]       <- cycles burned servicing syscalls
      0.00%     0.00%  [vdso]

     73.39%     0.05%  [.] syscall             <- 73% of children, 0.05% self

So: JIT-compiled JavaScript issues ~54k directory syscalls per turn; the kernel time servicing them is the stall. majflt=0 throughout (no swap or page-fault involvement); main thread in state R with wchan=0.

Headless runs confirm the split between local and API time:

duration_ms:     74448
duration_api_ms:  5144   <- 93% of runtime is local

Scaling with tree size, same binary and model:

| Working directory | Local time (duration_ms − duration_api_ms) |
|---|---|
| Empty temp directory | ~2.0s |
| The multi-repo workspace | ~33–36s |

I was not able to identify which subsystem issues these calls. It is not ripgrep, hooks, plugins, MCP servers, or FileIndex's git path — each ruled out by measurement below. Deeper attribution was not possible from outside the process: the native-installer binary ignores NODE_OPTIONS and NODE_V8_COVERAGE, so no V8 CPU profile can be obtained, and JIT frames do not symbolise in perf.

What Should Happen?

Whatever this scan is looking for, dependency directories are the wrong place to look for it. If it is discovering config-shaped files (.claude/commands/*.md, .claude/agents/*.md, SKILL.md, output styles), those cannot legitimately live inside node_modules, vendor, or .git — so descending into them is pure cost with no possible benefit. #53853 shows it is worse than merely wasteful: that walk surfaced ~315 npm package README/LICENSE files as fake skills, injecting roughly 6,000 tokens of noise into every system-reminder.

Suggested fixes, in order of usefulness:

  1. Expose a documented exclusion setting (e.g. a settings.json glob list), shipped with sane defaults — node_modules, .git, vendor, dist, build, .venv, target, __pycache__. The defaults fix the common cases for everyone; the setting is the escape hatch for layouts that weren't anticipated, without needing a release each time one turns up.
  2. Honour .gitignore / .ignore / .rgignore for whatever it is scanning.
  3. Cache the result across turns within a session, invalidating on change.

At minimum, the scan's duration should be visible in --debug output so users can attribute the stall.

The "many nested repos under one parent" layout is common (multi-repo dev environments, monorepo-of-repos, task worktrees). Any of the above would make it usable.

Error Messages/Logs

No error is emitted — the stall is silent.

--debug in headless mode prints only the result line. ANTHROPIC_LOG=debug instruments only the HTTP client (4 lines, one API call at 3,080ms) and emits no scan-timing lines.

Steps to Reproduce

  1. Create a parent directory containing many independent git checkouts, with dependency trees installed (my case: 83 repos, ~2.3M files, ~1.27M inside 16 node_modules trees, ~139k directories).
  2. cd to the parent directory and start Claude Code.
  3. Ask it to run any trivial Bash command, e.g. echo hello.
  4. Observe 30–40s of wall-clock before the command executes.

Quantify with:

time <claude-binary> --model <model> --output-format stream-json --verbose \
  -p "say ok" | jq -c 'select(.type=="result")|{duration_ms,duration_api_ms}'

Compare the same command from an empty temp directory: ~2s local vs ~33s.

To observe the syscall storm directly (needs root, plus ptrace_scope=0 or a root shell):

sudo strace -f -c -p <claude-pid>     # then trigger one turn in that session

Claude Model

Reproduced identically with Sonnet, Haiku, and Opus. Model choice does not affect local time (duration_api_ms is 1.4–6.4s in all cases).

Is this a regression?

Unclear — likely a threshold effect rather than a code regression. The workspace worked acceptably for months and degraded as dependency trees grew. Two versions tested behave identically (2.1.218 and 2.1.220), so it was not introduced by either.

Last Working Version

Not determinable — onset correlates with workspace growth, not with a version change.

Claude Code Version

2.1.220 (also reproduced on 2.1.218). Native installer build (single compiled binary).

Platform

AWS Bedrock (also reproduces under enterprise auth — the backend is irrelevant, 93% of the time is local)

Operating System

Linux 6.17 x86_64 (VM, 7 cores, 43 GB RAM)

Terminal/Shell

bash

Additional Information

Ruled out by direct measurement — each of these was tested, not assumed:

| Hypothesis | Result |
|---|---|
| Sandbox / bwrap setup | Stall persists with sandbox disabled (23s). bwrap startup measured 0.138–0.142s over 5 runs, including --unshare-user --unshare-pid |
| Auto-mode permission classifier | Stall persists with auto mode off (switched to acceptEdits mid-session), and on commands a PreToolUse hook explicitly returns allow for |
| OpenTelemetry export | No OTel env vars set, no telemetry keys in any settings file, no enterprise managed-settings file present |
| PreToolUse hooks | Instrumented end-to-end: 0.3–1.5s |
| All hooks + all plugins disabled | --settings '{"hooks":{},"enabledPlugins":{}}' → 35,066ms local vs 33,260ms baseline (no change) |
| MCP servers (12 configured) | --strict-mcp-config → 34,662ms vs 38,033ms (~4s, not the bulk) |
| Statusline command | 0.58–0.67s |
| Shell snapshot replay | Snapshot 6.7 KB / 157 lines / 12 evals; sourcing costs 0.16–0.19s |
| Login shell startup | bash --norc --noprofile 0.032s; bash -lc 0.140s |
| $PATH probe overhead | ~30 ENOENT sh probes per turn (npm node_modules/.bin PATH pollution) at ~4ms each ≈ 0.12s |
| Subprocesses spawned per turn | All cheap: credential helper 0.53s, statusline 0.37s, two hook scripts 0.17–0.20s; total ~3–4s |
| Memory / swap exhaustion | 48% memory used; vmstat live samples show si=0 so=0 (nonzero figures in vmstat's first line are since-boot averages) |
| Disk I/O | No processes in D-state; per-device I/O essentially idle |
| CPU contention | Reproduces at load 3.16 on 7 cores after closing other applications |
| Transcript history on disk | Moving all session transcripts aside: 56.6s vs 52.5s (no change) |
| FileIndex / ripgrep discovery | Takes the fast git path here: git ls-files returns 1,483 files in 0.03s. The traced discovery command (rg --no-config --files --hidden <project>) completes in 1.10s — not the bottleneck |
| .ignore / .rgignore | No effect (the walk is in-process readdir, not ripgrep) |
| RIPGREP_CONFIG_PATH | No effect — discovery passes --no-config |
| CLAUDE_CODE_USE_NATIVE_FILE_SEARCH=0 | No effect |
| USE_BUILTIN_RIPGREP=0 | No effect on the stall (35,969ms vs 36,419ms baseline). Verified via execve trace that it does switch discovery from the bundled ripgrep to system /usr/bin/rg — the flag works as documented, but ripgrep accounts for only ~1.1s of the run, so it cannot explain the stall |
| CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING=1 | No effect (33,781ms vs 33,954ms) |
| CLAUDE_CODE_DISABLE_ATTACHMENTS=1, CLAUDE_CODE_GLOB_NO_IGNORE=1 | No effect |
| Model / provider | Sonnet, Haiku, Opus all identical locally |

No user-accessible mitigation was found. Seven documented flags and ignore-file mechanisms were tested; all measured flat.

Related issues:

  • #53853 — same root cause from a different angle ("discovery recursively walks the directory tree… no exclusion mechanism for node_modules/… doesn't read .gitignore"), reported as context pollution rather than latency. Closed stale, unfixed.
  • #10181 — open; user-visible symptom ("Bash tool stays Running for tens of seconds") on Linux.
  • #45437 — per-turn FileIndex re-scan; fixed for the non-git path only.
  • #26224 — open; general multi-minute freezes.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗