[BUG] Stale connection pool after sleep/wake/network change causes ECONNRESET on next API call
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:
- Sleep/wake: macOS suspends all network state. After wake, prior TCP connections are invalid, but the pool still holds references to them
- Wi-Fi/VPN switch: Source IP changes, prior connections are bound to old IP → RST on write
- NAT timeout: Corporate/home routers expire idle TCP mappings after 5-30 minutes. The next request uses a zombie socket
- No TCP keep-alive probes: The Anthropic Python SDK configures
SO_KEEPALIVEwithTCP_KEEPIDLE=60,TCP_KEEPINTVL=60,TCP_KEEPCNT=5. The JS/Bun runtime does NOT expose equivalent socket options forfetch()
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 viaCLAUDE_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
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