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') {
|
||||
|
||||
@@ -29,7 +29,8 @@ export const UsersActions = createActionGroup({
|
||||
'Load Room Users Failure': props<{ error: string }>(),
|
||||
|
||||
'User Joined': props<{ user: User }>(),
|
||||
'User Left': props<{ userId: string }>(),
|
||||
'User Left': props<{ userId: string; serverId?: string; serverIds?: string[] }>(),
|
||||
'Sync Server Presence': props<{ roomId: string; users: User[] }>(),
|
||||
|
||||
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
||||
'Update User Role': props<{ userId: string; role: User['role'] }>(),
|
||||
|
||||
@@ -7,6 +7,105 @@ import {
|
||||
import { User, BanEntry } from '../../shared-kernel';
|
||||
import { UsersActions } from './users.actions';
|
||||
|
||||
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(serverIds)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = Array.from(new Set(
|
||||
serverIds.filter((serverId): serverId is string => typeof serverId === 'string' && serverId.trim().length > 0)
|
||||
));
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function mergePresenceServerIds(
|
||||
existingServerIds: readonly string[] | undefined,
|
||||
incomingServerIds: readonly string[] | undefined
|
||||
): string[] | undefined {
|
||||
return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]);
|
||||
}
|
||||
|
||||
function buildDisconnectedVoiceState(user: User): User['voiceState'] {
|
||||
if (!user.voiceState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...user.voiceState,
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function buildInactiveScreenShareState(user: User): User['screenShareState'] {
|
||||
if (!user.screenShareState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...user.screenShareState,
|
||||
isSharing: false,
|
||||
streamId: undefined,
|
||||
sourceId: undefined,
|
||||
sourceName: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function buildInactiveCameraState(user: User): User['cameraState'] {
|
||||
if (!user.cameraState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...user.cameraState,
|
||||
isEnabled: false
|
||||
};
|
||||
}
|
||||
|
||||
function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: User): User {
|
||||
const presenceServerIds = mergePresenceServerIds(existingUser?.presenceServerIds, incomingUser.presenceServerIds);
|
||||
const isOnline = (presenceServerIds?.length ?? 0) > 0 || incomingUser.isOnline === true;
|
||||
const status = isOnline
|
||||
? (incomingUser.status !== 'offline'
|
||||
? incomingUser.status
|
||||
: (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online'))
|
||||
: 'offline';
|
||||
|
||||
return {
|
||||
...existingUser,
|
||||
...incomingUser,
|
||||
presenceServerIds,
|
||||
isOnline,
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
function buildPresenceRemovalChanges(
|
||||
user: User,
|
||||
update: { serverId?: string; serverIds?: readonly string[] }
|
||||
): Partial<User> {
|
||||
const nextPresenceServerIds = update.serverIds !== undefined
|
||||
? normalizePresenceServerIds(update.serverIds)
|
||||
: normalizePresenceServerIds((user.presenceServerIds ?? []).filter((serverId) => serverId !== update.serverId));
|
||||
const isOnline = (nextPresenceServerIds?.length ?? 0) > 0;
|
||||
const shouldClearLiveState = !isOnline
|
||||
|| (!!user.voiceState?.serverId && !nextPresenceServerIds?.includes(user.voiceState.serverId));
|
||||
|
||||
return {
|
||||
presenceServerIds: nextPresenceServerIds,
|
||||
isOnline,
|
||||
status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline',
|
||||
voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState,
|
||||
screenShareState: shouldClearLiveState ? buildInactiveScreenShareState(user) : user.screenShareState,
|
||||
cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsersState extends EntityState<User> {
|
||||
currentUserId: string | null;
|
||||
hostId: string | null;
|
||||
@@ -86,11 +185,61 @@ export const usersReducer = createReducer(
|
||||
error
|
||||
})),
|
||||
on(UsersActions.userJoined, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, state)
|
||||
),
|
||||
on(UsersActions.userLeft, (state, { userId }) =>
|
||||
usersAdapter.removeOne(userId, state)
|
||||
usersAdapter.upsertOne(buildPresenceAwareUser(state.entities[user.id], user), state)
|
||||
),
|
||||
on(UsersActions.syncServerPresence, (state, { roomId, users }) => {
|
||||
let nextState = state;
|
||||
|
||||
const seenUserIds = new Set<string>();
|
||||
|
||||
for (const user of users) {
|
||||
seenUserIds.add(user.id);
|
||||
nextState = usersAdapter.upsertOne(
|
||||
buildPresenceAwareUser(nextState.entities[user.id], user),
|
||||
nextState
|
||||
);
|
||||
}
|
||||
|
||||
const stalePresenceUpdates = Object.values(nextState.entities)
|
||||
.filter((user): user is User =>
|
||||
!!user
|
||||
&& user.id !== nextState.currentUserId
|
||||
&& user.presenceServerIds?.includes(roomId) === true
|
||||
&& !seenUserIds.has(user.id)
|
||||
)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
changes: buildPresenceRemovalChanges(user, { serverId: roomId })
|
||||
}));
|
||||
|
||||
return stalePresenceUpdates.length > 0
|
||||
? usersAdapter.updateMany(stalePresenceUpdates, nextState)
|
||||
: nextState;
|
||||
}),
|
||||
on(UsersActions.userLeft, (state, { userId, serverId, serverIds }) => {
|
||||
const existingUser = state.entities[userId];
|
||||
|
||||
if (!existingUser) {
|
||||
return (!serverId && !serverIds)
|
||||
? usersAdapter.removeOne(userId, state)
|
||||
: state;
|
||||
}
|
||||
|
||||
if (!serverId && !serverIds) {
|
||||
return usersAdapter.removeOne(userId, state);
|
||||
}
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: buildPresenceRemovalChanges(existingUser, {
|
||||
serverId,
|
||||
serverIds
|
||||
})
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
on(UsersActions.updateUser, (state, { userId, updates }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
@@ -171,6 +320,8 @@ export const usersReducer = createReducer(
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId');
|
||||
const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId');
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
@@ -183,9 +334,8 @@ export const usersReducer = createReducer(
|
||||
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
|
||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
// Use explicit undefined check - if undefined is passed, clear the value
|
||||
roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId,
|
||||
serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId
|
||||
roomId: hasRoomId ? voiceState.roomId : prev.roomId,
|
||||
serverId: hasServerId ? voiceState.serverId : prev.serverId
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -82,7 +82,13 @@ export const selectIsCurrentUserAdmin = createSelector(
|
||||
/** Selects users who are currently online (not offline). */
|
||||
export const selectOnlineUsers = createSelector(
|
||||
selectAllUsers,
|
||||
(users) => users.filter((user) => user.status !== 'offline' || user.isOnline === true)
|
||||
(users) => users.filter((user) => {
|
||||
if (Array.isArray(user.presenceServerIds)) {
|
||||
return user.presenceServerIds.length > 0 || user.isOnline === true || user.status !== 'offline';
|
||||
}
|
||||
|
||||
return user.status !== 'offline' || user.isOnline === true;
|
||||
})
|
||||
);
|
||||
|
||||
/** Creates a selector that returns users with a specific role. */
|
||||
|
||||
Reference in New Issue
Block a user