Matthias Nott
2026-03-07 5db84bd89c8808b0895c7206e8a6a58043f9f8dc
services/websocket.ts
....@@ -17,7 +17,7 @@
1717 const MAX_RECONNECT_DELAY = 30000;
1818 const RECONNECT_MULTIPLIER = 2;
1919 const LOCAL_TIMEOUT = 2500;
20
-const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
20
+const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets
2121
2222 export class WebSocketClient {
2323 private ws: WebSocket | null = null;
....@@ -27,22 +27,24 @@
2727 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2828 private localTimer: ReturnType<typeof setTimeout> | null = null;
2929 private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
30
- private pongReceived: boolean = true;
30
+ private lastMessageAt: number = 0; // timestamp of last received message (any type)
3131 private shouldReconnect: boolean = false;
3232 private connected: boolean = false;
3333 private callbacks: WebSocketClientOptions = {};
3434
3535 constructor() {
36
- // When app comes back to foreground, check if socket is still alive
36
+ // When app comes back to foreground, verify the connection
3737 AppState.addEventListener("change", (state: AppStateStatus) => {
3838 if (state === "active" && this.shouldReconnect) {
39
- if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
40
- // Socket is dead — force immediate reconnect
39
+ if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
40
+ // Socket is definitively dead — force immediate reconnect
4141 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
4242 this.urlIndex = 0;
4343 this.tryUrl();
44
- } else {
45
- // Socket looks open but might be zombie — send a ping to verify
44
+ } else if (this.connected) {
45
+ // Socket might be alive — send a ping and restart heartbeat
46
+ // to give it a fresh 2-interval window to prove liveness
47
+ this.startHeartbeat();
4648 this.sendPing();
4749 }
4850 }
....@@ -80,10 +82,11 @@
8082
8183 private startHeartbeat() {
8284 this.stopHeartbeat();
83
- this.pongReceived = true;
85
+ this.lastMessageAt = Date.now();
8486 this.heartbeatTimer = setInterval(() => {
85
- if (!this.pongReceived) {
86
- // No pong since last ping — socket is zombie, force reconnect
87
+ const silentMs = Date.now() - this.lastMessageAt;
88
+ if (silentMs > HEARTBEAT_INTERVAL * 2) {
89
+ // No message (including pong) for 2 full intervals — zombie socket
8790 this.connected = false;
8891 this.callbacks.onClose?.();
8992 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
....@@ -91,6 +94,7 @@
9194 this.tryUrl();
9295 return;
9396 }
97
+ // Send a ping so the server has something to respond to
9498 this.sendPing();
9599 }, HEARTBEAT_INTERVAL);
96100 }
....@@ -101,7 +105,6 @@
101105
102106 private sendPing() {
103107 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
104
- this.pongReceived = false;
105108 try {
106109 this.ws.send(JSON.stringify({ type: "ping" }));
107110 } catch {
....@@ -147,13 +150,10 @@
147150
148151 ws.onmessage = (event) => {
149152 if (ws !== this.ws) return; // stale
153
+ this.lastMessageAt = Date.now(); // any message proves the connection is alive
150154 try {
151155 const data = JSON.parse(event.data) as WebSocketMessage;
152
- // Handle pong responses from heartbeat
153
- if (data.type === "pong") {
154
- this.pongReceived = true;
155
- return;
156
- }
156
+ if (data.type === "pong") return; // heartbeat response, don't forward
157157 this.callbacks.onMessage?.(data);
158158 } catch {
159159 this.callbacks.onMessage?.({ type: "text", content: String(event.data) });