API Error: The socket connection was closed unexpectedly. + SOLUTION FOR ANTHROPIC DEVS

Open 💬 22 comments Opened May 18, 2026 by brunos3d

Bug: "Socket connection was closed unexpectedly" during long agentic sessions

Summary

Claude Code CLI throws API Error: The socket connection was closed unexpectedly consistently
during long agentic tasks. The error is systemic — it occurs across all projects, prompts, and
directories, always mid-session, never at startup. The session must be fully restarted to recover.

---

Environment

  • Claude Code version: 2.1.143
  • OS: Arch Linux (Manjaro), kernel 6.12.85-1
  • Install method: Official installer (curl | bash) + pacman/AUR (same binary, both 2.1.143)
  • Binary runtime: Bun 1.3.14 / Node v24.3.0 (confirmed via strings on the binary)

---

Error

API Error: The socket connection was closed unexpectedly.
For more information, pass `verbose: true` in the second argument to fetch()

The error message itself is a raw Bun/JSC runtime string (hardcoded in the JSC engine section
of the binary). It surfaces with no actionable guidance and no automatic retry.

---

Root Cause Analysis

Finding 1: Bun does not set SO_KEEPALIVE on its TCP sockets

Confirmed live via ss -tnop state established '( dport = :443 )':

# Every other application on the system:
chrome  → 34.194.3.76:443    timer:(keepalive,16sec,0)   ← SO_KEEPALIVE ON
code    → 4.228.31.153:443   timer:(keepalive,39sec,0)   ← SO_KEEPALIVE ON

# Claude Code (before workaround):
claude  → 160.79.104.10:443  (no timer)                  ← SO_KEEPALIVE NOT SET
claude  → 2607:6bc0::10:443  (no timer)                  ← SO_KEEPALIVE NOT SET

Without SO_KEEPALIVE, idle TCP connections receive no probe packets. During agentic tasks,
the HTTP/2 connection sits idle while tools execute locally. That idle window is long enough
for the Anthropic server, Cloudflare, or intermediate router NAT to silently drop the connection.
When Claude attempts the next API call on the dropped connection, Bun throws the error.

Finding 2: All application-level keepalives are disabled via GrowthBook feature flags

Inspecting ~/.claude.json (cachedGrowthBookFeatures):

"tengu_bridge_poll_interval_config": {
  "heartbeat_interval_ms": 0,
  "session_keepalive_interval_ms": 0,
  "session_keepalive_interval_v2_ms": 0
}

All three keepalive/heartbeat intervals are 0. There is no fallback mechanism at the
application layer when the TCP layer provides none.

The environment variable CLAUDE_CODE_REMOTE_SEND_KEEPALIVES exists in the binary and
appears to override this, but it is not documented and users cannot be expected to discover it.

Finding 3: Stale connection retry only fires for background agents, not CLI

Binary strings show two distinct code paths:

tengu_streaming_stale_connection_retry   ← exists for background agents
cli_nonstreaming_fallback_started        ← exists for CLI
Error streaming (non-streaming fallback disabled):

In practice, the CLI path does not recover — the error propagates directly to the user with
no retry. Background agents have a retry path; CLI sessions do not.

Finding 4: NODE_OPTIONS=--dns-result-order=ipv4first is not fully honored by Bun

After setting NODE_OPTIONS=--dns-result-order=ipv4first as a workaround, active connections
still include IPv6:

192.168.1.150:36660   → 160.79.104.10:443       ← IPv4 (api.anthropic.com)
[2804:...]:47658      → [2607:6bc0::10]:443      ← IPv6 (api.anthropic.com) — still present
[2804:...]:56560      → [2600:1901:0:3084::]:443 ← IPv6 (other Claude service)

Bun uses its own native DNS resolution for some code paths, bypassing Node.js's dns module
settings. The flag has partial effect only.

Finding 5: Error persists after forcing SO_KEEPALIVE via LD_PRELOAD

As a workaround, SO_KEEPALIVE was forced on all sockets via an LD_PRELOAD shim:

int socket(int domain, int type, int protocol) {
    int fd = orig_socket(domain, type, protocol);
    if (fd >= 0 && (type & 0xf) == SOCK_STREAM) {
        int opt = 1;
        setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));
    }
    return fd;
}

After applying this, ss -tnop confirms keepalive timers are now active on all claude sockets.
A stress test (two consecutive 90-second idle windows) passed successfully.

However, the error continued to occur during shorter tasks. This indicates a second,
independent cause: the Bun HTTP/2 client does not gracefully handle server-initiated connection
closure (HTTP/2 GOAWAY frame or TCP RST) and surfaces it as a fatal unrecoverable error
instead of transparently retrying on a new connection.

---

Reproduction

Run a long agentic task with multiple tool-call turns and significant idle time between turns:

claude --dangerously-skip-permissions -p "Run one at a time:
1. date
2. python3 -c \"import time; time.sleep(90); print('survived 90s')\"
3. python3 -c \"import time; time.sleep(90); print('survived second 90s')\"
4. date"

Without fixes: fails during step 2 or 3 with the socket error.
With LD_PRELOAD SO_KEEPALIVE fix: passes (3m21s, both sleeps survived).
During normal interactive agentic use: still fails intermittently even with the fix.

---

Suggested Fixes (for Anthropic)

Fix 1 — Set SO_KEEPALIVE on API sockets (one line, highest impact):

socket.setKeepAlive(true, 30000); // or equivalent in Bun's net API

This is the single most impactful change. Chrome and VS Code both do this; Claude Code does not.

Fix 2 — Enable heartbeat_interval_ms by default for CLI sessions:
The GrowthBook flag currently forces 0 for all keepalive intervals. CLI sessions should
have a non-zero default (e.g. 30s) independent of the server-side feature flag value.

Fix 3 — Implement transparent retry on stale connection for CLI:
The background agent path (tengu_streaming_stale_connection_retry) already handles this.
The CLI path should get the same treatment instead of surfacing a fatal error.

Fix 4 — Handle HTTP/2 GOAWAY gracefully:
When the server closes an HTTP/2 connection (normal lifecycle behavior), Bun should
transparently open a new connection and retry the request rather than propagating the
low-level socket error to the user.

Fix 5 — Improve the error message:
"pass verbose: true in the second argument to fetch()" is a raw Bun internal error.
Users cannot act on this. At minimum it should say the session can be resumed with --continue.

---

Workaround (for users until fixed)

Add to ~/.zshrc or ~/.bashrc:

export CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true
export BUN_CONFIG_HTTP_IDLE_TIMEOUT=300
export BUN_CONFIG_HTTP_RETRY_COUNT=3
export CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000
export NODE_OPTIONS="--dns-result-order=ipv4first"

For a more reliable fix, also apply the SO_KEEPALIVE shim:

cat > /tmp/ka.c << 'EOF'
#define _GNU_SOURCE
#include <stddef.h>
#include <sys/socket.h>
#include <dlfcn.h>
int socket(int domain, int type, int protocol) {
    static int (*orig)(int,int,int);
    if (!orig) orig = dlsym(RTLD_NEXT, "socket");
    int fd = orig(domain, type, protocol);
    if (fd >= 0 && (type & 0xf) == SOCK_STREAM) {
        int v = 1;
        setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &v, sizeof(v));
    }
    return fd;
}
EOF
gcc -shared -fPIC -O2 -o ~/.local/lib/libkeepalive.so /tmp/ka.c -ldl
echo 'export LD_PRELOAD="$HOME/.local/lib/libkeepalive.so${LD_PRELOAD:+:$LD_PRELOAD}"' >> ~/.zshrc

Also lower the TCP keepalive probe interval (requires sudo):

sudo sysctl -w net.ipv4.tcp_keepalive_time=60
echo "net.ipv4.tcp_keepalive_time=60" | sudo tee /etc/sysctl.d/99-claude-keepalive.conf

---

Evidence References

| Finding | Method | Confirmed |
|---|---|---|
| No SO_KEEPALIVE on claude sockets | ss -tnop live output | ✅ |
| All keepalives at 0ms | ~/.claude.json GrowthBook cache | ✅ |
| Binary is Bun 1.3.14 | strings on binary | ✅ |
| Error is Bun/JSC hardcoded string | strings on binary, line 42426 | ✅ |
| stale-connection retry missing for CLI | strings code path analysis | ✅ |
| IPv6 connections despite ipv4first flag | ss -tnop after applying NODE_OPTIONS | ✅ |
| Error persists after SO_KEEPALIVE forced | Live reproduction during diagnostic session | ✅ |
| 90s idle stress test passes with shim | Controlled test, two 90s windows | ✅ |

View original on GitHub ↗

22 Comments

github-actions[bot] · 1 month ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/47059
  2. https://github.com/anthropics/claude-code/issues/37078

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

brunos3d · 1 month ago

