Skip to content

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

Description

@WYSIATI

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions