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:
@@ -416,7 +416,10 @@ export class RoomMembersSyncEffects {
|
||||
|
||||
if (currentRoom?.id === room.id && departedUserId) {
|
||||
actions.push(
|
||||
UsersActions.userLeft({ userId: departedUserId })
|
||||
UsersActions.userLeft({
|
||||
userId: departedUserId,
|
||||
serverId: room.id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ interface RoomPresenceSignalingMessage {
|
||||
type: string;
|
||||
reason?: string;
|
||||
serverId?: string;
|
||||
serverIds?: string[];
|
||||
users?: { oderId: string; displayName: string }[];
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
@@ -185,8 +186,8 @@ export class RoomsEffects {
|
||||
|
||||
/**
|
||||
* Tracks user IDs we already know are in voice. Lives outside the
|
||||
* NgRx store so it survives `clearUsers()` dispatched on server switches
|
||||
* and prevents false join/leave sounds during state re-syncs.
|
||||
* NgRx store so it survives room switches and presence re-syncs,
|
||||
* preventing false join/leave sounds during state refreshes.
|
||||
*/
|
||||
private knownVoiceUsers = new Set<string>();
|
||||
private roomNavigationRequestVersion = 0;
|
||||
@@ -696,15 +697,11 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/** Reloads messages and users when the viewed server changes. */
|
||||
/** Reloads messages and bans when the viewed server changes. */
|
||||
onViewServerSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.viewServerSuccess),
|
||||
mergeMap(({ room }) => [
|
||||
UsersActions.clearUsers(),
|
||||
MessagesActions.loadMessages({ roomId: room.id }),
|
||||
UsersActions.loadBans()
|
||||
])
|
||||
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1199,52 +1196,63 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/** Clears messages and users from the store when leaving a room. */
|
||||
/** Clears viewed messages when leaving a room. */
|
||||
onLeaveRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.leaveRoomSuccess),
|
||||
mergeMap(() => {
|
||||
this.knownVoiceUsers.clear();
|
||||
return [MessagesActions.clearMessages(), UsersActions.clearUsers()];
|
||||
})
|
||||
mergeMap(() => [MessagesActions.clearMessages()])
|
||||
)
|
||||
);
|
||||
|
||||
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
||||
signalingMessages$ = createEffect(() =>
|
||||
this.webrtc.onSignalingMessage.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectSavedRooms)
|
||||
),
|
||||
mergeMap(([
|
||||
message,
|
||||
currentUser,
|
||||
currentRoom
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
const signalingMessage: RoomPresenceSignalingMessage = message;
|
||||
const myId = currentUser?.oderId || currentUser?.id;
|
||||
const viewedServerId = currentRoom?.id;
|
||||
const room = this.resolveRoom(signalingMessage.serverId, currentRoom, savedRooms);
|
||||
const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId);
|
||||
|
||||
switch (signalingMessage.type) {
|
||||
case 'server_users': {
|
||||
if (!signalingMessage.users || isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
|
||||
return EMPTY;
|
||||
|
||||
const joinActions = signalingMessage.users
|
||||
const syncedUsers = signalingMessage.users
|
||||
.filter((u) => u.oderId !== myId)
|
||||
.map((u) =>
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(u, buildKnownUserExtras(currentRoom, u.oderId))
|
||||
buildSignalingUser(u, {
|
||||
...buildKnownUserExtras(room, u.oderId),
|
||||
presenceServerIds: [signalingMessage.serverId]
|
||||
})
|
||||
);
|
||||
|
||||
return [
|
||||
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
||||
UsersActions.clearUsers(),
|
||||
...joinActions
|
||||
const actions: Action[] = [
|
||||
UsersActions.syncServerPresence({
|
||||
roomId: signalingMessage.serverId,
|
||||
users: syncedUsers
|
||||
})
|
||||
];
|
||||
|
||||
if (shouldClearReconnectFlag) {
|
||||
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
case 'user_joined': {
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId) || signalingMessage.oderId === myId)
|
||||
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
|
||||
return EMPTY;
|
||||
|
||||
if (!signalingMessage.oderId)
|
||||
@@ -1254,24 +1262,47 @@ export class RoomsEffects {
|
||||
oderId: signalingMessage.oderId,
|
||||
displayName: signalingMessage.displayName
|
||||
};
|
||||
|
||||
return [
|
||||
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
||||
const actions: Action[] = [
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
||||
user: buildSignalingUser(joinedUser, {
|
||||
...buildKnownUserExtras(room, joinedUser.oderId),
|
||||
presenceServerIds: [signalingMessage.serverId]
|
||||
})
|
||||
})
|
||||
];
|
||||
|
||||
if (shouldClearReconnectFlag) {
|
||||
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
case 'user_left': {
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||
return EMPTY;
|
||||
|
||||
if (!signalingMessage.oderId)
|
||||
return EMPTY;
|
||||
|
||||
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
||||
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
||||
const remainingServerIds = Array.isArray(signalingMessage.serverIds)
|
||||
? signalingMessage.serverIds
|
||||
: undefined;
|
||||
|
||||
if (!remainingServerIds || remainingServerIds.length === 0) {
|
||||
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
||||
}
|
||||
|
||||
const actions: Action[] = [
|
||||
UsersActions.userLeft({
|
||||
userId: signalingMessage.oderId,
|
||||
serverId: signalingMessage.serverId,
|
||||
serverIds: remainingServerIds
|
||||
})
|
||||
];
|
||||
|
||||
if (shouldClearReconnectFlag) {
|
||||
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
case 'access_denied': {
|
||||
@@ -1354,13 +1385,13 @@ export class RoomsEffects {
|
||||
]) => {
|
||||
switch (event.type) {
|
||||
case 'voice-state':
|
||||
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice') : EMPTY;
|
||||
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
|
||||
case 'voice-channel-move':
|
||||
return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
|
||||
case 'screen-state':
|
||||
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen') : EMPTY;
|
||||
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen');
|
||||
case 'camera-state':
|
||||
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera') : EMPTY;
|
||||
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera');
|
||||
case 'server-state-request':
|
||||
return this.handleServerStateRequest(event, currentRoom, savedRooms);
|
||||
case 'server-state-full':
|
||||
@@ -1405,9 +1436,18 @@ export class RoomsEffects {
|
||||
if (!vs)
|
||||
return EMPTY;
|
||||
|
||||
const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
||||
? UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || existingUser?.displayName || 'User' },
|
||||
{ presenceServerIds: [vs.serverId] }
|
||||
)
|
||||
})
|
||||
: null;
|
||||
// Detect voice-connection transitions to play join/leave sounds.
|
||||
// Use the local knownVoiceUsers set (not the store) so that
|
||||
// clearUsers() from server-switching doesn't create false transitions.
|
||||
// Use the local knownVoiceUsers set (not the store) so presence
|
||||
// re-syncs and room switches do not create false transitions.
|
||||
const weAreInVoice = this.webrtc.isVoiceConnected();
|
||||
const nowConnected = vs.isConnected ?? false;
|
||||
const wasKnown = this.knownVoiceUsers.has(userId);
|
||||
@@ -1436,6 +1476,7 @@ export class RoomsEffects {
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || 'User' },
|
||||
{
|
||||
presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
|
||||
voiceState: {
|
||||
isConnected: vs.isConnected ?? false,
|
||||
isMuted: vs.isMuted ?? false,
|
||||
@@ -1452,8 +1493,16 @@ export class RoomsEffects {
|
||||
);
|
||||
}
|
||||
|
||||
return of(UsersActions.updateVoiceState({ userId,
|
||||
const actions: Action[] = [];
|
||||
|
||||
if (presenceRefreshAction) {
|
||||
actions.push(presenceRefreshAction);
|
||||
}
|
||||
|
||||
actions.push(UsersActions.updateVoiceState({ userId,
|
||||
voiceState: vs }));
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (kind === 'screen') {
|
||||
|
||||
Reference in New Issue
Block a user