Cross-issue index + evidence that this is a server-side regression, not a user environment problem

After reviewing all related open issues and their workarounds, I want to make the case that this is a server-side regression affecting all users, and that the root cause and fix are now fully understood.

---

Related issues — same root cause, different symptoms

| Issue | Platform | Status | Workaround found by users |
|---|---|---|---|
| #5674 | macOS | Open (38 comments, since Aug 2025) | Cloudflare Warp VPN, MTU reduction, removing Tailscale |
| #28557 | macOS/cross | Open (10 comments) | Disabling IPv6, MTU to 1492 |
| #37077 | macOS | Open | None |
| #49761 | macOS | Open | None |
| #52899 | macOS | Open, stale | None |
| #54287 | cross | Closed as duplicate | None |
| #56017 | macOS | Open | None |
| #56059 | macOS | Open | Full system reboot |
| #60133 (this issue) | Linux | Open | Full root cause identified — see below |

Every single one of these is the same failure: an idle HTTP/2 TCP connection is silently dropped, and Claude Code crashes instead of reconnecting.

The reason they look like different problems (VPNs, MTU, IPv6, Docker, Tailscale) is that users are accidentally changing their network routing in ways that affect how quickly idle connections get dropped — not fixing the actual cause.

---

Why users think it's their environment (it isn't)

  • "Disabling IPv6 fixed it"api.anthropic.com resolves IPv6-first via getaddrinfo. IPv6 paths through home routers tend to have shorter stateful firewall idle timeouts than IPv4 NAT. Switching to IPv4 just buys more time before the same drop occurs.
  • "Lowering MTU to 1450/1492 fixed it" → Changes routing path selection, which incidentally picks a path with a longer idle timeout. Not a real fix.
  • "Removing Tailscale/TunnelBear fixed it" → Those VPNs set tcp.mssdflt=512, causing packet fragmentation. But Cloudflare Warp also fixes it — because Warp's tunnel enforces its own keepalive, compensating for the missing SO_KEEPALIVE in the Claude binary.
  • "Full reboot fixed it" → Clears TCP state tables and re-establishes fresh connections. Temporary fix only.
  • "Only happens on macOS, not Linux/Windows" → macOS has more aggressive TCP defaults (blackhole=1, shorter NAT timeouts) that expose the missing keepalive faster. On Linux the connection survives slightly longer, but the same drop eventually happens.

---

The actual root cause (confirmed via binary analysis on Linux)

The Claude Code binary (Bun 1.3.14) does not set SO_KEEPALIVE on its TCP sockets.

Confirmed live via ss -tnop:

# Every other process on the system:
chrome  → api:443   timer:(keepalive,16sec,0)   ← SO_KEEPALIVE ON
vscode  → api:443   timer:(keepalive,39sec,0)   ← SO_KEEPALIVE ON

# Claude Code:
claude  → api:443   (no timer)                  ← SO_KEEPALIVE NOT SET

Without SO_KEEPALIVE, the kernel never sends probe packets on idle connections. The HTTP/2 TCP connection is shared across multiple request/response cycles. Between API turns (while tools execute locally), the connection sits completely idle. After enough idle time, the server or any stateful device in the path silently closes it. Bun discovers this only when it tries to write to the dead socket → "socket connection was closed unexpectedly."

This is compounded by a server-side configuration found in ~/.claude.json (GrowthBook feature flags):

"heartbeat_interval_ms": 0,
"session_keepalive_interval_ms": 0,
"session_keepalive_interval_v2_ms": 0

All application-level keepalives are explicitly set to zero. The binary has the infrastructure for keepalives (CLAUDE_CODE_REMOTE_SEND_KEEPALIVES env var, heartbeat timer, session keepalive timer), but the server-side feature flags disable them entirely. This appears to be a regression introduced around v2.1.126→v2.1.143, consistent with @romancone's report that 2.1.126 worked and 2.1.143 does not — the binary code didn't change, the feature flags did.

---

Why this is Anthropic's fix to make (not users')

The fix on Anthropic's side is a one-liner:

socket.setKeepAlive(true, 30000);

Set SO_KEEPALIVE on the API socket. That's it. Chrome does it. VS Code does it. Every HTTP client library does it by default. The Claude binary doesn't.

Additionally:

  1. Re-enable heartbeat/keepalive feature flagsheartbeat_interval_ms: 0 removes the last fallback. Even a 30-second heartbeat would prevent the majority of these failures.
  2. Implement stale connection retry for CLI sessions — the binary already has tengu_streaming_stale_connection_retry for background agents. CLI sessions need the same path instead of surfacing a fatal unrecoverable error.
  3. Handle HTTP/2 GOAWAY gracefully — when the server closes an HTTP/2 connection (normal lifecycle), Bun should transparently reconnect rather than crashing the session.

---

Working workaround for all affected users (Linux and macOS)

# Add to ~/.zshrc or ~/.bashrc
export CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true
export BUN_CONFIG_HTTP_IDLE_TIMEOUT=300
export BUN_CONFIG_HTTP_RETRY_COUNT=3
export CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000
export NODE_OPTIONS="--dns-result-order=ipv4first"

For a complete OS-level fix that forces SO_KEEPALIVE on every TCP socket Claude opens:

# Build a small LD_PRELOAD shim (Linux; works on macOS with minor changes)
cat > /tmp/ka.c << 'CSRC'
#define _GNU_SOURCE
#include <stddef.h>
#include <sys/socket.h>
#include <dlfcn.h>
int socket(int domain, int type, int protocol) {
    static int (*orig)(int,int,int);
    if (!orig) orig = dlsym(RTLD_NEXT, "socket");
    int fd = orig(domain, type, protocol);
    if (fd >= 0 && (type & 0xf) == SOCK_STREAM) {
        int v = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &v, sizeof(v));
    }
    return fd;
}
CSRC
mkdir -p ~/.local/lib
gcc -shared -fPIC -O2 -o ~/.local/lib/libkeepalive.so /tmp/ka.c -ldl
echo 'export LD_PRELOAD="$HOME/.local/lib/libkeepalive.so${LD_PRELOAD:+:$LD_PRELOAD}"' >> ~/.zshrc

# Lower TCP keepalive probe interval
sudo sysctl -w net.ipv4.tcp_keepalive_time=60
echo "net.ipv4.tcp_keepalive_time=60" | sudo tee /etc/sysctl.d/99-claude-keepalive.conf

Verified: After applying this, all Claude sockets show timer:(keepalive,Xs,0) in ss -tnop, and two consecutive 90-second idle windows survive without error (3m21s stress test passed).

Full diagnostic report, binary analysis, evidence table, and all commands: #60133

jshaofa-ui · 1 month ago

Proposed Solution

Root Cause

Two independent causes:

  1. No SO_KEEPALIVE — Bun doesn't set keepalive on TCP sockets, idle connections are silently dropped
  2. No CLI stale-connection retry — Background agents have tengu_streaming_stale_connection_retry, CLI doesn't

Fix 1: Set SO_KEEPALIVE on API sockets (one line, highest impact)

socket.setKeepAlive(true, 30000); // 30s keepalive

Fix 2: Enable heartbeat keepalive by default for CLI

const defaultKeepalive = {
  heartbeat_interval_ms: 30000,      // was 0
  session_keepalive_interval_ms: 30000,
};

Fix 3: Implement transparent retry on stale connection for CLI

async function apiCallWithRetry(request: ApiRequest): Promise<ApiResponse> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try { return await apiCall(request); }
    catch (err) {
      if (isStaleConnectionError(err) && attempt < 2) {
        await resetConnection(); continue;
      }
      throw err;
    }
  }
}

Fix 4: Improve error message

Replace raw Bun error with actionable guidance: "Connection lost. Try claude --continue to resume."

Impact

  • Severity: High — breaks long agentic sessions
  • Users Affected: All CLI users with long-running sessions
  • Fix Complexity: Low-Medium (Fix 1 is one line)
  • Risk: Low — additive changes, retry is idempotent

Full solution: solutions/claude-code-60133-socket-connection-closed-fix.md

brunos3d · 1 month ago

@jshaofa-ui Your analysis is correct and aligns exactly with what we confirmed through live diagnostics on this issue.

A few notes from our investigation:

  • Fix 1 is the single highest-impact change — one call to socket.setKeepAlive(true, 30000) would resolve the majority of cases. Chrome and VS Code both do this; Claude Code does not.
  • Fix 2 is critical because even if the binary sets keepalives, the GrowthBook flags (heartbeat_interval_ms: 0, session_keepalive_interval_ms: 0) override everything at the application layer. CLI sessions need a non-zero default that is independent of server-side flag values.
  • Fix 3 is the proper long-term solution — the background agent path already has tengu_streaming_stale_connection_retry; the CLI path just propagates the error with no recovery.
  • Fix 4 is low-effort but important: the current message (pass verbose: true in the second argument to fetch()) is a raw Bun internal string that users cannot act on at all.

