Matthias Nott
2026-03-08 6cbe1fb2618af557262a8717c494e7958494bf2d
services/websocket.ts
....@@ -4,12 +4,14 @@
44 type WebSocketMessage = Record<string, unknown>;
55 type MessageCallback = (data: WebSocketMessage) => void;
66 type StatusCallback = () => void;
7
+type ReconnectCallback = (attempt: number) => void;
78 type ErrorCallback = (error: Event) => void;
89
910 interface WebSocketClientOptions {
1011 onMessage?: MessageCallback;
1112 onOpen?: StatusCallback;
1213 onClose?: StatusCallback;
14
+ onReconnecting?: ReconnectCallback;
1315 onError?: ErrorCallback;
1416 }
1517
....@@ -17,6 +19,7 @@
1719 const MAX_RECONNECT_DELAY = 30000;
1820 const RECONNECT_MULTIPLIER = 2;
1921 const LOCAL_TIMEOUT = 2500;
22
+const CONNECT_TIMEOUT = 5000; // max time for any single connection attempt
2023 const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets
2124
2225 export class WebSocketClient {
....@@ -25,11 +28,14 @@
2528 private urlIndex: number = 0;
2629 private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
2730 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
31
+ private connectTimer: ReturnType<typeof setTimeout> | null = null;
2832 private localTimer: ReturnType<typeof setTimeout> | null = null;
2933 private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
3034 private lastMessageAt: number = 0; // timestamp of last received message (any type)
3135 private shouldReconnect: boolean = false;
3236 private connected: boolean = false;
37
+ private wasConnected: boolean = false; // true if we ever connected (for reconnect logic)
38
+ private reconnectAttempt: number = 0;
3339 private callbacks: WebSocketClientOptions = {};
3440
3541 constructor() {
....@@ -68,6 +74,7 @@
6874 private cleanup() {
6975 if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
7076 if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
77
+ if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
7178 this.stopHeartbeat();
7279 if (this.ws) {
7380 const old = this.ws;
....@@ -139,9 +146,22 @@
139146 }, LOCAL_TIMEOUT);
140147 }
141148
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
+
142159 ws.onopen = () => {
143160 if (ws !== this.ws) return; // stale
161
+ if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
144162 this.connected = true;
163
+ this.wasConnected = true;
164
+ this.reconnectAttempt = 0;
145165 if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
146166 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
147167 this.startHeartbeat();
....@@ -178,15 +198,19 @@
178198
179199 private scheduleReconnect() {
180200 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);
181204 this.reconnectTimer = setTimeout(() => {
182205 this.reconnectTimer = null;
183206 this.reconnectDelay = Math.min(
184207 this.reconnectDelay * RECONNECT_MULTIPLIER,
185208 MAX_RECONNECT_DELAY
186209 );
187
- // Alternate between URLs on each reconnect attempt
210
+ // Always try local first (daemon restarts are local), then remote on retry
188211 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;
190214 }
191215 this.tryUrl();
192216 }, this.reconnectDelay);