From 6cbe1fb2618af557262a8717c494e7958494bf2d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 08 Mar 2026 07:03:30 +0100
Subject: [PATCH] fix: robust WebSocket reconnection after daemon restart

---
 types/index.ts                 |    2 +-
 services/websocket.ts          |   28 ++++++++++++++++++++++++++--
 contexts/ConnectionContext.tsx |    1 +
 components/ui/StatusDot.tsx    |    2 +-
 app/settings.tsx               |    8 +++++---
 5 files changed, 34 insertions(+), 7 deletions(-)

diff --git a/app/settings.tsx b/app/settings.tsx
index c4da07e..55f9fa0 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -57,7 +57,7 @@
   }, [host, localHost, port, macAddress, saveServerConfig]);
 
   const handleConnect = useCallback(() => {
-    if (status === "connected" || status === "connecting") {
+    if (status === "connected" || status === "connecting" || status === "reconnecting") {
       disconnect();
     } else {
       connect();
@@ -155,6 +155,8 @@
                       ? "Connected"
                       : status === "connecting"
                       ? "Connecting..."
+                      : status === "reconnecting"
+                      ? "Reconnecting..."
                       : "Disconnected"}
                   </Text>
                 </View>
@@ -349,8 +351,8 @@
                 >
                   {status === "connected"
                     ? "Disconnect"
-                    : status === "connecting"
-                    ? "Connecting..."
+                    : status === "connecting" || status === "reconnecting"
+                    ? "Reconnecting..."
                     : "Connect"}
                 </Text>
               </Pressable>
diff --git a/components/ui/StatusDot.tsx b/components/ui/StatusDot.tsx
index 543408d..3f3bd83 100644
--- a/components/ui/StatusDot.tsx
+++ b/components/ui/StatusDot.tsx
@@ -13,7 +13,7 @@
       ? "#22c55e"
       : status === "compacting"
       ? "#3b82f6"
-      : status === "connecting"
+      : status === "connecting" || status === "reconnecting"
       ? "#eab308"
       : "#ef4444";
 
diff --git a/contexts/ConnectionContext.tsx b/contexts/ConnectionContext.tsx
index 6734d9c..f1fab69 100644
--- a/contexts/ConnectionContext.tsx
+++ b/contexts/ConnectionContext.tsx
@@ -52,6 +52,7 @@
     wsClient.setCallbacks({
       onOpen: () => setStatus("connected"),
       onClose: () => setStatus("disconnected"),
+      onReconnecting: () => setStatus("reconnecting"),
       onError: () => setStatus("disconnected"),
       onMessage: (data) => {
         const msg = data as unknown as WsIncoming;
diff --git a/services/websocket.ts b/services/websocket.ts
index 895d995..09c71eb 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -4,12 +4,14 @@
 type WebSocketMessage = Record<string, unknown>;
 type MessageCallback = (data: WebSocketMessage) => void;
 type StatusCallback = () => void;
+type ReconnectCallback = (attempt: number) => void;
 type ErrorCallback = (error: Event) => void;
 
 interface WebSocketClientOptions {
   onMessage?: MessageCallback;
   onOpen?: StatusCallback;
   onClose?: StatusCallback;
+  onReconnecting?: ReconnectCallback;
   onError?: ErrorCallback;
 }
 
@@ -17,6 +19,7 @@
 const MAX_RECONNECT_DELAY = 30000;
 const RECONNECT_MULTIPLIER = 2;
 const LOCAL_TIMEOUT = 2500;
+const CONNECT_TIMEOUT = 5000; // max time for any single connection attempt
 const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets
 
 export class WebSocketClient {
@@ -25,11 +28,14 @@
   private urlIndex: number = 0;
   private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
   private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+  private connectTimer: ReturnType<typeof setTimeout> | null = null;
   private localTimer: ReturnType<typeof setTimeout> | null = null;
   private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
   private lastMessageAt: number = 0; // timestamp of last received message (any type)
   private shouldReconnect: boolean = false;
   private connected: boolean = false;
+  private wasConnected: boolean = false; // true if we ever connected (for reconnect logic)
+  private reconnectAttempt: number = 0;
   private callbacks: WebSocketClientOptions = {};
 
   constructor() {
@@ -68,6 +74,7 @@
   private cleanup() {
     if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
     if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
+    if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
     this.stopHeartbeat();
     if (this.ws) {
       const old = this.ws;
@@ -139,9 +146,22 @@
       }, LOCAL_TIMEOUT);
     }
 
+    // Timeout: if this connection attempt doesn't succeed within CONNECT_TIMEOUT,
+    // treat it as failed. Prevents hanging on unreachable remote URLs.
+    this.connectTimer = setTimeout(() => {
+      this.connectTimer = null;
+      if (ws === this.ws && !this.connected) {
+        // Force close and let onclose trigger reconnect
+        try { ws.close(); } catch { /* ignore */ }
+      }
+    }, CONNECT_TIMEOUT);
+
     ws.onopen = () => {
       if (ws !== this.ws) return; // stale
+      if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
       this.connected = true;
+      this.wasConnected = true;
+      this.reconnectAttempt = 0;
       if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
       this.reconnectDelay = INITIAL_RECONNECT_DELAY;
       this.startHeartbeat();
@@ -178,15 +198,19 @@
 
   private scheduleReconnect() {
     if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+    this.reconnectAttempt++;
+    // Signal reconnecting state to UI so it shows yellow/orange, not red
+    this.callbacks.onReconnecting?.(this.reconnectAttempt);
     this.reconnectTimer = setTimeout(() => {
       this.reconnectTimer = null;
       this.reconnectDelay = Math.min(
         this.reconnectDelay * RECONNECT_MULTIPLIER,
         MAX_RECONNECT_DELAY
       );
-      // Alternate between URLs on each reconnect attempt
+      // Always try local first (daemon restarts are local), then remote on retry
       if (this.urls.length > 1) {
-        this.urlIndex = this.urlIndex === 0 ? 1 : 0;
+        // First attempt: local. Second: remote. Then alternate.
+        this.urlIndex = this.reconnectAttempt % 2 === 1 ? 0 : 1;
       }
       this.tryUrl();
     }, this.reconnectDelay);
diff --git a/types/index.ts b/types/index.ts
index fc57c4a..bea9e77 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -20,7 +20,7 @@
   macAddress?: string;
 }
 
-export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "compacting";
+export type ConnectionStatus = "disconnected" | "connecting" | "reconnecting" | "connected" | "compacting";
 
 // --- WebSocket protocol ---
 

--
Gitblit v1.3.1