We also confirmed via ss -tnop that NODE_OPTIONS=--dns-result-order=ipv4first has partial effect only — Bun uses its own native DNS resolution for some code paths and bypasses the Node.js dns module entirely.

While Anthropic works on the proper fix, we've put together a user-side workaround script that applies all four mitigations (env vars, SO_KEEPALIVE LD_PRELOAD shim, sysctl tuning, persistence across reboots) for both Linux and macOS. Posting it in the next comment.

brunos3d · 1 month ago

User-side Workaround Scripts

⚠️ Disclaimer — Read before running These scripts make OS-level changes (environment variables, a compiled C shim loaded via LD_PRELOAD/DYLD_INSERT_LIBRARIES, and kernel TCP sysctl parameters). They are an advanced workaround for a confirmed bug that should be fixed in the Claude Code binary itself. Each user's environment is different — review every step before confirming it. Changes that are safe on a personal workstation may have unintended side effects on servers, containers, or systems with custom network stacks (VPNs, firewalls, custom routing). The scripts are fully reversible via the uninstall script below, but use at your own risk.

---

Two scripts — an installer and a full revert. Each phase is optional and prompts for Y/n before making any change.

Tested on: Arch Linux (Manjaro) kernel 6.12, Claude Code 2.1.143, Bun 1.3.14. macOS paths and compile flags are included but less battle-tested.

---

Install: claude-keepalive-fix.sh

# Download and run (Linux / macOS)
curl -fsSL https://gist.githubusercontent.com/brunos3d/66abd16661cfcfd4b5a943b4baf96f24/raw/claude-keepalive-fix.sh -o claude-keepalive-fix.sh
chmod +x claude-keepalive-fix.sh
./claude-keepalive-fix.sh
# Phase 3 and 4 will prompt for sudo — only for sysctl changes
Full gist (both scripts): https://gist.github.com/brunos3d/66abd16661cfcfd4b5a943b4baf96f24

What each phase does:

| Phase | Change | Reversible |
|-------|--------|-----------|
| 1 | Adds CLAUDE_CODE_REMOTE_SEND_KEEPALIVES, BUN_CONFIG_HTTP_IDLE_TIMEOUT, etc. to your shell config | ✅ by uninstall script |
| 2 | Compiles a ~15KB C shim that forces SO_KEEPALIVE=1 on every TCP socket via LD_PRELOAD | ✅ by uninstall script |
| 3 | Sets net.ipv4.tcp_keepalive_time=60 (Linux) or fixes mssdflt/blackhole (macOS) via sysctl | ✅ resets on reboot or via uninstall |
| 4 | Persists the sysctl value across reboots | ✅ by uninstall script |

<details>
<summary><strong>claude-keepalive-fix.sh (click to expand)</strong></summary>

#!/usr/bin/env bash
# =============================================================================
# claude-keepalive-fix.sh
# Workaround for: "API Error: The socket connection was closed unexpectedly"
# GitHub issue: https://github.com/anthropics/claude-code/issues/60133
#
# This script applies OS-level TCP keepalive fixes that compensate for
# the Claude Code binary (Bun 1.3.14) not setting SO_KEEPALIVE on its
# TCP sockets, combined with Anthropic's server-side keepalive flags
# being set to 0ms via GrowthBook feature flags.
#
# Supports: Linux, macOS
# Requires: gcc or clang, sudo (for sysctl phase only)
# =============================================================================

set -uo pipefail

# ── colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'

info()    { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC}   $*"; }
warn()    { echo -e "${YELLOW}[WARN]${NC} $*"; }
error()   { echo -e "${RED}[ERR]${NC}  $*"; }
step()    { echo -e "\n${BOLD}${BLUE}▶ $*${NC}"; }
dim()     { echo -e "${DIM}  $*${NC}"; }

ask() {
    local prompt="$1"
    echo -e "\n${BOLD}${YELLOW}?${NC} ${BOLD}${prompt}${NC} ${DIM}[Y/n]${NC} "
    read -r -n1 reply 2>/dev/null || read -r reply
    echo
    [[ "$reply" =~ ^[Yy]$ ]] || [[ -z "$reply" ]]
}

# ── detect OS ────────────────────────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"

if [[ "$OS" == "Darwin" ]]; then
    PLATFORM="macOS"
    LIB_EXT="dylib"
    LIB_FLAG="-dynamiclib"
    PRELOAD_VAR="DYLD_INSERT_LIBRARIES"
    CC="clang"
elif [[ "$OS" == "Linux" ]]; then
    PLATFORM="Linux"
    LIB_EXT="so"
    LIB_FLAG="-shared -fPIC"
    PRELOAD_VAR="LD_PRELOAD"
    CC="gcc"
else
    error "Unsupported OS: $OS. Only Linux and macOS are supported."
    exit 1
fi

# ── detect shell config ───────────────────────────────────────────────────────
detect_shell_config() {
    local shell_name
    shell_name="$(basename "${SHELL:-bash}")"
    if [[ "$shell_name" == "zsh" ]]; then
        echo "${ZDOTDIR:-$HOME}/.zshrc"
    elif [[ "$shell_name" == "bash" ]]; then
        if [[ "$PLATFORM" == "macOS" ]]; then
            echo "$HOME/.bash_profile"
        else
            echo "$HOME/.bashrc"
        fi
    else
        echo "$HOME/.profile"
    fi
}

SHELL_CONFIG="$(detect_shell_config)"
LIB_DIR="$HOME/.local/lib"
LIB_FILE="$LIB_DIR/libkeepalive.$LIB_EXT"
MARKER="# claude-keepalive-fix — managed block"

# ── header ───────────────────────────────────────────────────────────────────
echo
echo -e "${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║         Claude Code — Socket Keepalive Fix  v1.0            ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}"
echo
echo -e "  Platform : ${BOLD}$PLATFORM${NC} ($ARCH)"
echo -e "  Shell cfg: ${BOLD}$SHELL_CONFIG${NC}"
echo -e "  Library  : ${BOLD}$LIB_FILE${NC}"
echo
echo -e "  This script applies ${BOLD}4 independent fixes${NC}. Each one is optional"
echo -e "  and will ask for confirmation before making any change."
echo -e "  To revert everything, run: ${BOLD}claude-keepalive-uninstall.sh${NC}"
echo
echo -e "  Issue ref: ${DIM}https://github.com/anthropics/claude-code/issues/60133${NC}"
echo
echo -e "${YELLOW}  ⚠  Read each step before confirming. Some steps require sudo.${NC}"
echo

# ── phase 1: environment variables ───────────────────────────────────────────
step "Phase 1 of 4 — Environment variables"
echo
echo -e "  Adds the following exports to ${BOLD}$SHELL_CONFIG${NC}:"
echo
echo -e "  ${DIM}CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true${NC}"
dim "    Enables application-level session keepalives in Claude Code"
echo -e "  ${DIM}BUN_CONFIG_HTTP_IDLE_TIMEOUT=300${NC}"
dim "    Extends Bun's HTTP connection pool idle timeout to 5 minutes"
echo -e "  ${DIM}BUN_CONFIG_HTTP_RETRY_COUNT=3${NC}"
dim "    Enables automatic HTTP retry on connection failure"
echo -e "  ${DIM}CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000${NC}"
dim "    Extends the stream idle watchdog timeout to 2 minutes"
echo -e "  ${DIM}NODE_OPTIONS=--dns-result-order=ipv4first${NC}"
dim "    Prefers IPv4 over IPv6 for DNS results (more stable NAT path)"
echo

if ask "Apply environment variable fixes to $SHELL_CONFIG?"; then
    # Check if already applied
    if grep -q "$MARKER" "$SHELL_CONFIG" 2>/dev/null; then
        warn "Env vars block already present in $SHELL_CONFIG — skipping."
    else
        cat >> "$SHELL_CONFIG" << ENVBLOCK

$MARKER — begin
export CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true
export BUN_CONFIG_HTTP_IDLE_TIMEOUT=300
export BUN_CONFIG_HTTP_RETRY_COUNT=3
export CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000
export NODE_OPTIONS="--dns-result-order=ipv4first"
$MARKER — end
ENVBLOCK
        success "Environment variables written to $SHELL_CONFIG"
    fi
else
    warn "Skipped — env vars not applied."
fi

