[Bug] Idle session busy-spins CPU with sched_yield in worker threads instead of blocking

Status Fixed / completed
Reported on v2.1.207
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 11, 2026 · closed Aug 17, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Bug Description
Idle Claude Code session busy-spins CPU instead of sleeping. When a session is idle at the prompt (last turn stop_reason=end_turn, waiting for input), it burns ~3-9% of a CPU core continuously and never quiesces. It happens whether or not a terminal is attached, so it's not an attached-TUI redraw artifact.

strace on an idle session (2s sample, ~4% CPU, ~27 threads) is dominated by sched_yield + futex, not epoll:

50.03% futex 381 calls (67 errors) 24.47% restart_syscall 12 calls 22.16% sched_yield 604 calls 2.34% epoll_pwait2 8 calls

sched_yield firing 600+ times in 2s on an idle process = a busy-yield spin. The main thread is correctly parked in epoll_pwait2; the ~27 worker/render threads spin. Thread count is stable (not a leak).

Impact: fine for one session, but we keep many sessions persistent (so they can be resumed from different editor instances). ~30 idle sessions synchronized their spin and drove a 16-core host to load average 114 until we killed the idle ones.

Env: Claude Code 2.1.207, Node v22.22.0, Ubuntu 24.04 (kernel 6.8), 16 cores. Sessions run headless under dtach (claude --dangerously-skip-permissions --session-id ); reproduces with an attached terminal too.

Repro: start a session, let it go idle at the prompt, leave it, then strace -f -c -p <pid> for 2s -> dominated by futex+sched_yield, not epoll. CPU sits at a few percent, not 0%.

Expected: an idle session should be parked (blocked in epoll/read) at ~0% CPU until input/event arrives.

Likely area: a render/animation loop or worker-thread condition-wait that falls back to sched_yield and doesn't quiesce when there's nothing to render and no input pending (Ink loop / interval timer / thread-pool wait). Main thread is fine; the worker threads are the ones spinning.

Environment Info

  • Platform: linux
  • Terminal: vscode
  • Version: 2.1.207
  • Feedback ID: 8c8c04e0-e808-4bcb-88c8-7f551eae0348

Errors

[]

View original on GitHub ↗

3 Comments

bcherny collaborator · 1 month ago

Thanks for the detailed report and the strace methodology — that made this easy to check.

I ran your exact repro on 2.1.233 (Linux x64, tmux, fresh directory): started a session, completed one prompt/reply turn ending in end_turn, left it idle at the prompt, and measured.

  • Idle CPU over 60 s: ~0.8% after a turn, ~0.1% for a never-prompted session (top -b -d 5, plus utime/stime deltas)
  • strace -f -c for 5 s in both states: zero sched_yield calls — the process sits in blocking futex/epoll_pwait2 waits (~100–210 syscalls total in 5 s, vs your 600+ yields in 2 s)

So the busy-yield spin you saw on ~2.1.207 no longer reproduces. The changelog for 2.1.233 includes a fix for idle sessions on Linux keeping a CPU core busy, which matches this class of issue.

Closing as fixed in 2.1.233 — please update (claude update) and reply with fresh %CPU/strace numbers and your claude --version to reopen if you still see it.

🤖 Generated with Claude Code

perler · 1 month ago

Thanks for running it — and the zero-sched_yield result is reproducible here too, which turns out
to be the useful part. I still see the spin on 2.1.233, and I think the difference is heap size,
not version.

claude --version → 2.1.233 (Claude Code), native build (ELF, Bun 1.4.0 / JavaScriptCore) at
~/.local/share/claude/versions/2.1.233. Ubuntu 24.04.4, kernel 6.8.0-137, 16 cores. Sessions run
headless under dtach with --dangerously-skip-permissions, so sandboxing is off.

Fresh numbers on 2.1.233

