CLI hangs in a 100%-CPU main-thread loop and ignores SIGTERM/SIGINT (19 sample captures, 3 versions, identical stack)
Claude Code CLI: sessions hang in a 100%-CPU main-thread loop (19 captures, 3 versions, one stack)
Summary
Since 2026-08-21 ~22:20Z, Claude Code CLI sessions on this machine hang permanently: one core
at 100%, all samples on the main thread, no syscalls, flat physical footprint (~390 MB),
JIT frames looping inside a ~200-byte window. It is a hang, not a crash — no crash report,
no jetsam kill, the process must be killed by hand. Sessions typically die 30 minutes to
2 hours in, under ordinary subagent-heavy work.
19 sample captures collected. Two frame fingerprints, differing by a constant build
offset — i.e. one code path across three shipped versions.
12x +0x1bed894 -> +0x1bee18c -> +0x1beeb5c -> +0x1beee2c 2.1.231
7x +0x1c4a010 -> +0x1c4a908 -> +0x1c4b2d8 -> +0x1c4b5a8 2.1.238 (1), 2.1.239 (6)
All four deltas are exactly 0x5C77C.
Environment
- macOS 26.2 (25C56), ARM64, 17 GB
- CLI 2.1.231 / 2.1.238 / 2.1.239 (all embed
Bun/1.4.0), installed viaclaude.ai/install.sh - Reproduces in Orca, and reported by the user in VS Code
- Reproduces with and without
--chrome, on default and non-defaultCLAUDE_CONFIG_DIR
Disclosure on the host state, since it may be a precondition rather than noise: the machine was
under sustained heavy load for all of these captures — load average ~28, swap 9.9 GB of 11.2 GB
in use, uptime 2 days. All captures come from a single boot, so a load- or timing-dependent
race cannot be excluded from this data set. Memory pressure was ruled out as the mechanism
(no jetsam events, no crash reports, flat ~390 MB footprint, majflt=0), not as a trigger.
Signature in the CLI's own --debug-file log
8 of 8 freezes that had a debug log end identically — the last line written is the end of a
hook-output burst, then nothing, ever:
[DEBUG] [API REQUEST] /v1/messages ... source=agent:custom:software-engineer
[DEBUG] Stream started - received first chunk
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook output does not start with {, treating as plain text
<end of file; process still R+ at 100% hours later>
The number of hook lines is that pane's constant (3 with a given hook set, 2 with one hook
removed), so the signal is the position, not the count: the hang is where the CLI resumes
its own work after post-stream hook processing. The preceding stream is usually a subagent
(source=agent:custom:*) but not always — one freeze followed a source=repl_main_thread
stream, and one followed a source=side_query (auto-mode classifier) request.
The hang is unrecoverable and untelemetrable — no catchable signal is ever delivered
Tested directly against two live frozen processes:
| signal | frozen process | same build, healthy process |
|---|---|---|
| SIGTERM | ignored — still R+ at 99% after 15 s | exits 143 |
| SIGINT | ignored — still alive after 8 s | exits 0 |
| SIGHUP | ignored | — |
| SIGKILL | terminates (the only option) | — |
Signal handling is deferred to the JS event loop, which by definition never runs again. So noSIGTERM handler runs, nothing is flushed, no graceful shutdown or exit telemetry occurs, and
JSC's sampling profiler — which writes its report only at VM shutdown — can never be harvested
from an affected process. Every affected session ends in SIGKILL and reports nothing, which
may be why this has no server-side signal.
Two consequences worth acting on independently of the root cause:
- A watchdog that aborts a main-thread stall beyond some threshold would convert an
unrecoverable hang into a recoverable error.
- Installing native (non-deferred) handlers for SIGTERM/SIGINT, even minimal ones that flush
and _exit, would make affected processes diagnosable.
A related observation that may be a second bug
The [event-loop-stall] detector sometimes catches the same loop while it still terminates:
[WARN] [event-loop-stall] blocked for 6426ms (expected 200ms, actual 6626ms) ... [likely sleep/wake] cpu=6487ms majflt=0 rss=338MB
[WARN] [event-loop-stall] blocked for 6018ms (expected 200ms, actual 6218ms) ... [likely sleep/wake] cpu=6163ms majflt=0 rss=341MB
cpu ≈ blocked means the main thread was executing, not waiting — yet both are tagged[likely sleep/wake]. That heuristic misfires on a CPU-bound stall, and it is the one signal
that would let this be detected automatically. (Genuine sleep/wake stalls in the same logs showcpu=5–113ms against ~1 s of wall.)
Ruled out
| axis | how |
|---|---|
| CLI version | 2.1.231 (stable), 2.1.238, 2.1.239 all hang with the same stack |
| JIT | BUN_JSC_useDFGJIT=0 (DFG+FTL off) hangs identically. BUN_JSC_useJIT=0 cannot be tested — it also disables SharedArrayBuffer and the CLI dies at startup with ReferenceError: SharedArrayBuffer is not defined (note claude --version still succeeds under it, so that is a false green) |
| Terminal emulator | Orca and VS Code |
| Multi-account setup | hangs with and without CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR overrides |
| A specific hook | removing all 11 entries of one third-party hook changed nothing |
| Memory pressure | no jetsam events, no crash report, flat ~390 MB footprint, majflt=0 |
| fd exhaustion, symlink cycles, message-graph cycles, permission-rule count, terminal geometry, config-dir state size | all checked and excluded; the largest config dir is the one that ran cleanest |
Separate defect found while investigating: the bash-command parse has no effective timeout
Independent of the hang above, and worth fixing on its own. Every Bash tool call is parsed by
the bundled hand-written recursive-descent bash parser during permission evaluation
(parseCommand / parseCommandRaw / the subcommand splitter / the dangerous-redirection
check), repeatedly — the splitter parses each subcommand and the prefix resolver recurses.
The parse is given a 50 ms deadline and a 50 000-node budget:
function Jc_(e,t){ let o={ nodeCount:0, deadline:performance.now()+(t??50), aborted:!1, ... };
try{ let i=Zc_(o); ... } catch{ return null } }
function Qc_(e){ if(e.nodeCount++, e.nodeCount>50000) throw e.aborted=!0,Error("budget");
if((e.nodeCount&127)===0 && performance.now()>e.deadline) throw e.aborted=!0,Error("timeout") }
function qn(e,t,r,n,o){ return Qc_(e), {type:t, text:sTt(e,r,n), ...} } // only caller of Qc_
Qc_ is reachable only through qn, i.e. only when a parse node is created, and the
deadline is additionally only evaluated on every 128th node. So:
- Any loop at the token/lexer level that creates no nodes is bounded by nothing — not the
deadline, not the budget. The only remaining guard is the 10 000-character input cap.
- The
try/catcharound the parse cannot mitigate this, because a non-terminating loop raises
nothing.
Suggested fix: check the deadline on token consumption (or on a monotonic lexer-position
counter) rather than only on node creation, and drop the & 127 mask so a slow parse cannot
overshoot by up to 127 nodes.
We could not exhibit an input that triggers it: the parser was extracted from the binary and
fuzzed with ~16 000 inputs (random shell-token strings, hand-written unterminated<< / ${ / $(( / backtick / [[ fragments, quote and backslash runs, nested
substitutions, Unicode including emoji and combining marks to exercise the non-ASCII byte-table
path, and lone surrogates) — all returned. So this is reported as a latent robustness gap, not
as a reproduction of the hang.
What we cannot do
No deterministic reproducer. It needs a real session doing subagent-heavy work; a scripted
reproducer did not trigger it in 30 minutes.
Attachments available on request
- 19 ×
samplecaptures (sample.txt, full call graphs) - Per-capture process state, open-fd count, and effective environment
- 6 ×
--debug-filelogs covering the 8 instrumented freezes - A watchdog script that captures all of the above automatically on a 60-second sustained spin