# ── phase 2: SO_KEEPALIVE shim ───────────────────────────────────────────────
step "Phase 2 of 4 — SO_KEEPALIVE LD_PRELOAD shim"
echo
echo -e "  Compiles a small C library (${BOLD}~15KB${NC}) that intercepts every"
echo -e "  TCP socket() call and forces ${BOLD}SO_KEEPALIVE=1${NC} before returning it."
echo
echo -e "  Without this, the OS kernel never sends keepalive probe packets"
echo -e "  on Claude's idle connections, even with tcp_keepalive_time set."
echo
echo -e "  The library is installed to: ${BOLD}$LIB_FILE${NC}"
echo -e "  ${PRELOAD_VAR} is added to your shell config so Claude picks it up."
echo
echo -e "  ${DIM}Requires: $CC${NC}"
echo

if ! command -v "$CC" &>/dev/null; then
    if [[ "$PLATFORM" == "macOS" ]] && command -v clang &>/dev/null; then
        CC="clang"
    else
        warn "$CC not found. Install build tools and re-run this phase."
        warn "  macOS: xcode-select --install"
        warn "  Debian/Ubuntu: sudo apt install gcc"
        warn "  Arch: sudo pacman -S gcc"
        warn "Skipping Phase 2."
        CC=""
    fi
fi

if [[ -n "${CC:-}" ]]; then
    if ask "Build and install the SO_KEEPALIVE shim?"; then
        mkdir -p "$LIB_DIR"

        # Write the C source
        cat > /tmp/claude_keepalive.c << 'CSRC'
#define _GNU_SOURCE
#include <stddef.h>
#include <sys/socket.h>
#include <dlfcn.h>

/*
 * Intercepts socket() and forces SO_KEEPALIVE on every SOCK_STREAM fd.
 * Installed via LD_PRELOAD (Linux) / DYLD_INSERT_LIBRARIES (macOS).
 * See: https://github.com/anthropics/claude-code/issues/60133
 */
int socket(int domain, int type, int protocol) {
    static int (*orig_socket)(int, int, int) = NULL;
    if (!orig_socket)
        orig_socket = dlsym(RTLD_NEXT, "socket");
    int fd = orig_socket(domain, type, protocol);
    if (fd >= 0 && (type & 0xf) == SOCK_STREAM) {
        int opt = 1;
        setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));
    }
    return fd;
}
CSRC

        # Compile
        if [[ "$PLATFORM" == "macOS" ]]; then
            $CC -dynamiclib -O2 -o "$LIB_FILE" /tmp/claude_keepalive.c 2>&1
        else
            $CC -shared -fPIC -O2 -o "$LIB_FILE" /tmp/claude_keepalive.c -ldl 2>&1
        fi

        if [[ -f "$LIB_FILE" ]]; then
            success "Library built: $LIB_FILE ($(du -sh "$LIB_FILE" | cut -f1))"

            # Add PRELOAD to shell config (inside the managed block if it exists, else append)
            if grep -q "$MARKER" "$SHELL_CONFIG" 2>/dev/null; then
                # Insert before the end marker
                if [[ "$PLATFORM" == "macOS" ]]; then
                    sed -i '' "/$MARKER — end/i\\
export ${PRELOAD_VAR}=\"\$HOME/.local/lib/libkeepalive.${LIB_EXT}\${${PRELOAD_VAR}:+:\$${PRELOAD_VAR}}\"
" "$SHELL_CONFIG"
                else
                    sed -i "/$MARKER — end/i export ${PRELOAD_VAR}=\"\$HOME/.local/lib/libkeepalive.${LIB_EXT}\${${PRELOAD_VAR}:+:\$${PRELOAD_VAR}}\"" "$SHELL_CONFIG"
                fi
            else
                cat >> "$SHELL_CONFIG" << PRELOADBLOCK

$MARKER — begin
export ${PRELOAD_VAR}="\$HOME/.local/lib/libkeepalive.${LIB_EXT}\${${PRELOAD_VAR}:+:\$${PRELOAD_VAR}}"
$MARKER — end
PRELOADBLOCK
            fi
            success "${PRELOAD_VAR} added to $SHELL_CONFIG"
        else
            error "Compilation failed. Check that $CC is properly installed."
        fi
        rm -f /tmp/claude_keepalive.c
    else
        warn "Skipped — SO_KEEPALIVE shim not installed."
    fi
fi

# ── phase 3: sysctl tcp_keepalive_time ───────────────────────────────────────
step "Phase 3 of 4 — TCP keepalive probe interval (requires sudo)"
echo
if [[ "$PLATFORM" == "Linux" ]]; then
    CURRENT_KA="$(cat /proc/sys/net/ipv4/tcp_keepalive_time 2>/dev/null || echo unknown)"
    echo -e "  Current ${BOLD}net.ipv4.tcp_keepalive_time${NC} = $CURRENT_KA seconds"
    echo
    echo -e "  This controls how long a TCP connection can be idle before the"
    echo -e "  kernel starts sending keepalive probe packets."
    echo -e "  The default is ${BOLD}120s${NC}. This script sets it to ${BOLD}60s${NC}."
    echo
    echo -e "  Only takes effect alongside Phase 2 (SO_KEEPALIVE shim)."
    echo -e "  ${DIM}Requires: sudo${NC}"
    echo
    if ask "Set tcp_keepalive_time=60 (requires sudo)?"; then
        sudo sysctl -w net.ipv4.tcp_keepalive_time=60
        success "tcp_keepalive_time set to 60 seconds (active now)"
    else
        warn "Skipped — tcp_keepalive_time not changed."
    fi
elif [[ "$PLATFORM" == "macOS" ]]; then
    CURRENT_MSS="$(sysctl -n net.inet.tcp.mssdflt 2>/dev/null || echo unknown)"
    CURRENT_BH="$(sysctl -n net.inet.tcp.blackhole 2>/dev/null || echo unknown)"
    echo -e "  Current ${BOLD}net.inet.tcp.mssdflt${NC} = $CURRENT_MSS  (should be 1440, not 512)"
    echo -e "  Current ${BOLD}net.inet.tcp.blackhole${NC} = $CURRENT_BH  (should be 0)"
    echo
    echo -e "  macOS has non-standard TCP defaults that worsen socket drops:"
    echo -e "  ${BOLD}mssdflt=512${NC} fragments packets (VPNs like Tailscale set this)"
    echo -e "  ${BOLD}blackhole=1${NC} silently drops packets instead of sending RST"
    echo
    echo -e "  This step corrects both values. ${DIM}Requires: sudo${NC}"
    echo
    if ask "Fix macOS TCP defaults (requires sudo)?"; then
        sudo sysctl -w net.inet.tcp.mssdflt=1440
        sudo sysctl -w net.inet.tcp.blackhole=0
        sudo sysctl -w net.inet.tcp.delayed_ack=0
        success "macOS TCP sysctl values corrected"
    else
        warn "Skipped — macOS TCP defaults not changed."
    fi
fi

# ── phase 4: persist sysctl across reboots ───────────────────────────────────
step "Phase 4 of 4 — Persist sysctl settings across reboots (requires sudo)"
echo
if [[ "$PLATFORM" == "Linux" ]]; then
    SYSCTL_FILE="/etc/sysctl.d/99-claude-keepalive.conf"
    echo -e "  Writes ${BOLD}$SYSCTL_FILE${NC} so the keepalive time"
    echo -e "  survives reboots. Without this, Phase 3 resets on next boot."
    echo -e "  ${DIM}Requires: sudo${NC}"
    echo
    if ask "Persist tcp_keepalive_time=60 to $SYSCTL_FILE?"; then
        echo "net.ipv4.tcp_keepalive_time=60" | sudo tee "$SYSCTL_FILE" > /dev/null
        success "Persisted to $SYSCTL_FILE"
    else
        warn "Skipped — sysctl will reset on next reboot."
    fi
elif [[ "$PLATFORM" == "macOS" ]]; then
    PLIST="/Library/LaunchDaemons/com.claude.tcp-tuning.plist"
    echo -e "  Creates a LaunchDaemon plist ${BOLD}$PLIST${NC}"
    echo -e "  to re-apply the TCP fixes on every boot. ${DIM}Requires: sudo${NC}"
    echo
    if ask "Create LaunchDaemon to persist macOS TCP fixes?"; then
        sudo tee "$PLIST" > /dev/null << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>       <string>com.claude.tcp-tuning</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/sbin/sysctl</string>
    <string>net.inet.tcp.mssdflt=1440</string>
    <string>net.inet.tcp.blackhole=0</string>
    <string>net.inet.tcp.delayed_ack=0</string>
  </array>
  <key>RunAtLoad</key>   <true/>
</dict>
</plist>
PLIST
        sudo launchctl load "$PLIST" 2>/dev/null || true
        success "LaunchDaemon installed: $PLIST"
    else
        warn "Skipped — sysctl will reset on next reboot."
    fi
fi

# ── done ─────────────────────────────────────────────────────────────────────
echo
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║                      All phases complete                     ║${NC}"
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo
echo -e "  ${BOLD}To activate in the current terminal:${NC}"
echo -e "  ${DIM}source $SHELL_CONFIG${NC}"
echo
echo -e "  ${BOLD}To verify the fix is working:${NC}"
echo -e "  ${DIM}ss -tnop state established '( dport = :443 )' | grep claude${NC}"
echo -e "  ${DIM}→ You should see timer:(keepalive,...) on all claude connections${NC}"
echo
echo -e "  ${BOLD}To revert all changes:${NC}"
echo -e "  ${DIM}bash claude-keepalive-uninstall.sh${NC}"
echo
echo -e "  ${DIM}Issue: https://github.com/anthropics/claude-code/issues/60133${NC}"
echo

</details>

---

Uninstall / Revert: claude-keepalive-uninstall.sh

Run this if Anthropic ships a fix or if you want to roll back all changes:

curl -fsSL https://gist.githubusercontent.com/brunos3d/66abd16661cfcfd4b5a943b4baf96f24/raw/claude-keepalive-uninstall.sh -o claude-keepalive-uninstall.sh
chmod +x claude-keepalive-uninstall.sh
./claude-keepalive-uninstall.sh
# Phase 3 will prompt for sudo to reset sysctl and remove the persist file

<details>
<summary><strong>claude-keepalive-uninstall.sh (click to expand)</strong></summary>

UN#!/usr/bin/env bash
# =============================================================================
# claude-keepalive-fix.sh
# Workaround for: "API Error: The socket connection was closed unexpectedly"
# GitHub issue: https://github.com/anthropics/claude-code/issues/60133
#
# This script applies OS-level TCP keepalive fixes that compensate for
# the Claude Code binary (Bun 1.3.14) not setting SO_KEEPALIVE on its
# TCP sockets, combined with Anthropic's server-side keepalive flags
# being set to 0ms via GrowthBook feature flags.
#
# Supports: Linux, macOS
# Requires: gcc or clang, sudo (for sysctl phase only)
# =============================================================================

set -uo pipefail

# ── colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'

info()    { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC}   $*"; }
warn()    { echo -e "${YELLOW}[WARN]${NC} $*"; }
error()   { echo -e "${RED}[ERR]${NC}  $*"; }
step()    { echo -e "\n${BOLD}${BLUE}▶ $*${NC}"; }
dim()     { echo -e "${DIM}  $*${NC}"; }

ask() {
    local prompt="$1"
    echo -e "\n${BOLD}${YELLOW}?${NC} ${BOLD}${prompt}${NC} ${DIM}[Y/n]${NC} "
    read -r -n1 reply 2>/dev/null || read -r reply
    echo
    [[ "$reply" =~ ^[Yy]$ ]] || [[ -z "$reply" ]]
}

# ── detect OS ────────────────────────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"

if [[ "$OS" == "Darwin" ]]; then
    PLATFORM="macOS"
    LIB_EXT="dylib"
    LIB_FLAG="-dynamiclib"
    PRELOAD_VAR="DYLD_INSERT_LIBRARIES"
    CC="clang"
elif [[ "$OS" == "Linux" ]]; then
    PLATFORM="Linux"
    LIB_EXT="so"
    LIB_FLAG="-shared -fPIC"
    PRELOAD_VAR="LD_PRELOAD"
    CC="gcc"
else
    error "Unsupported OS: $OS. Only Linux and macOS are supported."
    exit 1
fi

# ── detect shell config ───────────────────────────────────────────────────────
detect_shell_config() {
    local shell_name
    shell_name="$(basename "${SHELL:-bash}")"
    if [[ "$shell_name" == "zsh" ]]; then
        echo "${ZDOTDIR:-$HOME}/.zshrc"
    elif [[ "$shell_name" == "bash" ]]; then
        if [[ "$PLATFORM" == "macOS" ]]; then
            echo "$HOME/.bash_profile"
        else
            echo "$HOME/.bashrc"
        fi
    else
        echo "$HOME/.profile"
    fi
}

SHELL_CONFIG="$(detect_shell_config)"
LIB_DIR="$HOME/.local/lib"
LIB_FILE="$LIB_DIR/libkeepalive.$LIB_EXT"
MARKER="# claude-keepalive-fix — managed block"

# ── header ───────────────────────────────────────────────────────────────────
echo
echo -e "${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║         Claude Code — Socket Keepalive Fix  v1.0            ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}"
echo
echo -e "  Platform : ${BOLD}$PLATFORM${NC} ($ARCH)"
echo -e "  Shell cfg: ${BOLD}$SHELL_CONFIG${NC}"
echo -e "  Library  : ${BOLD}$LIB_FILE${NC}"
echo
echo -e "  This script applies ${BOLD}4 independent fixes${NC}. Each one is optional"
echo -e "  and will ask for confirmation before making any change."
echo -e "  To revert everything, run: ${BOLD}claude-keepalive-uninstall.sh${NC}"
echo
echo -e "  Issue ref: ${DIM}https://github.com/anthropics/claude-code/issues/60133${NC}"
echo
echo -e "${YELLOW}  ⚠  Read each step before confirming. Some steps require sudo.${NC}"
echo

# ── phase 1: environment variables ───────────────────────────────────────────
step "Phase 1 of 4 — Environment variables"
echo
echo -e "  Adds the following exports to ${BOLD}$SHELL_CONFIG${NC}:"
echo
echo -e "  ${DIM}CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true${NC}"
dim "    Enables application-level session keepalives in Claude Code"
echo -e "  ${DIM}BUN_CONFIG_HTTP_IDLE_TIMEOUT=300${NC}"
dim "    Extends Bun's HTTP connection pool idle timeout to 5 minutes"
echo -e "  ${DIM}BUN_CONFIG_HTTP_RETRY_COUNT=3${NC}"
dim "    Enables automatic HTTP retry on connection failure"
echo -e "  ${DIM}CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000${NC}"
dim "    Extends the stream idle watchdog timeout to 2 minutes"
echo -e "  ${DIM}NODE_OPTIONS=--dns-result-order=ipv4first${NC}"
dim "    Prefers IPv4 over IPv6 for DNS results (more stable NAT path)"
echo

if ask "Apply environment variable fixes to $SHELL_CONFIG?"; then
    # Check if already applied
    if grep -q "$MARKER" "$SHELL_CONFIG" 2>/dev/null; then
        warn "Env vars block already present in $SHELL_CONFIG — skipping."
    else
        cat >> "$SHELL_CONFIG" << ENVBLOCK

$MARKER — begin
export CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true
export BUN_CONFIG_HTTP_IDLE_TIMEOUT=300
export BUN_CONFIG_HTTP_RETRY_COUNT=3
export CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000
export NODE_OPTIONS="--dns-result-order=ipv4first"
$MARKER — end
ENVBLOCK
        success "Environment variables written to $SHELL_CONFIG"
    fi
else
    warn "Skipped — env vars not applied."
fi

# ── phase 2: SO_KEEPALIVE shim ───────────────────────────────────────────────
step "Phase 2 of 4 — SO_KEEPALIVE LD_PRELOAD shim"
echo
echo -e "  Compiles a small C library (${BOLD}~15KB${NC}) that intercepts every"
echo -e "  TCP socket() call and forces ${BOLD}SO_KEEPALIVE=1${NC} before returning it."
echo
echo -e "  Without this, the OS kernel never sends keepalive probe packets"
echo -e "  on Claude's idle connections, even with tcp_keepalive_time set."
echo
echo -e "  The library is installed to: ${BOLD}$LIB_FILE${NC}"
echo -e "  ${PRELOAD_VAR} is added to your shell config so Claude picks it up."
echo
echo -e "  ${DIM}Requires: $CC${NC}"
echo

if ! command -v "$CC" &>/dev/null; then
    if [[ "$PLATFORM" == "macOS" ]] && command -v clang &>/dev/null; then
        CC="clang"
    else
        warn "$CC not found. Install build tools and re-run this phase."
        warn "  macOS: xcode-select --install"
        warn "  Debian/Ubuntu: sudo apt install gcc"
        warn "  Arch: sudo pacman -S gcc"
        warn "Skipping Phase 2."
        CC=""
    fi
fi

if [[ -n "${CC:-}" ]]; then
    if ask "Build and install the SO_KEEPALIVE shim?"; then
        mkdir -p "$LIB_DIR"

        # Write the C source
        cat > /tmp/claude_keepalive.c << 'CSRC'
#define _GNU_SOURCE
#include <stddef.h>
#include <sys/socket.h>
#include <dlfcn.h>

/*
 * Intercepts socket() and forces SO_KEEPALIVE on every SOCK_STREAM fd.
 * Installed via LD_PRELOAD (Linux) / DYLD_INSERT_LIBRARIES (macOS).
 * See: https://github.com/anthropics/claude-code/issues/60133
 */
