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:
@@ -23,9 +23,10 @@ export class ServerSignalingCoordinator<TMessage> {
|
||||
private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>();
|
||||
private readonly serverSignalingUrlMap = new Map<string, string>();
|
||||
private readonly peerSignalingUrlMap = new Map<string, string>();
|
||||
private readonly peerKnownSignalUrls = new Map<string, Set<string>>();
|
||||
private readonly signalingManagers = new Map<string, SignalingManager>();
|
||||
private readonly signalingSubscriptions = new Map<string, Subscription[]>();
|
||||
private readonly peerServerMap = new Map<string, Set<string>>();
|
||||
private readonly peerServerMap = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
constructor(
|
||||
private readonly callbacks: ServerSignalingCoordinatorCallbacks<TMessage>
|
||||
@@ -126,15 +127,28 @@ export class ServerSignalingCoordinator<TMessage> {
|
||||
}
|
||||
|
||||
setPeerSignalUrl(peerId: string, signalUrl: string): void {
|
||||
const knownSignalUrls = this.peerKnownSignalUrls.get(peerId) ?? new Set<string>();
|
||||
|
||||
knownSignalUrls.add(signalUrl);
|
||||
this.peerKnownSignalUrls.set(peerId, knownSignalUrls);
|
||||
this.peerSignalingUrlMap.set(peerId, signalUrl);
|
||||
}
|
||||
|
||||
getPeerSignalUrl(peerId: string): string | undefined {
|
||||
return this.peerSignalingUrlMap.get(peerId);
|
||||
const preferredSignalUrl = this.peerSignalingUrlMap.get(peerId);
|
||||
|
||||
if (preferredSignalUrl) {
|
||||
return preferredSignalUrl;
|
||||
}
|
||||
|
||||
const knownSignalUrls = this.peerKnownSignalUrls.get(peerId);
|
||||
|
||||
return knownSignalUrls?.values().next().value;
|
||||
}
|
||||
|
||||
deletePeerSignalUrl(peerId: string): void {
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
this.peerKnownSignalUrls.delete(peerId);
|
||||
}
|
||||
|
||||
addJoinedServer(signalUrl: string, serverId: string): void {
|
||||
@@ -197,64 +211,86 @@ export class ServerSignalingCoordinator<TMessage> {
|
||||
return joinedServerIds;
|
||||
}
|
||||
|
||||
trackPeerInServer(peerId: string, serverId: string): void {
|
||||
if (!peerId || !serverId)
|
||||
trackPeerInServer(peerId: string, serverId: string, signalUrl: string): void {
|
||||
if (!peerId || !serverId || !signalUrl)
|
||||
return;
|
||||
|
||||
const trackedServers = this.peerServerMap.get(peerId) ?? new Set<string>();
|
||||
const trackedSignalUrls = this.peerServerMap.get(peerId) ?? new Map<string, Set<string>>();
|
||||
const trackedServers = trackedSignalUrls.get(signalUrl) ?? new Set<string>();
|
||||
|
||||
trackedServers.add(serverId);
|
||||
this.peerServerMap.set(peerId, trackedServers);
|
||||
trackedSignalUrls.set(signalUrl, trackedServers);
|
||||
this.peerServerMap.set(peerId, trackedSignalUrls);
|
||||
this.setPeerSignalUrl(peerId, signalUrl);
|
||||
}
|
||||
|
||||
hasTrackedPeerServers(peerId: string): boolean {
|
||||
return this.peerServerMap.has(peerId);
|
||||
return this.getTrackedServerIds(peerId).size > 0;
|
||||
}
|
||||
|
||||
replacePeerSharedServers(peerId: string, serverIds: string[]): boolean {
|
||||
replacePeerSharedServers(peerId: string, signalUrl: string, serverIds: string[]): boolean {
|
||||
const sharedServerIds = serverIds.filter((serverId) => this.hasJoinedServer(serverId));
|
||||
|
||||
if (sharedServerIds.length === 0) {
|
||||
this.peerServerMap.delete(peerId);
|
||||
return false;
|
||||
this.removePeerSignalScope(peerId, signalUrl);
|
||||
return this.hasTrackedPeerServers(peerId);
|
||||
}
|
||||
|
||||
this.peerServerMap.set(peerId, new Set(sharedServerIds));
|
||||
const trackedSignalUrls = this.peerServerMap.get(peerId) ?? new Map<string, Set<string>>();
|
||||
|
||||
trackedSignalUrls.set(signalUrl, new Set(sharedServerIds));
|
||||
this.peerServerMap.set(peerId, trackedSignalUrls);
|
||||
this.setPeerSignalUrl(peerId, signalUrl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
untrackPeerFromServer(peerId: string, serverId: string): boolean {
|
||||
const trackedServers = this.peerServerMap.get(peerId);
|
||||
untrackPeerFromServer(peerId: string, signalUrl: string, serverId: string): boolean {
|
||||
const trackedSignalUrls = this.peerServerMap.get(peerId);
|
||||
|
||||
if (!trackedSignalUrls)
|
||||
return false;
|
||||
|
||||
const trackedServers = trackedSignalUrls.get(signalUrl);
|
||||
|
||||
if (!trackedServers)
|
||||
return false;
|
||||
return this.hasTrackedPeerServers(peerId);
|
||||
|
||||
trackedServers.delete(serverId);
|
||||
|
||||
if (trackedServers.size === 0) {
|
||||
trackedSignalUrls.delete(signalUrl);
|
||||
this.untrackPeerSignalUrl(peerId, signalUrl);
|
||||
} else {
|
||||
trackedSignalUrls.set(signalUrl, trackedServers);
|
||||
}
|
||||
|
||||
if (trackedSignalUrls.size === 0) {
|
||||
this.peerServerMap.delete(peerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.peerServerMap.set(peerId, trackedServers);
|
||||
this.peerServerMap.set(peerId, trackedSignalUrls);
|
||||
return true;
|
||||
}
|
||||
|
||||
deletePeerTracking(peerId: string): void {
|
||||
this.peerServerMap.delete(peerId);
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
this.peerKnownSignalUrls.delete(peerId);
|
||||
}
|
||||
|
||||
clearPeerTracking(): void {
|
||||
this.peerServerMap.clear();
|
||||
this.peerSignalingUrlMap.clear();
|
||||
this.peerKnownSignalUrls.clear();
|
||||
}
|
||||
|
||||
getPeersOutsideServer(serverId: string): string[] {
|
||||
const peersToClose: string[] = [];
|
||||
|
||||
this.peerServerMap.forEach((peerServerIds, peerId) => {
|
||||
if (!peerServerIds.has(serverId)) {
|
||||
this.peerServerMap.forEach((_peerServerIdsBySignalUrl, peerId) => {
|
||||
if (!this.getTrackedServerIds(peerId).has(serverId)) {
|
||||
peersToClose.push(peerId);
|
||||
}
|
||||
});
|
||||
@@ -292,4 +328,64 @@ export class ServerSignalingCoordinator<TMessage> {
|
||||
this.memberServerIdsBySignalUrl.set(signalUrl, createdSet);
|
||||
return createdSet;
|
||||
}
|
||||
|
||||
private getTrackedServerIds(peerId: string): Set<string> {
|
||||
const trackedServerIds = new Set<string>();
|
||||
const trackedSignalUrls = this.peerServerMap.get(peerId);
|
||||
|
||||
if (!trackedSignalUrls) {
|
||||
return trackedServerIds;
|
||||
}
|
||||
|
||||
trackedSignalUrls.forEach((serverIds) => {
|
||||
serverIds.forEach((serverId) => trackedServerIds.add(serverId));
|
||||
});
|
||||
|
||||
return trackedServerIds;
|
||||
}
|
||||
|
||||
private removePeerSignalScope(peerId: string, signalUrl: string): void {
|
||||
const trackedSignalUrls = this.peerServerMap.get(peerId);
|
||||
|
||||
if (!trackedSignalUrls) {
|
||||
this.untrackPeerSignalUrl(peerId, signalUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
trackedSignalUrls.delete(signalUrl);
|
||||
|
||||
if (trackedSignalUrls.size === 0) {
|
||||
this.peerServerMap.delete(peerId);
|
||||
} else {
|
||||
this.peerServerMap.set(peerId, trackedSignalUrls);
|
||||
}
|
||||
|
||||
this.untrackPeerSignalUrl(peerId, signalUrl);
|
||||
}
|
||||
|
||||
private untrackPeerSignalUrl(peerId: string, signalUrl: string): void {
|
||||
const knownSignalUrls = this.peerKnownSignalUrls.get(peerId);
|
||||
|
||||
if (!knownSignalUrls) {
|
||||
if (this.peerSignalingUrlMap.get(peerId) === signalUrl) {
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
knownSignalUrls.delete(signalUrl);
|
||||
|
||||
if (knownSignalUrls.size === 0) {
|
||||
this.peerKnownSignalUrls.delete(peerId);
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.peerKnownSignalUrls.set(peerId, knownSignalUrls);
|
||||
|
||||
if (this.peerSignalingUrlMap.get(peerId) === signalUrl) {
|
||||
this.peerSignalingUrlMap.set(peerId, knownSignalUrls.values().next().value as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,26 @@ interface IncomingSignalingMessageHandlerDependencies {
|
||||
peerManager: PeerConnectionManager;
|
||||
signalingCoordinator: ServerSignalingCoordinator<IncomingSignalingMessage>;
|
||||
logger: WebRTCLogger;
|
||||
getLocalOderId(): string | null;
|
||||
getEffectiveServerId(): string | null;
|
||||
setServerTime(serverTime: number): void;
|
||||
}
|
||||
|
||||
const USER_JOINED_FALLBACK_OFFER_DELAY_MS = 1_000;
|
||||
const PEER_NEGOTIATION_GRACE_MS = 3_000;
|
||||
// Once a local offer has been sent, the peer is actively in negotiation - wait much
|
||||
// longer before treating it as stale, so a slow answer path doesn't cause an
|
||||
// unnecessary teardown/re-offer cycle.
|
||||
const PEER_NEGOTIATION_OFFER_SENT_GRACE_MS = 20_000;
|
||||
// How long the non-initiator waits for the elected initiator's offer before
|
||||
// giving up and creating the connection itself.
|
||||
const NON_INITIATOR_GIVE_UP_MS = 5_000;
|
||||
|
||||
export class IncomingSignalingMessageHandler {
|
||||
private readonly userJoinedFallbackTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Tracks when we first started waiting for a remote-initiated offer from each peer. */
|
||||
private readonly nonInitiatorWaitStart = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: IncomingSignalingMessageHandlerDependencies
|
||||
) {}
|
||||
@@ -105,6 +120,7 @@ export class IncomingSignalingMessageHandler {
|
||||
|
||||
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const users = Array.isArray(message.users) ? message.users : [];
|
||||
const localOderId = this.dependencies.getLocalOderId();
|
||||
|
||||
this.dependencies.logger.info('Server users', {
|
||||
count: users.length,
|
||||
@@ -120,15 +136,22 @@ export class IncomingSignalingMessageHandler {
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
|
||||
if (localOderId && user.oderId === localOderId)
|
||||
continue;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(user.oderId);
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(user.oderId, signalUrl);
|
||||
|
||||
if (message.serverId) {
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(user.oderId, message.serverId);
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(user.oderId, message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
const existing = this.dependencies.peerManager.activePeerConnections.get(user.oderId);
|
||||
|
||||
if (this.canReusePeerConnection(existing)) {
|
||||
if (this.hasActivePeerConnection(existing)) {
|
||||
// Peer is already up - move on (timer already cleared above).
|
||||
this.nonInitiatorWaitStart.delete(user.oderId);
|
||||
this.dependencies.logger.info('Reusing active peer connection', {
|
||||
connectionState: existing?.connection.connectionState ?? 'unknown',
|
||||
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
|
||||
@@ -140,6 +163,56 @@ export class IncomingSignalingMessageHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.scheduleUserJoinedFallbackOffer(user.oderId, signalUrl, message.serverId);
|
||||
|
||||
if (this.isPeerConnectionNegotiating(existing)) {
|
||||
this.dependencies.logger.info('Awaiting existing peer negotiation from server_users snapshot', {
|
||||
ageMs: existing ? Date.now() - existing.createdAt : undefined,
|
||||
connectionState: existing?.connection.connectionState ?? 'unknown',
|
||||
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!localOderId) {
|
||||
this.dependencies.logger.info('Deferring server_users peer initiation until logical identity is ready', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const shouldInitiate = this.shouldInitiatePeer(user.oderId, localOderId);
|
||||
|
||||
if (!shouldInitiate) {
|
||||
if (existing) {
|
||||
this.dependencies.logger.info('Removing stale peer while waiting for remote offer', {
|
||||
connectionState: existing.connection.connectionState,
|
||||
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.removePeer(user.oderId);
|
||||
}
|
||||
|
||||
this.dependencies.logger.info('Waiting for remote offer based on deterministic initiator selection', {
|
||||
localOderId,
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.dependencies.logger.info('Removing failed peer before recreate', {
|
||||
connectionState: existing.connection.connectionState,
|
||||
@@ -164,6 +237,10 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
if (message.oderId && message.oderId === this.dependencies.getLocalOderId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.info('User joined', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
@@ -179,11 +256,27 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
if (message.oderId && message.serverId) {
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(message.oderId, message.serverId);
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(message.oderId, message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
if (message.oderId) {
|
||||
const existing = this.dependencies.peerManager.activePeerConnections.get(message.oderId);
|
||||
|
||||
if (this.hasActivePeerConnection(existing)) {
|
||||
// Already connected - cancel any stale timer and move on.
|
||||
this.clearUserJoinedFallbackOffer(message.oderId);
|
||||
this.nonInitiatorWaitStart.delete(message.oderId);
|
||||
} else {
|
||||
this.scheduleUserJoinedFallbackOffer(message.oderId, signalUrl, message.serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
if (message.oderId && message.oderId === this.dependencies.getLocalOderId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.info('User left', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
@@ -192,10 +285,13 @@ export class IncomingSignalingMessageHandler {
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
this.clearUserJoinedFallbackOffer(message.oderId);
|
||||
this.nonInitiatorWaitStart.delete(message.oderId);
|
||||
|
||||
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, message.serverIds)
|
||||
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
|
||||
: (message.serverId
|
||||
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, message.serverId)
|
||||
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId)
|
||||
: false);
|
||||
|
||||
if (!hasRemainingSharedServers) {
|
||||
@@ -212,12 +308,18 @@ export class IncomingSignalingMessageHandler {
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
this.nonInitiatorWaitStart.delete(fromUserId);
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
|
||||
|
||||
const effectiveServerId = this.dependencies.getEffectiveServerId();
|
||||
|
||||
if (effectiveServerId && !this.dependencies.signalingCoordinator.hasTrackedPeerServers(fromUserId)) {
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(fromUserId, effectiveServerId);
|
||||
this.dependencies.signalingCoordinator.trackPeerInServer(fromUserId, effectiveServerId, signalUrl);
|
||||
}
|
||||
|
||||
this.dependencies.peerManager.handleOffer(fromUserId, sdp);
|
||||
@@ -230,6 +332,11 @@ export class IncomingSignalingMessageHandler {
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
|
||||
this.dependencies.peerManager.handleAnswer(fromUserId, sdp);
|
||||
}
|
||||
@@ -241,16 +348,197 @@ export class IncomingSignalingMessageHandler {
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
|
||||
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
|
||||
this.dependencies.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||
}
|
||||
|
||||
private canReusePeerConnection(peer: PeerData | undefined): boolean {
|
||||
private scheduleUserJoinedFallbackOffer(peerId: string, signalUrl: string, serverId?: string): void {
|
||||
this.clearUserJoinedFallbackOffer(peerId);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.userJoinedFallbackTimers.delete(peerId);
|
||||
const localOderId = this.dependencies.getLocalOderId();
|
||||
const existing = this.dependencies.peerManager.activePeerConnections.get(peerId);
|
||||
|
||||
if (this.hasActivePeerConnection(existing)) {
|
||||
this.nonInitiatorWaitStart.delete(peerId);
|
||||
this.dependencies.logger.info('Skip user_joined fallback offer - peer already active', {
|
||||
connectionState: existing?.connection.connectionState ?? 'unknown',
|
||||
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localOderId) {
|
||||
this.dependencies.logger.info('Retrying peer initiation once logical identity is ready', {
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isPeerConnectionNegotiating(existing)) {
|
||||
this.dependencies.logger.info('Delaying fallback offer while peer negotiation is still in progress', {
|
||||
ageMs: existing ? Date.now() - existing.createdAt : undefined,
|
||||
connectionState: existing?.connection.connectionState ?? 'unknown',
|
||||
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
|
||||
localOderId,
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldInitiate = this.shouldInitiatePeer(peerId, localOderId);
|
||||
|
||||
if (!shouldInitiate) {
|
||||
// Track how long we've been waiting for the remote initiator's offer.
|
||||
if (!this.nonInitiatorWaitStart.has(peerId)) {
|
||||
this.nonInitiatorWaitStart.set(peerId, Date.now());
|
||||
}
|
||||
|
||||
const waitStart = this.nonInitiatorWaitStart.get(peerId) ?? Date.now();
|
||||
const waitMs = Date.now() - waitStart;
|
||||
|
||||
if (waitMs < NON_INITIATOR_GIVE_UP_MS) {
|
||||
this.dependencies.logger.info('Waiting for remote initiator offer', {
|
||||
localOderId,
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl,
|
||||
waitMs
|
||||
});
|
||||
|
||||
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
// The elected initiator never sent an offer - take over.
|
||||
this.nonInitiatorWaitStart.delete(peerId);
|
||||
|
||||
if (existing) {
|
||||
this.dependencies.logger.info('Removing stale peer before non-initiator takeover offer', {
|
||||
connectionState: existing.connection.connectionState,
|
||||
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
|
||||
localOderId,
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl,
|
||||
waitMs
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.removePeer(peerId);
|
||||
}
|
||||
|
||||
this.dependencies.logger.info('Non-initiator takeover - creating peer connection after remote initiator timeout', {
|
||||
localOderId,
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl,
|
||||
waitMs
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.createPeerConnection(peerId, true);
|
||||
void this.dependencies.peerManager.createAndSendOffer(peerId);
|
||||
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.dependencies.logger.info('Removing stale peer before user_joined fallback offer', {
|
||||
connectionState: existing.connection.connectionState,
|
||||
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.removePeer(peerId);
|
||||
}
|
||||
|
||||
this.dependencies.logger.info('Create peer connection from user_joined fallback offer', {
|
||||
oderId: peerId,
|
||||
serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.dependencies.peerManager.createPeerConnection(peerId, true);
|
||||
void this.dependencies.peerManager.createAndSendOffer(peerId);
|
||||
|
||||
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
|
||||
}, USER_JOINED_FALLBACK_OFFER_DELAY_MS);
|
||||
|
||||
this.userJoinedFallbackTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
private clearUserJoinedFallbackOffer(peerId: string): void {
|
||||
const timer = this.userJoinedFallbackTimers.get(peerId);
|
||||
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
this.userJoinedFallbackTimers.delete(peerId);
|
||||
}
|
||||
|
||||
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
|
||||
if (!localOderId)
|
||||
return false;
|
||||
|
||||
if (peerId === localOderId)
|
||||
return false;
|
||||
|
||||
return localOderId < peerId;
|
||||
}
|
||||
|
||||
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
|
||||
if (!peer)
|
||||
return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
return connectionState !== 'closed' && connectionState !== 'failed';
|
||||
return connectionState === 'connected' || peer.dataChannel?.readyState === 'open';
|
||||
}
|
||||
|
||||
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
|
||||
if (!peer || this.hasActivePeerConnection(peer))
|
||||
return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
if (connectionState === 'closed' || connectionState === 'failed')
|
||||
return false;
|
||||
|
||||
const signalingState = peer.connection?.signalingState;
|
||||
const ageMs = Date.now() - peer.createdAt;
|
||||
|
||||
// If a local offer (or pranswer) has already been sent, the peer is actively
|
||||
// negotiating with the remote side. Use a much longer grace period so that
|
||||
// a slow signaling round-trip does not trigger a premature teardown.
|
||||
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer')
|
||||
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
|
||||
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked).
|
||||
// TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
|
||||
if (connectionState === 'connecting')
|
||||
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
|
||||
return ageMs < PEER_NEGOTIATION_GRACE_MS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
|
||||
sendRawMessage(message: Record<string, unknown>): void {
|
||||
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
|
||||
const messageType = typeof message['type'] === 'string' ? message['type'] : 'unknown';
|
||||
|
||||
if (targetPeerId) {
|
||||
const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId);
|
||||
@@ -102,6 +103,11 @@ export class SignalingTransportHandler<TMessage> {
|
||||
if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.warn('[signaling] Missing peer signal route for outbound raw message', {
|
||||
targetPeerId,
|
||||
type: messageType
|
||||
});
|
||||
}
|
||||
|
||||
const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null;
|
||||
@@ -118,12 +124,19 @@ export class SignalingTransportHandler<TMessage> {
|
||||
|
||||
if (connectedManagers.length === 0) {
|
||||
this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown'
|
||||
type: messageType
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.warn('[signaling] Broadcasting raw message to all signaling managers due to unresolved route', {
|
||||
connectedSignalUrls: connectedManagers.map(({ signalUrl }) => signalUrl),
|
||||
serverId,
|
||||
targetPeerId,
|
||||
type: messageType
|
||||
});
|
||||
|
||||
for (const { manager } of connectedManagers) {
|
||||
manager.sendRawMessage(message);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* Manages the WebSocket connection to the signaling server,
|
||||
* including automatic reconnection and heartbeats.
|
||||
*/
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
of
|
||||
} from 'rxjs';
|
||||
import type { SignalingMessage } from '../../../shared-kernel';
|
||||
import { recordDebugNetworkSignalingPayload } from '../logging/debug-network-metrics';
|
||||
import { IdentifyCredentials, JoinedServerInfo } from '../realtime.types';
|
||||
@@ -54,19 +58,42 @@ export class SignalingManager {
|
||||
|
||||
/** Open (or re-open) a WebSocket to the signaling server. */
|
||||
connect(serverUrl: string): Observable<boolean> {
|
||||
if (this.lastSignalingUrl === serverUrl) {
|
||||
if (this.isSocketOpen()) {
|
||||
return of(true);
|
||||
}
|
||||
|
||||
if (this.isSocketConnecting()) {
|
||||
return this.waitForOpen();
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
return new Observable<boolean>((observer) => {
|
||||
try {
|
||||
this.logger.info('[signaling] Connecting to signaling server', { serverUrl });
|
||||
|
||||
if (this.signalingWebSocket) {
|
||||
this.signalingWebSocket.close();
|
||||
}
|
||||
const previousSocket = this.signalingWebSocket;
|
||||
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
this.signalingWebSocket = new WebSocket(serverUrl);
|
||||
const socket = new WebSocket(serverUrl);
|
||||
|
||||
this.signalingWebSocket = socket;
|
||||
|
||||
if (previousSocket && previousSocket !== socket) {
|
||||
try {
|
||||
previousSocket.close();
|
||||
} catch {
|
||||
this.logger.warn('[signaling] Failed to close previous signaling socket', {
|
||||
url: serverUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
this.signalingWebSocket.onopen = () => {
|
||||
this.logger.info('[signaling] Connected to signaling server', {
|
||||
serverUrl,
|
||||
readyState: this.getSocketReadyStateLabel()
|
||||
@@ -77,9 +104,13 @@ export class SignalingManager {
|
||||
this.connectionStatus$.next({ connected: true });
|
||||
this.reIdentifyAndRejoin();
|
||||
observer.next(true);
|
||||
observer.complete();
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onmessage = (event) => {
|
||||
socket.onmessage = (event) => {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
const rawPayload = this.stringifySocketPayload(event.data);
|
||||
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
|
||||
|
||||
@@ -109,7 +140,10 @@ export class SignalingManager {
|
||||
}
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onerror = (error) => {
|
||||
socket.onerror = (error) => {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
this.logger.error('[signaling] Signaling socket error', error, {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: serverUrl
|
||||
@@ -121,7 +155,10 @@ export class SignalingManager {
|
||||
observer.error(error);
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onclose = (event) => {
|
||||
socket.onclose = (event) => {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
this.logger.warn('[signaling] Disconnected from signaling server', {
|
||||
attempts: this.signalingReconnectAttempts,
|
||||
code: event.code,
|
||||
@@ -216,9 +253,12 @@ export class SignalingManager {
|
||||
this.stopHeartbeat();
|
||||
this.clearReconnect();
|
||||
|
||||
if (this.signalingWebSocket) {
|
||||
this.signalingWebSocket.close();
|
||||
this.signalingWebSocket = null;
|
||||
const socket = this.signalingWebSocket;
|
||||
|
||||
this.signalingWebSocket = null;
|
||||
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +267,10 @@ export class SignalingManager {
|
||||
return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
isSocketConnecting(): boolean {
|
||||
return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.CONNECTING;
|
||||
}
|
||||
|
||||
/** The URL last used to connect (needed for reconnection). */
|
||||
getLastUrl(): string | null {
|
||||
return this.lastSignalingUrl;
|
||||
@@ -273,7 +317,7 @@ export class SignalingManager {
|
||||
* No-ops if a timer is already pending or no URL is stored.
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl)
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl || this.isSocketOpen() || this.isSocketConnecting())
|
||||
return;
|
||||
|
||||
const delay = Math.min(
|
||||
@@ -283,6 +327,11 @@ export class SignalingManager {
|
||||
|
||||
this.signalingReconnectTimer = setTimeout(() => {
|
||||
this.signalingReconnectTimer = null;
|
||||
|
||||
if (this.isSocketOpen() || this.isSocketConnecting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.signalingReconnectAttempts++;
|
||||
this.logger.info('[signaling] Attempting reconnect', {
|
||||
attempt: this.signalingReconnectAttempts,
|
||||
@@ -297,6 +346,44 @@ export class SignalingManager {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private waitForOpen(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Observable<boolean> {
|
||||
if (this.isSocketOpen()) {
|
||||
return of(true);
|
||||
}
|
||||
|
||||
return new Observable<boolean>((observer) => {
|
||||
let settled = false;
|
||||
|
||||
const subscription = this.connectionStatus$.subscribe(({ connected }) => {
|
||||
if (!connected || settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
observer.next(true);
|
||||
observer.complete();
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
subscription.unsubscribe();
|
||||
observer.next(this.isSocketOpen());
|
||||
observer.complete();
|
||||
}, timeoutMs);
|
||||
|
||||
return () => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel any pending reconnect timer and reset the attempt counter. */
|
||||
private clearReconnect(): void {
|
||||
if (this.signalingReconnectTimer) {
|
||||
@@ -415,21 +502,23 @@ export class SignalingManager {
|
||||
const record = payload as Record<string, unknown>;
|
||||
const voiceState = this.summarizeVoiceState(record['voiceState']);
|
||||
const users = this.summarizeUsers(record['users']);
|
||||
|
||||
return {
|
||||
displayName: typeof record['displayName'] === 'string' ? record['displayName'] : undefined,
|
||||
fromUserId: typeof record['fromUserId'] === 'string' ? record['fromUserId'] : undefined,
|
||||
isScreenSharing: typeof record['isScreenSharing'] === 'boolean' ? record['isScreenSharing'] : undefined,
|
||||
const preview: Record<string, unknown> = {
|
||||
keys: Object.keys(record).slice(0, 10),
|
||||
oderId: typeof record['oderId'] === 'string' ? record['oderId'] : undefined,
|
||||
roomId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined,
|
||||
serverId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined,
|
||||
targetPeerId: typeof record['targetUserId'] === 'string' ? record['targetUserId'] : undefined,
|
||||
type: typeof record['type'] === 'string' ? record['type'] : 'unknown',
|
||||
userCount: Array.isArray(record['users']) ? record['users'].length : undefined,
|
||||
users,
|
||||
voiceState
|
||||
type: typeof record['type'] === 'string' ? record['type'] : 'unknown'
|
||||
};
|
||||
|
||||
this.assignPreviewValue(preview, 'displayName', typeof record['displayName'] === 'string' ? record['displayName'] : undefined);
|
||||
this.assignPreviewValue(preview, 'fromUserId', typeof record['fromUserId'] === 'string' ? record['fromUserId'] : undefined);
|
||||
this.assignPreviewValue(preview, 'isScreenSharing', typeof record['isScreenSharing'] === 'boolean' ? record['isScreenSharing'] : undefined);
|
||||
this.assignPreviewValue(preview, 'oderId', typeof record['oderId'] === 'string' ? record['oderId'] : undefined);
|
||||
this.assignPreviewValue(preview, 'roomId', typeof record['roomId'] === 'string' ? record['roomId'] : undefined);
|
||||
this.assignPreviewValue(preview, 'serverId', typeof record['serverId'] === 'string' ? record['serverId'] : undefined);
|
||||
this.assignPreviewValue(preview, 'targetPeerId', typeof record['targetUserId'] === 'string' ? record['targetUserId'] : undefined);
|
||||
this.assignPreviewValue(preview, 'userCount', Array.isArray(record['users']) ? record['users'].length : undefined);
|
||||
this.assignPreviewValue(preview, 'users', users);
|
||||
this.assignPreviewValue(preview, 'voiceState', voiceState);
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
private summarizeVoiceState(value: unknown): Record<string, unknown> | undefined {
|
||||
@@ -438,15 +527,18 @@ export class SignalingManager {
|
||||
if (!voiceState)
|
||||
return undefined;
|
||||
|
||||
return {
|
||||
const summary: 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
|
||||
};
|
||||
|
||||
this.assignPreviewValue(summary, 'roomId', typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined);
|
||||
this.assignPreviewValue(summary, 'serverId', typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined);
|
||||
this.assignPreviewValue(summary, 'volume', typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private summarizeUsers(value: unknown): Record<string, unknown>[] | undefined {
|
||||
@@ -461,15 +553,22 @@ export class SignalingManager {
|
||||
if (!user)
|
||||
continue;
|
||||
|
||||
users.push({
|
||||
displayName: typeof user['displayName'] === 'string' ? user['displayName'] : undefined,
|
||||
oderId: typeof user['oderId'] === 'string' ? user['oderId'] : undefined
|
||||
});
|
||||
const summary: Record<string, unknown> = {};
|
||||
|
||||
this.assignPreviewValue(summary, 'displayName', typeof user['displayName'] === 'string' ? user['displayName'] : undefined);
|
||||
this.assignPreviewValue(summary, 'oderId', typeof user['oderId'] === 'string' ? user['oderId'] : undefined);
|
||||
|
||||
users.push(summary);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
private assignPreviewValue(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||
if (value !== undefined)
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user