From 8cdf33e27c633ac30e8851c4617f6063c141660d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 17:53:05 +0100
Subject: [PATCH] fix: audio routing, WebSocket reconnection, inverted chat list
---
services/websocket.ts | 65 ++++++++++++++++++++++++++++++++
1 files changed, 65 insertions(+), 0 deletions(-)
diff --git a/services/websocket.ts b/services/websocket.ts
index 03f1a59..4c74e56 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -1,3 +1,4 @@
+import { AppState, AppStateStatus } from "react-native";
import { WsOutgoing } from "../types";
type WebSocketMessage = Record<string, unknown>;
@@ -16,6 +17,7 @@
const MAX_RECONNECT_DELAY = 30000;
const RECONNECT_MULTIPLIER = 2;
const LOCAL_TIMEOUT = 2500;
+const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
export class WebSocketClient {
private ws: WebSocket | null = null;
@@ -24,9 +26,28 @@
private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private localTimer: ReturnType<typeof setTimeout> | null = null;
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
+ private pongReceived: boolean = true;
private shouldReconnect: boolean = false;
private connected: boolean = false;
private callbacks: WebSocketClientOptions = {};
+
+ constructor() {
+ // When app comes back to foreground, check if socket is still alive
+ AppState.addEventListener("change", (state: AppStateStatus) => {
+ if (state === "active" && this.shouldReconnect) {
+ if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ // Socket is dead — force immediate reconnect
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+ this.urlIndex = 0;
+ this.tryUrl();
+ } else {
+ // Socket looks open but might be zombie — send a ping to verify
+ this.sendPing();
+ }
+ }
+ });
+ }
setCallbacks(callbacks: WebSocketClientOptions) {
this.callbacks = callbacks;
@@ -45,6 +66,7 @@
private cleanup() {
if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
+ this.stopHeartbeat();
if (this.ws) {
const old = this.ws;
this.ws = null;
@@ -53,6 +75,43 @@
old.onerror = null;
old.onmessage = null;
try { old.close(); } catch { /* ignore */ }
+ }
+ }
+
+ private startHeartbeat() {
+ this.stopHeartbeat();
+ this.pongReceived = true;
+ this.heartbeatTimer = setInterval(() => {
+ if (!this.pongReceived) {
+ // No pong since last ping — socket is zombie, force reconnect
+ this.connected = false;
+ this.callbacks.onClose?.();
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+ this.urlIndex = 0;
+ this.tryUrl();
+ return;
+ }
+ this.sendPing();
+ }, HEARTBEAT_INTERVAL);
+ }
+
+ private stopHeartbeat() {
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
+ }
+
+ private sendPing() {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.pongReceived = false;
+ try {
+ this.ws.send(JSON.stringify({ type: "ping" }));
+ } catch {
+ // Send failed — socket is dead
+ this.connected = false;
+ this.callbacks.onClose?.();
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+ this.urlIndex = 0;
+ this.tryUrl();
+ }
}
}
@@ -82,6 +141,7 @@
this.connected = true;
if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+ this.startHeartbeat();
this.callbacks.onOpen?.();
};
@@ -89,6 +149,11 @@
if (ws !== this.ws) return; // stale
try {
const data = JSON.parse(event.data) as WebSocketMessage;
+ // Handle pong responses from heartbeat
+ if (data.type === "pong") {
+ this.pongReceived = true;
+ return;
+ }
this.callbacks.onMessage?.(data);
} catch {
this.callbacks.onMessage?.({ type: "text", content: String(event.data) });
--
Gitblit v1.3.1