int socket(int domain, int type, int protocol) {
    static int (*orig_socket)(int, int, int) = NULL;
    if (!orig_socket)
        orig_socket = dlsym(RTLD_NEXT, "socket");
    int fd = orig_socket(domain, type, protocol);
    if (fd >= 0 && (type & 0xf) == SOCK_STREAM) {
        int opt = 1;
        setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));
    }
    return fd;
}
CSRC

        # Compile
        if [[ "$PLATFORM" == "macOS" ]]; then
            $CC -dynamiclib -O2 -o "$LIB_FILE" /tmp/claude_keepalive.c 2>&1
        else
            $CC -shared -fPIC -O2 -o "$LIB_FILE" /tmp/claude_keepalive.c -ldl 2>&1
        fi

        if [[ -f "$LIB_FILE" ]]; then
            success "Library built: $LIB_FILE ($(du -sh "$LIB_FILE" | cut -f1))"

            # Add PRELOAD to shell config (inside the managed block if it exists, else append)
            if grep -q "$MARKER" "$SHELL_CONFIG" 2>/dev/null; then
                # Insert before the end marker
                if [[ "$PLATFORM" == "macOS" ]]; then
                    sed -i '' "/$MARKER — end/i\\
export ${PRELOAD_VAR}=\"\$HOME/.local/lib/libkeepalive.${LIB_EXT}\${${PRELOAD_VAR}:+:\$${PRELOAD_VAR}}\"
" "$SHELL_CONFIG"
                else
                    sed -i "/$MARKER — end/i export ${PRELOAD_VAR}=\"\$HOME/.local/lib/libkeepalive.${LIB_EXT}\${${PRELOAD_VAR}:+:\$${PRELOAD_VAR}}\"" "$SHELL_CONFIG"
                fi
            else
                cat >> "$SHELL_CONFIG" << PRELOADBLOCK

$MARKER — begin
export ${PRELOAD_VAR}="\$HOME/.local/lib/libkeepalive.${LIB_EXT}\${${PRELOAD_VAR}:+:\$${PRELOAD_VAR}}"
$MARKER — end
PRELOADBLOCK
            fi
            success "${PRELOAD_VAR} added to $SHELL_CONFIG"
        else
            error "Compilation failed. Check that $CC is properly installed."
        fi
        rm -f /tmp/claude_keepalive.c
    else
        warn "Skipped — SO_KEEPALIVE shim not installed."
    fi
fi

# ── phase 3: sysctl tcp_keepalive_time ───────────────────────────────────────
step "Phase 3 of 4 — TCP keepalive probe interval (requires sudo)"
echo
if [[ "$PLATFORM" == "Linux" ]]; then
    CURRENT_KA="$(cat /proc/sys/net/ipv4/tcp_keepalive_time 2>/dev/null || echo unknown)"
    echo -e "  Current ${BOLD}net.ipv4.tcp_keepalive_time${NC} = $CURRENT_KA seconds"
    echo
    echo -e "  This controls how long a TCP connection can be idle before the"
    echo -e "  kernel starts sending keepalive probe packets."
    echo -e "  The default is ${BOLD}120s${NC}. This script sets it to ${BOLD}60s${NC}."
    echo
    echo -e "  Only takes effect alongside Phase 2 (SO_KEEPALIVE shim)."
    echo -e "  ${DIM}Requires: sudo${NC}"
    echo
    if ask "Set tcp_keepalive_time=60 (requires sudo)?"; then
        sudo sysctl -w net.ipv4.tcp_keepalive_time=60
        success "tcp_keepalive_time set to 60 seconds (active now)"
    else
        warn "Skipped — tcp_keepalive_time not changed."
    fi
elif [[ "$PLATFORM" == "macOS" ]]; then
    CURRENT_MSS="$(sysctl -n net.inet.tcp.mssdflt 2>/dev/null || echo unknown)"
    CURRENT_BH="$(sysctl -n net.inet.tcp.blackhole 2>/dev/null || echo unknown)"
    echo -e "  Current ${BOLD}net.inet.tcp.mssdflt${NC} = $CURRENT_MSS  (should be 1440, not 512)"
    echo -e "  Current ${BOLD}net.inet.tcp.blackhole${NC} = $CURRENT_BH  (should be 0)"
    echo
    echo -e "  macOS has non-standard TCP defaults that worsen socket drops:"
    echo -e "  ${BOLD}mssdflt=512${NC} fragments packets (VPNs like Tailscale set this)"
    echo -e "  ${BOLD}blackhole=1${NC} silently drops packets instead of sending RST"
    echo
    echo -e "  This step corrects both values. ${DIM}Requires: sudo${NC}"
    echo
    if ask "Fix macOS TCP defaults (requires sudo)?"; then
        sudo sysctl -w net.inet.tcp.mssdflt=1440
        sudo sysctl -w net.inet.tcp.blackhole=0
        sudo sysctl -w net.inet.tcp.delayed_ack=0
        success "macOS TCP sysctl values corrected"
    else
        warn "Skipped — macOS TCP defaults not changed."
    fi
fi

# ── phase 4: persist sysctl across reboots ───────────────────────────────────
step "Phase 4 of 4 — Persist sysctl settings across reboots (requires sudo)"
echo
if [[ "$PLATFORM" == "Linux" ]]; then
    SYSCTL_FILE="/etc/sysctl.d/99-claude-keepalive.conf"
    echo -e "  Writes ${BOLD}$SYSCTL_FILE${NC} so the keepalive time"
    echo -e "  survives reboots. Without this, Phase 3 resets on next boot."
    echo -e "  ${DIM}Requires: sudo${NC}"
    echo
    if ask "Persist tcp_keepalive_time=60 to $SYSCTL_FILE?"; then
        echo "net.ipv4.tcp_keepalive_time=60" | sudo tee "$SYSCTL_FILE" > /dev/null
        success "Persisted to $SYSCTL_FILE"
    else
        warn "Skipped — sysctl will reset on next reboot."
    fi
elif [[ "$PLATFORM" == "macOS" ]]; then
    PLIST="/Library/LaunchDaemons/com.claude.tcp-tuning.plist"
    echo -e "  Creates a LaunchDaemon plist ${BOLD}$PLIST${NC}"
    echo -e "  to re-apply the TCP fixes on every boot. ${DIM}Requires: sudo${NC}"
    echo
    if ask "Create LaunchDaemon to persist macOS TCP fixes?"; then
        sudo tee "$PLIST" > /dev/null << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>       <string>com.claude.tcp-tuning</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/sbin/sysctl</string>
    <string>net.inet.tcp.mssdflt=1440</string>
    <string>net.inet.tcp.blackhole=0</string>
    <string>net.inet.tcp.delayed_ack=0</string>
  </array>
  <key>RunAtLoad</key>   <true/>
</dict>
</plist>
PLIST
        sudo launchctl load "$PLIST" 2>/dev/null || true
        success "LaunchDaemon installed: $PLIST"
    else
        warn "Skipped — sysctl will reset on next reboot."
    fi
fi

# ── done ─────────────────────────────────────────────────────────────────────
echo
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║                      All phases complete                     ║${NC}"
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo
echo -e "  ${BOLD}To activate in the current terminal:${NC}"
echo -e "  ${DIM}source $SHELL_CONFIG${NC}"
echo
echo -e "  ${BOLD}To verify the fix is working:${NC}"
echo -e "  ${DIM}ss -tnop state established '( dport = :443 )' | grep claude${NC}"
echo -e "  ${DIM}→ You should see timer:(keepalive,...) on all claude connections${NC}"
echo
echo -e "  ${BOLD}To revert all changes:${NC}"
echo -e "  ${DIM}bash claude-keepalive-uninstall.sh${NC}"
echo
echo -e "  ${DIM}Issue: https://github.com/anthropics/claude-code/issues/60133${NC}"
echo

</details>

---

After running the install script, open a new terminal (or source ~/.zshrc) and verify with:

# Confirm keepalive timers are active on claude connections
ss -tnop state established '( dport = :443 )' | grep claude
# Expected: timer:(keepalive,Xs,0) on each line

Stress test to confirm it works:

claude --dangerously-skip-permissions -p "Run one at a time:
1. date
2. python3 -c \"import time; time.sleep(90); print('survived 90s')\"
3. python3 -c \"import time; time.sleep(90); print('survived second 90s')\"
4. date"
# Should complete all 4 steps (~3 min 20 sec total) without socket errors
quboliu · 1 month ago

Thanks for your analysis.

cubicYYY · 1 month ago

Still not fixed

searchingtofind · 1 month ago

