fix: Fix users unable to see or hear each other in voice channels due to

stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Server:
- Close stale WebSocket connections sharing the same oderId in
  handleIdentify instead of letting them linger up to 45s
- Make user_joined/user_left broadcasts identity-aware so duplicate
  sockets don't produce phantom join/leave events
- Include serverIds in user_left payload for multi-room presence
- Simplify findUserByOderId now that stale sockets are cleaned up

Client - signaling:
- Add fallback offer system with 1s timer for missed user_joined races
- Add non-initiator takeover after 5s when the initiator fails to send
  an offer (NON_INITIATOR_GIVE_UP_MS)
- Scope peerServerMap per signaling URL to prevent cross-server
  collisions
- Add socket identity guards on all signaling event handlers
- Replace canReusePeerConnection with hasActivePeerConnection and
  isPeerConnectionNegotiating with extended grace periods

Client - peer connections:
- Extract replaceUnusablePeer helper to deduplicate stale peer
  replacement in offer and ICE handlers
- Add stale connectionstatechange guard to ignore events from replaced
  RTCPeerConnection instances
- Use deterministic initiator election in peer recovery reconnects
- Track createdAt on PeerData for staleness detection

Client - presence:
- Add multi-room presence tracking via presenceServerIds on User
- Replace clearUsers + individual userJoined with syncServerPresence
  for atomic server roster updates
- Make userLeft handle partial server removal instead of full eviction

Documentation:
- Add server-side connection hygiene, non-initiator takeover, and stale
  peer replacement sections to the realtime README
This commit is contained in:
2026-04-04 02:47:58 +02:00
parent ae0ee8fac7
commit de2d3300d4
24 changed files with 1128 additions and 164 deletions

View File

@@ -1,4 +1,6 @@
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
interface WsMessage {
[key: string]: unknown;
@@ -24,6 +26,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
}
}
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
const usersByOderId = new Map<string, ConnectedUser>();
connectedUsers.forEach((user) => {
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
return;
}
usersByOderId.set(user.oderId, user);
});
return Array.from(usersByOderId.values());
}
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
connectionId !== excludeConnectionId
&& user.oderId === oderId
&& user.serverIds.has(serverId)
&& user.ws.readyState === WebSocket.OPEN
);
}
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
const serverIds = new Set<string>();
connectedUsers.forEach((user, connectionId) => {
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
return;
}
user.serverIds.forEach((serverId) => serverIds.add(serverId));
});
return Array.from(serverIds);
}
export function notifyUser(oderId: string, message: WsMessage): void {
const user = findUserByOderId(oderId);
@@ -33,5 +72,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
}
export function findUserByOderId(oderId: string) {
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId);
let match: ConnectedUser | undefined;
connectedUsers.forEach((user) => {
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
match = user;
}
});
return match;
}

View File

@@ -1,6 +1,12 @@
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId } from './broadcast';
import {
broadcastToServer,
findUserByOderId,
getServerIdsForOderId,
getUniqueUsersInServer,
isOderIdConnectedToServer
} from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service';
interface WsMessage {
@@ -14,24 +20,53 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
return normalized || fallback;
}
function readMessageId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
if (!normalized || normalized === 'undefined' || normalized === 'null') {
return undefined;
}
return normalized;
}
/** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = Array.from(connectedUsers.values())
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
const users = getUniqueUsersInServer(serverId, user.oderId)
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
}
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
user.oderId = String(message['oderId'] || connectionId);
const newOderId = readMessageId(message['oderId']) ?? connectionId;
// Close stale connections from the same identity so offer routing
// always targets the freshest socket (e.g. after page refresh).
connectedUsers.forEach((existing, existingId) => {
if (existingId !== connectionId && existing.oderId === newOderId) {
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId})`);
try {
existing.ws.close();
} catch { /* already closing */ }
connectedUsers.delete(existingId);
}
});
user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`);
}
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const sid = String(message['serverId']);
const sid = readMessageId(message['serverId']);
if (!sid)
return;
@@ -48,16 +83,20 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
return;
}
const isNew = !user.serverIds.has(sid);
const isNewConnectionMembership = !user.serverIds.has(sid);
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
user.serverIds.add(sid);
user.viewedServerId = sid;
connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
console.log(
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
);
sendServerUsers(user, sid);
if (isNew) {
if (isNewIdentityMembership) {
broadcastToServer(sid, {
type: 'user_joined',
oderId: user.oderId,
@@ -68,7 +107,10 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
}
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const viewSid = String(message['serverId']);
const viewSid = readMessageId(message['serverId']);
if (!viewSid)
return;
user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user);
@@ -78,7 +120,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
}
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!leaveSid)
return;
@@ -90,17 +132,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
connectedUsers.set(connectionId, user);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
if (remainingServerIds.includes(leaveSid)) {
return;
}
broadcastToServer(leaveSid, {
type: 'user_left',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
serverId: leaveSid,
serverIds: Array.from(user.serverIds)
serverIds: remainingServerIds
}, user.oderId);
}
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
const targetUserId = String(message['targetUserId'] || '');
const targetUserId = readMessageId(message['targetUserId']) ?? '';
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);

View File

@@ -6,7 +6,11 @@ import {
import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
import { connectedUsers } from './state';
import { broadcastToServer } from './broadcast';
import {
broadcastToServer,
getServerIdsForOderId,
isOderIdConnectedToServer
} from './broadcast';
import { handleWebSocketMessage } from './handler';
/** How often to ping all connected clients (ms). */
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
user.serverIds.forEach((sid) => {
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
return;
}
broadcastToServer(sid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid,
serverIds: []
serverIds: remainingServerIds
}, user.oderId);
});