[BUG] CLI hangs indefinitely on stuck streaming response — UI swallows input but redraws on SIGWINCH; only external kill recovers
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The CLI enters a permanent "soft-hang" state mid-conversation:
- The in-flight streaming response to the Anthropic API never completes.
- Keyboard input (including Ctrl+C and Esc) is silently consumed — no echo, no visible effect.
- The TUI still redraws on window resize (SIGWINCH), so the process is clearly not crashed.
- The only way out is
kill -INT/-TERM/-9from a separate terminal.claude --resumeafter that recovers the session intact.
I've hit this many times in the last few weeks. It reproduces reliably after the laptop sleeps/wakes or after a local network/proxy reconnects while a streaming response is in flight. No client-side inactivity timeout appears to fire — I've left a wedged session sitting for 45+ minutes with zero recovery.
What Should Happen?
What Should Happen? (required)
A streaming request that stalls (no bytes received for some inactivity window) should be aborted automatically, the user should be shown an error like "stream stalled, retry?", and the TUI should return to an interactive prompt — without requiring the user to kill the process from another terminal.
At minimum, Ctrl+C (or Esc) should reliably cancel an in-flight request even when the response stream is wedged.
Error Messages/Logs
# Hung process — alive, idle, single dangling socket:
$ ps -p 3771 -o pid,state,etime,%cpu,wchan,command
PID STAT ELAPSED %CPU WCHAN COMMAND
3771 S+ 45:45 1.0 - claude --resume
$ lsof -p 3771 -nP | grep TCP
claude 3771 ... TCP 198.18.0.1:54969 -> 198.18.0.28:443 (ESTABLISHED)
# (Healthy claude processes on the same machine have 3–5 rotating sockets;
# the hung one has exactly one, on the dead stream.)
# sample(1) — main thread parked on kevent64, "HTTP Client" Bun thread also on kevent64:
2488 Thread_xxx DispatchQueue_1: com.apple.main-thread (serial)
... kevent64 (in libsystem_kernel.dylib) + 8 [0x18d9cbba8]
2488 Thread_xxx: HTTP Client
... kevent64 (in libsystem_kernel.dylib) + 8 [0x18d9cbba8]
# No CPU, no work pending — event loop is alive but every thread is idle waiting on I/O.
Steps to Reproduce
- Start a session in a terminal:
claude --resume - Send a prompt that yields a long streaming response.
- While the response is streaming, cause the underlying TCP connection to silently die. Reliable triggers in my setup:
- Sleep/wake the laptop
- Reconnect / switch nodes in a local VPN/proxy (fake-IP routing in my case, but I believe any network event that drops the TCP without sending FIN/RST will do — e.g. WiFi roam, NAT timeout)
- The TUI is now frozen:
- Typing produces no echo and no UI reaction
- Ctrl+C / Esc do nothing
- Resizing the terminal window DOES trigger a clean repaint (SIGWINCH path works)
- No timeout fires. Observed > 45 minutes wedged.
- Recovery requires
kill -INT <pid>(or-TERM/-9) from another terminal, thenclaude --resume.
Why this happens (best guess)
- The event loop is healthy — SIGWINCH triggers a full Ink re-render.
- stdin (raw mode,
isigdisabled) is still being read, but the keystroke handler is awaiting a promise that never resolves — that promise is the streaming response. - The TCP looks
ESTABLISHEDlocally because the kernel never received FIN/RST (a proxy/NAT dropping the upstream after sleep/wake or a peer crash without an RST will produce exactly this state). - There is no client-side inactivity / read timeout on the response stream, so the
for await (chunk of stream)loop never gives up. - Because raw mode disables
isig, keyboard Ctrl+C is delivered as byte 0x03 (not SIGINT), so it goes through the same wedged input pipeline. Only an externalkill -INTreaches the process.
Suggested fixes (in order of impact)
- Stream inactivity timeout on the API client: if no bytes received for N seconds (configurable, e.g. 90s), abort the request and surface a retry prompt. This alone eliminates the hang.
- Always-available cancel keybind: route Ctrl+C through a synchronous abort path, not through the awaiting state machine.
- TCP keepalive on outbound sockets (
socket.setKeepAlive(true, 30000)) — bounds the half-open window to ~75s after probing starts. - Heartbeat indicator while waiting on bytes (a moving dot every few seconds), so "waiting" vs "frozen" is distinguishable to the user.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.132 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other
Additional Information
Additional Information (optional)
- macOS 26.4.1 (Darwin 25.4.0), Apple Silicon.
- Terminal: Ghostty (not in the dropdown — selected "Other").
- A local VPN/proxy is in use (fake-IP routing via 198.18.0.0/15), which I believe is the trigger but not the root cause: any TCP that dies without FIN/RST should produce the same symptom, since the client has no inactivity timeout to catch it.
Not duplicates of:
- #59810 (general timeout detection feature) — related direction, but this is a concrete reproducible bug, not a feature ask.
- #59827 (
goalfunction loop) — different layer. - #59750 / #59899 / #59814 (Windows TUI unresponsive) — different platform/path.
6 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Follow-up datapoint: I just confirmed that sending
kill -INT <pid>from another terminal causes the wedged process to exit immediately and cleanly — no-TERMor-9needed. The SIGINT handler is wired up and working.This narrows down suggested fix #2: it's not about adding a SIGINT handler (already there), it's about making sure the in-TUI Ctrl+C reaches the same abort path. Today the keystroke is delivered as stdin byte
0x03(raw mode,isigdisabled) and gets stuck in the same wedged input pipeline as every other key. Routing Ctrl+C through a synchronous path that calls the existing SIGINT abort handler — instead of going through the awaiting state machine — should fully resolve the "unable to interrupt" half of the bug.(The other half — "no inactivity timeout, so the stall happens at all" — still needs suggested fix #1.)
Root Cause
Event loop starvation during streaming:
killcan recoverProposed Fix
1. Add streaming timeout
2. Never drop input during streaming
Files to Modify
src/api/streaming.ts— Timeout + abort mechanismsrc/cli/input.ts— Queue input during streamingsrc/cli/tui.ts— Streaming state indicatorFull solution:
solutions/claude-code-59913-streaming-hang-fix.mdThanks for the duplicate-flag — I went through all three.
#37080 is already closed (consolidated into #33949 by the author, not fixed by maintainers).
#33949 and #25979 are open and they do cover suggested fix #1 (stream inactivity timeout). I'm happy to defer that half of this issue to #33949, which has the deeper repo-archaeology and a larger user signal.
What I believe is not covered by any of those:
sample(1)trace I posted shows the main thread parked onkevent64not blocked inside a stream read.SIGWINCH triggers a clean Ink re-render via the same event loop — so the loop isn't starved; only the input state machine is wmise.handler" but "make the in-TUI Ctr
isigoff, so0x03arrives as a stdin byte and gets stuck in the same wedged input pipeline as every other key.So I'd suggest keeping this issue open as the input-pipeline-wedge / Ctrl+C-can't-cancel angle, and treating the underlying SSE . Happy to split if maintainersprefer — let me know.
Update — I retract part of the previous follow-up. Tested in detail and the picture is more nuanced.
What's still true
claude-code's
SIGINTexit path does not perform terminal teardown. Verified directly: afterkill -INT <pid>cleanly exits the process, the hosting terminal is left with:-icanon -echo) — the kernel side, notcsetattrwas calledCSI ? 1049 lfrom outside)So the signal-exit handler should restore the saved pre-launch termios and emit the matching disable sequences (
CSI < u,CSI > 4 ; 0 m,CSI ? 1003 / 1002 / 1000 l,CSI ? 2004 l,CSI ? 1049 l). Same handler should apply toSIGINT,SIGTERM, andSIGHUP. This is just standard TUI hygiene and is independently worth fixing.What's not true (correcting my previous comment)
I previously claimed that adding TUI teardown would resolve the "wedged window after external kill" symptom. It will not. I tested by injecting the full disable sequence — plus DECSTR, plus RIS — directly into the wedged pty's slave from outside. Ghostty visibly processed every sequence (alt screen exit, screen clear), but the window's keyboard input forwarding stayed dead. The corruption lives at a Ghostty per-surface level that's below in-band reset, and that's a separate bug (filed/added on the Ghostty side: ghostty-org/ghostty#12629).
So:
kill -INTremains the only escape hatch, and we hit the Ghostty surface bug every time.Diagnostic detail (in case useful)
After external
kill -INT: zsh foreground,tpgid == zsh.pgid, display direction works (writing to/dev/ttysNrenders), but zsh's stdin read offset never advances on user keypresses — bytes don't reach the pty slave. Ghostty itself is idle (~2% CPU, no hot thread on sample), other tabs in the same process work fine.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.