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
@@ -60,6 +60,41 @@ export async function doCreateAndSendOffer(
}
}
/**
* Replace a peer whose underlying connection is `failed` or `closed`.
* Returns the existing peer if still usable, or `undefined` after cleanup.
*/
function replaceUnusablePeer(
context: PeerConnectionManagerContext,
peerId: string,
reason: string
): void {
const { logger, state } = context;
const peerData = state.activePeerConnections.get(peerId);
if (!peerData)
return;
const cs = peerData.connection.connectionState;
if (cs !== 'failed' && cs !== 'closed')
return;
logger.info('Replacing unusable peer', {
connectionState: cs,
peerId,
reason,
signalingState: peerData.connection.signalingState
});
try {
peerData.connection.close();
} catch { /* already closing */ }
state.activePeerConnections.delete(peerId);
state.peerNegotiationQueue.delete(peerId);
}
export async function doHandleOffer(
context: PeerConnectionManagerContext,
fromUserId: string,
@@ -70,6 +105,8 @@ export async function doHandleOffer(
logger.info('Handling offer', { fromUserId });
replaceUnusablePeer(context, fromUserId, 'incoming offer');
let peerData = state.activePeerConnections.get(fromUserId);
if (!peerData) {
@@ -82,16 +119,15 @@ export async function doHandleOffer(
signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
if (hasCollision) {
const localId =
callbacks.getIdentifyCredentials()?.oderId || callbacks.getLocalPeerId();
const isPolite = localId > fromUserId;
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
const isPolite = !localOderId || localOderId > fromUserId;
if (!isPolite) {
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId });
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
return;
}
logger.info('Rolling back local offer (polite side)', { fromUserId, localId });
logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
await peerData.connection.setLocalDescription({
type: 'rollback'
@@ -211,6 +247,8 @@ export async function doHandleIceCandidate(
): Promise<void> {
const { logger, state } = context;
replaceUnusablePeer(context, fromUserId, 'early ICE');
let peerData = state.activePeerConnections.get(fromUserId);
if (!peerData) {