[BUG] CLI hangs indefinitely on stuck streaming response — UI swallows input but redraws on SIGWINCH; only external kill recovers

Status Fixed / completed
Reported on v2.1.132
Maintainer reply None cached
Activity 6 comments · opened May 17, 2026 · closed May 21, 2026

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/-9 from a separate terminal. claude --resume after 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

  1. Start a session in a terminal: claude --resume
  2. Send a prompt that yields a long streaming response.
  3. 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)
  1. 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)
  1. No timeout fires. Observed > 45 minutes wedged.
  2. Recovery requires kill -INT <pid> (or -TERM/-9) from another terminal, then claude --resume.

Why this happens (best guess)

  • The event loop is healthy — SIGWINCH triggers a full Ink re-render.
  • stdin (raw mode, isig disabled) is still being read, but the keystroke handler is awaiting a promise that never resolves — that promise is the streaming response.
  • The TCP looks ESTABLISHED locally 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 external kill -INT reaches the process.

Suggested fixes (in order of impact)

  1. 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.
  2. Always-available cancel keybind: route Ctrl+C through a synchronous abort path, not through the awaiting state machine.
  3. TCP keepalive on outbound sockets (socket.setKeepAlive(true, 30000)) — bounds the half-open window to ~75s after probing starts.
  4. 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 (goal function loop) — different layer.
  • #59750 / #59899 / #59814 (Windows TUI unresponsive) — different platform/path.

View original on GitHub ↗

6 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/37080
  2. https://github.com/anthropics/claude-code/issues/25979
  3. https://github.com/anthropics/claude-code/issues/33949

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

jinxiang-dlyai · 3 months ago

Follow-up datapoint: I just confirmed that sending kill -INT <pid> from another terminal causes the wedged process to exit immediately and cleanly — no -TERM or -9 needed. 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, isig disabled) 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.)

jshaofa-ui · 3 months ago

Root Cause

Event loop starvation during streaming:

  1. Streaming read operation blocks the main event loop
  2. Keyboard input handling is on a channel that's never read (reactor stuck in streaming read)
  3. SIGWINCH works because signal handlers bypass the event loop
  4. Only external kill can recover

Proposed Fix

1. Add streaming timeout

const STREAMING_TIMEOUT_MS = 300_000; // 5 minutes

async function* streamResponse(request: ApiRequest): AsyncIterable<Chunk> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(
        new Error('Streaming response timed out after 5 minutes')
    ), STREAMING_TIMEOUT_MS);
    try {
        const response = await fetch(request.url, { ...request.init, signal: controller.signal });
        for await (const chunk of response.body) yield chunk;
    } finally { clearTimeout(timeoutId); }
}

2. Never drop input during streaming

handleInput(key: string): void {
    if (this.isStreaming) {
        this.inputQueue.push(key);  // Queue instead of drop
        if (key === 'Ctrl+C') this.abortStreaming();
        return;
    }
    this.processInput(key);
}

Files to Modify

  1. src/api/streaming.ts — Timeout + abort mechanism
  2. src/cli/input.ts — Queue input during streaming
  3. src/cli/tui.ts — Streaming state indicator

Full solution: solutions/claude-code-59913-streaming-hang-fix.md

eraseliu001 · 3 months ago

Thanks 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:

  1. In-TUI Ctrl+C / Esc completely swallowed. #33949 explicitly notes "ESC partially works around this by aborting the dead connection" — so over there ESC at least reaches the abort path. On 2.1.132 / macOS in this ces any effect at all** until theprocess is killed externally. That points to an input-routing failure separate from the streaming-timeout issue.
  1. SIGWINCH-still-redraws dat"event loop starved by streamingread" hypothesis (re: @jshaofa-ui's comment above). The sample(1) trace I posted shows the main thread parked on kevent64 not 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.
  1. **External SIGINT exits cleanl a SIGINT abort handler alreadyexists and works. The fix for the "can't interrupt" half is therefore not "add a SIGINT

handler" but "make the in-TUI Ctrisig off, so 0x03 arrives 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.

eraseliu001 · 3 months ago

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 SIGINT exit path does not perform terminal teardown. Verified directly: after kill -INT <pid> cleanly exits the process, the hosting terminal is left with:

  • termios in claude's raw-mode state (-icanon -echo) — the kernel side, no tcsetattr was called
  • the alt screen buffer still active (the cursor visibly jumps when I send CSI ? 1049 l from outside)
  • presumably other in-band modes still set (kitty keyboard / modifyOtherKeys / mouse tracking / bracketed paste)

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 to SIGINT, SIGTERM, and SIGHUP. 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:

  • Fix #1 (stream inactivity timeout) — still the most important. Without it the hang happens at all, external kill -INT remains the only escape hatch, and we hit the Ghostty surface bug every time.
  • Fix #2 / signal-exit teardown — still worth doing on principle, but won't on its own recover the host window. Frame it as "be a good citizen TUI," not as "this fixes the post-kill window state."

Diagnostic detail (in case useful)

After external kill -INT: zsh foreground, tpgid == zsh.pgid, display direction works (writing to /dev/ttysN renders), 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.

github-actions[bot] · 1 month ago

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.