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