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:
+43
-5
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user