Confirming this reproduces on macOS 26.2 Tahoe (Darwin 25.2.0), Claude Code 2.1.145, with the full mitigation set from this thread applied:

  • Kernel SO_KEEPALIVE shim via net.inet.tcp.always_keepalive=1, keepidle=60000, keepintvl=30000 (macOS equivalent of the Linux LD_PRELOAD shim — no binary injection needed)
  • Persisted across reboots via LaunchDaemon
  • All recommended env vars: CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true, BUN_CONFIG_HTTP_IDLE_TIMEOUT=300, BUN_CONFIG_HTTP_RETRY_COUNT=3, CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000
  • MTU/MSS fixes (mssdflt=1440, delayed_ack=0, blackhole=0)
  • NODE_OPTIONS=--dns-result-order=ipv4first deliberately omitted (breaks Happy Eyeballs on macOS)

Verified the local network path is healthy: 0/10 failures on both IPv4 (160.79.104.10) and IPv6 (2607:6bc0::10) sustained tests, all responses ~50ms.

Idle-drop failures are gone, but ECONNRESET mid-session continues — matching Finding 5 (Bun HTTP/2 not handling server-initiated GOAWAY/RST). Symptom is constant retry loops during normal active agentic work, not just long-idle sessions.

Two notes for triage:

  1. The \platform:linux\ label is misleading — this affects macOS identically once the user has exhausted kernel-level workarounds. Suggest broadening to \platform:macos\ as well or removing the platform restriction.
  2. The userspace mitigation surface is now fully exhausted. Anything short of a CLI-side \tengu_streaming_stale_connection_retry\ path or graceful \GOAWAY\ handling in the Bun fetch wrapper leaves this unresolved.
brunos3d · 1 month ago

Yeah folks, my workarounds does not fix it completely, but it’s because of an Anthropic API internal issue. I was actually the one who originally reported this problem to Anthropic.

I strongly suspect this issue may only be affecting PRO users. One of the reasons I believe that is because the exact same behavior also happens retroactively on older Claude Code CLI versions, which makes it feel less like a recent client-side regression and more like something happening at the API or infrastructure layer.

What also makes this suspicious to me is that there should not really be a reason for API response behavior and runtime instability to differ this drastically between subscription tiers under normal circumstances.

Anthropic acquired Bun in December of last year:
https://www.anthropic.com/news/anthropic-acquires-bun-as-claude-code-reaches-usd1b-milestone

Initially, the reported failures looked like standard Node.js runtime errors, but more recently the stack traces and failure patterns have started resembling Bun-specific behavior.

Of course, this could still be a simple engineering mistake, infrastructure regression, or an unintended side effect of internal changes. But I have my doubts. I’m curious how many people here are actually PRO users, because if this issue is confirmed to disproportionately affect PRO accounts, then this could become a major community concern.

Important note: this is ultimately my personal opinion and speculation based on my observations. I may be completely wrong. However, what is evident is that reports about this problem appear to have been dismissed since last year, back when the runtime behavior looked Node.js-related, and now again while the behavior appears more consistent with Bun.

cc: @cubicYYY @muxunting @searchingtofind @jshaofa-ui

searchingtofind · 1 month ago

I have a max subscription, and this hit me suddenly, and across multiple long persisting sessions, without any anthropic side update pushed, which is, odd, to say the least. I have been using max subscription for 10 months, without anything like this happening. With The third party API changes coming mid june... it did feel targeted for some reason...

brunos3d · 1 month ago

Yeah @searchingtofind bro, same feeling.

ianmedeiros · 1 month ago

Adding some complementary evidence in case it's useful — independently hit the same symptom on macOS (Brazilian residential ISP) and went through a similar investigation. Used Claude Code itself to help dig through pcaps and traces, so flagging upfront that some of my interpretation may be AI-shaped pattern-matching rather than ground truth.

What I can add on top of @brunos3d analysis

1. Packet-level evidence that some mid-stream breaks are middlebox-injected, not just lifecycle-related

I ran tcpdump of api.anthropic.com traffic in parallel with claude --debug --debug-file across several sessions. The mid-stream socket closes that survive Fix 1 (SO_KEEPALIVE) and would need Fix 3/4 to handle are, in my case at least, spoofed RSTs from a middlebox on the network path, not HTTP/2 GOAWAY or natural server-side closure.

Packet excerpt from one of the breaks (IPv4 path, request 64655465-9cbb-4691-beea-8b87a80ed18d, 2026-05-24T22:39:14Z):

19:39:14.644312  server → client  Flags [P.], seq 23620:23712     ← real data
19:39:14.644411  client → server  Flags [.],  ack 23712            ← client ACKs
19:39:14.651840  server → client  Flags [R],  seq 657,377,615     ← RST, sequence number ~27,000× off the legitimate flow
19:39:15.123460  server → client  Flags [P.], seq 23712:23800     ← server STILL sends real data
19:39:15.772036  server → client  Flags [P.], seq 23800:23881     ← server STILL sends real data

Two impossible-if-real-server fingerprints:

  • The RST's sequence number is wildly out of range vs the active flow (657 million while the real flow seq is 23,712).
  • The "server" keeps retransmitting real data segments AFTER the alleged RST. A real server would have closed the socket.

Same fingerprint reproduced on the IPv6 path (request 8a91f305-6768-4721-bf2f-4d7bf63c5313, RST seq ~1.95 billion vs legitimate flow at 8,516).

This is presumably an ISP DPI box on my path — not Anthropic's fault. But it strengthens the case for Fix 3 (transparent retry on the CLI path), because the underlying network event isn't a server lifecycle signal but a forged packet from a middlebox the user can't see or control. Users on flaky networks are going to keep hitting this regardless of any server-side fix.

2. Negative results on two of the recommended workaround env vars

In case other users see them in your workaround stack and assume they're helping:

  • BUN_CONFIG_HTTP_RETRY_COUNT=10 — I set this and ran a full session with BUN_CONFIG_VERBOSE_FETCH=curl capturing every fetch. The verbose log shows exactly one fetch attempt per request ID, even for the failing ones. The env var appears to only govern Bun's internal HTTP client (e.g., bun install), not user-level fetch(). So setting it doesn't add fetch-layer retries.
  • CLAUDE_CODE_MAX_RETRIES=50 — for the session-breaking ECONNRESET path I'm seeing, the SDK never logs an "attempt N/M" line. The retry path isn't being entered at all for this error class, so increasing the max doesn't matter. (Consistent with your Finding 3 — the CLI path doesn't have the retry treatment that background agents do.)

The other env vars in your workaround stack (CLAUDE_CODE_REMOTE_SEND_KEEPALIVES, BUN_CONFIG_HTTP_IDLE_TIMEOUT, CLAUDE_STREAM_IDLE_TIMEOUT_MS, NODE_OPTIONS=--dns-result-order=ipv4first) I haven't tested yet — going to try them next.

3. BUN_CONFIG_VERBOSE_FETCH=curl works, with a security caveat

For anyone else trying to instrument this: the env var IS honored in the compiled binary, but the output goes to stderr, not stdout — easy to miss if you only redirect stdout. It produces full curl-style request output including Authorization: Bearer sk-ant-... headers verbatim. Anyone capturing this output for sharing should pipe through sed to redact tokens before they hit disk.

4. Address family observations

I tried disabling IPv6 on the active network service as a workaround. It appeared to help on first capture, but a follow-up session with verified-zero IPv6 packets (tcpdump confirmed) hit the same middlebox-RST fingerprint on the IPv4 path. So at least on my network, address family isn't the variable. Possibly relevant to anyone wondering whether --dns-result-order=ipv4first would help — it might not, depending on whether the middlebox sits on both paths.

Also confirmed @brunos3d's observation that Bun appears to bypass /etc/hosts for some fetches — I saw IPv6 SYNs in tcpdump output despite having api.anthropic.com pinned to an IPv4 in hosts.

Net

Strong +1 on the proposed fixes, particularly Fix 1 (SO_KEEPALIVE) and Fix 3 (CLI retry treatment). Happy to share full packet captures and debug traces privately with anyone on the team who wants them — they contain account info I'd rather not post publicly.

Specific request IDs from my captures, for server-side log correlation if useful:

  • 8a91f305-6768-4721-bf2f-4d7bf63c5313 — 2026-05-24T21:34:02Z (IPv6 path)
  • 64655465-9cbb-4691-beea-8b87a80ed18d — 2026-05-24T22:39:14Z (IPv4 path)
  • a4c16288-54be-4eaf-82a6-ee8cd97c55ae — 2026-05-24T23:11:09Z
  • d2d83bb1-cc44-4bfe-89fa-d1e26e370b29 — 2026-05-24T23:13:08Z
brunos3d · 1 month ago

<img width="1368" height="446" alt="Image" src="https://github.com/user-attachments/assets/c13c1c68-ec5d-4ba8-8a16-0a3fa801ee9a" />

This screenshot was taken during a real workflow session today, and it seems to reinforce the same hypothesis again.

The connection consistently fails around ~28 seconds with the exact same socket error every time:

“API Error: The socket connection was closed unexpectedly.”

