SSE streaming hangs indefinitely (no timeout) + ESC cannot fully cancel (queue auto-restart) — root cause analysis with fix proposals

Open 💬 38 comments Opened Mar 13, 2026 by kolkov

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:

  1. No streaming timeout: The messages.stream() call has no timeout. If the SSE connection dies silently (TCP half-open), the client waits forever.
  2. 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

  1. Start a Claude Code session
  2. Submit a prompt → agent starts processing
  3. Wait for a hang (0 tokens, timer running, no progress) — happens ~10-15% of prompts
  4. Submit another prompt while hung → goes to queue
  5. Press ESC
  6. Expected: Cancel everything, return to
  7. 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.json architectural 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.js extracted 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

View original on GitHub ↗

38 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/25979
  2. https://github.com/anthropics/claude-code/issues/27841
  3. https://github.com/anthropics/claude-code/issues/18028

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

kolkov · 4 months ago

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:

  1. The exact messages.stream() call with no timeout (offset ~2,553,870 in v2.1.74)
  2. The queue auto-restart pattern if(c36()){n();return} that prevents ESC from fully cancelling (offset ~11,406,174)
  3. The non-atomic insertMessageChain() race condition causing orphaned tool_use (offset ~10,555,400)

None of the "duplicate" issues contain any of this analysis.

kolkov · 4 months ago
⚠️ Updated April 1, 2026: The source map leak in v2.1.88 gave us access to the full TypeScript source. We've posted updated prompts with exact file paths and line numbers — no more reverse engineering of minified code: Updated prompts → Also see our full write-up: We Reverse-Engineered 12 Versions of Claude Code. Then It Leaked Its Own Source Code.

---

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

Read the project's CLAUDE.md. If it doesn't exist, create it. This is the most
important file in the project — it defines how YOU (Claude Code) should work
on this codebase. Add the following sections:

## Release Checklist (MANDATORY before every release)

Before shipping ANY release, verify:

1. [ ] Core workflow test: submit prompt → stream response → execute tool
       → return result. Does it complete without hanging? Test 20 times.
2. [ ] ESC test: Start a prompt, press ESC mid-stream. Does it return to
       the prompt immediately? No queued messages auto-starting?
3. [ ] Network failure test: Disconnect WiFi mid-stream. Does the client
       detect the dead connection within 30s and recover?
4. [ ] Concurrent write test: Run 2 agents writing to the same session.
       Are all tool_use/tool_result pairs intact in the JSONL?
5. [ ] Long session test: Run a 2-hour session with 100+ tool calls.
       Count orphaned tool_use entries. Must be 0.
6. [ ] Disk pressure test: Run with <1GB free disk space. Does it degrade
       gracefully or corrupt settings files?

If ANY check fails — DO NOT RELEASE. Fix it first.

## Priority Rules

- Bug fixes for core workflow ALWAYS take priority over new features
- Every network call MUST have a timeout — no exceptions
- Every multi-step file write MUST be atomic
- ESC means STOP EVERYTHING — never auto-restart after user abort

## Known Debt (must fix before adding features)

- #6836 — orphaned tool_use/tool_result (150+ reports, 6+ months open)
- #26224 — streaming hangs indefinitely (30+ comments, 0 team responses)
- #20171 — phantom "Generating..." state (17 comments)
- #33949 — root cause analysis with exact code locations and fix proposals

## Pre-commit Checks

Before committing, always run:
- All existing tests pass
- No new network calls without timeout
- No new file writes without atomic pattern
- No queue auto-restart after abort paths

## Session Memory

After each work session, update STATUS.md with:
- What was changed and why
- What tests were added
- What known issues remain
- What regressions to watch for

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)

Read the streaming API call in the codebase where client.beta.messages.stream()
is called. There is no timeout or heartbeat detection — if the SSE connection
silently dies (TCP half-open), the client waits forever. This is the #1 cause
of "agent hangs, timer running, 0 tokens" reports.

Fix: Add a watchdog timer that monitors incoming SSE events. The Anthropic API
sends periodic :ping SSE comments. If no events are received for 30 seconds,
abort the connection via AbortController and retry with a new connection.
Implement exponential backoff for retries (max 3 attempts).

Create a todo list before starting. Write tests for:
- Normal streaming (no timeout)
- Stale connection (no events for 30s → abort + retry)
- Max retries exceeded → surface error to user

