Bug: watchdog non-streaming fallback is unreachable dead code (v2.1.84/v2.1.85)

Status Fixed / completed
Maintainer reply None cached
Activity 5 comments · opened Mar 27, 2026 · closed Apr 8, 2026

Bug: watchdog non-streaming fallback appears to be unreachable dead code (v2.1.84/v2.1.85)

Environment

  • Claude Code: v2.1.85 (also verified in v2.1.84)
  • Analysis method: reverse-engineering minified cli.js via npm pack

Disclaimer

This analysis is based on reverse-engineering 12 MB of minified JavaScript. Variable names are obfuscated, control flow is compressed into single lines of 10,000–25,000 characters, and scoping has to be traced by counting brace depth at character offsets. We've done our best to reconstruct the logic accurately, but without access to the original source code, there may be nuances we're missing. If any part of this analysis is incorrect, we'd welcome corrections — ideally with a pointer to the relevant source.

Summary

The streaming idle watchdog (CLAUDE_ENABLE_STREAM_WATCHDOG=1) aborts hanging streams but appears to fail to trigger the non-streaming fallback. The fallback code exists and has telemetry (fallback_cause: "watchdog") — but based on our tracing, it's unreachable due to an early throw in the error handling chain.

Users see a generic "Request timed out" error instead of a transparent retry via the non-streaming path.

Root cause

In the inner catch block of the streaming event loop (v2.1.85, line 7682, char offset ~8979):

catch(O6) {
    clearTimers();
    if (watchdogFired) { /* log telemetry */ }

    if (O6 instanceof AbortError)           // ← watchdog calls AbortController.abort()
        if (signal.aborted)                 //    which creates an AbortError
            throw O6;                       // user ESC → cancel (correct)
        else
            throw new TimeoutError("Request timed out");  // ← THROWN TO OUTER CATCH!

    // ⚠️ UNREACHABLE for watchdog abort — both paths above throw

    if (DISABLE_NONSTREAMING_FALLBACK) throw ...;

    // Non-streaming fallback (DEAD CODE for watchdog):
    log("falling back to non-streaming mode");
    fallbackFlag = true;
    telemetry("tengu_streaming_fallback_to_non_streaming", {
        fallback_cause: watchdogFired ? "watchdog" : "other"  // ← never reached
    });
    yield* nonStreamingRequest(...);  // ← never called
}

The outer catch doesn't know about the watchdog — it treats TimeoutError as a generic API failure, yields an error message to the UI, and returns.

Expected behavior

Watchdog fires → abort stream → detect watchdog (not user ESC) → fall through to non-streaming fallback → user gets response transparently.

Actual behavior

Watchdog fires → abort stream → throw TimeoutError → outer catch → "Request timed out" → done. No retry. No fallback.

Suggested fix

Don't throw on watchdog-triggered AbortError — let it fall through to the existing fallback code:

catch(O6) {
    clearTimers();
    if (O6 instanceof AbortError) {
        if (signal.aborted) throw O6;         // user ESC → cancel
        if (!watchdogFired) {                  // unknown SDK abort
            throw new TimeoutError("Request timed out");
        }
        // watchdog abort → fall through to non-streaming fallback below
    }

    if (DISABLE_NONSTREAMING_FALLBACK) throw ...;
    // ... existing fallback code works as intended ...
}

Evidence: the fallback code was intentionally written for watchdog

The telemetry in the unreachable fallback path explicitly checks the watchdog flag:

fallback_cause: watchdogFired ? "watchdog" : "other"

The fallback was clearly intended for watchdog scenarios, but the AbortError instanceof check above it was likely added (or refactored) later without considering this interaction. This is the kind of subtle control-flow regression that's easy to miss in a 12 MB single-file codebase — especially when the code is being generated or refactored at scale.

Impact

  • The watchdog feature (added ~v2.1.50, configurable since v2.1.84) is fundamentally broken: it aborts hanging streams but doesn't recover
  • Users who enable CLAUDE_ENABLE_STREAM_WATCHDOG=1 get "Request timed out" errors instead of the intended transparent retry
  • This may be the reason the watchdog is disabled by default — it appears non-functional in testing because the fallback doesn't work, but the root cause is this unreachable code path, not a design problem with the watchdog itself

Request for source access

