CLI hangs in a 100%-CPU main-thread loop and ignores SIGTERM/SIGINT (19 sample captures, 3 versions, identical stack)

Status Open
Reported on v2.1.231
Maintainer reply None cached
Activity 0 comments · opened Aug 23, 2026

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 via claude.ai/install.sh
  • Reproduces in Orca, and reported by the user in VS Code
  • Reproduces with and without --chrome, on default and non-default CLAUDE_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 no
SIGTERM 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:

  1. A watchdog that aborts a main-thread stall beyond some threshold would convert an

unrecoverable hang into a recoverable error.

  1. 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 show
cpu=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:

  1. 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.

  1. The try/catch around 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 × sample captures (sample.txt, full call graphs)
  • Per-capture process state, open-fd count, and effective environment
  • 6 × --debug-file logs covering the 8 instrumented freezes
  • A watchdog script that captures all of the above automatically on a 60-second sustained spin

View original on GitHub ↗