claude.exe main thread spins a core at ~99% indefinitely after sleep/hibernate — internal uv__io_poll busy-loop (uv_backend_timeout()==0)
Environment
- Claude Code: observed on
2.1.165(current2.1.173) - OS: macOS 26.0.1 (build 25A362), Apple Silicon (arm64)
- Binary:
/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe(ARM64 Mach-O, Bun single-executable) - MCP servers configured:
blender-mcp(uvx, STDIO),atlascloud-mcp(npm exec, STDIO) — both healthy/idle during the incident (see "Ruled out")
Symptom
A claude session pins one core flat at ~99% CPU and never recovers — a dead-flat pin, not bursty work. Persists for days (observed instances running 4+ days). Killable with a graceful kill (no -9). On 2026-06-05 I found four such processes launched within a single 4-minute window — pointing at one system-wide trigger rather than four independent faults. Two had reparented to launchd (orphaned/headless, invisible outside Activity Monitor).
Correlated trigger: a deep-sleep + hibernate cycle. pmset -g log around the wake showed Low Power Sleep → Wake from Hibernate at 1% battery with TCPKeepAlive=inactive — the most socket-destructive suspend macOS performs. Hypothesis: a streaming/API socket fd is left dead after the suspend and the event loop never sleeps again.
Diagnosis (sample + static disassembly)
Attaching a live debugger is blocked by taskgated on the hardened Bun binary, so the hot path was sampled and the on-disk binary disassembled statically at the sampled offsets (__TEXT base 0x100000000, runtime slide subtracted).
Thread state: only the main thread is hot (~98.5%); all ~26 tokio/Bun-pool worker threads are parked (S, 0%).
Sample distribution: 606 / 832 main-thread samples land inside the kevent64 syscall itself, with only a handful in the post-poll processing path → the loop is polling with a zero timeout and getting ~0 events back every iteration.
User/system split: UTIME ≫ STIME (~4.4:1 over 4 days) → JS runs in userland on every loop turn (a callback fires each tick); it is not wedged in the kernel waiting on a fd.
Hot path = libuv uv__io_poll kqueue loop (claude.exe + 0x85e320):
+260 b940d660 ldr w0, [x19,#0xd4] ; w0 = kq fd ← loop top
+264 91038263 add x3, x19,#0xe0 ; x3 = eventlist
+268 d2800001 mov x1, #0 ; changelist = NULL
+272 52800002 mov w2, #0 ; nchanges = 0
+276 52808004 mov w4, #0x400 ; nevents = 1024
+280 aa1503e5 mov x5, x21 ; flags = 0
+284 aa1403e6 mov x6, x20 ; timeout = *x20 ← spin/sleep decider
+288 94b6d93d bl kevent64
+292 b900ce60 str w0, [x19,#0xcc] ; loop->nfds = ret
+296 3100041f cmn w0, #1 ; ret == -1 ?
+300 540000a1 b.ne +320
+304 94b6d708 bl __error
+308 b9400008 ldr w8, [x0]
+312 7100111f cmp w8, #4 ; errno == EINTR ?
+316 54fffe40 b.eq +260 ; yes → re-poll immediately
The timeout arg (*x20) is 0 every iteration → kevent64 returns immediately with no events → spin.
Ruled out
- MCP servers —
blender-mcpandatlascloud-mcpchildren both alive,S+, 0% CPU. Their STDIO is wired over AF_UNIX socketpairs (the prime suspect for a wedged-fd poll), but both peers were live and non-EOF. - TTY — the controlling pty (
ttys016) session was live (login → zsh → claude), not a disconnected terminal. - Pipes / EOF / zombies / orphans — none present in the full fd table; kqueue (fd 3) watched only
count=2healthy fds.
With every fd healthy, the zero timeout can only come from internal loop state, leaving two externally-indistinguishable causes:
- an active
uv_idle/uv_prepare/uv_checkhandle (these forcetimeout=0by design), or - an overdue timer whose next deadline is permanently in the past, so
uv_backend_timeout()returns 0.
The process cannot self-heal — it's a logic-level spin in its own loop, not a resource it's waiting on.
What would pin the exact handle/timer
The one step not completed (needs root): sudo spindump <pid> 5 (spindump symbolicates Bun frames better than sample and may name the offending timer/idle callback) plus sudo dtruss -p <pid> | grep kevent to watch kevent64(...) = 0 printing back-to-back with no gap. Happy to capture and attach these if a live offender recurs.
Suspected mechanism / ask
A post-suspend dead socket (or its cleanup path) appears to leave a libuv handle/timer active such that the backend timeout is permanently 0. The loop then busy-polls kevent64 forever instead of sleeping. Likely fix territory: on wake (or on a detected dead streaming fd), ensure the associated handle/timer is close()/unref()'d so the loop can compute a non-zero backend timeout again.
Repro recipe (best-effort)
- Leave one or more
claudesessions idle. - Let the Mac run to ~1% battery so it enters hibernate (not just sleep), with TCP keepalive off.
- Wake; inspect Activity Monitor /
psforclaudeprocesses at ~99% on a single thread.
---
Appendix A — Live recurrence captured 2026-06-11 (Claude Code 2.1.173)
Four live offenders found simultaneously, all on the current 2.1.173 (so the fault survived the 2.1.165→2.1.173 update chain). Same OS as above (macOS 26.0.1 / 25A362, Apple M4 / arm64 T6041).
| PID | %CPU | Elapsed | CPU time burned | PPID | State |
|----:|-----:|--------:|----------------:|-----:|:------|
| 1270 | 99.6 | 6d 12h | 1031m (17.2h) | 1239 | R+ |
| 27175 | 98.5 | 4d 08h | 361m | 27170 | R+ |
| 30298 | 99.0 | 4d 05h | 154m | 30293 | R+ |
| 48054 | 98.2 | 6d 00h | 539m | 45982 | R+ |
New data point — not orphaned this time. Unlike the 2026-06-05 cluster, all four here have a live non-launchd parent (PPID ≠ 1, state S+, still attached to a TTY session). So the spin is not contingent on reparenting/orphaning — it happens in fully attached sessions too.
Cross-PID signature is identical (sample <pid>, 1 ms interval). The hot leaf is kevent64+8 in libsystem_kernel.dylib on every one:
| PID | samples inside kevent64 (of ~4170 leaf) |
|----:|---:|
| 1270 | 2914 |
| 27175 | 1678 |
| 30298 | 1796 |
| 48054 | 1734 |
Hot frame on all four: claude.exe + 0x85e344 (post-kevent64 return addr) → +0x85e320 loop body. (sample files: ~/Desktop/claude-cpu-spin-2026-06-11/sample-<pid>.txt.)
Fresh static disassembly @ 2.1.173 (__TEXT base 0x100000000; the loop sits at the same ~+0x85e3xx region as 2.1.165). The zero-timeout build and the loop-back are visible in machine code:
; --- zero timeout struct built on the stack right before the poll ---
10085e354: movi.2d v0, #0000000000000000 ; zero
10085e358: stur q0, [sp, #0x14] ; timespec/eventlist slot = {0,0}
10085e35c: stur q0, [sp, #0x24]
10085e360: str wzr, [sp, #0x34]
; --- event-list walk: stride #0x30 == sizeof(struct kevent64_s) ---
10085e2f0: ... ; (loop top)
10085e310: add x10, x10, #0x30 ; ++event ptr (48 bytes)
10085e314: cbnz w11, 0x10085e2f0 ; more events → continue
; --- backward branches that close the poll loop ---
10085e36c: cbnz w8, 0x10085e24c ; → re-enter poll
10085e370: b 0x10085e218 ; → poll head (unconditional loop-back)
bl into the kevent64 dyld stub sits inside the body (+0x85e268 / +0x85e274); sample independently symbolicated it as DYLD-STUB$$kevent64 → kevent64. The timespec{0,0} written at +0x85e354–0x85e360 is the uv_backend_timeout()==0 condition in machine form → immediate-return poll → spin. Matches the 2.1.165 finding exactly, at new offsets.
Appendix B — Root spindump captured live 2026-06-11 (sudo spindump <pid> 5)
Captured on all four live offenders (12.9–17.1 MB each). The stripped Bun binary means the claude.exe frames show as ??? even under spindump — but the report carries the binary UUID, so Anthropic can symbolicate these offsets against their own 2.1.173 build. That is the missing link.
Binary identity (confirms the official signed build):
Path: /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
UUID: 02381B7D-E583-336E-9410-4C9FF00732E1
Codesigning ID: com.anthropic.claude-code
Team ID: Q6L2SF6YDW
Load base: 0x104224000 (slide 0x4224000 off __TEXT 0x100000000)
Parent: zsh [1239] Responsible: Terminal [862]
Single-thread spin, machine-confirmed. Process CPU over the 5 s window = 4.968 s, 100% of it on the main thread (com.apple.main-thread). All 27 sibling threads — Bun Pool 0–11, HTTP Client, CFThreadLoop, 12× tokio-rt-worker, NSEventThread — are parked at 0 CPU. Efficiency 0.22 cycles/instruction (~4.5 IPC) = a tight, cache-resident loop, not a memory/lock stall.
Main-thread call tree (118 samples). Under the uv_run-level frame the loop forks every iteration into the poll and a callback chain — the callback firing each turn is what holds the backend timeout at 0:
118 start + 7184 (dyld)
118 ??? (claude.exe + 13116980) ; Bun/JS entry
118 ??? (claude.exe + 16613848)
118 ??? (claude.exe + 6839632) [0x1048a9d50] ← uv_run level
65 ??? (claude.exe + 16861844) [0x105238a94] ← uv__io_poll
34+19 kevent64 + 8 (libsystem_kernel) ← in the syscall (zero timeout)
4 ??? (claude.exe + 8774432) [0x104a82320] ← +0x85e320 loop body (see Appendix A asm)
38 ??? (claude.exe + 16859876) [0x1052382e4] ← SIBLING: handle/timer callback, fires every tick
25 ??? (claude.exe + 17339572)
22 ??? (claude.exe + 17339120)
20 ??? (claude.exe + 7637288)
18 ??? (claude.exe + 4074192)
18 ??? (claude.exe + 46541904) ← deep userland callback body
~55% of main-thread samples are in kevent64 (immediate-return poll); ~32% are in the sibling callback chain at +0x1052382e4. This is the externally-visible fingerprint of an active uv_idle/uv_prepare/uv_check handle (or a permanently-overdue timer) whose callback executes on every loop turn — forcing uv_backend_timeout()==0 so the loop never sleeps.
For Anthropic — the actionable offsets (file-relative, symbolicate vs UUID 02381B7D-…):
| offset (load-base-relative) | role |
|---|---|
| +0x685d50 | uv_run loop level |
| +0x1014a94 | uv__io_poll (calls kevent64) |
| +0x85e320 | poll loop body — builds timespec{0,0}, backward branch (Appendix A) |
| +0x1052382e4 | the handle/timer callback fired every iteration ← likely root cause |
Raw artifacts (not for transmission — local only): ~/Desktop/claude-cpu-spin-2026-06-11/{sample,asm-*,spindump-*}.txt.
---
Reported by Bamdad Espahbod — Luvi Agency, Product Design & UX/UI Division
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