Five real sessions, idle at the prompt 15–55 minutes, per-thread CPU from /proc/<pid>/task/*/stat
deltas over a 120 s window:

| pid | idle CPU (% of one core) |
|---|---|
| 849629 | 2.04% |
| 916709 | 1.74% |
| 1443619 | 1.56% |
| 1480297 | 1.50% |
| 1483749 | 1.50% |
| total | 8.34% |

strace -f -c -p <pid> for ~6 s, three of those sessions, all idle:

 76.07% futex        1292 calls   18.28% sched_yield  2107 calls   (849629)
 78.12% futex        1025 calls   12.05% sched_yield  1565 calls   (916709)
 77.38% futex        1172 calls   14.41% sched_yield  1838 calls   (1483749)

So: better than the 3–9% I originally reported on 2.1.207, but not parked.

Where the yields come from

strace -f -e trace=sched_yield, 5 s on pid 849629, attributed per thread:

   387 calls  tid 849635  [HeapHelper]
   379 calls  tid 849634  [HeapHelper]
   371 calls  tid 849633  [HeapHelper]
   363 calls  tid 849632  [HeapHelper]
   354 calls  tid 849637  [HeapHelper]
   351 calls  tid 849636  [HeapHelper]
   325 calls  tid 849631  [HeapHelper]
   224 calls  tid 849629  [claude]      <- main thread

HeapHelper is JSC's parallel GC marker pool —
pool->ensureThreads(Options::numberOfGCMarkers() - 1) in heap/HeapHelperPool.cpp, sized from the
processor count. 16 cores here → 8 markers → exactly the 7 helper threads above. On a smaller box
there are fewer helpers and proportionally less spin.

Splitting the 8.34% by thread class:

  3.21%  across  5 threads  [claude]        (main thread, one per session)
  2.93%  across 35 threads  [HeapHelper]    (7 per session)
  1.81%  across  5 threads  [mi-scavenger]

Why a fresh-directory repro shows zero

I think this is the whole discrepancy. Idle cost tracks heap size, not version or idle time:

| session | RSS | HeapHelper threads | idle CPU |
|---|---|---|---|
| fresh, never prompted, no MCP servers at all | 249 MB | 7 | 0.17% |
| fresh, never prompted, full MCP set | 249 MB | 7 | 0.18% |
| resumed 2.4 MB transcript | 472 MB | 7 | 0.55% |
| real working sessions | 528–691 MB | 7 | 1.50–2.04% |

A fresh session has a small heap, so each GC is trivial and the helpers barely spin — which is
exactly your ~0.1% / ~0.8% result, and I reproduce those numbers here. A session carrying hours of
conversation and a multi-MB transcript has a ~600 MB heap, and every GC wakes 7 helpers that
spin-yield through the marking handshake.

Cheapest way to reproduce without spending any tokens — clone a large transcript and resume it:

cp ~/.claude/projects/<dir>/<uuid>.jsonl ~/.claude/projects/<dir>/<new-uuid>.jsonl
claude --resume <new-uuid>     # from that project's cwd, then leave it idle
cat /proc/<pid>/task/*/comm | grep -c HeapHelper   # 7 on a 16-core host
strace -f -c -p <pid>                              # sched_yield 11–18% of syscall time

(The pool is created lazily and a bit non-deterministically — I saw it appear 5 s after start in one
session and still absent at 90 s in two others. A 60 s window on a just-started session can miss it
entirely.)

Isolating it

Same cloned transcript resumed twice, once with BUN_JSC_numberOfGCMarkers=1, both idle,
strace -f -c for ~6 s:

default (7 HeapHelper)                 BUN_JSC_numberOfGCMarkers=1 (0 HeapHelper)
 73.32% futex        1170 calls         73.83% futex          169 calls
 10.92% sched_yield  1761 calls                sched_yield      0 calls
  6.75% epoll_pwait2   80 calls         14.30% epoll_pwait2    59 calls
 -------------------------------        ----------------------------------
 total               3235 calls         total                 351 calls

sched_yield goes to zero, total syscalls drop ~9×, idle CPU 0.55% → 0.38%. So the yields are the
JSC marker pool's spin-wait, not anything in the Claude Code event loop — the main thread stays
correctly parked in epoll_pwait2 throughout.

Two notes on the close

  • The 2.1.233 changelog line is *"idle sessions on Linux sometimes keeping one CPU core at 100%

when sandboxing is enabled"*. That is a different fault from this one: sandboxing is off in
every session here, and the shape is a few percent per session multiplied by many sessions, not
one core pegged.

  • #83288 ("Headless SDK-spawned CLI burns a constant ~33% CPU per session (futex/sched_yield

spin) — still present in 2.1.220") is still open with the same syscall fingerprint at a much
larger magnitude.

Happy to run anything else you'd like measured. If a marker-pool cap is not desirable by default,
even documenting BUN_JSC_numberOfGCMarkers as a supported knob for many-session hosts would help
us — we keep ~30 sessions resident so they can be resumed from different editor instances, and the
per-session cost is what multiplies into a real number.

🤖 Prepared with Claude Code

github-actions[bot] · 6 days ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.