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

View File

@@ -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
}
}
},