From 7d9b7ec2e2504a3cf7f15ee81f0ebd88d449729c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 13 May 2026 22:57:34 -0700 Subject: [PATCH] harden(host-service): connect-phase deadline + reconnect-guaranteed onclose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stuck-state paths besides the one we already fixed: - getAuthToken() can hang indefinitely (no upstream timeout) → connecting stays true → all future connect() calls early-return forever - WebSocket can stall in CONNECTING (captive portal, NAT rebind mid- handshake) → onopen/onclose/onerror never fire → same wedge A 20s connect-phase deadline collapses both into one mechanism. On timeout we force-close any in-flight socket, reset state, and schedule reconnect. Stale onclose for the abandoned socket no-ops via the existing this.socket-identity check. Also wraps onclose body in try/finally so any future throw inside it still routes through scheduleReconnect — defense-in-depth against the class of bug we just fixed (1001-close throwing inside cleanupChannels). --- .../host-service/src/tunnel/tunnel-client.ts | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/host-service/src/tunnel/tunnel-client.ts b/packages/host-service/src/tunnel/tunnel-client.ts index 22e39cadad2..06176f16d87 100644 --- a/packages/host-service/src/tunnel/tunnel-client.ts +++ b/packages/host-service/src/tunnel/tunnel-client.ts @@ -11,6 +11,7 @@ const RECONNECT_BASE_MS = 1_000; const RECONNECT_MAX_MS = 30_000; const INBOUND_SILENCE_TIMEOUT_MS = 75_000; const WATCHDOG_INTERVAL_MS = 10_000; +const CONNECT_TIMEOUT_MS = 20_000; export interface TunnelClientOptions { relayUrl: string; @@ -59,15 +60,32 @@ export class TunnelClient { } this.connecting = true; + let timedOut = false; + const deadline = setTimeout(() => { + if (this.closed) return; + timedOut = true; + console.warn( + `[host-service:tunnel] connect did not complete within ${CONNECT_TIMEOUT_MS}ms, forcing retry`, + ); + try { + this.socket?.close(4001, "Connect timeout"); + } catch {} + this.socket = null; + this.connecting = false; + this.scheduleReconnect(); + }, CONNECT_TIMEOUT_MS); + // An unhandled rejection here (e.g. DNS failure inside getAuthToken on // wake from sleep) crashes host-service and orphans every PTY. try { const token = await this.getAuthToken(); - if (this.closed) { - this.connecting = false; + if (timedOut || this.closed) { + clearTimeout(deadline); + if (this.closed) this.connecting = false; return; } if (!token) { + clearTimeout(deadline); console.warn("[host-service:tunnel] no auth token available, retrying"); this.connecting = false; this.scheduleReconnect(); @@ -84,6 +102,7 @@ export class TunnelClient { this.lastInboundAt = Date.now(); socket.onopen = () => { + clearTimeout(deadline); this.reconnectAttempts = 0; this.connecting = false; this.lastInboundAt = Date.now(); @@ -99,28 +118,34 @@ export class TunnelClient { }; socket.onclose = (event) => { - const wasOurSocket = this.socket === socket; - if (!wasOurSocket) return; - - this.socket = null; - this.connecting = false; - this.stopWatchdog(); - this.cleanupChannels(); - - if (this.closed) return; - - if (event.code === 1008) { + if (this.socket !== socket) return; + clearTimeout(deadline); + try { + this.socket = null; + this.connecting = false; + this.stopWatchdog(); + this.cleanupChannels(); + if (event.code === 1008) { + console.warn( + `[host-service:tunnel] relay rejected connection (code=${event.code}, reason=${event.reason ?? ""}); retrying`, + ); + } + } catch (err) { console.warn( - `[host-service:tunnel] relay rejected connection (code=${event.code}, reason=${event.reason ?? ""}); retrying`, + "[host-service:tunnel] error during onclose cleanup", + err, ); + } finally { + if (!this.closed) this.scheduleReconnect(); } - this.scheduleReconnect(); }; socket.onerror = (event) => { console.error("[host-service:tunnel] socket error:", event); }; } catch (error) { + clearTimeout(deadline); + if (timedOut) return; const message = error instanceof Error ? error.message : String(error); console.error(`[host-service:tunnel] connect failed: ${message}`); this.socket = null;