The timing consistency is what stands out the most here. It really looks like some kind of ~30s connection/socket timeout happening somewhere between the CLI and the backend infrastructure.

I’m sharing this mostly as additional debugging evidence in case the Anthropic team is actively investigating the issue. The pattern seems too consistent to be random.

After some retries with the same prompt, it worked, but tokens were wasted.

mosjin · 1 month ago

I have also encountered this error very frequently: Claude Code v2.1.168 as below, so I need manually go on my current work frequently:

● API Error: The socket connection was closed unexpectedly. For more information, pass verbose: true in the second argument to fetch()

ningbochongde · 1 month ago

There is a high probability of occurrence when performing tasks involving deep research or multiple function calls

j-ts · 1 month ago

I'm getting this error in both Claude CLI 2.1.170 and Claude App 1.11847.5 (9692f0) on windows platform.

mnrsiat · 1 month ago

Getting this error regularly on Claude cli 2.1.177 on MacOS for a couple weeks now. I am going to attempt the workaround script provided by brunos3d (thanks @brunos3d !) and will report back on my results

nicolasesprit · 1 month ago

Reproducing on macOS Apple Silicon — IPv6 was one factor, but a second transport-independent cause remains

Environment

  • Claude Code: 2.1.177
  • OS: macOS 26.3.1, Apple Silicon (M4)
  • Network: residential fiber, no proxy, no VPN, dual-stack

Symptom

The socket connection was closed unexpectedly after an idle window (~30 min, session left open) and during normal use. Session must be restarted (or resumed with --continue).

Finding #4 confirmed. With NODE_OPTIONS=--dns-result-order=ipv4first set and verified, lsof showed every Claude connection still using IPv6 — the flag is ignored by Bun's native DNS resolver:
claude.ex IPv6 TCP [...]->[2607:6bc0::10]:443 (ESTABLISHED) # api.anthropic.com

claude.ex IPv6 TCP [...]->[2600:1901:0:3084::]:443 (ESTABLISHED)

No keepalive timer on any socket. The DYLD_INSERT_LIBRARIES SO_KEEPALIVE shim does not work on Apple Silicon — SIP/hardened-runtime blocks injection into signed binaries, so the macOS workaround path is unavailable.

Two superimposed causes observed:

  1. Forcing IPv4 at the OS level (networksetup -setv6off Wi-Fi, confirmed via lsof → all connections now 160.79.104.10:443) noticeably increased time-to-failure — idle sessions survive much longer. So the IPv6 path is one trigger.
  2. The error still recurs on IPv4 after enough idle/normal use. This is transport-independent and matches the report's second cause: Bun's HTTP/2 client surfacing a server-initiated close (GOAWAY / RST) as a fatal error instead of transparently retrying on a new connection.

Workarounds applied (all active, none fully prevent it): CLAUDE_CODE_REMOTE_SEND_KEEPALIVES=true, BUN_CONFIG_HTTP_IDLE_TIMEOUT=300, BUN_CONFIG_HTTP_RETRY_COUNT=3, CLAUDE_STREAM_IDLE_TIMEOUT_MS=120000, NODE_OPTIONS=--dns-result-order=ipv4first, macOS sysctl mssdflt=1440 / blackhole=0 / delayed_ack=0.

For macOS the OS-level shim path is a dead end (SIP). The two fixes that would actually help here are application-level: Fix 2 (non-zero heartbeat_interval_ms default for CLI sessions) and Fix 4 (transparent retry on HTTP/2 GOAWAY). The latter is the only thing that addresses the residual IPv4 failures.

mnrsiat · 24 days ago
Getting this error regularly on Claude cli 2.1.177 on MacOS for a couple weeks now. I am going to attempt the workaround script provided by brunos3d (thanks @brunos3d !) and will report back on my results

The results are unclear. I have not seen the exact "socket closed unexpectedly" error again, so possibly this specific issue is suppressed. I have seen several similar errors, both with API dropped types of messages and also interruptions due to sleep, which may have been due to a caffeinate problem that surely confused the issue. I think it is worth trying on YOUR system though.

Update - since fixing my caffeinate problem I have had no issues with long runs of 6 or 7 hours Claude running unattended. Strong evidence the workarounds @brunos3d created are effective.

mnrsiat · 24 days ago

Naive question I expect, but why doesn't the CLI just open a new connection if the existing one has died?

ejosess · 23 days ago

"Coding is largely solved", they say.

1 year of terminal Flickring and this GitHub issue both prove otherwise. Amazingly, Anthropic can't make long-running tasks work in MacOS even tho they say "coding is largely solved". Ok, so can't you ask for Mythos to fix this? OMG.

After weeks of ponderation and wasted tokens, I just cancelled my Anthropic subscription. And when the company I work for recently asked me to choose between Anthropic and OpenAI, you know what the choice was. And all of the team members followed. $200 per team member going to OpenAI instead of Anthropic because of a stupid, very annoying bug that could be easily fixed if they even cared.

JCSopko · 14 days ago

Been following this since filing #62146 back in May (closed by the stale-bot on 2026-06-28 for inactivity — not resolved, so consolidating the follow-up here rather than reopening a separate thread). Have two things that might help triage: real before/after data on the env-var workaround, and a new failure shape that doesn't show up in the single-session case this issue mostly discusses.

The env-var workaround actually works — here's the data

Saw @mnrsiat's "the results are unclear" above. We ran a controlled measurement on the two most directly-applicable knobs and have a clean answer.

Applied 2026-05-27/31:

ANTHROPIC_LOG=debug
BUN_CONFIG_HTTP_IDLE_TIMEOUT=300
BUN_CONFIG_HTTP_RETRY_COUNT=3

(via .claude/settings.local.json's env block — targets 2 of the 5 mechanisms from the original report: idle-timeout handling and retry count.)

Socket-disconnect corpus, before/after (our own tracked incidents, not a public sample):

| Window | Range | socket_closed count |
|---|---|---|
| Pre-apply baseline | 7 days, 2026-05-24 → 05-31 | 40 |
| Post-apply | first 3.7 days | 1 |
| Post-apply | 2026-06-05 → 06-12 | 4 (isolated, non-clustered) |
| Post-apply | 2026-06-12 → 2026-07-02 | 0 (20 days) |

That's a real, sustained effect — not noise, not a lucky week. For anyone still deciding whether to bother with these two env vars: it's worth it.

But it doesn't close the gap for concurrent multi-agent fan-out

Two incidents today (2026-07-02), both hitting the class this issue is about, both with the mitigation above confirmed active:

  1. A single agent-tool sub-process died mid-response: API Error: Connection closed mid-response (idleReason: failed). Respawned clean, no work lost.
  2. A multi-agent session (1 coordinator + 10 concurrent worker processes, each independently streaming a long response) showed the same error surfacing across several of the 10 concurrent processes — the session stayed alive overall (each worker's output is saved to disk the moment it finishes, so nothing was lost), but file-completion timestamps show 1-2 stragglers per batch landing 10-19 minutes after their cohort, consistent with a mid-stream disconnect forcing a retry from scratch on that one connection.

Status page was clean (All Systems Operational) through the whole window both times.

This tracks with what @loseinworld reported back on #62146 in May: 4 parallel sub-agent calls in one turn → 2 of 4 hit ConnectionRefused (50% failure rate), sequential issuing far more reliable. We adopted a conditional "go sequential if transport looks degraded" rule as a workaround, but that's reactive — it only helps after you've already eaten one failure.

We confirmed the env-var mitigation does reach these worker processes (not obvious — worth stating precisely since it rules out a propagation gap as the explanation): stripped the three vars from a freshly-spawned child process's own environment before launch, and the CLI still reported them set, meaning it's re-reading .claude/settings.local.json and reapplying env at each process's own startup, independent of what spawned it. So this isn't "the fix didn't reach the sub-processes" — it's "the fix helps the single-stream case a lot, and the transport failure still occurs under concurrent fan-out even when every process individually has the mitigation."

Questions, if anyone's triaging

  1. Is Connection closed mid-response (idleReason: failed) (current CLI, 2.1.198) the same underlying failure as the original socket connection was closed unexpectedly raw fetch() error this issue documents, just with newer/more-structured harness-side labeling — or a distinct code path? Asking because the "idleReason" framing suggests a named health-check timeout rather than a raw transport exception, and I'd rather not assume they're the same bug.
  2. Is there a reconnect-and-resume path being considered for mid-stream disconnects specifically on concurrent sub-process/sub-agent calls? (Echoing @mnrsiat's question above — why not open a new connection when the old one's confirmed dead, at least for the non-interactive worker-process case where there's no user watching a live stream to disrupt.)
  3. Happy to share the full incident corpus (timestamps, error classes, concurrency-at-failure) if it's useful for triage — we've been tracking this since May with an outside-process watcher, so the data's already structured.