Matthias Nott
2026-03-08 6cbe1fb2618af557262a8717c494e7958494bf2d
fix: robust WebSocket reconnection after daemon restart

- Add 5s connection timeout per attempt (prevents hanging on unreachable URLs)
- Always try local URL first on reconnect (daemon restarts are local)
- Add "reconnecting" status so UI shows yellow dot instead of red during auto-reconnect
- Track wasConnected/reconnectAttempt for smarter retry logic
- Clean up connectTimer in cleanup()
5 files modified
changed files
app/settings.tsx patch | view | blame | history
components/ui/StatusDot.tsx patch | view | blame | history
contexts/ConnectionContext.tsx patch | view | blame | history
services/websocket.ts patch | view | blame | history
types/index.ts patch | view | blame | history
app/settings.tsx
....@@ -57,7 +57,7 @@
5757 }, [host, localHost, port, macAddress, saveServerConfig]);
5858
5959 const handleConnect = useCallback(() => {
60
- if (status === "connected" || status === "connecting") {
60
+ if (status === "connected" || status === "connecting" || status === "reconnecting") {
6161 disconnect();
6262 } else {
6363 connect();
....@@ -155,6 +155,8 @@
155155 ? "Connected"
156156 : status === "connecting"
157157 ? "Connecting..."
158
+ : status === "reconnecting"
159
+ ? "Reconnecting..."
158160 : "Disconnected"}
159161 </Text>
160162 </View>
....@@ -349,8 +351,8 @@
349351 >
350352 {status === "connected"
351353 ? "Disconnect"
352
- : status === "connecting"
353
- ? "Connecting..."
354
+ : status === "connecting" || status === "reconnecting"
355
+ ? "Reconnecting..."
354356 : "Connect"}
355357 </Text>
356358 </Pressable>
components/ui/StatusDot.tsx
....@@ -13,7 +13,7 @@
1313 ? "#22c55e"
1414 : status === "compacting"
1515 ? "#3b82f6"
16
- : status === "connecting"
16
+ : status === "connecting" || status === "reconnecting"
1717 ? "#eab308"
1818 : "#ef4444";
1919
contexts/ConnectionContext.tsx
....@@ -52,6 +52,7 @@
5252 wsClient.setCallbacks({
5353 onOpen: () => setStatus("connected"),
5454 onClose: () => setStatus("disconnected"),
55
+ onReconnecting: () => setStatus("reconnecting"),
5556 onError: () => setStatus("disconnected"),
5657 onMessage: (data) => {
5758 const msg = data as unknown as WsIncoming;
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);
types/index.ts
....@@ -20,7 +20,7 @@
2020 macAddress?: string;
2121 }
2222
23
-export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "compacting";
23
+export type ConnectionStatus = "disconnected" | "connecting" | "reconnecting" | "connected" | "compacting";
2424
2525 // --- WebSocket protocol ---
2626