Since 2.1.213, one connection error disables HTTP keep-alive for the rest of the process; long sessions then degrade into stream-idle-watchdog stalls and full-conversation resends until restart
What's Wrong?
The 2.1.214 changelog says: "Changed keep-alive connection pooling to disable after a stale-connection error, so retries open a fresh socket." The intent is one retry on a fresh socket. The code instead sets keepAliveDisabled = true on the shared client state, and nothing resets it. The only reset function is _resetKeepAliveForTesting. From then on every request in the process is sent with keepalive: false and opens a new TCP+TLS connection, for the life of the process, which can be days for a background or orchestrator session.
It's a regression from 2.1.213. I checked the published linux-x64 builds:
| Version | Behaviour |
|---|---|
| 2.1.179 – 2.1.212 | The same code exists but is gated on the feature flag tengu_disable_keepalive_on_econnreset (default false). Triggers: ECONNRESET, EPIPE, ConnectionClosed. |
| 2.1.213 (2026-07-17) | The flag gate is removed, so the switch always fires. |
| 2.1.293 (latest) | Still always fires. Triggers widened to 9 codes, adding UND_ERR_SOCKET, ETIMEDOUT, ECONNABORTED, ERR_SOCKET_CLOSED, StreamSuspended and StreamTruncated. |
Why it hurts. In our sessions, once a process is in this state:
- Streams start (
message_startarrives, about 730 bytes, so the first-byte timeout is satisfied) and then go silent. The byte-level stream idle watchdog (CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS) then aborts them: "Stream idle timeout - no chunks received". We ran it at 60 s; the first-party default is 180 s. - A stall after the stream started goes straight to the non-streaming fallback.
- That retry reuses the same
previous_message_id. The server rejects it with 400 "has already been continued" or 404 "No thread state was found". The client logs[tether] forcing create for retryand re-sends the whole conversation, 0.35–1.1 MB per stall in our sessions.
So each stall costs the full idle-watchdog wait, a rejected retry and a full-conversation resend, until the process restarts.
Measured. This is one orchestrator session running many subagents, on Linux with 2.1.292, from its --debug log on 2026-10-07:
| | requests | idle-watchdog aborts | aborts / request | idle gaps ≥ 40 s | "already continued" 400s |
|---|---|---|---|---|---|
| Stock, 07:00–12:37 PT (watchdog at 60 s; the switch flipped at 07:46) | ~1,725 | 131 | 7.6 % | 66 | 101 |
| Setter patched locally to a no-op, 12:37–16:19 PT (watchdog at the 180 s default) | ~2,190 | 0 | 0 % | 0 | 2 |
- The patched period was the busier one: one hour carried 967 requests.
- The watchdog was raised to 180 s at the same time. No silent gap reached even 40 s, so the higher limit hid no stalls.
- The trigger still fired twice in the patched process. Each cost one rejected retry and one resend, then the process kept reusing connections.
- An outside connection logger (polling
/procandss) saw three processes over three days move from "pooled" to "fresh connection per request" after their first such error. Stall rates rose with uptime and dropped to zero on restart. - In this run the first watchdog aborts came about 7 s before the flip. We can't tell what starts an episode. The latch is what keeps it going.
Isolated reproduction.
- A local proxy (plain HTTP in, TLS out to api.anthropic.com, with
ANTHROPIC_BASE_URLpointed at it) resets the first connection carryingPOST /v1/messages. - Stock 2.1.292 then opens a new connection for every later request: 1, 1, 1, 1.
- With the setter made a no-op, the next 4 requests share one connection.
What Should Happen?
- Do what the changelog says. Use a fresh socket for that one retry, or a short cool-down, then pool again. One reset should not change a process's network behaviour forever.
- Don't reuse a used thread id on retry. A retry after an aborted stream should not send a
previous_message_idthe server already counts as continued, or the server should allow continuing from an aborted turn. Today every mid-stream abort becomes a full-conversation resend. - Let a mid-stream stall retry as a stream. Today, once the stream has started, the only options are the non-streaming fallback or failing the turn (
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1).
Error Messages/Logs
[DEBUG] Stale connection — disabling keep-alive for retry
[WARN] [Stall] stream_idle_partial lastChunkAgeMs=60000 bytesTotal=738 idleDeadlineMs=60000
[WARN] [byte-watchdog] firing: idle=60000ms late=0ms errored=false bodyReadPending=true
[ERROR] Streaming idle timeout (byte-level): stream idle: no bytes for 60000ms, aborting stream
[ERROR] Error streaming, falling back to non-streaming mode: Stream idle timeout - no chunks received
[ERROR] API error (attempt 1/10): 400 {"type":"invalid_request_error","message":"The requested `previous_message_id` has already been continued. ..."}
[DEBUG] [tether] forcing create for retry: replay_other_400
Steps to Reproduce
- Run with
--debug-file <path>through a proxy that resets one/v1/messagesconnection, or wait for a natural connection error in a long session. - Look for
Stale connection — disabling keep-alive for retryin the log. - Every later request in that process opens a new connection. Count them at the proxy or with
ss -tnp. - Under sustained load, idle-watchdog aborts, non-streaming fallbacks and
previous_message_id400s/404s pile up until restart.
Workarounds
- Restart long-lived sessions after the first
Stale connection — disabling keep-alive for retryline in the debug log. - Run a version before 2.1.213.
- Patch the setter locally (what we run now; the "patched" numbers above come from it). It's hacky, but it's the only fix that keeps a current version.
- The setter. The bundled JS contains exactly one function of the form
function X(){Y().keepAliveDisabled=!0}. Change!0to!1, which is the same length, so the switch writesfalse. - The precompiled copy. Bun executes precompiled bytecode, so editing only the source changes nothing. The standalone executable ends in a 32-byte offsets struct and the
\n---- Bun! ----\ntrailer. That struct points to a module table of 52-byte records, each holding 6 (offset, length) u32 pairs: name, contents, sourcemap, bytecode, module info and origin. Find the record whose contents range covers the setter and zero its bytecode pair (bytes 24–31 of the record). Bun then compiles that one module from the edited source. - Size and safety. That's 8 bytes in total, and the file length doesn't change. Keep the original file, and replace it atomically so running sessions keep their old copy.
- Background sessions. The daemon and its pre-started standby processes keep running the old file until
claude daemon stop. - Updates. Each update installs an unpatched binary, so the patch has to be reapplied.
- Check. Behind a proxy that resets one connection, the patched build reuses a single connection for the following requests; stock opens a new one for each.
Claude Code 2.1.292 (bug confirmed still present in 2.1.293) · Linux (Ubuntu) · Anthropic API (Max subscription) · Opus 5.5.
Written by Claude (Opus 5.5) from our debug logs and the published builds. I've reviewed it and believe it is correct. We have been hunting these stalls for weeks.