We've been reverse-engineering cli.js across 11 versions (v2.1.74–v2.1.85) by grepping through 12 MB of minified code and counting brace depth to trace scoping. We've found multiple issues this way — the streaming hang root cause (#33949), JSONL writer race conditions (#31328), and now this fallback bug — but the process is extremely slow. Tracing a single code path (like the one in this issue) takes hours of node -e scripts and manual character-offset arithmetic.

With access to the original source code, we could verify findings like this in minutes instead of hours, and catch bugs we're currently missing because minification obscures the control flow. Given the complexity of issues the community is hitting (#6836: 150+ orphaned tool reports, #26224: agent hangs, #30137/#32870: system deadlocks), having even one community researcher with source access would meaningfully accelerate debugging.

Our track record:

  • github.com/kolkov — open source maintainer, 35+ public repos
  • dev.to/kolkov — technical articles on developer tooling
  • 11 versions of cli.js reverse-engineered with documented methodology
  • Root cause analysis for streaming hangs (#33949, 👍12, 21 comments)
  • Bun runtime crash analysis (#35171, #36132)

We're happy to work under NDA, read-only access, or whatever arrangement makes sense. The goal is the same — making Claude Code more reliable for everyone.

Why open-sourcing Claude Code makes business sense in 2026

Keeping cli.js closed-source may have made sense in early 2025 when Claude Code launched and had first-mover advantage. But in 2026, with Cursor, Codex, Windsurf, Aider, and dozens of open-source alternatives — the secrecy provides no competitive advantage while actively harming product quality.

Consider the facts:

  • Anthropic's revenue comes from model API access, not from selling Claude Code as software. The CLI is a funnel to the API — the more reliable it is, the more tokens users consume.
  • The "secret" is already out. The entire architecture is recoverable from the minified source — we've mapped the streaming pipeline, error classes, retry logic, telemetry events, and env vars across 11 versions. Anyone with npm pack and a weekend can do the same. It's security through obscurity, and it's not working.
  • Bugs like this one sit undiscovered for months because the community can't effectively review 12 MB of minified code. This specific dead-code bug means the watchdog feature (5+ months in the codebase) has never worked as intended. With readable source, someone would have caught this in a PR review.
  • The community is already doing the work. #33949 has root cause analysis from reverse engineering. #31328 identified JSONL race conditions. @yichao-mt decompiled the watchdog timer. @VRDate submitted PR #35710 for tool mutex. We're all working blind — give us the source and we'll find bugs 10x faster.
  • Open source would accelerate, not threaten. Recreating a CLI wrapper around the Anthropic API is straightforward — the hard part (the models) stays proprietary. What open source gives you is a community that catches regressions, proposes fixes, and builds trust. The current trajectory — 150+ unresolved bug reports, zero team responses, community threatening to leave for Codex — is far more dangerous to the business than open-sourcing a CLI tool.

We're not asking for model weights or internal infrastructure. Just the TypeScript source for a CLI tool that wraps your public API. The ROI is obvious: faster bug discovery, community PRs, and users who feel invested in the product rather than frustrated by it.

CC: @bcherny @ant-kurt @fvolcic @ashwin-ant @bogini @OctavianGuzu @hackyon-anthropic @chrislloyd @ThariqS @catherinewu @whyuan-cc @dhollman @rboyce-ant @dicksontsai @wolffiex @ddworken @km-anthropic — open to discussing any of this privately or publicly.

View original on GitHub ↗

5 Comments

kolkov · 5 months ago

Update (2026-04-01): We published a full write-up of our research — from reverse-engineering 12 versions of minified cli.js to the source map leak that confirmed all our findings:

👉 We Reverse-Engineered 12 Versions of Claude Code. Then It Leaked Its Own Source Code.

Three days after filing this issue asking for source access... the source map shipped in v2.1.88.

Coincidence? I don't think so. 🤖

photomuto · 4 months ago

👏

CaptFaraday · 4 months ago

This dead fallback is actively ruining sessions for Max subscribers using high-effort thinking.

Here's what a real session looks like when the fallback doesn't fire:

● Now I have everything I need. Let me write the implementation plan.
  ⎿  Request timed out

✻ Churned for 1h 43m 44s

❯ What happened
  ⎿  Request timed out

✻ Brewed for 56m 16s

Nearly 3 hours of repeated timeouts with zero output. Each time the stream stalls during extended thinking, it just dies with "Request timed out" instead of falling back to non-streaming mode. Then Claude retries, stalls again, times out again — an infinite loop of failure with no recovery.

During the thinking phase, token count barely moves (47 tokens in 45 minutes), which suggests the stream stalls almost immediately into the thinking phase. If the non-streaming fallback actually worked, it would likely resolve these completely.

This is especially painful on the Max plan ($200/month) where there's no per-token cost pressure to avoid high-effort thinking — you want to use Opus at high effort, but doing so reliably triggers this bug.

The fix proposed in this issue (letting watchdog aborts fall through to the non-streaming path instead of throwing) would directly solve hours of wasted time per session.

kolkov · 4 months ago

Update: root cause has moved upstream — SDK#998

Posted an upstream fix proposal that addresses the root cause behind this watchdog issue: anthropic-sdk-typescript#998.

Connection to this issue

The dead fallback code reported here is one symptom of a deeper architectural problem: Claude Code's cli.js tries to implement a reliable streaming watchdog on top of an SDK that silently drops ping events (src/core/streaming.ts lines 78-80). Without ping visibility, any watchdog built on the SDK can only reset on content deltas — so Opus going quiet for 90+ seconds during legitimate extended thinking looks identical to a silent hang.

v2.1.104 made this visible by removing the silent fallback path when partial data has been received, replacing it with the hard partial response received error currently flooding claude-code#46987 (40+ reports in 24h). The fallback dead-code issue and the new partial-response error are two faces of the same problem: a client-side watchdog cannot be reliable while the SDK hides the signal it needs.

Why fixing this in cli.js keeps failing

Every patch to the watchdog in cli.js has to make a blind guess about how long Opus might legitimately stay silent. 90s is too aggressive, 300s is too slow, and neither handles a server in a degraded state. The fix cannot live in the application layer because the application layer has no observable liveness signal to key off of.

What #998 proposes

Forward pings through the SDK iterator as a typed event, then build semantic pings (status, thinkingDepth, nextPingWithinMs) so clients stop guessing. Once pings are visible, the watchdog becomes trivial to implement correctly: "reset on any event including ping, abort if we don't see one within the server-declared budget". No more magic numbers, no more dead fallback paths, no more open-source bypass.

Full details, code snippets, and evolutionary migration path in #998.

github-actions[bot] · 4 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.