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:
@@ -31,6 +31,35 @@ export function createPeerConnection(
|
||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
|
||||
let dataChannel: RTCDataChannel | null = null;
|
||||
let peerData: PeerData | null = null;
|
||||
|
||||
const adoptDataChannel = (channel: RTCDataChannel): void => {
|
||||
const primaryChannel = dataChannel;
|
||||
const shouldAdoptAsPrimary = !primaryChannel || primaryChannel.readyState === 'closed';
|
||||
|
||||
if (shouldAdoptAsPrimary) {
|
||||
dataChannel = channel;
|
||||
|
||||
if (peerData) {
|
||||
peerData.dataChannel = channel;
|
||||
}
|
||||
|
||||
const existing = state.activePeerConnections.get(remotePeerId);
|
||||
|
||||
if (existing) {
|
||||
existing.dataChannel = channel;
|
||||
}
|
||||
} else if (primaryChannel !== channel) {
|
||||
logger.info('Received secondary data channel while primary channel is still active', {
|
||||
channelLabel: channel.label,
|
||||
primaryChannelLabel: primaryChannel.label,
|
||||
primaryReadyState: primaryChannel.readyState,
|
||||
remotePeerId
|
||||
});
|
||||
}
|
||||
|
||||
handlers.setupDataChannel(channel, remotePeerId);
|
||||
};
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
@@ -53,6 +82,19 @@ export function createPeerConnection(
|
||||
state: connection.connectionState
|
||||
});
|
||||
|
||||
// Ignore events from a connection that was already replaced in the Map
|
||||
// (e.g. handleOffer recreated the peer while this handler was still queued).
|
||||
const currentPeer = state.activePeerConnections.get(remotePeerId);
|
||||
|
||||
if (currentPeer && currentPeer.connection !== connection) {
|
||||
logger.info('Ignoring stale connectionstatechange', {
|
||||
remotePeerId,
|
||||
state: connection.connectionState
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
recordDebugNetworkConnectionState(remotePeerId, connection.connectionState);
|
||||
|
||||
switch (connection.connectionState) {
|
||||
@@ -103,27 +145,20 @@ export function createPeerConnection(
|
||||
handlers.handleRemoteTrack(event, remotePeerId);
|
||||
};
|
||||
|
||||
connection.ondatachannel = (event) => {
|
||||
logger.info('Received data channel', { remotePeerId });
|
||||
adoptDataChannel(event.channel);
|
||||
};
|
||||
|
||||
if (isInitiator) {
|
||||
dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
||||
handlers.setupDataChannel(dataChannel, remotePeerId);
|
||||
} else {
|
||||
connection.ondatachannel = (event) => {
|
||||
logger.info('Received data channel', { remotePeerId });
|
||||
dataChannel = event.channel;
|
||||
|
||||
const existing = state.activePeerConnections.get(remotePeerId);
|
||||
|
||||
if (existing) {
|
||||
existing.dataChannel = dataChannel;
|
||||
}
|
||||
|
||||
handlers.setupDataChannel(dataChannel, remotePeerId);
|
||||
};
|
||||
}
|
||||
|
||||
const peerData: PeerData = {
|
||||
peerData = {
|
||||
connection,
|
||||
dataChannel,
|
||||
createdAt: Date.now(),
|
||||
isInitiator,
|
||||
pendingIceCandidates: [],
|
||||
audioSender: undefined,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -484,15 +484,23 @@ function summarizePeerMessage(payload: PeerMessage, base?: Record<string, unknow
|
||||
}
|
||||
|
||||
if (voiceState) {
|
||||
summary['voiceState'] = {
|
||||
const voiceStateSummary: Record<string, unknown> = {
|
||||
isConnected: voiceState['isConnected'] === true,
|
||||
isMuted: voiceState['isMuted'] === true,
|
||||
isDeafened: voiceState['isDeafened'] === true,
|
||||
isSpeaking: voiceState['isSpeaking'] === true,
|
||||
roomId: typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined,
|
||||
serverId: typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined,
|
||||
volume: typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined
|
||||
isSpeaking: voiceState['isSpeaking'] === true
|
||||
};
|
||||
|
||||
if (typeof voiceState['roomId'] === 'string')
|
||||
voiceStateSummary['roomId'] = voiceState['roomId'];
|
||||
|
||||
if (typeof voiceState['serverId'] === 'string')
|
||||
voiceStateSummary['serverId'] = voiceState['serverId'];
|
||||
|
||||
if (typeof voiceState['volume'] === 'number')
|
||||
voiceStateSummary['volume'] = voiceState['volume'];
|
||||
|
||||
summary['voiceState'] = voiceStateSummary;
|
||||
}
|
||||
|
||||
return summary;
|
||||
|
||||
@@ -200,23 +200,44 @@ export function schedulePeerReconnect(
|
||||
return;
|
||||
}
|
||||
|
||||
attemptPeerReconnect(state, peerId, handlers);
|
||||
attemptPeerReconnect(context, peerId, handlers);
|
||||
}, PEER_RECONNECT_INTERVAL_MS);
|
||||
|
||||
state.peerReconnectTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
export function attemptPeerReconnect(
|
||||
state: PeerConnectionManagerState,
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
handlers: RecoveryHandlers
|
||||
): void {
|
||||
const { callbacks, logger, state } = context;
|
||||
|
||||
if (state.activePeerConnections.has(peerId)) {
|
||||
handlers.removePeer(peerId, { preserveReconnectState: true });
|
||||
}
|
||||
|
||||
handlers.createPeerConnection(peerId, true);
|
||||
void handlers.createAndSendOffer(peerId);
|
||||
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
||||
|
||||
if (!localOderId) {
|
||||
logger.info('Skipping reconnect offer until logical identity is ready', { peerId });
|
||||
handlers.createPeerConnection(peerId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldInitiate = peerId !== localOderId && localOderId < peerId;
|
||||
|
||||
handlers.createPeerConnection(peerId, shouldInitiate);
|
||||
|
||||
if (shouldInitiate) {
|
||||
void handlers.createAndSendOffer(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Waiting for remote reconnect offer based on deterministic initiator selection', {
|
||||
localOderId,
|
||||
peerId
|
||||
});
|
||||
}
|
||||
|
||||
export function requestVoiceStateFromPeer(
|
||||
|
||||
Reference in New Issue
Block a user