fix: WebSocket heartbeat to prevent idle drops + debounce reconnect sound

30s ping/pong keepalive prevents proxy/NAT from killing idle connections.
Reconnect sound now only plays if downtime exceeds 3s, suppressing phantom
audio from brief network blips.
This commit is contained in:
Jannis Braun
2026-02-23 19:45:32 +01:00
parent edcdf8b207
commit 77c5bda1fd
4 changed files with 38 additions and 3 deletions
+6
View File
@@ -604,6 +604,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
return; return;
} }
// Fast-path heartbeat — never reaches business logic
if (parsed.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
// Handle authenticated events // Handle authenticated events
if (userId && username) { if (userId && username) {
handleClientEvent(parsed, userId, username); handleClientEvent(parsed, userId, username);
+3 -1
View File
@@ -186,7 +186,8 @@ export type ClientEvent =
| { type: 'dm_call_accept'; dmChannelId: string } | { type: 'dm_call_accept'; dmChannelId: string }
| { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string }
| { type: 'dm_call_end'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string }
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean }; | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean }
| { type: 'ping' };
// Server → Client Events // Server → Client Events
export type ServerEvent = export type ServerEvent =
@@ -220,6 +221,7 @@ export type ServerEvent =
| { type: 'channel_updated'; channel: Channel; serverId: string } | { type: 'channel_updated'; channel: Channel; serverId: string }
| { type: 'channel_deleted'; channelId: string; serverId: string } | { type: 'channel_deleted'; channelId: string; serverId: string }
| { type: 'server_updated'; server: Server } | { type: 'server_updated'; server: Server }
| { type: 'pong' }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
// ─── API Request/Response Types ───────────────────────────────────────────── // ─── API Request/Response Types ─────────────────────────────────────────────
@@ -13,6 +13,7 @@ export function SoundController() {
// Refs to track previous states // Refs to track previous states
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const prevIsWsConnected = useRef<boolean>(false); const prevIsWsConnected = useRef<boolean>(false);
const wsDisconnectedAt = useRef<number>(0);
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted); const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted);
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened); const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened);
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn); const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
@@ -24,12 +25,17 @@ export function SoundController() {
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null); const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null); const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
// WebSocket Reconnect Sound — suppress during active voice (LiveKit handles its own reconnection) // WebSocket Reconnect Sound — suppress during active voice and brief blips (<3s)
useEffect(() => { useEffect(() => {
if (isInitialMount.current) return; if (isInitialMount.current) return;
if (!isWsConnected && prevIsWsConnected.current) {
// Record when we lost connection
wsDisconnectedAt.current = Date.now();
}
if (isWsConnected && !prevIsWsConnected.current) { if (isWsConnected && !prevIsWsConnected.current) {
const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected; const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected;
if (!isInActiveVoice) { const downtime = wsDisconnectedAt.current > 0 ? Date.now() - wsDisconnectedAt.current : Infinity;
if (!isInActiveVoice && downtime > 3000) {
audioManager.playSound('reconnect'); audioManager.playSound('reconnect');
} }
} }
+21
View File
@@ -9,6 +9,7 @@ import type { ServerEvent, ClientEvent } from '@opencord/shared';
let globalWs: WebSocket | null = null; let globalWs: WebSocket | null = null;
let reconnectAttempts = 0; let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined; let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
let currentToken: string | null = null; let currentToken: string | null = null;
let isInitialized = false; let isInitialized = false;
@@ -280,6 +281,10 @@ function handleEvent(event: ServerEvent): void {
break; break;
} }
case 'pong':
// Heartbeat response — no action needed
break;
case 'error': case 'error':
console.error('WebSocket error:', event.message); console.error('WebSocket error:', event.message);
break; break;
@@ -300,6 +305,14 @@ function connect(): void {
ws.onopen = () => { ws.onopen = () => {
reconnectAttempts = 0; reconnectAttempts = 0;
ws.send(JSON.stringify({ type: 'auth', token: currentToken })); ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
// Start heartbeat to keep connection alive through proxies/NATs
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30_000);
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
@@ -313,6 +326,10 @@ function connect(): void {
ws.onclose = () => { ws.onclose = () => {
globalWs = null; globalWs = null;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = undefined;
}
if (currentToken) { if (currentToken) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++; reconnectAttempts++;
@@ -332,6 +349,10 @@ function disconnect(): void {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
reconnectTimer = undefined; reconnectTimer = undefined;
} }
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = undefined;
}
if (globalWs) { if (globalWs) {
globalWs.close(); globalWs.close();
globalWs = null; globalWs = null;