fix: Broken voice states and connectivity drops

This commit is contained in:
2026-04-11 12:32:22 +02:00
parent 0865c2fe33
commit ef1182d46f
28 changed files with 1244 additions and 162 deletions

View File

@@ -117,6 +117,12 @@ The signaling layer's only job is getting two peers to exchange SDP offers/answe
Each signaling URL gets its own `SignalingManager` (one WebSocket each). `SignalingTransportHandler` picks the right socket based on which server the message is for. `ServerSignalingCoordinator` tracks which peers belong to which servers and which signaling URLs, so we know when it is safe to tear down a peer connection after leaving a server.
Room affinity is authoritative at this layer as well. The renderer repairs each room's saved `sourceId` / `sourceUrl` from server-directory responses and routes `join_server`, `view_server`, and room-scoped signaling traffic to that room's signaling URL first. If that route fails, alternate endpoints can be tried temporarily, but server-scoped raw messages are no longer broadcast to every connected signaling manager when the route is unknown.
Cold-start routing now waits for the initial server-directory health probes so same-backend aliases can collapse to one canonical signaling endpoint before any saved rooms reconnect. When a room is reconnected on a chosen socket, its background rooms are re-joined on that same socket as well so stale per-signal memberships do not keep orphan managers alive, and reconnect replay only sends `view_server` for rooms that manager still has joined.
This is still a non-federated model. Different signaling servers do not share peer registries or relay WebRTC offers for each other, so users in the same room must converge on the same signaling endpoint to discover one another reliably.
```mermaid
sequenceDiagram
participant UI as App

View File

@@ -84,6 +84,7 @@ export const SIGNALING_TYPE_CONNECTED = 'connected';
export const SIGNALING_TYPE_SERVER_USERS = 'server_users';
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
export const P2P_TYPE_STATE_REQUEST = 'state-request';
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';

View File

@@ -33,6 +33,8 @@ export class ServerMembershipSignalingHandler<TMessage> {
return;
}
this.migrateServerSignalUrl(roomId, resolvedSignalUrl);
this.dependencies.signalingCoordinator.setServerSignalUrl(roomId, resolvedSignalUrl);
this.dependencies.signalingCoordinator.setLastJoinedServer(resolvedSignalUrl, {
serverId: roomId,
@@ -55,6 +57,8 @@ export class ServerMembershipSignalingHandler<TMessage> {
return;
}
this.migrateServerSignalUrl(serverId, resolvedSignalUrl);
this.dependencies.signalingCoordinator.setServerSignalUrl(serverId, resolvedSignalUrl);
this.dependencies.signalingCoordinator.setLastJoinedServer(resolvedSignalUrl, {
serverId,
@@ -99,7 +103,9 @@ export class ServerMembershipSignalingHandler<TMessage> {
return;
}
for (const { signalUrl, serverIds } of this.dependencies.signalingCoordinator.getJoinedServerEntries()) {
const joinedEntries = this.dependencies.signalingCoordinator.getJoinedServerEntries();
for (const { signalUrl, serverIds } of joinedEntries) {
for (const joinedServerId of serverIds) {
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(signalUrl, {
type: SIGNALING_TYPE_LEAVE_SERVER,
@@ -109,6 +115,11 @@ export class ServerMembershipSignalingHandler<TMessage> {
}
this.dependencies.signalingCoordinator.clearJoinedServers();
for (const { signalUrl } of joinedEntries) {
this.dependencies.signalingCoordinator.pruneUnusedSignalUrl(signalUrl);
}
this.dependencies.runFullCleanup();
}
@@ -116,11 +127,13 @@ export class ServerMembershipSignalingHandler<TMessage> {
const resolvedSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(serverId);
if (resolvedSignalUrl) {
this.dependencies.signalingCoordinator.removeJoinedServer(resolvedSignalUrl, serverId);
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, {
type: SIGNALING_TYPE_LEAVE_SERVER,
serverId
});
this.dependencies.signalingCoordinator.removeJoinedServer(resolvedSignalUrl, serverId);
this.dependencies.signalingCoordinator.pruneUnusedSignalUrl(resolvedSignalUrl);
} else {
this.dependencies.signalingTransport.sendRawMessage({
type: SIGNALING_TYPE_LEAVE_SERVER,
@@ -143,4 +156,26 @@ export class ServerMembershipSignalingHandler<TMessage> {
?? this.dependencies.signalingCoordinator.getServerSignalUrl(serverId)
?? this.getCurrentSignalingUrl();
}
private migrateServerSignalUrl(serverId: string, nextSignalUrl: string): void {
const previousSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(serverId);
if (!previousSignalUrl || previousSignalUrl === nextSignalUrl) {
return;
}
this.dependencies.signalingTransport.sendRawMessageToSignalUrl(previousSignalUrl, {
type: SIGNALING_TYPE_LEAVE_SERVER,
serverId
});
this.dependencies.signalingCoordinator.removeJoinedServer(previousSignalUrl, serverId);
this.dependencies.signalingCoordinator.pruneUnusedSignalUrl(previousSignalUrl);
this.dependencies.logger.info('Migrated server to a new signaling route', {
previousSignalUrl,
serverId,
signalUrl: nextSignalUrl
});
}
}

View File

@@ -110,6 +110,10 @@ export class ServerSignalingCoordinator<TMessage> {
this.lastJoinedServerBySignalUrl.set(signalUrl, joinedServer);
}
getLastJoinedServer(signalUrl: string): JoinedServerInfo | null {
return this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null;
}
clearLastJoinedServers(): void {
this.lastJoinedServerBySignalUrl.clear();
}
@@ -156,15 +160,57 @@ export class ServerSignalingCoordinator<TMessage> {
}
removeJoinedServer(signalUrl: string, serverId: string): void {
this.getOrCreateMemberServerSet(signalUrl).delete(serverId);
const memberServerIds = this.memberServerIdsBySignalUrl.get(signalUrl);
if (!memberServerIds) {
this.repairLastJoinedServer(signalUrl, serverId);
return;
}
memberServerIds.delete(serverId);
if (memberServerIds.size === 0) {
this.memberServerIdsBySignalUrl.delete(signalUrl);
}
this.repairLastJoinedServer(signalUrl, serverId);
}
removeJoinedServerEverywhere(serverId: string): void {
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
memberServerIds.delete(serverId);
for (const signalUrl of Array.from(this.memberServerIdsBySignalUrl.keys())) {
this.removeJoinedServer(signalUrl, serverId);
this.pruneUnusedSignalUrl(signalUrl);
}
}
pruneUnusedSignalUrl(signalUrl: string): void {
if (this.getMemberServerIdsForSignalUrl(signalUrl).size > 0) {
return;
}
this.memberServerIdsBySignalUrl.delete(signalUrl);
this.lastJoinedServerBySignalUrl.delete(signalUrl);
const subscriptions = this.signalingSubscriptions.get(signalUrl);
if (subscriptions) {
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
this.signalingSubscriptions.delete(signalUrl);
}
const manager = this.signalingManagers.get(signalUrl);
if (manager) {
manager.destroy();
this.signalingManagers.delete(signalUrl);
}
this.removeSignalUrlFromPeerTracking(signalUrl);
}
getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet<string> {
return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set<string>();
}
@@ -344,6 +390,45 @@ export class ServerSignalingCoordinator<TMessage> {
return trackedServerIds;
}
private repairLastJoinedServer(signalUrl: string, removedServerId: string): void {
const lastJoined = this.lastJoinedServerBySignalUrl.get(signalUrl);
if (!lastJoined) {
return;
}
const memberServerIds = this.memberServerIdsBySignalUrl.get(signalUrl);
if (!memberServerIds || memberServerIds.size === 0) {
this.lastJoinedServerBySignalUrl.delete(signalUrl);
return;
}
if (lastJoined.serverId !== removedServerId && memberServerIds.has(lastJoined.serverId)) {
return;
}
const nextServerId = memberServerIds.values().next().value as string | undefined;
if (!nextServerId) {
this.lastJoinedServerBySignalUrl.delete(signalUrl);
return;
}
this.lastJoinedServerBySignalUrl.set(signalUrl, {
...lastJoined,
serverId: nextServerId
});
}
private removeSignalUrlFromPeerTracking(signalUrl: string): void {
const peerIds = new Set<string>([...this.peerKnownSignalUrls.keys(), ...this.peerServerMap.keys()]);
for (const peerId of peerIds) {
this.removePeerSignalScope(peerId, signalUrl);
}
}
private removePeerSignalScope(peerId: string, signalUrl: string): void {
const trackedSignalUrls = this.peerServerMap.get(peerId);

View File

@@ -1,6 +1,7 @@
import type { SignalingMessage } from '../../../shared-kernel';
import { PeerData } from '../realtime.types';
import {
SIGNALING_TYPE_ACCESS_DENIED,
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_CONNECTED,
SIGNALING_TYPE_ICE_CANDIDATE,
@@ -98,6 +99,10 @@ export class IncomingSignalingMessageHandler {
this.handleIceCandidateSignalingMessage(message, signalUrl);
return;
case SIGNALING_TYPE_ACCESS_DENIED:
this.handleAccessDeniedSignalingMessage(message, signalUrl);
return;
default:
return;
}
@@ -357,6 +362,16 @@ export class IncomingSignalingMessageHandler {
this.dependencies.peerManager.handleIceCandidate(fromUserId, candidate);
}
private handleAccessDeniedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
if (!message.serverId) {
return;
}
// Remove the server from the coordinator for this signal URL so it won't
// be re-joined on the next reconnect cycle.
this.dependencies.signalingCoordinator.removeJoinedServer(signalUrl, message.serverId);
}
private scheduleUserJoinedFallbackOffer(peerId: string, signalUrl: string, serverId?: string): void {
this.clearUserJoinedFallbackOffer(peerId);

View File

@@ -118,6 +118,13 @@ export class SignalingTransportHandler<TMessage> {
if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) {
return;
}
this.dependencies.logger.warn('[signaling] Missing server signal route for outbound raw message', {
serverId,
type: messageType
});
return;
}
const connectedManagers = this.getConnectedSignalingManagers();
@@ -161,14 +168,14 @@ export class SignalingTransportHandler<TMessage> {
displayName: normalizedDisplayName
};
const identifyMessage = {
type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName: normalizedDisplayName
};
if (signalUrl) {
this.sendRawMessageToSignalUrl(signalUrl, identifyMessage);
this.sendRawMessageToSignalUrl(signalUrl, {
type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName: normalizedDisplayName,
connectionScope: signalUrl
});
return;
}
@@ -178,8 +185,13 @@ export class SignalingTransportHandler<TMessage> {
return;
}
for (const { manager } of connectedManagers) {
manager.sendRawMessage(identifyMessage);
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
manager.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName: normalizedDisplayName,
connectionScope: managerSignalUrl
});
}
}
}

View File

@@ -283,7 +283,8 @@ export class SignalingManager {
if (credentials) {
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
oderId: credentials.oderId,
displayName: credentials.displayName });
displayName: credentials.displayName,
connectionScope: this.lastSignalingUrl ?? undefined });
}
const memberIds = this.getMemberServerIds();
@@ -296,17 +297,10 @@ export class SignalingManager {
const lastJoined = this.getLastJoinedServer();
if (lastJoined) {
if (lastJoined && memberIds.has(lastJoined.serverId)) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
serverId: lastJoined.serverId });
}
} else {
const lastJoined = this.getLastJoinedServer();
if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId: lastJoined.serverId });
}
}
}