| .. | .. |
|---|
| 4 | 4 | type WebSocketMessage = Record<string, unknown>; |
|---|
| 5 | 5 | type MessageCallback = (data: WebSocketMessage) => void; |
|---|
| 6 | 6 | type StatusCallback = () => void; |
|---|
| 7 | +type ReconnectCallback = (attempt: number) => void; |
|---|
| 7 | 8 | type ErrorCallback = (error: Event) => void; |
|---|
| 8 | 9 | |
|---|
| 9 | 10 | interface WebSocketClientOptions { |
|---|
| 10 | 11 | onMessage?: MessageCallback; |
|---|
| 11 | 12 | onOpen?: StatusCallback; |
|---|
| 12 | 13 | onClose?: StatusCallback; |
|---|
| 14 | + onReconnecting?: ReconnectCallback; |
|---|
| 13 | 15 | onError?: ErrorCallback; |
|---|
| 14 | 16 | } |
|---|
| 15 | 17 | |
|---|
| .. | .. |
|---|
| 17 | 19 | const MAX_RECONNECT_DELAY = 30000; |
|---|
| 18 | 20 | const RECONNECT_MULTIPLIER = 2; |
|---|
| 19 | 21 | const LOCAL_TIMEOUT = 2500; |
|---|
| 22 | +const CONNECT_TIMEOUT = 5000; // max time for any single connection attempt |
|---|
| 20 | 23 | const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets |
|---|
| 21 | 24 | |
|---|
| 22 | 25 | export class WebSocketClient { |
|---|
| .. | .. |
|---|
| 25 | 28 | private urlIndex: number = 0; |
|---|
| 26 | 29 | private reconnectDelay: number = INITIAL_RECONNECT_DELAY; |
|---|
| 27 | 30 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 31 | + private connectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 28 | 32 | private localTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 29 | 33 | private heartbeatTimer: ReturnType<typeof setInterval> | null = null; |
|---|
| 30 | 34 | private lastMessageAt: number = 0; // timestamp of last received message (any type) |
|---|
| 31 | 35 | private shouldReconnect: boolean = false; |
|---|
| 32 | 36 | private connected: boolean = false; |
|---|
| 37 | + private wasConnected: boolean = false; // true if we ever connected (for reconnect logic) |
|---|
| 38 | + private reconnectAttempt: number = 0; |
|---|
| 33 | 39 | private callbacks: WebSocketClientOptions = {}; |
|---|
| 34 | 40 | |
|---|
| 35 | 41 | constructor() { |
|---|
| .. | .. |
|---|
| 68 | 74 | private cleanup() { |
|---|
| 69 | 75 | if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 70 | 76 | if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } |
|---|
| 77 | + if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; } |
|---|
| 71 | 78 | this.stopHeartbeat(); |
|---|
| 72 | 79 | if (this.ws) { |
|---|
| 73 | 80 | const old = this.ws; |
|---|
| .. | .. |
|---|
| 139 | 146 | }, LOCAL_TIMEOUT); |
|---|
| 140 | 147 | } |
|---|
| 141 | 148 | |
|---|
| 149 | + // Timeout: if this connection attempt doesn't succeed within CONNECT_TIMEOUT, |
|---|
| 150 | + // treat it as failed. Prevents hanging on unreachable remote URLs. |
|---|
| 151 | + this.connectTimer = setTimeout(() => { |
|---|
| 152 | + this.connectTimer = null; |
|---|
| 153 | + if (ws === this.ws && !this.connected) { |
|---|
| 154 | + // Force close and let onclose trigger reconnect |
|---|
| 155 | + try { ws.close(); } catch { /* ignore */ } |
|---|
| 156 | + } |
|---|
| 157 | + }, CONNECT_TIMEOUT); |
|---|
| 158 | + |
|---|
| 142 | 159 | ws.onopen = () => { |
|---|
| 143 | 160 | if (ws !== this.ws) return; // stale |
|---|
| 161 | + if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; } |
|---|
| 144 | 162 | this.connected = true; |
|---|
| 163 | + this.wasConnected = true; |
|---|
| 164 | + this.reconnectAttempt = 0; |
|---|
| 145 | 165 | if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 146 | 166 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 147 | 167 | this.startHeartbeat(); |
|---|
| .. | .. |
|---|
| 178 | 198 | |
|---|
| 179 | 199 | private scheduleReconnect() { |
|---|
| 180 | 200 | if (this.reconnectTimer) clearTimeout(this.reconnectTimer); |
|---|
| 201 | + this.reconnectAttempt++; |
|---|
| 202 | + // Signal reconnecting state to UI so it shows yellow/orange, not red |
|---|
| 203 | + this.callbacks.onReconnecting?.(this.reconnectAttempt); |
|---|
| 181 | 204 | this.reconnectTimer = setTimeout(() => { |
|---|
| 182 | 205 | this.reconnectTimer = null; |
|---|
| 183 | 206 | this.reconnectDelay = Math.min( |
|---|
| 184 | 207 | this.reconnectDelay * RECONNECT_MULTIPLIER, |
|---|
| 185 | 208 | MAX_RECONNECT_DELAY |
|---|
| 186 | 209 | ); |
|---|
| 187 | | - // Alternate between URLs on each reconnect attempt |
|---|
| 210 | + // Always try local first (daemon restarts are local), then remote on retry |
|---|
| 188 | 211 | if (this.urls.length > 1) { |
|---|
| 189 | | - this.urlIndex = this.urlIndex === 0 ? 1 : 0; |
|---|
| 212 | + // First attempt: local. Second: remote. Then alternate. |
|---|
| 213 | + this.urlIndex = this.reconnectAttempt % 2 === 1 ? 0 : 1; |
|---|
| 190 | 214 | } |
|---|
| 191 | 215 | this.tryUrl(); |
|---|
| 192 | 216 | }, this.reconnectDelay); |
|---|