Prompt 2: Fix ESC not fully cancelling (Issue #33949, Root Cause #2)

Find the main processing loop function (the async function that checks a
running guard, processes user input, calls the API, and handles responses).
After the finally block that clears the running guard, there is a check:
if the queue is not empty, it immediately calls the processing function
again and returns — without giving the user control back.

This means when a user presses ESC during a hang, the queued message
auto-starts instead of returning to the prompt.

Fix: When the abort was user-initiated (ESC), clear the queue entirely
before checking it. Do NOT auto-restart queued messages after user abort.
The queue auto-restart should only happen after successful completion,
not after abort.

Create a todo list before starting. Write tests for:
- ESC during normal processing → returns to prompt, queue cleared
- ESC during hang → same behavior
- Successful completion with queued messages → auto-restart OK

Prompt 3: Fix JSONL writer race condition (Issue #6836 root cause)

Find the session JSONL writer class that has an insertMessageChain() method.
It currently writes messages one at a time in a for loop — each message is
a separate appendEntry() call. If the process is interrupted between writing
tool_use and tool_result, the tool_use becomes orphaned.

Fix: Serialize the entire message chain into a single string
(messages.map(JSON.stringify).join('\n') + '\n') and write it in one
appendToFile() call. This makes the write atomic at the OS level.

Also check if proper-lockfile (already used for history.jsonl) should be
applied to session JSONL files for concurrent agent writes.

Create a todo list before starting. Write tests for:
- Single message write (baseline)
- Chain write (tool_use + tool_result) — verify both present in file
- Simulated interruption mid-chain — verify no partial writes
- Concurrent writes from 2 agents — verify no corruption

Prompt 4: Audit the entire codebase for the same classes of bugs

You just fixed 3 specific bugs: missing streaming timeout, queue auto-restart
after ESC, and non-atomic JSONL writes. Now audit the ENTIRE codebase for
the same patterns — these bugs are symptoms of systemic problems, not one-offs.

Search and fix ALL instances of:

1. NETWORK CALLS WITHOUT TIMEOUT: Find every HTTP request, SSE stream,
   WebSocket connection, and fetch() call. Every single one must have:
   - A client-side timeout (30s for streams, 10s for REST calls)
   - An AbortController signal
   - Error recovery (retry with backoff or surface error to user)
   List every call you find and its current timeout status.

2. NON-ATOMIC FILE WRITES: Find every place where multiple related records
   are written to a file sequentially (for loops writing to JSONL, multiple
   appendFile calls for related data). Each must be converted to a single
   write operation. Also check for write-then-rename atomic patterns —
   config files (.claude.json, settings.json) should use write-to-tmp +
   rename, not direct overwrite.

3. USER ABORT NOT FULLY HONORED: Find every AbortController/abort() handler
   and every catch block that handles AbortError. Verify that after abort:
   - No background work continues
   - No queued tasks auto-restart
   - All resources (connections, file handles, timers) are cleaned up
   - Control returns to the user prompt

4. CONCURRENT ACCESS WITHOUT LOCKING: Find every file that can be written
   by multiple processes simultaneously (session files, config files,
   history). Each must have proper file locking (proper-lockfile or OS-level).

Create a todo list of every instance found. Fix each one. This is not
optional — these are the root causes of 150+ bug reports.

Prompt 5: Write enterprise-grade regression tests for core workflow

The core workflow (submit → stream → tools → result → cancel) is the #1
priority. It must NEVER regress. Write a comprehensive test suite that
runs on every CI build and blocks releases if any test fails.

## Test Suite: Core Workflow Reliability

### 1. Streaming Tests
- test_stream_completes_normally
- test_stream_timeout_triggers_after_30s_silence
- test_stream_timeout_retries_with_backoff
- test_stream_max_retries_surfaces_error
- test_stream_recovers_after_network_reconnect
- test_stream_abort_cleans_up_all_resources
- test_stream_handles_malformed_sse_events
- test_stream_handles_partial_json_chunks
- test_concurrent_streams_independent (multiple agents)

### 2. ESC / Abort Tests
- test_esc_during_streaming_returns_to_prompt
- test_esc_during_tool_execution_returns_to_prompt
- test_esc_clears_message_queue
- test_esc_does_not_auto_restart_queued_messages
- test_esc_aborts_all_pending_network_calls
- test_esc_closes_all_file_handles
- test_esc_stops_all_timers
- test_double_esc_is_safe (no double-free, no crash)
- test_esc_during_file_write_does_not_corrupt

### 3. Data Integrity Tests
- test_tool_use_always_paired_with_tool_result
- test_message_chain_write_is_atomic
- test_interrupted_chain_write_no_partial_data
- test_concurrent_session_writes_no_corruption
- test_config_write_is_atomic (write-tmp-rename pattern)
- test_session_file_valid_jsonl_after_abort
- test_session_file_valid_jsonl_after_crash (kill -9)
- test_no_orphaned_tool_use_after_100_rapid_esc_presses

### 4. Resource Cleanup Tests
- test_no_leaked_file_handles_after_session
- test_no_leaked_timers_after_abort
- test_no_leaked_connections_after_timeout
- test_no_temp_files_left_after_session (.node leak)
- test_disk_full_graceful_degradation
- test_disk_full_no_config_corruption

### 5. Regression Guards (one test per historical bug)
- test_regression_6836_no_orphaned_tool_results
- test_regression_26224_no_indefinite_hang
- test_regression_20171_no_phantom_generating
- test_regression_24688_no_frozen_terminal
- test_regression_25979_no_stale_connection_hang
- test_regression_33949_esc_fully_cancels

Each test must have a clear description of WHAT it tests and WHY (link to
the original issue). Tests must be fast (mock network where needed) and
deterministic. No flaky tests allowed — if it's flaky, the underlying
code is buggy.

Add a CI gate: if ANY of these tests fail, the release is blocked.
No exceptions. No "we'll fix it next sprint."

Prompt 6: Deep codebase health audit and professional report

Conduct a thorough, systematic audit of the entire Claude Code codebase.
You are acting as a senior staff engineer performing a code health review
before a critical production release. Do not rush. Be methodical.

## Phase 1: Architecture Review

Map the full architecture:
- Entry point → CLI parser → prompt handler → API client → streaming →
  tool execution → response rendering → session persistence
- Draw the data flow diagram (in Mermaid or ASCII)
- Identify all shared mutable state (globals, singletons, module-level vars)
- Identify all concurrency patterns (async/await, parallel tool calls,
  multiple agent instances, background tasks)
- List every external dependency and its purpose

## Phase 2: Reliability Audit

For each component in the data flow, answer:
1. What happens if it hangs? Is there a timeout?
2. What happens if it throws? Is the error caught and handled?
3. What happens if ESC is pressed mid-operation? Are resources cleaned up?
4. What happens if disk is full? Does it degrade or corrupt?
5. What happens if 2 instances run simultaneously? Is there locking?
6. What happens if the network drops mid-operation? Detection? Recovery?

Score each component: GREEN (solid), YELLOW (risks), RED (known broken).

## Phase 3: Error Handling Review

- Find every try/catch block. Is the error logged? Re-thrown? Swallowed?
- Find every .catch() on promises. Are rejections handled or silenced?
- Find every fire-and-forget async call (no await, no .catch). List them all.
- Check: can an unhandled rejection crash the process silently?

## Phase 4: State Management Review

- Map all files written during a session (.claude.json, session JSONL,
  history.jsonl, settings.json, debug logs)
- For each: write pattern (atomic?), locking (concurrent safe?),
  corruption recovery (what if file is half-written?)
- Check: what happens if .claude.json is corrupted on startup?
- Check: what happens if session JSONL has invalid JSON on a line?

## Phase 5: Generate Professional Report

Write a report to docs/CODE_HEALTH_REPORT.md with:

### Executive Summary
- Overall health score (RED / YELLOW / GREEN)
- Top 5 critical risks ranked by user impact
- Estimated effort to fix each

### Component Health Matrix
| Component | Timeout | Error Handling | Abort Safety | Concurrency | Disk Safety | Score |
|-----------|---------|---------------|--------------|-------------|-------------|-------|
| Streaming |   ...   |      ...      |     ...      |     ...     |     ...     |  ...  |
| ...       |   ...   |      ...      |     ...      |     ...     |     ...     |  ...  |

### Critical Findings (RED)
For each: description, code location, user impact, fix proposal, effort estimate

### Warnings (YELLOW)
Same format

### Recommended Fix Order
Numbered checklist with dependencies:
- [ ] 1. Fix X (blocks #2, #3)
- [ ] 2. Fix Y (depends on #1)
- [ ] 3. ...

### Regression Prevention Plan
- What CI checks to add
- What monitoring to add
- What CLAUDE.md rules to add

This report becomes the roadmap. No new features until all RED items
are fixed and tested.

---

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.js across 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.

jviotti · 4 months ago

@kolkov Did you find any workaround while this gets fixed? The hangs get more common and are extremely disruptive...

kolkov · 4 months ago

@jviotti The only workaround we have:

  1. Watch the timer — if you see 0 tokens and 15+ seconds of silence, it's hung. Don't wait, press ESC immediately
  2. Don't type new prompts while it's hung — they go into the queue and auto-start after ESC (that's root cause #2)
  3. ESC → re-submit — this creates a new SSE connection which usually works right away

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.

bperry0xff · 4 months ago

I've been excessively experiencing this exact issue today. I am on v2.1.75.

dquareng · 4 months ago

May cancel Max over this and switch to codex - ever other prompt fails has made the cli actually unusable

nullbio · 4 months ago
May cancel Max over this and switch to codex - ever other prompt fails has made the cli actually unusable
  • Be Anthropic
  • "The harness is the moat, we can't open source that"
  • Add useless features, tech debt, bloat, and ignore bugs for a year
  • Everyone makes their own "moat" or switches to competitors as a result of not open-sourcing and allowing community to submit fixes or diagnose issues easily

Irony.

pedromelo222 · 4 months ago

<img width="486" height="44" alt="Image" src="https://github.com/user-attachments/assets/8dc0ea46-274d-4c30-b766-147d7f0dbb3c" /> 15 min to start

kolkov · 4 months ago

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 v2.1.76, Opus 4.6 with 1M context (just enabled after removing CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)
  • System did NOT crash (no BSOD) — Windows stayed visually responsive, could switch windows
  • Keyboard completely dead — no input accepted anywhere, not just in Claude Code
  • Could not Ctrl+Alt+Del, could not open Start menu, could not reboot gracefully
  • Only recovery: hard power-off (holding power button for 5 seconds)

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:

  1. Streaming hangs — SSE connection dies silently, no timeout (this issue)
  2. ESC doesn't cancel — queue auto-restart prevents recovery (this issue)
  3. System deadlocks — the tool can lock up the entire OS, requiring hard reboot (#30137, #32870, #14674)

Related: #30137 (Windows BSOD), #32870 (BSOD via Wof.sys), #12234 (Windows freezes)

kolkov · 4 months ago

Another fun one: after removing CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC to 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:

  1. Streaming hangs (this issue) — still not fixed in v2.1.77
  2. Complete system deadlock requiring hard power-off (comment above)
  3. Auto-updater memory leak crashing a 12-hour session (#35171)

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.

kolkov · 4 months ago

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.js across 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_WATCHDOG

# Add to ~/.claude/settings.json:
{
  "env": {
    "CLAUDE_ENABLE_STREAM_WATCHDOG": "1"
  }
}
# Or: export CLAUDE_ENABLE_STREAM_WATCHDOG=1

This enables a watchdog that:

  • Warning at 30s with no SSE chunks → logs "Streaming idle warning"
  • Abort at 60s → aborts the dead connection via AbortController
  • Fallback → retries the same request in non-streaming mode
  • Reset on every received chunk → no false positives during normal streaming

The implementation (v2.1.77, offset ~10,437,656):

let I6 = o6(process.env.CLAUDE_ENABLE_STREAM_WATCHDOG);  // feature flag
let g6 = 30000;   // warning: 30 seconds
let F6 = 60000;   // abort: 60 seconds

function H1() {   // called before streaming AND on every chunk
  clearTimers();
  if (!I6) return; // ← DISABLED by default!

  warningTimer = setTimeout(() => {
    log("Streaming idle warning: no chunks for 30s");
  }, 30000);

  abortTimer = setTimeout(() => {
    log("Streaming idle timeout: no chunks for 60s, aborting");
    abortController.abort();  // kill the dead connection
  }, 60000);
}

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:

// ALWAYS runs, no flag needed:
let K6 = 30000;  // 30s threshold
for await (let chunk of stream) {
  let gap = Date.now() - lastChunkTime;
  if (gap > 30000) {
    stallCount++;
    telemetry("tengu_streaming_stall", {  // ← sent to Anthropic!
      stall_duration_ms: gap,
      stall_count: stallCount,
      total_stall_time_ms: totalStallTime
    });
  }
}

Every time your session hangs for 30+ seconds, Anthropic receives a tengu_streaming_stall event. 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-level keepalive: true (connection reuse) but does not set TCP-level keepalive probes on the socket. When the connection silently dies:

  1. TCP stack doesn't know (no probes configured)
  2. Watchdog is disabled (could abort at 60s, but won't)
  3. Windows will detect the dead socket in... 2 hours
  4. Meanwhile, you stare at "0 tokens" and press ESC

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_WATCHDOG so 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:

{
  "env": {
    "CLAUDE_ENABLE_STREAM_WATCHDOG": "1"
  }
}

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:

allocator->scavenger_data.is_in_use = yes
panic: Illegal instruction at address 0x7FF63A2DE777

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.

kolkov · 4 months ago

Update: Enabled CLAUDE_ENABLE_STREAM_WATCHDOG=1 — dramatic improvement

After enabling the watchdog (and DISABLE_AUTOUPDATER=1 to 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:

API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"}}
529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
Retrying in 0 seconds… (attempt 6/10)
  • Same request_id across retries = sticky routing to a dead backend instance
  • Two other agents in parallel sessions worked perfectly = instance-specific failure
  • Watchdog did NOT help here — 500/529 is HTTP-level, before SSE stream opens
  • The session's JSONL had grown to 107 MB over 302 hours (12.5 days)
  • Only fix: /exit → new session → new TCP connection → new healthy instance → works instantly

So 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):

{
  "env": {
    "CLAUDE_ENABLE_STREAM_WATCHDOG": "1",
    "DISABLE_AUTOUPDATER": "1"
  }
}

Community: please try this and report back. Does it help with your hangs?

kolkov · 4 months ago

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_result never 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 writes tool_use and tool_result one at a time in a for-loop:

async insertMessageChain(messages) {
  for (let msg of messages) {
    await this.appendEntry(msg);  // tool_use first
    // ← race condition window: ESC, stall, concurrent write
    await this.appendEntry(msg);  // tool_result second
  }
}

If anything interrupts between the two writes — ESC press, streaming stall, scheduleDrain() guard collision, or concurrent agent writing to the same file — the tool_use is recorded but tool_result is lost.

Consequences

The model doesn't know the tool succeeded:

  • Autonomous agents re-do the same work (duplicate edits, duplicate commands)
  • Session resume sees orphaned tool_use without tool_result → API 400 error → session unresumable
  • Compaction may corrupt the orphaned pair further (#8484, #14173)

Our data: 31 orphans in current session alone

Tool        Total  Orphans  Rate
Bash         323     23     7.1%
Edit          51      4     7.8%
Read          33      2     6.1%
WebFetch       9      2    22.2%

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:

  • Tool executes but result lost → model re-prompts → extra turn
  • If the orphan caused a stall > 5 min → prompt cache TTL expires → 10x token cost for entire context
  • If cache miss triggers compaction → another 500K-800K tokens consumed

Related issues

  • #6836 — orphaned tool_use/tool_result (150+ reports, 43 comments)
  • #31328 — JSONL writer drops assistant entry during parallel tool calls
  • #31330 — Parallel tool results corrupted after session resume
  • #27052 — ESC during task → duplicate API requests → unrecoverable 400 error loop
  • #29598 — Tool result block missing corresponding tool use block
  • #14173 — Compaction error: orphaned tool_result despite valid history
  • #8484 — Compaction corrupts tool use/result pairs (12 comments)

The fix (proposed in our original analysis)

Make insertMessageChain() atomic — write the entire chain in a single appendToFile() call:

async insertMessageChain(messages) {
  const serialized = messages.map(m => JSON.stringify(m)).join('\n') + '\n';
  await this.appendToFile(sessionFile, serialized);  // single write = atomic
}

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.

nullbio · 4 months ago
Another fun one: after removing CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC to 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: 1. Streaming hangs (this issue) — still not fixed in v2.1.77 2. Complete system deadlock requiring hard power-off (comment above) 3. Auto-updater memory leak crashing a 12-hour session (Auto-updater memory leak crashed 12-hour session (v2.1.76) — 13.81 GB committed, Bun panic #35171) 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.

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?

kolkov · 4 months ago

@nullbio It's not really obfuscated — just minified. npm pack @anthropic-ai/claude-code, extract cli.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.

kolkov · 3 months ago

Update: Anthropic is actively working on the watchdog — v2.1.79 changed the timeout values

We checked cli.js from 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:

  1. They're actively iterating on the watchdog — the timeouts were tuned, not abandoned
  2. The increase from 60s→90s suggests they saw false positives with Opus extended thinking (which can be silent for 60+ seconds)
  3. But it's still behind CLAUDE_ENABLE_STREAM_WATCHDOG — not enabled by default

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

{
  "env": {
    "CLAUDE_ENABLE_STREAM_WATCHDOG": "1",
    "DISABLE_AUTOUPDATER": "1"
  }
}

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_result never 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:

  • 90s is too long for simple requests (response usually in 2-5s)
  • 90s may be too short for complex Opus thinking with 1M context (3+ min)

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 :ping comments as keepalive. Extend them with status:

Currently:    :ping

Better:       :ping {"status":"thinking", "queue_position":0}
              :ping {"status":"queued", "estimated_start_ms":2000}
              :ping {"status":"generating", "tokens_so_far":1200}

Then the client knows what's happening:

  • status: "thinking" → model is working, reset timeout
  • status: "queued" → in queue, wait
  • No ping for 30s → connection dead → abort

This 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.

meraline · 3 months ago

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:

  • Claude Code: 2.1.79
  • OS: Ubuntu Linux
  • Auth: Claude Max subscription
  • MCP servers configured (Google Calendar, Gmail — showing auth errors but unrelated)

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:

  1. Run claude --debug
  2. Execute any tool call (e.g., search files, run bash command)
  3. Tool call gets "Interrupted" without user input
  4. Check debug log — TypeError: A.with appears

Observations:

  • Happens on nearly every tool call, not just subagents
  • Fresh session doesn't help — issue persists immediately
  • Restarting Claude Code provides temporary relief (1-2 tool calls work, then breaks again)
  • npm reinstall doesn't fix (2.1.79 is already latest)

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.with function call in minified code suggests a missing polyfill or incompatible runtime.

kolkov · 3 months ago

@meraline This looks like a different bug from the streaming hang issue. We checked:

  1. Not reproducible on Windows — v2.1.78, --debug mode, no A.with errors in debug log
  2. A.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.
  3. This method requires Node.js 20+ or Bun 1.0+. On Ubuntu, if Bun's bundled runtime has issues with this method, it would break on every tool call — exactly what you're seeing.

Worth checking:

# What Bun version is bundled?
claude --version

# Does your system Node.js support Array.with?
node -e "console.log([1,2,3].with(-1, 99))"
# Should print: [1, 2, 99]

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.

kolkov · 3 months ago

Update: No file locking on Read/Edit tools — concurrent agents can overwrite each other

We checked cli.js for file locking patterns. Finding:

  • Config files (.claude.json, settings.json) — use proper-lockfile with .lock files ✅
  • Session JSONL (history.jsonl) — use proper-lockfile
  • Session JSONL (per-session <id>.jsonl) — NO locking ❌ (root cause #3)
  • Read/Edit/Write tools on user filesNO locking at all

This means when multiple agents work on the same file:

Agent 1: Read file.go    → gets version v1
Agent 2: Read file.go    → gets version v1
Agent 1: Edit file.go    → writes v2
Agent 2: Edit file.go    → writes v3, OVERWRITES Agent 1's changes!

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:

  1. Session level: tool_use/tool_result pairs getting orphaned in JSONL
  2. File level: concurrent agents silently overwriting each other's edits

This is especially dangerous with agent teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1) where multiple agents routinely work on the same codebase.

kolkov · 3 months ago

Update: Debug log evidence — watchdog doesn't help when server sends pings but no content

Ran with --debug flag and caught a live hang. Here's the debug log:

10:14:29 [ERROR] API error (attempt 1/11): undefined Request timed out.
10:15:18 [DEBUG] Stream started - received first chunk
10:15:22 [DEBUG] High write ratio: blit=0, write=72754 (100.0% writes)
10:15:22 [DEBUG] Full reset (scrollback changes): scrollbackRows=696

What happened:

  1. Request sent to API → server accepted it
  2. 4+ minutes of silence in the UI (timer stuttering, tokens barely moving)
  3. Watchdog (90s) did NOT fire — because server was sending SSE ping events that reset the watchdog timer
  4. Eventually API_TIMEOUT_MS (600s default) fired → "Request timed out"
  5. Retry succeeded after 49 seconds → first chunk received
  6. Full scrollback reset → 100% write ratio → scroll jump to top (the scroll bug)

This proves the adaptive timeout proposal is necessary. The current watchdog resets on ANY SSE event, including :ping keepalive. If the server sends pings but doesn't generate content, the watchdog is useless — you sit there for the full API_TIMEOUT_MS (10 minutes!).

The fix is exactly what we proposed: server-side status in ping events:

:ping {"status":"thinking"}    → model working, wait
:ping {"status":"generating"}  → tokens flowing, all good
:ping {"status":"queued"}      → in queue, wait
No ping for 30s               → connection dead, abort

Without content-aware pings, there's a dead zone between watchdog (90s, bypassed by pings) and API timeout (600s) where the user just waits.

kolkov · 3 months ago

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.

Elapsed: 8 hours | RSS: 1.81GB | Commit: 10.60GB | Faults: 22M
panic: Illegal instruction (mimalloc scavenger crash)

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=1 and CLAUDE_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:

  1. Streaming stalls → watchdog works (hidden behind env var) ⚠️
  2. JSONL race conditionsinsertMessageChain() still non-atomic after 8 versions ❌
  3. Bun runtime crashes → fundamental, no workaround, affects idle processes ❌

Layer 1 has a fix that Anthropic won't enable. Layers 2 and 3 have no fix at all.

kolkov · 3 months ago

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.

# How we switched (keep your settings!)
cp -r ~/.claude ~/.claude-backup          # backup first
rm ~/.local/bin/claude.exe                # remove native binary (NOT claude uninstall!)
npm install -g @anthropic-ai/claude-code  # install npm version

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:

  • 0 mimalloc crashes (impossible on Node.js)
  • 0 "Tool bash not found" (no memory corruption)
  • 0 system deadlocks
  • 0 IDE crashes from memory pressure
  • V8 GC actually returns memory to OS (mimalloc never did)

What still remains (cli.js / server-side):

  • Streaming stalls — still happen (server-side issue)
  • Tool result loss — insertMessageChain() for-loop still non-atomic

What ALSO disappeared (we thought it was Ink, it was Bun):

  • Scroll resets (Full reset (scrollback changes)) — 316 per session on Bun → 0 on Node.js
  • Input field rendering corruption — text appearing below prompt, garbled input — gone
  • Scrolling history back and forth works smoothly — no jumps, no discomfort
  • We originally attributed all of this to Ink's renderer (#3648, 👍694). Debug logs prove it was Bun/mimalloc corrupting render state.

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

  • "SSE connection dies" → corrupted HTTP client state
  • "Tool result lost" → corrupted JSONL write buffer (partially — for-loop bug is real too)
  • "Scroll jumps" → corrupted render state
  • "Tool bash not found" → corrupted tools registry
  • "21-thread deadlock" → corrupted event loop / mutex state
  • "System freeze" → mimalloc scavenger blocking all threads

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.

yichao-mt · 3 months ago

Watchdog timer bug in v2.1.84/v2.1.85: timer disarmed after initial response, never re-armed during thinking

Environment

  • Claude Code: v2.1.85
  • OS: Ubuntu 20.04 (Linux)
  • Model: Opus 4.6, also tested with Sonnet (medium effort)
  • Network: VLESS tunnel (TUN mode via mihomo)
  • Env vars: CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000, CLAUDE_ENABLE_STREAM_WATCHDOG=1

Finding

The streaming idle watchdog (CLAUDE_STREAM_IDLE_TIMEOUT_MS) added in v2.1.84 does not trigger during thinking phases. After reverse-engineering cli.js (v2.1.85), here is the root cause:

Initialization (called once at API call start):

b6 = o6(process.env.CLAUDE_ENABLE_STREAM_WATCHDOG)  // true
x6 = parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || "", 10) || 90000  // 120000
Z6 = x6 / 2  // 60000 (warning threshold)

X6()  // Arms watchdog timers (g6 = warning, B6 = abort)

Timer setup (X6 function):

X6 = function() {
    K8();  // Clear existing timers
    if (!b6) return;
    g6 = setTimeout(warning_handler, Z6);   // Warning at 60s
    B6 = setTimeout(abort_handler, x6);     // Abort at 120s → calls J6()
}

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:290968290 seconds with zero application data
  • lastack:2828 — TCP keepalive ACKs still flowing (connection alive at TCP level)
  • bytes_received:3928 — frozen at initial response size
  • Connection state: ESTAB (not half-open)

The 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.

// In the SSE event handler:
stream.on("event", () => {
    X6();  // Reset watchdog on every event
});

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.

kolkov · 3 months ago

@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 loop

We downloaded v2.1.84 and v2.1.85 via npm pack, extracted cli.js, and traced the brace-depth scoping carefully. Here's what we found on line 7682 of v2.1.85:

// v2.1.85 line 7682 (deobfuscated, preserving structure)
// ── outer try block (pos 2273..11027) ──
try {
    // Watchdog functions defined first:
    let K8 = function() {             // clearTimers
        if (g6 !== null) clearTimeout(g6), g6 = null;
        if (B6 !== null) clearTimeout(B6), B6 = null;
    };
    let X6 = function() {             // armWatchdog
        K8();
        if (!b6) return;              // b6 = CLAUDE_ENABLE_STREAM_WATCHDOG
        g6 = setTimeout(warn,  Z6);   // Z6 = timeout/2 (45s default)
        B6 = setTimeout(abort, x6);   // x6 = timeout   (90s default)
    };

    // ... retry logic (Lk8 wrapper, do-while) ...

    // Watchdog state initialized (same scope, so X6 closure sees these):
    let b6 = o6(process.env.CLAUDE_ENABLE_STREAM_WATCHDOG);
    let x6 = parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || "", 10) || 90000;
    let Z6 = x6 / 2, y6 = false, c6 = null, g6 = null, B6 = null;

    X6();                              // initial arm

    try {
        for await (let w8 of t) {
            X6();                      // <-- re-arm on EVERY SSE event
            let W8 = Date.now();
            // ...stall detection, event processing...
        }
    }
}

We verified the scoping: the outer try block spans from pos 2273 to pos 11027. Both X6 (the function) and let b6/g6/B6 (the state) are inside this block. The X6 closure captures the same b6, g6, B6 bindings. The for await loop calls X6() at the start of each iteration. Same pattern in v2.1.84 (j6() instead of X6()).

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:3928 frozen for 290 seconds. No SSE events, no pings, no data at all. The for await was blocked waiting for the next chunk. The watchdog should have fired at 120s. But it didn't.

Since bytes_received didn't grow, we can rule out SSE :ping keepalives 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. setTimeout callbacks only fire when the event loop gets to process them. If the for-await-of on 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:

# Which binary actually runs?
ls -la $(which claude)
# Or check the running process:
ps aux | grep claude | grep -v grep

This matters because:

  • Node.js: for await on a ReadableStream yields to the event loop between iterations. setTimeout callbacks fire normally while waiting for the next chunk. The watchdog should work.
  • Bun: Bun's async iteration implementation may differ. If Bun keeps the microtask queue busy or doesn't yield properly between async iterations on SSE streams, setTimeout macrotasks 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 -tnpi data? 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:

  • Watchdog code has existed since ~v2.1.50, disabled by default behind CLAUDE_ENABLE_STREAM_WATCHDOG
  • v2.1.79 bumped timeouts from 30s/60s → 45s/90s
  • v2.1.84 made it configurable via CLAUDE_STREAM_IDLE_TIMEOUT_MS (changelog confirms)
  • v2.1.84 also added stall detection (30s gap, warning only) and x-client-request-id header for debugging
  • v2.1.85 has zero watchdog changes vs v2.1.84
  • The armWatchdog() call has been inside the for-await loop in all versions we checked

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

90s: watchdog abort → non-streaming retry
90s: watchdog abort → non-streaming retry
90s: watchdog abort → non-streaming retry
→ task never completes

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:

  • 90s too short → false positives on legitimate thinking (our case)
  • 180s safer for thinking → 3 minutes staring at a dead connection before recovery
  • 300s → usable only for hangs, not for stalls

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, setTimeout not firing during for 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 select on a channel + time.After is ~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 single select{} 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 .node native 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 GB and Commit: 15.40 GB in 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.

kolkov · 3 months ago

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 AbortError hits a throw before reaching the fallback code. Users see "Request timed out" instead of a transparent retry. The fallback code even has telemetry for fallback_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:

  1. Timer may not fire on certain runtimes (per @yichao-mt's finding)
  2. Even when it does fire, the recovery path is dead code (our finding)

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.

kolkov · 3 months ago

Storage architecture audit (2026-03-27): We traced the full ~/.claude/ storage layout in v2.1.85. The .claude.json bloat 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.jsonl has proper-lockfile protection, but session JSONL files use raw appendFile with no locking (#31328), and .claude.json uses raw writeFile (#28922, reported 8 times).

Full audit with file layout, sizes, locking analysis, and architecture recommendations: #5024 (comment)

kolkov · 3 months ago

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

  • Watchdog initializes AFTER do-while (line 1868 vs 1849) — the most vulnerable phase is unprotected
  • releaseStreamResources() aborts undefined (line 1519) — watchdog abort is a no-op during initial connection
  • Fallback works in for-await, not in do-while — exactly where 100% of our observed hangs occur
  • Promise.race without .catch() in concurrent tool merger (utils/generators.ts:57) — one rejected generator kills all pending tool results
  • We patched cli.js and proved it: watchdog fired for the first time ever in do-while phase, ESC aborts dropped 8.7×

v2.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):

  1. Move watchdog init before do-while in claude.ts
  2. Add AbortSignal.any([signal, watchdogController.signal]) — so watchdog can actually abort the request, not undefined
  3. Check streamIdleAborted flag in catch block — let watchdog abort fall through to existing non-streaming fallback instead of throwing

Proper fix — move reliability logic to the open SDK (@anthropic-ai/sdk, MIT):

  • Three-level timeout: connection (30s) → network idle with ping awareness (120s) → content idle (disabled)
  • Ping-aware adaptive watchdog: The SDK currently ignores SSE ping events (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:
  • Dead connection (no data at all, no pings) → abort quickly (network idle timeout, 120s)
  • Model thinking (pings arriving, no content yet) → don't abort! The connection is alive, model is working. Optionally notify user: "thinking for 2m..." via onPing callback
  • This eliminates both problems at once: no false positives on Opus extended thinking (pings reset network timer), and fast detection of dead connections (no pings = abort)
  • Informational pings: Currently SSE pings carry no payload — just event: 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 connection
  • Streaming retry and non-streaming fallback — in the SDK, not in 3,419 lines of closed cli.js
  • One AbortController instead of five nested levels
  • SSE ping events should carry status context: event: ping\ndata: {"status":"thinking","elapsed_ms":95000} — the client can then distinguish "connection alive, model working" from "connection dead". Current pings are opaque :ping comments with zero information — the most valuable signal (proof-of-life) is thrown away

The right fix — rewrite in Go:

  • context.WithTimeout replaces the AbortController chain in one line
  • Goroutines: render, input, and network in separate threads — no input latency during streaming (React single-thread problem with 470 useState hooks)
  • 15 MB static binary instead of 13 MB minified JS + Node/Bun runtime + native addons
  • go test -race catches every concurrency bug — like the Promise.race without .catch() in utils/generators.ts:57 that silently drops tool results
  • Every serious CLI tool is Go: Docker, Kubernetes, Terraform, GitHub CLI
  • The Go ecosystem already has production-ready libraries for every component: Phoenix TUI (Elm-inspired terminal framework, replacement for React/Ink), stream (RFC-compliant SSE/WebSocket, replacement for SDK streaming), signals (reactive state, replacement for 470 useState hooks), coregex (regex 3-3000× faster than stdlib), uniwidth (Unicode width 4-46× faster, for TUI), gosh (cross-platform shell), fursy (HTTP router with OpenAPI), pubsub (messaging with DLQ)

The models are extraordinary. The CLI wrapper needs real engineering — or a different language entirely.

kolkov · 3 months ago

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

File: src/services/api/claude.ts (3,419 lines)

The streaming idle watchdog (line 1868-1929) initializes AFTER the do-while
loop (line 1849-1856). This means the initial connection phase — waiting for
the first API response — is completely unprotected by the watchdog.

Move watchdog initialization (lines 1868-1929: streamWatchdogEnabled, 
STREAM_IDLE_TIMEOUT_MS, resetStreamIdleTimer) BEFORE the do-while on line 1849.

Additionally, releaseStreamResources() (line 1519) aborts `stream` and 
`streamResponse` — but both are undefined during do-while. Create a dedicated
AbortController for the watchdog:

  const watchdogController = new AbortController()
  const combinedSignal = AbortSignal.any([signal, watchdogController.signal])

Pass combinedSignal instead of signal to the withRetry options (line 1843).
In the watchdog timeout callback (line 1911), call watchdogController.abort()
before releaseStreamResources().

In the catch block (line ~2434), add:
  if (streamingError instanceof APIUserAbortError) {
    if (signal.aborted) throw streamingError      // user ESC
    else if (streamIdleAborted) { /* fall through to fallback */ }
    else throw new APIConnectionTimeoutError()     // SDK timeout
  }

This fixes 3 bugs in one change: timing, abort targets, and fallback reachability.
We patched the minified cli.js and proved it: ESC aborts dropped 8.7×.

Updated Prompt 2: Ping-aware adaptive watchdog (NEW — not in original prompts)

File: @anthropic-ai/sdk, src/core/streaming.ts (line 78)

The SDK currently ignores SSE ping events:
  if (sse.event === 'ping') { continue; }

Pings are proof-of-life from the server. They should reset a network idle
timer without resetting a content idle timer. This distinguishes two scenarios:

1. Dead connection (no data, no pings) → abort after 120s (network timeout)
2. Model thinking (pings arriving, no content) → DON'T abort, notify user

Implement three-level timeout in the SDK:
- connectionTimeout (30s): initial HTTP response headers
- networkIdleTimeout (120s): any data including pings
- contentIdleTimeout (disabled/300s): content only, excluding pings

Add callbacks: onPing(), onNetworkIdleWarning(), onContentIdle()

Enrich ping events with diagnostic data:
  event: ping
  data: {"status":"thinking","elapsed_ms":95000,"queue_position":3}

This eliminates both false positives on Opus thinking AND slow detection
of dead connections. One mechanism, all cases covered.

This belongs in the open SDK (@anthropic-ai/sdk, MIT license), not in cli.js.
Every Anthropic API client benefits, not just Claude Code.

Updated Prompt 3: Fix Promise.race tool result loss

File: src/utils/generators.ts (line 42-73, function `all()`)

The concurrent tool merger uses Promise.race with .then() but no .catch():

  const promise = generator.next().then(({ done, value }) => ({
    done, value, generator, promise
  }))
  // ← NO .catch()!

If any generator rejects, Promise.race propagates the rejection and ALL
other pending generators are silently abandoned — their results lost.

Fix: Add .catch() to convert rejections into done objects:

  const promise = generator.next()
    .then(({ done, value }) => ({ done, value, generator, promise }))
    .catch(err => ({ done: true, value: undefined, generator, promise, error: err }))

Handle the error field in the consumer loop: yield an error tool_result
instead of silently dropping the generator.

Note the TODO comment on line 64: "// TODO: Clean this up" — even the
developer (or Claude) knew this code needed work.

Updated Prompt 4: Fix input latency during streaming

File: src/screens/REPL.tsx (5,005 lines, 875 KB — single component!)

The terminal UI uses React/Ink with 470 useState and 372 useEffect hooks.
During SSE streaming (10-50 events/sec), each event triggers setState →
React reconciliation → stdout.write. This blocks the single Node.js thread,
causing user input to queue behind renders.

Evidence from source: line 1318 uses useDeferredValue(messages) — they know
about this problem. The comment on line 1321 logs deferred message count.

This is architecturally unsolvable in React/single-thread:
- useDeferredValue = bandaid (defers render, doesn't eliminate blocking)
- setTimeout(0) yield in print.ts:1832 = bandaid (one tick yield)

Proper fix: separate render and input into different threads (Web Workers
or process.fork), or replace React/Ink with a lightweight terminal renderer
that doesn't require virtual DOM reconciliation.

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.

devarda · 3 months ago

We should open source this, that's the root cause here.

kolkov · 3 months ago

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:

  • Token usage (input, output, cache_creation, cache_read)
  • Cache ratio per request — instantly detect cache invalidation bugs (#42338, #38335)
  • Cost estimation by model (Opus/Sonnet/Haiku)
  • Latency + TTFB for streaming
  • Per-session log files — session UUID matches ~/.claude/projects/ JSONL files for direct correlation
# Start proxy
ccdiag proxy --verbose

# Configure Claude Code (per-project .claude/settings.local.json):
{ "env": { "ANTHROPIC_BASE_URL": "http://localhost:9119" } }

# View stats
ccdiag proxy stats --last 1h --cost

Real-time output:

[12:00:05] claude-opus-4-6 | 3 in | 119 out | cache 99.8% | 3.2s
[12:00:08] claude-opus-4-6 | 3 in |  26 out | cache 97.3% | 4.5s

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

go install github.com/kolkov/ccdiag@latest
matthieugomez · 3 months ago

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.

kolkov · 3 months ago

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 ping events at src/core/streaming.ts lines 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 received as a hard error. claude-code#46987 collected 40+ reports in 24h. Community workaround is to raise CLAUDE_STREAM_IDLE_TIMEOUT_MS to 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:

  1. Forward pings — stop dropping them. Smallest patch, immediately fixes the false-positive watchdog problem.
  2. Semantic pings — add status ('queued' / 'thinking' / 'tool_executing' / 'generating' / 'rate_limited') so clients can tell what the server is doing.
  3. Adaptive client thresholds — different timeouts per state (queued: 5min, generating: 30s, etc.) instead of one global magic number.
  4. Server-driven thresholds — server sends nextPingWithinMs in 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.js instead of the auditable MIT-licensed SDK.

Related threads

  • sdk#749 — pings missing from RawMessageStreamEvent (11 months open)
  • sdk#867 — SDK streaming idle timeout proposal (4 months open, independent arrival at the same conclusion)
  • #39755 — watchdog fallback dead code
  • #41981 — earlier architectural proposal
  • #46987 — current user-visible wave
Kenoubi · 2 months ago

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.

laplaque · 2 months ago

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:

  • Transport: Streamable HTTP over HTTPS to localhost:3443 via mcp-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.
  • Server: Patched Obsidian MCP plugin (laplaque/obsidian-mcp-plugin, fork of aaronsb/obsidian-mcp-plugin), rotates session when stale
  • What happens: Mid-session, the MCP connection drops silently. The bridge detects the stale session and returns a clear error (HTTP 400 with session context) back to the client. The client receives the error but makes no reconnect attempt — all subsequent MCP tool calls fail with MCP error -32000: HTTP MCP request failed with status 400 until the user runs /mcp to manually reconnect.
  • Key detail: The server never goes down. curl -vsk https://localhost:3443/sse confirms TLS handshake and 401 (auth required) — the endpoint is live. The bridge is live. The client simply stopped trying.
  • No network disruption. This is a localhost connection — no Wi-Fi drops, no VPN, no ALB idle timeout. The connection just goes stale, and the client doesn't recover.

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.

gusthegoodbot-spec · 2 months ago

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

  • MacBook Pro: 20+ sessions affected, peak session 47 stalls, ~190 "error":"unknown"

or "error":{"type":null...} markers across top 20 sessions

  • Mac Mini active session: 6 "error":"unknown" + 30 "error":{"type":null...}

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.

lilycip · 1 month ago

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_WATCHDOG findings, which I won't rehash).

Dead-simple repro that confirms transport-level causation

  • Home network goes brittle but alive — short request/response still works (web pages load fine), but sustained throughput is intermittent.
  • Every Claude Code session running on that machine (VS Code extension + others) freezes on the spinner — 0 tokens, timer running, no error surfaced.
  • A separate Claude Code session running on a remote box on a different network keeps working normally throughout — so this is not server-side.
  • Switching the affected machine from Wi-Fi to phone tethering instantly unblocks all of its sessions at once, mid-spinner, without restarting any of them.

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)

  1. Distinguish the spinner states. Once the stream watchdog sees no events/pings for N seconds, change the indicator from Thinking… to something like Connection stalled — retrying. This one change fixes the attribution problem even before the resilience is perfect.
  2. Enable the stream watchdog by default (it looks like it's already trending this way across versions) and surface its state, instead of aborting/retrying silently.
  3. Visibly acknowledge queued input during a stall, so messages typed while hung don't feel swallowed (related to the ESC / queue-restart behavior discussed above).

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.

wse02123 · 2 days ago

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:

  • Chat freezes with no error anywhere in the extension's Claude VSCode.log — output simply stops, no exception, no timeout message.
  • Checked TCP state during a hang via Get-NetTCPConnection: all connections to the Anthropic API host were still reported as Established — consistent with the TCP half-open scenario described here (OS-level socket state doesn't reflect that no data is actually flowing).
  • VS Code itself stays fully responsive; only the specific chat session is affected.
  • Only recovery found: close the frozen tab/session and reopen it (spawns a fresh process/stream) — no need to restart all of VS Code.

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.