Bundled CLI deadlocks permanently (futex wait, no sockets, SIGKILL-only) when the API accepts a stream but never sends response bytes
Summary
When the Anthropic API accepts a streaming request but never returns any response bytes (observed during a server-side throttled/starved window), the SDK's bundled CLI deadlocks permanently instead of timing out, erroring, or retrying. The hung process survives the load balancer killing the TCP connection, holds no sockets, writes nothing to the session transcript, ignores SIGTERM, and can only be cleared with SIGKILL. Long-running agent orchestrators built on the SDK hang indefinitely with no error surfaced to the application layer.
Environment
claude-agent-sdk(Python) 0.2.110, bundled CLI 2.1.191- Ubuntu 24.04 VM (x86_64, DigitalOcean), Python 3.12
- Persistent resumed sessions (
resumewith existing session IDs),stream-jsonoutput, hooks enabled, MCP config attached,max_turnsset, large long-lived session transcript (~30 MB JSONL; resume itself is not the trigger — see isolation matrix)
Incident timeline (2026-07-10, ~06:00–08:00Z window and again that evening)
Six consecutive production agent turns starved with an identical signature while ~12 near-identical probe requests interleaved between them all succeeded. The account had burned ~1M tokens that evening and every successful stream that night carried a RateLimitEvent, so the working hypothesis is a server-side throttle/starvation state applying to one request class. The server-side behavior is Anthropic's to diagnose; this report is about the client's response to it, which turns a transient server condition into a permanent silent hang.
Signature of a starved turn (reproducible observations)
- TCP connect + TLS handshake complete; request is fully sent. Roughly ~1.2 KB total is ever received (handshake/headers only — a TLS-resumed connection's overhead, no SSE events, zero response body bytes).
- No bytes arrive for 10–20 minutes; the load balancer then kills the connection (FIN/RST observed).
- After the connection dies, the CLI process:
- drops to ~0% CPU with zero open sockets (
ss -tnpempty for the pid), - makes no further syscalls except futex waits (strace: >99.9%
futex, noepoll_wait, noread), - writes nothing further to the session transcript (JSONL mtime frozen),
- never surfaces an error, timeout, or retry to the SDK caller —
receive_response()simply never yields, - ignores SIGTERM; only SIGKILL clears it.
- The SDK layer (
claude_agent_sdkPython) blocks forever awaiting messages; the application has no way to learn the turn is dead short of implementing its own wall-clock watchdog and force-disconnect (which is the workaround we now run).
What was ruled out (live bisect during the incident)
Each variable below was isolated and tested during the starvation window; all passed individually and as the fully assembled stack once conditions eased, so none of them is the trigger:
- Model (two different models starved; both fine later)
- Session identity and size (a fresh-UUID copy of the same 30 MB transcript resumed fine mid-incident; the original ID also starved on a brand-new generation)
- Auth/account (a raw 284 KB POST to the API from the same host returned in 0.38 s)
- Host/network (disk, memory, dmesg, DNS all clean; other TLS traffic normal)
- System prompt, MCP config, tools list, hooks, effort setting, the exact assembled user message (a harness sending the near-identical request through the same SDK path succeeded mid-incident)
- Untested deltas that remained between the passing harness and the starving production caller:
agents=subagent definitions,setting_sources, a long disallowed-tools list,max_turns=100
Expected behavior
Any of: a first-byte/idle-stream timeout on the CLI's HTTP client; an error event surfaced through stream-json when the transport dies; retry-with-backoff semantics; at minimum, exiting nonzero when the connection is gone and no response was ever received. A process whose connection was killed by the peer should not persist in a futex wait holding no sockets while the SDK caller awaits forever.
Actual behavior
Permanent silent deadlock, SIGTERM-immune, invisible to the SDK caller.
Workaround we deployed (for other affected users)
Application-level reaper: wrap the SDK's receive loop in asyncio.wait_for() with a wall-clock budget; on timeout, disconnect() bounded by its own short timeout, drop the client object, and SIGKILL the CLI subprocess if it survives. Also register a stderr callback (ClaudeAgentOptions(stderr=...)) — note the SDK only pipes CLI stderr when a callback is registered, so by default these hangs leave no diagnostics at all.
Evidence available on request
strace summaries (futex profile), ss socket captures across the hang, transcript mtime timelines, the bisect harness scripts, and timestamps of the starved vs. passing requests for server-side correlation.