[BUG] Stale connection pool after sleep/wake/network change causes ECONNRESET on next API call

Resolved 💬 5 comments Opened Mar 21, 2026 by WYSIATI Closed May 23, 2026

Summary

After laptop sleep/wake, Wi-Fi switch, VPN toggle, or extended idle (>5 min), the next API call fails with ECONNRESET because Claude Code reuses a stale TCP socket from the connection pool. The remote end (API server, load balancer, NAT gateway) has already closed the socket, but the local client doesn't know until it writes.

Root Cause Analysis

Bun's fetch() (and Node's undici) maintain a persistent HTTP connection pool with keep-alive. When the network changes:

  1. Sleep/wake: macOS suspends all network state. After wake, prior TCP connections are invalid, but the pool still holds references to them
  2. Wi-Fi/VPN switch: Source IP changes, prior connections are bound to old IP → RST on write
  3. NAT timeout: Corporate/home routers expire idle TCP mappings after 5-30 minutes. The next request uses a zombie socket
  4. No TCP keep-alive probes: The Anthropic Python SDK configures SO_KEEPALIVE with TCP_KEEPIDLE=60, TCP_KEEPINTVL=60, TCP_KEEPCNT=5. The JS/Bun runtime does NOT expose equivalent socket options for fetch()

There is currently no mechanism to:

  • Detect network interface changes
  • Flush the connection pool proactively
  • Set a max idle time for pooled connections
  • Health-check a connection before reuse

Proposed Fix

Layer 1: Network Change Detection

import os from 'os';

class NetworkMonitor {
  private lastInterfaces: Map<string, string[]> = new Map();
  private listeners = new Set<() => void>();
  private interval: NodeJS.Timeout | null = null;

  start(pollMs = 5000) {
    this.lastInterfaces = this.snapshot();
    this.interval = setInterval(() => {
      const current = this.snapshot();
      if (this.hasChanged(current)) {
        this.lastInterfaces = current;
        this.listeners.forEach(cb => cb());
      }
    }, pollMs);
    // Don't prevent process exit
    this.interval.unref();
  }

  stop() {
    if (this.interval) clearInterval(this.interval);
  }

  onNetworkChange(cb: () => void) {
    this.listeners.add(cb);
    return () => this.listeners.delete(cb);
  }

  private snapshot(): Map<string, string[]> {
    const ifaces = os.networkInterfaces();
    const result = new Map<string, string[]>();
    for (const [name, addrs] of Object.entries(ifaces)) {
      if (!addrs || name === 'lo' || name === 'lo0') continue;
      result.set(name, addrs.filter(a => !a.internal).map(a => a.address).sort());
    }
    return result;
  }

  private hasChanged(current: Map<string, string[]>): boolean {
    if (current.size !== this.lastInterfaces.size) return true;
    for (const [name, addrs] of current) {
      const prev = this.lastInterfaces.get(name);
      if (!prev || prev.join(',') !== addrs.join(',')) return true;
    }
    return false;
  }
}

Layer 2: Idle Connection Management

class ApiConnectionManager {
  private networkMonitor = new NetworkMonitor();
  private lastSuccessfulRequest = Date.now();
  private maxIdleMs: number;

  constructor() {
    this.maxIdleMs = parseInt(
      process.env.CLAUDE_CODE_CONNECTION_IDLE_TIMEOUT || '60000', 10
    );
    this.networkMonitor.start();
    this.networkMonitor.onNetworkChange(() => {
      this.resetPool();
      debug('Network change detected — connection pool reset');
    });
  }

  async preFlightCheck(): Promise<void> {
    const idleMs = Date.now() - this.lastSuccessfulRequest;
    if (idleMs > this.maxIdleMs) {
      this.resetPool();
      debug(`Connection idle for ${idleMs}ms (threshold: ${this.maxIdleMs}ms) — pool reset`);
    }
  }

  onRequestSuccess() {
    this.lastSuccessfulRequest = Date.now();
  }

  private resetPool() {
    // For Bun: Bun.gc(true) + new fetch context
    // For Node/undici: dispatcher.close() + new Agent
    // Platform-specific implementation
  }
}

// Integration: call preFlightCheck() before each API request
async function* streamApiResponse(request, options) {
  await connectionManager.preFlightCheck();
  // ... existing API call ...
  connectionManager.onRequestSuccess();
}

Design Decisions

  • Polling-based (5s interval): os.networkInterfaces() is cross-platform, costs ~0.1ms per call. OS-specific event APIs would be more efficient but add platform complexity
  • 60s idle threshold: Matches Python SDK's TCP_KEEPIDLE=60. Configurable via CLAUDE_CODE_CONNECTION_IDLE_TIMEOUT
  • Pre-flight check before each API call: Proactive, not just reactive to network changes
  • .unref() on interval: Doesn't prevent process exit
  • Debounced: Multiple rapid network changes (e.g., Wi-Fi bouncing) trigger only one reset

Testing Plan

Unit Tests

| Test | Input | Expected |
|------|-------|----------|
| Detect IP address change | Mock different IPs on successive calls | onNetworkChange callback fired |
| Ignore loopback changes | Mock loopback IP change | No callback |
| Detect interface removal | Mock: en0 present then absent | Callback fired |
| Detect interface addition | Mock: en1 absent then present | Callback fired |
| No false positive on stable network | Same interfaces 10 times | No callback |
| Pre-flight resets pool after idle | lastSuccessful = 90s ago, threshold = 60s | resetPool() called |
| Pre-flight skips within threshold | lastSuccessful = 30s ago | resetPool() NOT called |
| Custom timeout via env var | CLAUDE_CODE_CONNECTION_IDLE_TIMEOUT=30000 | 30s threshold |
| Multiple rapid changes coalesced | 5 changes in 1s | Pool reset once |

Integration Tests

| Test | Setup | Expected |
|------|-------|----------|
| API call after network change | Success → network change → API call | Fresh connection, succeeds |
| API call after idle > threshold | Success → wait 61s → API call | Pool reset, succeeds |
| API call within threshold | Success → wait 10s → API call | Reuses connection, no reset |
| Monitor cleanup on exit | Start monitor, exit | Interval cleared, no leaked timers |

E2E Tests

| Test | Steps | Expected |
|------|-------|----------|
| Laptop sleep/wake | Close lid 30s → open → send prompt | Network change logged, prompt succeeds |
| Wi-Fi switch | Connect to network A → switch to B → prompt | Pool reset logged, succeeds |
| VPN toggle | No VPN → connect → prompt → disconnect → prompt | Both transitions detected |

Related Issues

  • #5674 — Persistent ECONNRESET on macOS
  • #29591 — Cowork ECONNRESET on macOS (idle related)
  • #28309 — Cowork hvsock dies after idle on Windows
  • #26729 — Streaming Resilience feature request

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