Bug: `tengu_disable_keepalive_on_econnreset` heuristic never triggers on Bun's native fetch errors

Resolved 💬 1 comment Opened May 24, 2026 by pablowinck Closed Jun 24, 2026

Bug: tengu_disable_keepalive_on_econnreset heuristic never triggers on Bun's native fetch errors

Summary

Claude Code has an internal heuristic to disable HTTP keep-alive when a socket is reset (ECONNRESET/EPIPE) to prevent reuse of a half-closed connection from the pool. This heuristic relies on checking cause.code of the underlying error. However, errors thrown by Bun's native fetch() implementation do not carry a Node-style .code property, so the heuristic never triggers for these errors, causing repeated retries against the same dead socket.

This appears to be the root cause behind a large category of "socket connection was closed unexpectedly" failures (issues #5674, #49761, #56711, #37930, #60133, and many others).

Affected versions

Confirmed on 2.1.142 and 2.1.148 (macOS, ARM64). Likely affects all versions since Claude Code adopted the Bun runtime.

Evidence (binary analysis of v2.1.142)

The retry watchdog function (internally ej6) contains:

let X = T$3(z);  // T$3(H): H instanceof oW && (cause.code === "ECONNRESET" || cause.code === "EPIPE")
if (X && Z_("tengu_disable_keepalive_on_econnreset", false)) {
    N("Stale connection (ECONNRESET/EPIPE) — disabling keep-alive for retry");
    mg6();  // sets ug6 = true → next fetches go with keepalive:!1
}

The error message itself is thrown by Bun's native fetch implementation (zig/C++), not by the bundled JS. It appears at byte offset 64805864 of the SEA binary alongside other native fetch errors:

The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()
Was there a typo in the url or port?
Unable to connect. Is the computer able to access the url?
The response redirected too many times.
...

The error propagates as a plain Error with .message set to the string above. There is no .code attached because Bun's error does not follow Node's NodeJS.ErrnoException interface.

This means:

  • error.cause?.code === "ECONNRESET"always false
  • error.cause?.code === "EPIPE"always false
  • T$3(z) returns false for every Bun socket-close error
  • mg6() is never called to disable keep-alive
  • Next retry reuses the same dead socket from the connection pool
  • Fails again with the same error
  • Loop continues until oT3 (default 10) retries exhausted

Hypothesis on the underlying cause

When Claude Code's connection sits idle in the Bun HTTP connection pool, intermediate network equipment (CDN edge, ISP NAT/firewall, corporate proxies) closes the connection by sending FIN. On the next request, Bun pulls the half-closed socket from the pool, writes to it, and the kernel surfaces the close as the error above.

This is a textbook "connection reuse after silent FIN" scenario, which Node solves with keepAlive: false after detecting ECONNRESET. Claude Code already implements this remediation — it just never fires because of the .code check.

Suggested fix

Augment the T$3 predicate to also match on the error message string for Bun-originated errors:

const T$3 = (H) => {
    if (!(H instanceof oW)) return false;
    const cause = H.cause;
    if (cause?.code === "ECONNRESET" || cause?.code === "EPIPE") return true;
    // Bun's native fetch throws errors without a .code; match on message
    const msg = String(cause?.message || cause || "");
    return /socket connection was closed unexpectedly|broken pipe|connection reset/i.test(msg);
};

This is a one-liner conceptually; the impact is significant because it would cause the existing mg6() keep-alive-off path to fire on the most common failure mode on Bun.

Suggested secondary improvements (lower priority)

Comparing to other LLM CLIs:

  1. Stream-aware retry guard (Plandex pattern): only retry mid-stream errors if bytes_emitted === 0. Prevents silent double-output on retry mid-stream.
  2. Per-frame SSE idle timeout (Codex pattern): wrap stream.next() in a configurable timeout to detect "socket alive but mute" cases that TCP keepalive doesn't catch.
  3. Distinguish premature-close pre/post-chunks (Continue.dev pattern): emit different error classes for "closed before any data" vs "closed mid-response", because they require different remediation.

Repro

Set up under any network that occasionally closes idle TCP connections (most consumer ISPs in Brazil, India; many corporate networks behind stateful firewalls). Run multiple parallel agent sessions for 10–30 minutes. Failure rate scales with session duration and parallel agent count.

In our environment (Brazilian ISP, ZenTelecom, fixed public IP, no CGNAT confirmed by ISP), the failure rate is:

  • 50% of agent sessions fail within 5–30 min on Claude Code 2.1.142
  • After applying community workarounds (block QUIC via pf, MTU 1480, CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true, BUN_CONFIG_HTTP_IDLE_TIMEOUT=300, BUN_CONFIG_HTTP_RETRY_COUNT=5): 11% (1 of 9 fails)
  • The pattern is intermittent and uncorrelated with session duration — some 37min sessions complete while some 14min sessions die

Diagnostics

ANTHROPIC_LOG=debug confirms repeated retries against what appears to be the same connection. Wireshark capture during failure shows FIN from the upstream side (Cloudflare-fronted 160.79.104.10) ~100s after the previous transmission, followed by Claude Code retrying on the same TCP tuple until exhaustion.

Related issues

  • #5674 (canonical ECONNRESET issue, open since Aug 2025)
  • #60133 (most recent root-cause analysis, mentions CLAUDE_CODE_REMOTE_SEND_KEEPALIVES)
  • #49761 (QUIC/UDP 443 fallback)
  • #56711 (recent persistent ECONNRESET on macOS)
  • #37930 (UND_ERR_SOCKET frequency)
  • #43611 (Socket Connection Timeout)
  • #23081 (retry pool stale)

Environment

  • Claude Code: 2.1.142 and 2.1.148
  • OS: macOS Tahoe (Darwin 25.4.0), ARM64
  • Runtime: Bun (embedded in SEA, with Node v24.3.0 compat layer)
  • Network: Residential fiber, public IP, no CGNAT, no VPN active on path

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