main thread enters user-space spin loop after Bash tool child exits; SIGCHLD ignored; terminal unresponsive (2.1.118)
Claude Code 2.1.118 — main thread enters user-space spin loop after Bash tool child exits, terminal becomes unresponsive
Repo to file under: https://github.com/anthropics/claude-code/issues
Filed by: user xsupermarco@gmail.com (Marco)
Diagnosed by: new Claude Code session (PID 973745) on the same host, using gdb on the stuck PID.
Date observed: 2026-04-23
---
Context from the user filing this
This is the third confirmed bug we've hit in version 2.1.118 in roughly two days. The user's words, verbatim (translated from pt-BR):
"These last two days, we're developing something, and it breaks other points that should NOT have been touched. Tests aren't run — only syntax, no runtime, no logic."
I'm flagging this because the pattern (regressions in untouched code paths, plus what reads like syntax-only validation in the release pipeline) seems consistent across the bugs we've seen, not just specific to this hang. Worth a look beyond just the immediate fix.
---
TL;DR
After ~30+ minutes of normal interactive use, the claude CLI process became unresponsive in the terminal. The TUI no longer accepts input, but the process is alive: connected to the user's pty, with two ESTAB TLS sockets to api.anthropic.com still open. top shows it pinned at 50–60% CPU. There is exactly one orphaned [bash] <defunct> child of the claude process — a leftover from a Bash tool invocation that Claude never waitpid()'d. The main thread is spinning in a tight user-space loop inside the claude binary's .text section. All 20 worker threads (Bun pools, HeapHelpers, HTTP Client, File Watcher) are correctly idle.
This pattern (live process, idle workers, single zombie child not reaped, main thread in user-space spin) is consistent with a child-process lifecycle bug in the Bun-compiled runtime: an event/promise that resolves on the bash child's exit was never satisfied, and the event loop is busy-polling for it instead of either yielding or completing.
---
Environment
| Field | Value |
|---|---|
| Claude Code version | 2.1.118 |
| Binary path | /root/.local/share/claude/versions/2.1.118 |
| Invocation | claude --effort max --chrome |
| Runtime | Bun (visible in thread names: Bun Pool 0..9, HeapHelper, HTTP Client, File Watcher) |
| OS | Debian 13 |
| Kernel | Linux 6.17.13-2-pve |
| Arch | x86_64 |
| Run as | root |
| Terminal | inside tmux, on /dev/pts/4 |
| Network | LAN host 192.168.0.41, ESTAB sockets to 160.79.104.10:443 (api.anthropic.com) |
---
Symptoms (user-observable)
- Terminal stops echoing input.
- Cursor still in the Claude Code TUI prompt area; no error, no exit.
topshows theclaudeprocess at 50–60% CPU constantly.- Opening a second
claudesession in another tmux window works normally; the stuck one stays stuck indefinitely.
---
Process state at the moment of diagnosis (47 min into the hang)
PID PPID ELAPSED %CPU STAT CMD
957988 624203 47:27 60.7 Rl+ claude --effort max --chrome
STAT = Rl+— running, multi-threaded, foreground.wchan = 0— not blocked in any kernel syscall.Threads = 21.voluntary_ctxt_switches = 578328,nonvoluntary_ctxt_switches = 20495over 38 min — the main thread keeps yielding briefly but nothing breaks the spin.
Syscall sampling (passive, no ptrace)
50 reads of /proc/957988/syscall over 2 s, sampled every 40 ms:
50 running
100% of samples returned running, confirming the main thread is in user-space code, not in any kernel syscall.
---
Child process — the smoking gun
$ pgrep -aP 957988
969673 [bash] <defunct>
There is exactly one child of the claude process, and it is a zombie — a bash that finished and is waiting to be reaped by waitpid() from the parent. The parent (claude, PID 957988) never picked it up, even though many minutes have passed.
The defunct child correlates with the user's last activity in the session: a Bash tool invocation that ran a shell command. The bash exited; the claude runtime missed the close/exit event for it.
---
Backtrace (gdb 16.3 on the stripped binary)
gdb -batch -p 957988 -ex 'set pagination off' -ex 'thread apply all bt 30' -ex 'detach' -ex 'quit'
The binary is stripped (no DWARF symbols, no unwind info), so symbol resolution and stack unwinding are limited. Below is the relevant signal extracted from the full output.
Thread 1 — claude (LWP 957988), the spinning main thread
#0 0x00000000061db575 in ?? ()
#1 0x00007ffda552e940 in ?? ()
#2 0x0000000000000020 in ?? ()
#3 0x0000000000000000 in ?? ()
0x00000000061db575lies inside ther-xptext segment of the claude binary itself:
````
02d2f000-068c0000 r-xp 02b2e000 00:33 /root/.local/share/claude/versions/2.1.118
This is not libc, not libpthread, not JSC JIT — it is the AOT-compiled JS bundle of Claude Code (or a small native shim around it).
- Frames #1–#3 are stack pointers / frame pointers, not return addresses — the unwinder cannot proceed without unwind tables.
- Detach + re-check: process state remained
Rl+, CPU climbed from 48% to 60% — gdb attach was non-destructive but did not break the loop.
Thread 21 — claude aux (LWP 957989)
In pthread_cond_timedwait. Idle.
Threads 19–14 + 5–2 — Bun Pool 0..9
All in the same idle wait position deep in JSC code:
#0 0x0000000003062933 in ?? ()
#1 0x00000000030cfb59 in ?? ()
#2 0x0000000003c25ae9 in ?? ()
#3 0x00000000035deaf0 in ?? ()
#4 0x0000715994729b7b in ?? () from libc.so.6 (start_thread)
#5 0x00007159947a77f8 in ?? () from libc.so.6
Indistinguishable across all 10 pool threads → standard WTF::ThreadCondition::wait-style worker idle.
Thread 18 — HTTP Client
#0 0x0000000003f6a25a in ?? ()
#1 0x0000000004aba4d6 in ?? ()
Idle in network event loop.
Thread 13 — File Watcher
Blocked in read() on (presumably) an inotify fd. Normal.
Threads 12–6 — HeapHelper (7 threads)
All in pthread_cond_timedwait. Idle, waiting for a GC trigger.
Conclusion: every worker is idle exactly where it should be when the runtime has nothing to do. The 50%+ CPU is being burned entirely by Thread 1, in user-space, in the claude binary's text section.
Full gdb output saved on the host at /tmp/claude_957988_bt.txt (266 lines).
---
File descriptors and sockets
50 fds open. Highlights:
0, 1, 2, 11, 12, 13→/dev/pts/4(the user's terminal — still attached).4, 14, 41→anon_inode:[eventpoll]— Bun event loops.15, 43→anon_inode:[timerfd].16, 45→anon_inode:[eventfd].18→ ESTAB TCP192.168.0.41:43064 → 160.79.104.10:443(api.anthropic.com).44→ ESTAB TCP192.168.0.41:57418 → 160.79.104.10:443(api.anthropic.com).19–40→ various paths under/root/.claude/...(tasks, settings, sessions, plugins, shell-snapshots, credentials, etc).22→/root/.claude/tasks/ae57ad32-03dd-4eab-beb1-220de8ca8732/.lock(still held).19→/root/.claude/tasks/ae57ad32-03dd-4eab-beb1-220de8ca8732(the task dir itself, with 6 task JSON files inside — see below).
API sockets are still open and (presumably) on the server side waiting. The session is not network-disconnected.
Task dir contents (TaskList state at hang)
Six task JSONs were active. The first was in_progress, the other five pending. None had been closed. All written between 17:13 and 17:14, ~17 min after session start (16:56).
1.json status=in_progress "T01088: Finalize photos hash check + drop rc → 1.66"
2.json status=pending "T01089: Fix dblclick-to-copy in history messages"
3.json status=pending "T01090: Restore IP next to name in messages"
4.json status=pending "T01091: Filter session jsonl raw leak"
5.json status=pending "T01092: Fix Vega duplicated message 3s after response"
6.json status=pending "T01093: Fix empty Microfone/Voices comboboxes via cloudflared tunnel"
This is consistent with the user's report that the session locked up while working on T01088, before getting to the others.
---
Cumulative I/O at hang
rchar: 318808690 (~304 MB read)
wchar: 14034018 (~13 MB written)
syscr: 6168827
syscw: 47201
read_bytes: 1822208
write_bytes: 7207172
cancelled_write_bytes: 0
Read syscall count is two orders of magnitude higher than write — consistent with active streaming reads from somewhere (probably the API socket and/or a tool output pipe) before the freeze.
---
Hypothesis
A Bash tool was invoked. The runtime spawned bash as a child via fork+exec (or Bun's equivalent), wired up pipes for stdout/stderr/stdin, and registered a Promise/callback to fire on the child's exit.
The bash exited. One of the following happened:
- The runtime's
SIGCHLDhandler missed the signal (handler not installed, masked, or coalesced with anotherSIGCHLDand only one was processed). - The exit detection code path relies on observing
EOFon the child's stdout pipe viaepoll. The pipe FD never reportedEPOLLHUPbecause of a race, or theEPOLLHUPwas observed but the event handler errored out before resolving the awaiting promise. - The runtime is polling
waitpid(WNOHANG)in a tick callback that always returns 0 (because something is holding the kernel from delivering it — butwchan=0andSTAT=Rargue against the kernel side, so this is unlikely).
The [bash] <defunct> child confirms waitpid() was never called successfully on it.
The 50%+ CPU suggests an await / Promise.race / event loop predicate that the runtime keeps re-evaluating to not ready on every tick, never sleeping.
This kind of bug typically triggers when:
- A long-running tool (
Bash) outputs nothing for a while, then exits, and the user's terminal is being read on the same event loop tick. - A process-group / setsid configuration mismatch causes the shell child to exit through a path the parent isn't subscribed to.
---
Reproduction
I do not have a deterministic repro. The user reports it has happened more than once on this host, but not predictably.
Variables that may matter:
--effort max --chromeflags (chrome MCP server is part of the toolset).- Long session (30+ min, multiple tool calls before the hang).
- Bash tool with output to a pty/stream.
- Potentially: a Bash command that itself spawned children that survived the parent (creating reaping confusion).
If a deterministic repro is needed, I can collect more data on the next occurrence (e.g. install strace/perf/bpftrace ahead of time and capture the syscall stream live during the freeze).
---
What I recommend the Anthropic team do with this
- Audit child-process lifecycle in the Bash tool implementation — specifically the path that registers the "child has exited" event. Make sure both the
SIGCHLDand theEOF-on-stdoutpaths converge correctly and thatwaitpidis always called. - Add a watchdog in the event loop — if the loop runs N consecutive ticks at 100% with no I/O progress, log a warning (and ideally a stack dump) to
/root/.claude/sessions/<id>/runtime.log. This would give post-mortem evidence next time. - Ship the binary with at least minimal unwind info (
-fasynchronous-unwind-tables, keep.eh_frame). The strip removed everything; a future user with gdb cannot give you a real backtrace. - Reap orphan children defensively at session shutdown — even if the live-reaping path fails, a final sweep on SIGTERM/SIGINT would prevent zombies from accumulating.
---
Mitigation tried by the user
| Step | Result |
|---|---|
| Open new tmux pane and start fresh claude session | Worked — that's how this report is being written. |
| kill -CHLD <pid> on the stuck process | No effect. Sent at 52:47 etime. 3 s later: STAT=Rl+, CPU=65.3% (was 65.2% before), wchan=0, child zombie still present, syscall sampling still 100% running. The signal was delivered (no kill error) but the runtime did not act on it. |
| kill -9 <pid> | Process exited cleanly; the orphan [bash] <defunct> (PID 969673) was immediately reaped by init (now its new parent). The user's tmux pane survived as expected. |
Adjunct finding: SIGCHLD non-effect is itself a clue
A correctly-written runtime that detects child exit purely via pipe EOF would naturally ignore a forced SIGCHLD — that's expected. But Bun does install SIGCHLD handling (via libuv-style uv_signal_t infrastructure), so an extra SIGCHLD should at minimum re-enter the child-reaping code path. The fact that absolutely nothing changed (zombie preserved, CPU unchanged, state unchanged) suggests one of:
- The SIGCHLD handler was never installed at the OS level for this session (a
cat /proc/<pid>/statussnapshot showingSigCgtwould be informative next time — check whether bit 17 / SIGCHLD is set). - The handler is installed but its userspace continuation never runs because the spinning main thread never returns to the event loop. The 100%
runningsyscall sample supports this — the main thread is not callingepoll_wait, so a wakeup posted by a signal handler would have nowhere to be observed.
In other words, the runtime appears to be in a state where signal-driven recovery is structurally impossible, not just "ignored". This is what made kill -9 necessary.
---
Attachments
claude_957988_bt.txt— full gdb backtrace output (266 lines).- This report.
---
Related files on the host (kept for reference)
/root/.claude/tasks/ae57ad32-03dd-4eab-beb1-220de8ca8732/{1..6}.json— TaskList at moment of hang./proc/957988/maps— full memory map (binary loaded at00200000-069a1000)./proc/957988/status— process status snapshot.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