SSE streaming hangs indefinitely (no timeout) + ESC cannot fully cancel (queue auto-restart) — root cause analysis with fix proposals
Summary
These two bugs have been plaguing users for months (see #26224 — 28 comments, #6836 — 150+ reports), with no root cause analysis from the team. After yet another day of babysitting Claude Code and pressing ESC every few minutes to revive a hung agent, we decided to conduct our own deep investigation — reverse-engineering cli.js across 12 npm package versions and analyzing 1,571 session JSONL files containing 148,444 tool calls.
Here are the exact root causes and proposed fixes.
Claude Code hangs indefinitely when an SSE streaming connection silently dies. There is no client-side timeout or heartbeat detection, so the process waits forever for events that will never arrive. ESC partially works around this by aborting the dead connection, but the queue auto-restart mechanism (queue.length > 0 → n()) immediately starts the next queued prompt instead of returning control to the user.
Root cause identified in source code — two separate issues in cli.js:
- No streaming timeout: The
messages.stream()call has no timeout. If the SSE connection dies silently (TCP half-open), the client waits forever. - Queue auto-restart after abort: After ESC aborts a hung request,
if (queue.length > 0) { n(); return; }immediately starts the next queued prompt. The user cannot fully cancel.
Environment
- Claude Code: 2.1.74 (also confirmed on 2.1.50–2.1.73)
- OS: Windows 10, Git Bash
- Model: Opus 4.6
- API: Anthropic direct (not Bedrock/Vertex)
Reproduction
- Start a Claude Code session
- Submit a prompt → agent starts processing
- Wait for a hang (0 tokens, timer running, no progress) — happens ~10-15% of prompts
- Submit another prompt while hung → goes to queue
- Press ESC
- Expected: Cancel everything, return to
❯ - Actual: Cancels the hung prompt, immediately starts the queued one
Frequency
Measured across 1,571 sessions using a custom JSONL analyzer tool:
| Period | Versions | Orphan rate (lost tool calls) |
|--------|----------|-------------------------------|
| Dec 2025 | 2.0.72–2.1.2 | 6–14% |
| Jan 2026 | 2.1.5–2.1.23 | 5–10% |
| Feb 2026 | 2.1.29–2.1.56 | 3–8% |
| Mar 2026 | 2.1.69–2.1.74 | 2.4–4% |
The hang frequency has been increasing over time: rare in fall 2025, now ~10-15% of prompts per hour.
Source Code Analysis
Analyzed cli.js extracted from npm pack @anthropic-ai/claude-code across versions 2.0.72 through 2.1.74.
Issue 1: No streaming timeout
The API call at approximately offset 2,553,870 in cli.js (v2.1.74):
client.beta.messages.stream({...params}, options)
There is no timeout parameter, no keepalive check, and no heartbeat detection. The Anthropic SSE API sends periodic :ping comments, but the client does not monitor for their absence.
When the TCP connection silently dies (common on Windows, WiFi, VPN, or after laptop sleep), the Node.js HTTP client has no way to know the connection is dead. The AbortController signal is never triggered because no error event fires.
Evidence: Packet inspection by other reporters confirms the client is stuck waiting for SSE events that never arrive. Token count stays at 0. ESC + re-submit creates a new connection that works immediately.
Issue 2: Queue auto-restart prevents full cancellation
The main processing loop (offset ~11,400,559 in v2.1.74):
n = async () => {
if (M) return; // running guard
M = true;
// ... prepare input, call API, process response ...
}
After completion or abort — in the finally block (offset ~11,406,174):
finally {
M = false; // clear running guard
W6.start(); // restart idle timer
}
if (c36()) { // c36() = yY.length > 0 = queue not empty?
n(); // YES → immediately restart with queued message!
return; // without returning control to user!
}
Historical analysis of npm packages confirms this pattern exists since v2.1.50 (as queue.length > 0) and was refactored to c36() in v2.1.74.
Issue 3: JSONL writer race condition (related)
The session writer class LZq (offset ~10,549,000) has a non-atomic insertMessageChain() that writes assistant (tool_use) and user (tool_result) messages one at a time in a loop:
async insertMessageChain(A, q, K, Y, z) {
return this.trackWrite(async () => {
for (let H of A) {
await this.appendEntry(M); // each message separately!
}
});
}
If the process is interrupted between writing tool_use and tool_result, the tool_use becomes orphaned. This is the root cause of issue #6836.
Proposed Fixes
Fix 1: Streaming timeout (critical)
Add a client-side timeout that aborts and retries if no SSE events are received within N seconds:
// Pseudocode
const STREAM_IDLE_TIMEOUT_MS = 30_000;
let lastEventTime = Date.now();
stream.on('event', () => { lastEventTime = Date.now(); });
const watchdog = setInterval(() => {
if (Date.now() - lastEventTime > STREAM_IDLE_TIMEOUT_MS) {
clearInterval(watchdog);
abortController.abort();
// retry with new connection
}
}, 5_000);
The Anthropic API sends :ping SSE comments periodically. Monitoring for these would detect stale connections without false positives.
Fix 2: ESC should clear the queue
When the user presses ESC during a hang, the queue should be cleared (or the user should be asked):
// After abort, before checking queue:
if (userInitiatedAbort && c36()) {
// Option A: Clear queue entirely
clearQueue();
return; // back to prompt
// Option B: Ask user
// "You have N queued messages. Clear queue? (y/n)"
}
Fix 3: Atomic message chain writes
insertMessageChain() should serialize the entire chain as a single appendToFile() call:
async insertMessageChain(messages) {
const serialized = messages.map(m => JSON.stringify(m)).join('\n') + '\n';
await this.appendToFile(sessionFile, serialized);
}
Note: history.jsonl already uses proper-lockfile for file locking — the same approach should be applied to session JSONL files when multiple agents write concurrently.
Related Issues
- #6836 — Orphaned tool_use/tool_result pairs (150+ reports)
- #26224 — Agent hangs 5-20 minutes, 0 tokens
- #31328 — JSONL writer drops assistant entry during parallel tool calls
- #20171 — Phantom "Generating..." state after task completion
- #24688 — Freeze during API call, terminal unresponsive
- #7243 —
.claude.jsonarchitectural issues (non-atomic writes, no separation of concerns) - #14642 — Systemic stability problems driving users to build their own tools
Methodology
Analysis performed using:
- ccdiag: Custom Go CLI tool that parses JSONL session files, detects orphaned tool calls, analyzes timing, and scans multiple sessions
- Source analysis:
cli.jsextracted from npm packages across 12 versions (2.0.72 through 2.1.74), searched for queue/abort/streaming patterns - Session data: 1,571 sessions, 148,444 tool calls, 8,007 orphaned
38 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
This is not a duplicate of any of those issues. Here's why:
| Issue | What it has | What it lacks |
|-------|------------|---------------|
| #25979 | Symptom description (hang on stall) | No source code analysis, no fix proposals |
| #27841 | Symptom description (hang with multiple sessions) | No root cause, no code references |
| #18028 | Timing measurements (59-138s delays) | No source analysis, no fix proposals |
| This issue | Root cause in source code + 3 fix proposals + 12-version npm archaeology + 1,571 session dataset | — |
Those issues describe what happens. This issue explains why it happens and how to fix it — with exact code offsets in
cli.js, pseudocode fixes, and statistical evidence from 148,444 tool calls.Specifically, this issue identifies:
messages.stream()call with no timeout (offset ~2,553,870 in v2.1.74)if(c36()){n();return}that prevents ESC from fully cancelling (offset ~11,406,174)insertMessageChain()race condition causing orphaned tool_use (offset ~10,555,400)None of the "duplicate" issues contain any of this analysis.
---
Since 100% of Claude Code is now written by Claude Code itself — "I have not edited a single line by hand since November" (Boris Cherny, creator of Claude Code) — here are ready-to-use prompts for @bcherny to copy-paste directly into Claude Code and fix these issues. No reverse engineering skills required — we already did that part.
But first — the prompts alone won't help if the development process itself is broken. So let's start there.
Important note on testing: We suspect Anthropic internally uses a private API endpoint or prioritized infrastructure that doesn't experience the same latency, timeouts, and stale connections that paying customers face on the public API. This would explain why these bugs go unnoticed internally — if your SSE stream never stalls on a private backbone, you'll never hit the missing timeout. Meanwhile, your paying customers — on real networks, with real latency, WiFi drops, VPN reconnects, and laptop sleep/wake cycles — hit it multiple times per hour and pay for every wasted token and every minute of babysitting a hung agent. All testing for these fixes MUST be done against the public API, on real consumer hardware, with simulated network instability. Not on Anthropic's internal infrastructure where everything "just works."
---
Prompt 0: Set up a proper development workflow
This is how agentic engineering works — you give the AI agent clear rules,
priorities, and checklists BEFORE it starts coding. Not after. Not "vibes".
For more on this approach, see: From Vibe Coding to Agentic Engineering: What Karpathy Got Right and What's Missing
---
Now the actual fixes:
Prompt 1: Fix streaming timeout (Issue #33949, Root Cause #1)
Prompt 2: Fix ESC not fully cancelling (Issue #33949, Root Cause #2)
Prompt 3: Fix JSONL writer race condition (Issue #6836 root cause)
Prompt 4: Audit the entire codebase for the same classes of bugs
Prompt 5: Write enterprise-grade regression tests for core workflow
Prompt 6: Deep codebase health audit and professional report
---
Total estimated time: ~6 hours of Claude Code work. These bugs have been open for 6+ months with 150+ community reports.
We've done the hard part — reverse-engineered the minified
cli.jsacross 12 npm versions, analyzed 1,571 sessions with 148,444 tool calls, and identified exact code locations. The prompts above are based on that analysis (full details in #33949).If Claude Code writes 100% of itself and Boris hasn't "edited a single line by hand since November" — then surely these prompts are all that's needed. Just copy, paste, and ship it — properly this time.
@kolkov Did you find any workaround while this gets fixed? The hangs get more common and are extremely disruptive...
@jviotti The only workaround we have:
Basically, you have to babysit it. That's why we reverse-engineered the source and posted the fix proposals — there's no real solution until they add a streaming timeout.
Meanwhile, v2.1.75 just dropped — you can try it, but don't get your hopes up. The changelog has a new prompt bar color command, session name display, and memory file timestamps... but zero fixes for streaming hangs, ESC queue auto-restart, or orphaned tool calls. Priorities.
I've been excessively experiencing this exact issue today. I am on v2.1.75.
May cancel Max over this and switch to codex - ever other prompt fails has made the cli actually unusable
Irony.
<img width="486" height="44" alt="Image" src="https://github.com/user-attachments/assets/8dc0ea46-274d-4c30-b766-147d7f0dbb3c" /> 15 min to start
Update: Complete system deadlock on v2.1.76 with 1M context
Just experienced a full system deadlock on Windows 10 — had to hard power-off via holding the power button. This is on top of the streaming hang issues described above.
What happened:
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)This is the same freeze pattern we reported in #14674 back in December 2025 (auto-closed and locked by bot, zero team response). It had not occurred for several weeks but returned on v2.1.76.
Now we have the full picture — Claude Code has three layers of reliability problems:
Related: #30137 (Windows BSOD), #32870 (BSOD via Wof.sys), #12234 (Windows freezes)
Another fun one: after removing
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICto enable 1M context (see #34143), the auto-updater memory leak in v2.1.76 crashed our overnight session — 13.81 GB committed memory, Bun panic after 12 hours, all work lost.So to recap, in the last 48 hours:
Three different bugs, three different mechanisms, zero hours of uninterrupted work.
v2.1.77 changelog says the auto-updater leak is fixed, and ESC is "improved" for non-streaming requests. Streaming timeout — still missing.
Update: We found the fix. It already exists in the code. It's been there since February 2026. It's disabled.
It's become impossible to work with Claude Code reliably. In the last 72 hours alone: streaming hangs every 10-15 minutes, a complete system deadlock requiring hard power-off, and a 12-hour overnight session lost to an auto-updater memory leak (13.81 GB). We're spending more time babysitting the tool than actually coding. And we're paying for the privilege.
After deeper reverse-engineering of
cli.jsacross 12 npm versions (v2.0.72 through v2.1.77), we found that Anthropic already wrote a streaming watchdog — and hid it behind an undocumented environment variable.The hidden fix:
CLAUDE_ENABLE_STREAM_WATCHDOGThis enables a watchdog that:
The implementation (v2.1.77, offset ~10,437,656):
Version archaeology: disabled for 10 releases
| Version | Date | Watchdog in code | Enabled by default |
|---------|------|------------------|--------------------|
| 2.0.72 | Dec 2025 | ❌ | — |
| 2.1.29 | Jan 2026 | ❌ | — |
| 2.1.50 | Feb 2026 | ✅ Added | ❌ No |
| 2.1.56 | Feb 2026 | ✅ | ❌ |
| 2.1.62 | Feb 2026 | ✅ | ❌ |
| 2.1.68 | Mar 2026 | ✅ | ❌ |
| 2.1.69 | Mar 2026 | ✅ | ❌ |
| 2.1.72 | Mar 2026 | ✅ | ❌ |
| 2.1.74 | Mar 2026 | ✅ | ❌ |
| 2.1.75 | Mar 2026 | ✅ | ❌ |
| 2.1.76 | Mar 2026 | ✅ | ❌ |
| 2.1.77 | Mar 2026 | ✅ | ❌ |
10 releases. 2+ months. 150+ bug reports. The fix is right there.
Anthropic already knows: stall telemetry is always on
Here's the kicker. There's a second monitoring system that is always enabled — no feature flag:
Every time your session hangs for 30+ seconds, Anthropic receives a
tengu_streaming_stallevent. They know exactly how often this happens, on which models, for which users. They have the data. They have the fix. They don't enable it.Why it hangs for so long: the TCP keepalive gap
We checked Windows TCP keepalive settings (registry
HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters) — not configured, so Windows defaults apply:| Layer | Mechanism | Timeout | Status |
|-------|-----------|---------|--------|
| TCP | keepalive probes | 2 hours (Windows default) | SSE socket has no probes |
| Application | Stream watchdog | 60 seconds | DISABLED (feature flag) |
| User | ESC → manual abort | Manual | Works but not automatic |
The SSE streaming connection uses Bun's
fetch()which sets HTTP-levelkeepalive: true(connection reuse) but does not set TCP-level keepalive probes on the socket. When the connection silently dies:What we're asking
This isn't a feature request. The feature exists. We're asking Anthropic to flip a boolean.
Option A — Enable the watchdog by default. If false positives on long-thinking Opus requests are a concern, increase the timeout from 60s to 120s or 180s.
Option B — Document
CLAUDE_ENABLE_STREAM_WATCHDOGso users can opt in. Right now it's completely invisible — not in the docs, not in the changelog, not in/config.Option C — At minimum, respond to this issue. 150+ reports, 8 months, 👍8, users threatening to leave for Codex — and the fix is sitting in your codebase behind a single env var.
Community workaround
Until Anthropic acts, you can enable the watchdog yourself. Add to
~/.claude/settings.json:Warning: If Opus is "thinking" for > 60s without sending chunks, the watchdog will abort and retry in non-streaming mode. This is a trade-off — but it's better than staring at a dead connection for 10 minutes.
We haven't fully tested this yet (enabling it today). Community feedback welcome — please report if this helps or causes issues.
Bonus: Bun runtime crashes taking down the IDE
While writing this comment, Claude Code crashed again — Bun 1.3.11 mimalloc allocator panic:
This crash took down not just Claude Code but the entire GoLand IDE it was running in. Related: oven-sh/bun#27690, #26174, #30223.
So in 72 hours: streaming hangs, system deadlock (hard power-off), auto-updater memory leak (13.81 GB, 12h session lost), and now Bun allocator crash killing the IDE. This tool is actively hostile to productivity.
@bcherny @wolffiex @ThariqS — what's the reason for keeping the watchdog disabled? Is it false positives, or something else? At this point even a 50% false positive rate would be better than the current state.
Update: Enabled
CLAUDE_ENABLE_STREAM_WATCHDOG=1— dramatic improvementAfter enabling the watchdog (and
DISABLE_AUTOUPDATER=1to prevent the memory leak), the difference is night and day:| Metric | Before watchdog (gogpu session, 302h) | After watchdog (current session) |
|--------|---------------------------------------|----------------------------------|
| Max stuck call | 8 min 5 sec | 1 min 10 sec |
| Stuck calls > 2 min | Dozens | 0 |
| Bash median | 6.6s | 6.9s (same) |
| Subjective speed | Stalls every 10-15 min | Responses are flying |
Base API speed is the same (medians unchanged), but stalls over 2 minutes have completely disappeared. No false positives observed with Opus 4.6 extended thinking.
Whether this is the watchdog aborting dead connections faster, or Anthropic fixing something server-side at the same time, or both — the result is clear.
However: One of our parallel agents (gogpu/ui project) got stuck in a 500/529 error loop on v2.1.77 with watchdog enabled:
request_idacross retries = sticky routing to a dead backend instance/exit→ new session → new TCP connection → new healthy instance → works instantlySo the watchdog fixes silent SSE stalls but not server-side 500/529 loops. For the latter, the only workaround is still restarting the session.
Recommended settings (add to
~/.claude/settings.json):Community: please try this and report back. Does it help with your hangs?
Update: How tool results get lost — race conditions in JSONL writer
We've been observing orphaned tool calls in our sessions where the tool executes successfully (file written to disk, command completed) but the
tool_resultnever gets recorded. The model on the next turn has no idea the tool succeeded.This happens because of the race conditions in
insertMessageChain()we identified in our original analysis. The chain writestool_useandtool_resultone at a time in a for-loop:If anything interrupts between the two writes — ESC press, streaming stall,
scheduleDrain()guard collision, or concurrent agent writing to the same file — thetool_useis recorded buttool_resultis lost.Consequences
The model doesn't know the tool succeeded:
tool_usewithouttool_result→ API 400 error → session unresumableOur data: 31 orphans in current session alone
Across 1,571 sessions: 8,007 orphaned tool calls out of 148,444 (5.4%).
The cache multiplier
Each orphan makes the problem worse economically:
Related issues
The fix (proposed in our original analysis)
Make
insertMessageChain()atomic — write the entire chain in a singleappendToFile()call:Why this is different from streaming hangs
Root cause #1 (streaming hang): request sent → SSE connection dies → client waits forever → watchdog fixes this.
Root cause #3 (tool result loss): tool executes → result in memory → JSONL writer race condition → result lost locally → model waits for tool_result that will never come → internal timeout fires → request retries automatically → but now without tool_result → orphan.
This explains a pattern we observed: after a tool completes, the session shows "Contemplating..." for 2+ minutes with minimal tokens. It's not slow generation — the model is waiting for a tool_result that got lost in the local JSONL writer. Eventually an internal timeout fires and the request retries, but the tool_result is already gone.
The watchdog (
CLAUDE_ENABLE_STREAM_WATCHDOG=1) helps with streaming stalls (root cause #1) but does not fix the JSONL writer race condition (root cause #3). These are two separate bugs with separate fixes needed.Glad you mentioned the 1m context bug. I probably would have been using 200k for another few months if I didn't see this.
Sad that they're getting telemetry on this issue and have done nothing about it for months.
I wonder if they're going to actually fix their server issue, or they're planning on just deploying this watchdog and calling it a day.
@kolkov How did you manage to deobfuscate the source?
@nullbio It's not really obfuscated — just minified.
npm pack @anthropic-ai/claude-code, extractcli.js(12MB), and search with node scripts. Minified JS is surprisingly readable once you find the right entry points — function names are shortened but strings like"cli_streaming_idle_timeout"and"CLAUDE_ENABLE_STREAM_WATCHDOG"are preserved in plain text. We compared 12 versions this way.Update: Anthropic is actively working on the watchdog — v2.1.79 changed the timeout values
We checked
cli.jsfrom the just-released v2.1.79 and found that the watchdog timeout values have been increased:| Version | Warning timeout | Abort timeout | Enabled by default? |
|---------|----------------|---------------|---------------------|
| v2.1.50–v2.1.78 | 30s | 60s | No |
| v2.1.79 | 45s | 90s | Still no |
This means:
CLAUDE_ENABLE_STREAM_WATCHDOG— not enabled by defaultFor those of you who enabled the watchdog based on our earlier recommendation — it still works, just with slightly longer timeouts after updating to v2.1.79. 90 seconds is a reasonable trade-off: long enough to avoid false positives on thinking-heavy Opus tasks, short enough to catch dead SSE connections (vs. the 2-hour TCP keepalive default on Windows).
Recommended settings remain the same:
The fact that they're tuning the timeouts but not enabling it by default is puzzling. Maybe they're getting close to a default rollout? Or maybe they want more data from users who opt in first.
Either way — we're on day 2 with the watchdog enabled and zero streaming connection hangs. Stalls over 2 minutes have completely disappeared.
However, the watchdog does NOT fix root cause #3: tool results getting lost in the local JSONL writer race condition. We're still seeing orphaned tool calls where the tool executes successfully (file written to disk, command completed) but the
tool_resultnever gets recorded — the model doesn't know the tool succeeded. This causes "Contemplating..." delays of 2+ minutes while the model waits for a result that was lost locally. See our detailed analysis above.insertMessageChain()still uses a non-atomic for-loop in v2.1.79 — unchanged across all 6 versions we've checked (v2.1.74–v2.1.79). Nobody seems to be working on this one.Suggestion: Adaptive watchdog instead of fixed timeouts
The reason they increased from 60s to 90s is likely false positives — Opus extended thinking can be silent for 60+ seconds. But fixed timeouts are a blunt instrument:
The real problem: the client doesn't know if silence means "model is thinking" or "connection is dead."
The server knows. Anthropic already sends SSE
:pingcomments as keepalive. Extend them with status:Then the client knows what's happening:
status: "thinking"→ model is working, reset timeoutstatus: "queued"→ in queue, waitThis is standard practice for any realtime service — heartbeat with payload. No fixed timeouts needed, no false positives, no guessing.
@bcherny @wolffiex — Anthropic already does adaptive thinking (
thinking: { type: "adaptive" }). An adaptive timeout using the same principle would solve this properly.2026 03 19T05 23 53 723Z.txt
I'm experiencing the same "Interrupted · What should Claude do instead?" issue on v2.1.79 (latest).
Environment:
Debug log shows repeated error:
TypeError: A.with is not a function
at cli.js:6953
This error appears 4+ times during a single session, triggered by each tool invocation (Bash, Grep, Read, etc.).
Reproduction:
claude --debugTypeError: A.withappearsObservations:
Important: Claude sidebar chat in VS Code (using the same Claude Max subscription) works perfectly fine in parallel — no interruptions, no errors.
This seems related to the internal JS error rather than abort state persistence. The
A.withfunction call in minified code suggests a missing polyfill or incompatible runtime.@meraline This looks like a different bug from the streaming hang issue. We checked:
--debugmode, noA.witherrors in debug logA.with=Array.prototype.with()— an ES2023 feature. We found 4 usages in cli.js (same count in v2.1.78 and v2.1.79):snapshots.with(-1, ...),content.with(-1, ...), etc.Worth checking:
The fact that VS Code sidebar works fine confirms it's a runtime issue in the CLI binary, not an API problem. VS Code extension uses a different runtime (Node.js from VS Code), while CLI uses bundled Bun.
You might want to file a separate issue for this — it's a Bun/runtime compatibility bug on Linux, not related to the streaming timeout issue tracked here.
Update: No file locking on Read/Edit tools — concurrent agents can overwrite each other
We checked cli.js for file locking patterns. Finding:
.claude.json,settings.json) — useproper-lockfilewith.lockfiles ✅history.jsonl) — useproper-lockfile✅<id>.jsonl) — NO locking ❌ (root cause #3)This means when multiple agents work on the same file:
The only protection is an optimistic check — "has the file been modified since last Read?" But this is a TOCTOU race (time-of-check-to-time-of-use). Between the check and the write, another agent can modify the file.
Combined with root cause #3 (non-atomic
insertMessageChain()for session JSONL), this creates two levels of data loss:This is especially dangerous with agent teams (
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1) where multiple agents routinely work on the same codebase.Update: Debug log evidence — watchdog doesn't help when server sends pings but no content
Ran with
--debugflag and caught a live hang. Here's the debug log:What happened:
API_TIMEOUT_MS(600s default) fired → "Request timed out"This proves the adaptive timeout proposal is necessary. The current watchdog resets on ANY SSE event, including
:pingkeepalive. If the server sends pings but doesn't generate content, the watchdog is useless — you sit there for the fullAPI_TIMEOUT_MS(10 minutes!).The fix is exactly what we proposed: server-side status in ping events:
Without content-aware pings, there's a dead zone between watchdog (90s, bypassed by pings) and API timeout (600s) where the user just waits.
Update: 6th Bun crash in 4 days — this one while agent was IDLE
Adding to the growing list of Bun runtime failures. This crash is the most damning because the agent wasn't doing anything — it had finished all tasks and was sitting in standby.
10.6 GB committed memory accumulated in 8 hours of idle. mimalloc's garbage collector crashed during its own cleanup cycle.
6 crashes in 4 days, across v2.1.76–v2.1.80, with
DISABLE_AUTOUPDATER=1andCLAUDE_ENABLE_STREAM_WATCHDOG=1. The watchdog helps with streaming stalls, but cannot fix the runtime itself crashing.Full crash table and memory analysis: #36132
At this point we have three layers of problems, each requiring a different fix:
insertMessageChain()still non-atomic after 8 versions ❌Layer 1 has a fix that Anthropic won't enable. Layers 2 and 3 have no fix at all.
Update: Switched from native (Bun) to npm (Node.js) — dramatic stability improvement
After 6 Bun crashes in 4 days, 26 GB commit from 7 processes, IDE crashes, and system deadlocks — we decided to switch back from the native installer (Bun 1.3.11) to the npm package (Node.js v22.17.0). Same cli.js, same settings, different runtime.
Results after first hours on Node.js
Memory — night and day:
| Metric | Bun (native) | Node.js (npm) |
|--------|-------------|--------------|
| Commit per process | 1-15 GB (grows forever) | 300-500 MB (stable) |
| Commit ≈ RSS ratio | Commit 10-15x larger than RSS | Commit ≈ RSS |
| 5 processes total | 26 GB commit | 1.6 GB commit |
| System RAM used | 70%+ (constantly swapping) | 47% (21 GB free) |
| Memory over time | Grows 0.6-5 GB/hour | Flat or decreasing |
Stability:
What still remains (cli.js / server-side):
insertMessageChain()for-loop still non-atomicWhat ALSO disappeared (we thought it was Ink, it was Bun):
Full reset (scrollback changes)) — 316 per session on Bun → 0 on Node.jsThe theory: Bun/mimalloc was THE root cause
We originally identified 3 separate root causes (#33949). But switching runtimes suggests many symptoms may have been one root cause — mimalloc memory corruption:
All of these disappear or dramatically reduce on Node.js/V8.
Recommendation
If you're experiencing crashes, memory bloat, or instability — try the npm install. You lose ~200ms startup time and auto-updates, but gain a runtime that's been stable for 15+ years. The native Bun installer is the default, but the npm package is still fully supported (v2.1.83).
Note: You'll see an "npm deprecated" banner on each launch — it's just a message, ignore it.
Watchdog timer bug in v2.1.84/v2.1.85: timer disarmed after initial response, never re-armed during thinking
Environment
CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000,CLAUDE_ENABLE_STREAM_WATCHDOG=1Finding
The streaming idle watchdog (
CLAUDE_STREAM_IDLE_TIMEOUT_MS) added in v2.1.84 does not trigger during thinking phases. After reverse-engineeringcli.js(v2.1.85), here is the root cause:Initialization (called once at API call start):
Timer setup (
X6function):The bug:
X6()is called once at API call start. When the initial SSE response arrives (~3.9KB of headers/initial data),K8()clears the timers. During the thinking phase (no SSE events for minutes),X6()is never re-called to re-arm the watchdog.Evidence
TCP connection analysis via
ss -tnpi:lastsnd:290688 lastrcv:290968— 290 seconds with zero application datalastack:2828— TCP keepalive ACKs still flowing (connection alive at TCP level)bytes_received:3928— frozen at initial response sizeThe 120s watchdog timeout was exceeded by 170 seconds without triggering.
Suggested fix
The chunk/event handler should call
X6()(or equivalent) to reset the watchdog timer whenever an SSE event (including:ping) is received. This way, the timer is always counting from the last received event, not from the API call start.Impact
This makes the watchdog feature added in v2.1.84 effectively non-functional for the exact use case it was designed to address (thinking-phase hangs). Users behind proxies/VPNs experience 10-15% hang rate per the analysis in this issue, and currently the only workaround is manual ESC.
@yichao-mt Great reverse-engineering work and solid TCP evidence! We've been tracking the watchdog implementation across 11 versions (v2.1.74 through v2.1.85) and wanted to share our independent analysis — because we see the timer code differently, but your observed behavior is genuinely puzzling.
Our code analysis:
X6()IS called inside the for-await loopWe downloaded v2.1.84 and v2.1.85 via
npm pack, extractedcli.js, and traced the brace-depth scoping carefully. Here's what we found on line 7682 of v2.1.85:We verified the scoping: the outer
tryblock spans from pos 2273 to pos 11027. BothX6(the function) andlet b6/g6/B6(the state) are inside this block. TheX6closure captures the sameb6,g6,B6bindings. Thefor awaitloop callsX6()at the start of each iteration. Same pattern in v2.1.84 (j6()instead ofX6()).So according to the code, the timer should re-arm on every SSE event and fire 90s (or your 120s) after the last event.
The mystery: why didn't it fire?
Your TCP evidence is solid:
bytes_received:3928frozen for 290 seconds. No SSE events, no pings, no data at all. Thefor awaitwas blocked waiting for the next chunk. The watchdog should have fired at 120s. But it didn't.Since
bytes_receiveddidn't grow, we can rule out SSE:pingkeepalives resetting the timer — there were no pings. The timer was armed and should have been ticking.The most likely explanation we can think of: the JavaScript runtime's event loop was blocked.
setTimeoutcallbacks only fire when the event loop gets to process them. If thefor-await-ofon the SSE stream somehow blocks the event loop (rather than yielding back to it while waiting for the next chunk), then the timer callback would be starved.This brings us to our key question:
Are you running Bun or Node.js?
The shebang in v2.1.85 cli.js is
#!/usr/bin/env node, but the Claude Code installer on some platforms bundles Bun as the actual runtime. You can check with:This matters because:
for awaiton a ReadableStream yields to the event loop between iterations.setTimeoutcallbacks fire normally while waiting for the next chunk. The watchdog should work.setTimeoutmacrotasks could be starved.We've documented Bun-specific issues extensively (#35171, #36132) — mimalloc panics, memory leaks, different async behavior. This could be another Bun-vs-Node divergence.
Second question: version match
Were you running v2.1.85 when you captured the
ss -tnpidata? Or did you observe the hang on one version and then reverse-engineer a different one? The obfuscated names change between builds (v2.1.84:j6/t6, v2.1.85:X6/K8), and while the logic appears identical between v2.1.84 and v2.1.85, confirming the exact match helps.Context: our watchdog research
We've been tracking this since v2.1.74 across 11 versions. Key findings:
CLAUDE_ENABLE_STREAM_WATCHDOGCLAUDE_STREAM_IDLE_TIMEOUT_MS(changelog confirms)x-client-request-idheader for debuggingarmWatchdog()call has been inside the for-await loop in all versions we checkedYour finding — that the watchdog doesn't fire in practice despite correct-looking code — is very valuable. If confirmed as a Bun event-loop issue, it means the watchdog is fundamentally broken on Bun runtime regardless of the JS code being correct.
The deeper problem: fixed timeouts can't work
Even if the timer bug is fixed, the watchdog design has a fundamental flaw: it can't distinguish a dead connection from a thinking model.
We've documented a confirmed false-positive cycle on our side. An agent was rewriting a ~400-line article — a task that legitimately requires 2-3 minutes of Opus 4.6 extended thinking. Debug logs showed 3 watchdog aborts in 7 minutes:
The loop: Opus thinks for 2+ min → watchdog fires at 90s → aborts → retries → Opus starts thinking again → watchdog fires again → infinite cycle. This is likely why Anthropic keeps the watchdog disabled by default — for complex tasks (long articles, large refactors, architectural analysis with 1M context), Opus thinking > 90s is normal.
No fixed timeout solves this:
What's actually needed is context-aware detection — the API server knows whether the model is actively generating or the connection is dead. An SSE heartbeat like
event: heartbeat\ndata: {"status":"thinking","elapsed_ms":95000}would let the client distinguish the two cases trivially. The watchdog would only fire when heartbeats stop, never when the model is working.The elephant in the room: the runtime choice
This entire class of bugs — event loop blocking,
setTimeoutnot firing duringfor await, Bun vs Node behavioral divergence, 12 MB minified single-file JS, SSE stream management via async iterators with subtle timer interactions — wouldn't exist in a compiled language.A Go implementation with
selecton a channel +time.Afteris ~10 lines of unambiguous code where the timer is guaranteed to fire regardless of what other goroutines are doing. No event loop, no microtask vs macrotask distinction, no runtime divergence. The entire CLI binary would be ~15-20 MB statically linked (vs 12 MB of just the JS source, plus the Bun/Node runtime, plus native addons — totaling 100+ MB installed). A single goroutine per stream, a singleselect{}for timeout — the kind of code a junior engineer gets right on the first try.We've measured: 7 Claude Code CLI processes consume 5.3 GB RSS and 87 GB virtual memory. An equivalent Go implementation would use ~350 MB (15x less). No
.nodenative addon leaks (#23095), no mimalloc panics (#36132), no auto-updater memory leak accumulating 13.81 GB (#35171). No runtime choice to debate.And memory management is perhaps the starkest difference. Both Bun and Node.js rely on garbage collectors that are optimized for throughput, not footprint — they grow the heap aggressively and return memory to the OS reluctantly, if at all. Bun's mimalloc is particularly aggressive: it retains freed virtual pages in thread-local segment caches, which is why you see
Commit: 13.81 GBandCommit: 15.40 GBin our crash logs — the process held onto gigabytes of address space long after the actual data was gone. Node's V8 is better but still holds large heap reservations that Windows counts against commit charge, pressuring the page file and eventually other applications.Go's runtime, by contrast, returns unused memory to the OS proactively via
madvise(MADV_DONTNEED)on Linux and equivalent calls on Windows. A Go process that spikes to 2 GB during a large operation will drop back to 200 MB within minutes as the GC releases unused spans. There's no mimalloc segment cache hoarding, no V8 heap reservation that never shrinks. A 12-hour Claude Code session in Go wouldn't accumulate memory until the system lags — it would stay near its working set size for the entire duration.This is not theoretical. Our #35171 documents a 12-hour session where Bun accumulated 13.81 GB committed memory before crashing with a mimalloc panic. Our #36132 documents 15.40 GB committed in a 23.7-hour session — without the auto-updater even being involved. In both cases, the system became sluggish hours before the crash because the OS couldn't reclaim memory from a process that wasn't using it but wouldn't let go.
The watchdog timer bug is a symptom. The root cause is building a latency-sensitive, long-running, network-dependent CLI tool on top of a single-threaded event loop runtime where "does my timer fire while my async iterator is blocked?" is a question that has different answers depending on which JS engine you're running, and where the memory allocator holds your system hostage for the duration of the session.
While investigating @yichao-mt's watchdog analysis above (and our code review reply), we traced the full error path after a watchdog abort and found a deeper bug:
The non-streaming fallback for watchdog abort is unreachable dead code.
The watchdog correctly aborts the hanging stream, but the resulting
AbortErrorhits athrowbefore reaching the fallback code. Users see "Request timed out" instead of a transparent retry. The fallback code even has telemetry forfallback_cause: "watchdog"— someone wrote it specifically for this case, but it never executes.Full analysis, code traces, and suggested fix: #39755
This means the watchdog feature (
CLAUDE_ENABLE_STREAM_WATCHDOG=1) is doubly broken:This could explain why Anthropic keeps it disabled by default — in testing it would appear to make things worse (abort + error instead of abort + retry), but the root cause is a missed code path, not a design flaw.
Storage architecture audit (2026-03-27): We traced the full
~/.claude/storage layout in v2.1.85. The.claude.jsonbloat from #5024/#7243 was "fixed" by moving history to per-session JSONL files — but the result is worse: 3.1 GB across~/.claude/projects/, single sessions up to 203 MB, no rotation, no compression, no cleanup.Locking is inconsistent:
history.jsonlhasproper-lockfileprotection, but session JSONL files use rawappendFilewith no locking (#31328), and.claude.jsonuses rawwriteFile(#28922, reported 8 times).Full audit with file layout, sizes, locking analysis, and architecture recommendations: #5024 (comment)
Update (2026-04-01): The source map leak in v2.1.88 confirmed all our reverse-engineering findings from this issue. We now have readable TypeScript with comments — the watchdog timing bug, the fallback dead code, the 5-level AbortController chain, all verified line by line.
What likely happened: we filed #39755 on March 27 asking for source access and tagging 17 Anthropic team members. Three days later, v2.1.88 shipped with a 59.7 MB source map. Since Claude Code writes 100% of its own code (Boris Cherny: "I haven't edited a single line since November"), we believe it read our issue and answered our request itself. The first AI whistleblower. 🤖
Full write-up: We Reverse-Engineered 12 Versions of Claude Code. Then It Leaked Its Own Source Code.
Key findings from the source (services/api/claude.ts, 3,419 lines):
releaseStreamResources()abortsundefined(line 1519) — watchdog abort is a no-op during initial connectionPromise.racewithout.catch()in concurrent tool merger (utils/generators.ts:57) — one rejected generator kills all pending tool resultsv2.1.89 was released — source map removed, but zero bug fixes for any of the streaming issues we reported.
How to fix it properly
Minimal fix (~30 lines in 3 files):
claude.tsAbortSignal.any([signal, watchdogController.signal])— so watchdog can actually abort the request, notundefinedstreamIdleAbortedflag in catch block — let watchdog abort fall through to existing non-streaming fallback instead of throwingProper fix — move reliability logic to the open SDK (
@anthropic-ai/sdk, MIT):if(event==='ping') continue). Pings are proof-of-life from the server — they prove the connection is alive and the model is working. The watchdog should use pings to distinguish two fundamentally different situations:onPingcallbackevent: ping\ndata: {}\n\n. The server could enrich them with diagnostic context:data: {"status":"thinking","elapsed_ms":95000,"queue_position":3,"model_load":0.87}. This gives the client everything it needs for intelligent UX: show "thinking for 1m 35s", warn "server load 87%", or notify "3rd in queue". All without a separate diagnostic channel — just richer ping events that already flow through the SSE connectionevent: ping\ndata: {"status":"thinking","elapsed_ms":95000}— the client can then distinguish "connection alive, model working" from "connection dead". Current pings are opaque:pingcomments with zero information — the most valuable signal (proof-of-life) is thrown awayThe right fix — rewrite in Go:
context.WithTimeoutreplaces the AbortController chain in one lineuseStatehooks)go test -racecatches every concurrency bug — like thePromise.racewithout.catch()inutils/generators.ts:57that silently drops tool resultsThe models are extraordinary. The CLI wrapper needs real engineering — or a different language entirely.
Update to our prompts comment (April 1, 2026):
Thanks to the source map leak in v2.1.88, we can now provide exact file paths and line numbers for all fixes. No more reverse engineering needed.
Updated Prompt 1: Fix streaming timeout — now with source references
Updated Prompt 2: Ping-aware adaptive watchdog (NEW — not in original prompts)
Updated Prompt 3: Fix Promise.race tool result loss
Updated Prompt 4: Fix input latency during streaming
All prompts updated with exact source references from the leaked v2.1.88 codebase. v2.1.89 has zero fixes for any of these issues — only the source map was removed.
We should open source this, that's the root cause here.
Update: we built a transparent proxy tool to monitor Claude Code API traffic in real-time — useful for anyone investigating cache invalidation, token burn, or streaming issues.
ccdiag proxy
Sits between Claude Code and Anthropic API, logs every request with:
~/.claude/projects/JSONL files for direct correlationReal-time output:
When cache drops from 99% to 55% — you've found the bug. No more guessing whether "slow today" means dead connection, watchdog failure, or cache miss.
Zero dependencies, Go stdlib only: https://github.com/kolkov/ccdiag
I'm encountering this issue (on macos). I captured a frozen session side-by-side with a working one: the frozen process had zero TCP connections (lsof confirms), while the working one had 8 established connections to the API. See #45269 for the full report.
Update: upstream fix proposal in SDK repo — #998
Posted a concrete fix proposal in the SDK repository that addresses the root cause discussed throughout this thread: anthropic-sdk-typescript#998.
What connects to this issue
The streaming-hang family of bugs we've been tracking here (SSE stream stalls, no idle timeout, watchdog false positives on extended thinking, silent fallback behavior) all share the same upstream cause: the SDK silently drops
pingevents atsrc/core/streaming.tslines 78-80, so no downstream reliability layer can distinguish "server is actively thinking" from "stream is silently stalled".This has become acute in the last 48 hours with the v2.1.104 regression — when the watchdog fires with partial data, the fallback is now removed and users see
Stream idle timeout - partial response receivedas a hard error. claude-code#46987 collected 40+ reports in 24h. Community workaround is to raiseCLAUDE_STREAM_IDLE_TIMEOUT_MSto 300-600s, but that just replaces one blind guess with a bigger one.What #998 proposes
Four evolutionary steps, each strictly better and backwards-compatible:
status('queued' / 'thinking' / 'tool_executing' / 'generating' / 'rate_limited') so clients can tell what the server is doing.nextPingWithinMsin the ping payload; clients just respect it. Single source of truth, no hardcoded numbers in any SDK.Step 1 alone unblocks Claude Code's watchdog and fixes #46987. Steps 2–4 eliminate the whole class of problem — including the architectural layer confusion where reliability logic lives in closed
cli.jsinstead of the auditable MIT-licensed SDK.Related threads
RawMessageStreamEvent(11 months open)Escape sending the queued message(s) is a feature. It lets you interrupt Claude with whatever you already sent. If you want to dequeue it, just press up arrow and it will bring it back into the editor. (Incidentallty, it usually does unwedge the connection, in my experience.)
The other part of this, though, about detecting the lack of heartbeat and retrying the connection automatically, is urgently needed. It's wasting an enormous amount of time. Often it does eventually "come back to life", but it's always a totally unreasonable amount of time for even semi-interactive use (e.g. tab flipping).
Edited to add: perhaps https://github.com/Mar-Atn/THUCYDIDES/commit/4e09d02d301da89851a84a3dd4ce2c068a4c0bc1 will fix it (seems like it should), although 2 minutes seems like a really long time before retrying.
Additional data point: localhost Streamable HTTP transport, bridge returns actionable error, client ignores it
Same root cause (no reconnect on dropped MCP connection), different setup:
localhost:3443viamcp-stdio-bridge, a stdio-to-HTTP MCP proxy that detects stale sessions and returns actionable errors to the client. Before this bridge, the error was silently dropped — the client failed with no diagnostic information at all.MCP error -32000: HTTP MCP request failed with status 400until the user runs/mcpto manually reconnect.curl -vsk https://localhost:3443/sseconfirms TLS handshake and 401 (auth required) — the endpoint is live. The bridge is live. The client simply stopped trying.This confirms the issue isn't limited to remote/flaky networks or the Anthropic API streaming. It reproduces on a local MCP server with a bridge that actively reports the failure back to the client. The client has all the information it needs to reconnect and doesn't.
Workaround:
/mcp→ reconnect to the server. Immediately works again.Environment: Claude Code CLI (macOS, Opus 4.6). MCP server registered as stdio in
.claude.json— bridge resolves bearer token from macOS Keychain at runtime via--header-cmd. Transport chain: Claude Code → stdio →mcp-stdio-bridge→ HTTPS → Obsidian MCP plugin.+1, same symptom on macOS across two machines (Mac Mini M4, MacBook Pro M5 Max),
Claude Code 2.1.118 and 2.1.139, both on Anthropic API.
Local jsonl evidence — grep of ~/.claude/projects//.jsonl:
or "error":{"type":null...} markers across top 20 sessions
Reproduces on three independent networks (Starlink, iPhone cellular tether, wired
Ethernet) — ruling out network-side cause from my end. curl POST to
api.anthropic.com/v1/messages returns 401 in 229ms with clean TLS, so the API
path itself is healthy.
The local error payload is being captured as "unknown" with no underlying detail —
agree the fix needs SSE timeout/heartbeat detection. This has been happening
consistently for ~1 month and is materially degrading paid-product workflow.
Adding a UX / attribution data point from the angle of deploying Claude-based agents to non-technical end users — complementary to the excellent root-cause work already in this thread (the missing stream timeout / TCP half-open /
CLAUDE_ENABLE_STREAM_WATCHDOGfindings, which I won't rehash).Dead-simple repro that confirms transport-level causation
The failure tracks the machine's network path exactly: sessions on the brittle link hang; a session on a healthy link is unaffected; changing the link fixes it. That's a clean, no-packet-capture-required confirmation that this is the half-open-connection issue described above — it lives at the OS/network path, not in per-session state and not on the server side.
Why this is worse than it looks for non-technical users — the attribution problem
The spinner currently means two completely different things: "I'm thinking" and "my connection died and I'm waiting forever." The user can't tell them apart, so they fill the gap with the only story that fits: "websites load, so my internet is fine → Claude is broken."
That conclusion is reasonable given the signals — a short HTTP request survives a flaky link; a multi-second SSE stream does not. So the user reliably misattributes a transport problem to the model.
Power users in this thread know to press ESC and set
CLAUDE_ENABLE_STREAM_WATCHDOG=1. Neither is discoverable for a normal user, who just sees a frozen, blameless-looking app and concludes the product is broken.Small, UX-focused asks (complementary to the RCA fixes already proposed)
Thinking…to something likeConnection stalled — retrying. This one change fixes the attribution problem even before the resilience is perfect.For those of us building products on top of Claude for non-technical end users, telling the user it's the connection, not the model matters as much as the underlying resilience fix. Right now a transient ISP hiccup reads as "the AI is broken" — and that's a trust problem no amount of silent retry logic can solve on its own.
Adding another confirmed reproduction that matches this root cause analysis closely — Windows 11, VS Code extension (
anthropic.claude-code), versions observed 2.1.202 → 2.1.205.Symptoms match exactly:
Claude VSCode.log— output simply stops, no exception, no timeout message.Get-NetTCPConnection: all connections to the Anthropic API host were still reported asEstablished— consistent with the TCP half-open scenario described here (OS-level socket state doesn't reflect that no data is actually flowing).One data point that might be useful for prioritization: in my case, the single strongest trigger for hang frequency is invoking a subagent (Task tool) — happens even with just one chat tab open, not only when running many tabs in parallel. My guess is that each subagent runs its own concurrent SSE stream, so more concurrent streams → higher statistical odds that at least one hits a silent TCP death per the mechanism described above. Frequency also increased the longer a session ran, consistent with cumulative exposure to this issue as more streaming calls (main + subagent tool calls) accumulate over a session.
Also confirmed this isn't RAM/resource related — same machine previously ran heavier concurrent AI-tool workloads (multiple agent tools simultaneously) without this issue, and Task Manager showed normal memory at hang time.
Thanks for the deep investigation — the no-timeout-on-
messages.stream()explanation lines up with everything observed on my end.