fix: comprehensive client-side session management for federated DM calls

Four fixes addressing the full state management problem:

1. Passive ready handler: no longer auto-connects to LiveKit on
   page refresh. Prevents identity conflicts when the same user
   has multiple sessions fighting for one LiveKit identity slot.
   The user must re-accept to join; state is shown but not acted on.

2. SoundController sync guard: incomingCallLoading/outgoingCallLoading
   refs prevent multiple playSound calls during async audio load.
   If call is cancelled while sound loads, stops it immediately on
   completion. Eliminates the "5 ringtones at once" bug.

3. Host dm_call_accepted broadcasts now include federatedCallId so
   all clients (including remote instances) can match the event.

4. Removed all diagnostic console.log statements.
This commit is contained in:
Jannis Braun
2026-04-08 14:12:45 +02:00
parent 86fe713a7c
commit aae0b1a74e
5 changed files with 49 additions and 40 deletions
+3 -6
View File
@@ -3561,7 +3561,6 @@ function processDmCallAcceptEvent(
accepted: string[], accepted: string[],
rejected: Array<{ messageId: string; reason: string }>, rejected: Array<{ messageId: string; reason: string }>,
): void { ): void {
console.log(`[S2S_CALL_ACCEPT] from=${sourceInstance} federatedId=${event.federatedId} acceptor=${JSON.stringify(event.call?.acceptor)}`);
if (!event.call?.acceptor || !event.federatedId) { if (!event.call?.acceptor || !event.federatedId) {
rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' }); rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' });
return; return;
@@ -3577,11 +3576,9 @@ function processDmCallAcceptEvent(
.where(eq(schema.dmChannels.federatedId, event.federatedId)) .where(eq(schema.dmChannels.federatedId, event.federatedId))
.get(); .get();
const dmChannelId = channel?.id; const dmChannelId = channel?.id;
console.log(`[S2S_CALL_ACCEPT] channel lookup: federatedId=${event.federatedId} → dmChannelId=${dmChannelId ?? 'NOT FOUND'}`);
// Check if we're the HOST (have a VoiceRoom) // Check if we're the HOST (have a VoiceRoom)
const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined; const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined;
console.log(`[S2S_CALL_ACCEPT] room lookup: ${room ? `found (type=${room.roomType}, state=${(room.metadata as any).state})` : 'NOT FOUND'}`);
if (room && room.roomType === 'dm') { if (room && room.roomType === 'dm') {
const meta = room.metadata as DmRoomMeta; const meta = room.metadata as DmRoomMeta;
@@ -3600,12 +3597,12 @@ function processDmCallAcceptEvent(
}); });
} }
// Broadcast accepted locally // Broadcast accepted locally — include federatedCallId so all clients can match
console.log(`[S2S_CALL_ACCEPT] HOST path: broadcasting dm_call_accepted to dmChannelId=${dmChannelId}`);
connectionManager.sendToDmMembers(dmChannelId!, { connectionManager.sendToDmMembers(dmChannelId!, {
type: 'dm_call_accepted', type: 'dm_call_accepted',
dmChannelId: dmChannelId!, dmChannelId: dmChannelId!,
}); federatedCallId: event.federatedId,
} as ServerEvent);
// Fan out to ALL other remote instances (exclude the one that sent the accept) // Fan out to ALL other remote instances (exclude the one that sent the accept)
const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`; const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`;
-2
View File
@@ -48,7 +48,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
} }
const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string }; const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string };
console.log(`[LIVEKIT_TOKEN] userId=${request.userId} username=${request.username} channelId=${channelId} dmChannelId=${dmChannelId}`);
// Determine room name based on channel type // Determine room name based on channel type
let roomName: string; let roomName: string;
@@ -119,7 +118,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
const jwt = await token.toJwt(); const jwt = await token.toJwt();
const livekitUrl = config.livekit.url ?? ''; const livekitUrl = config.livekit.url ?? '';
console.log(`[LIVEKIT_TOKEN] ISSUED room=${roomName} identity=${identity} url=${livekitUrl}`);
const response: LiveKitTokenResponse = { const response: LiveKitTokenResponse = {
token: jwt, token: jwt,
+9 -15
View File
@@ -1444,8 +1444,6 @@ function handleDmCallStart(event: Record<string, unknown>, userId: string, usern
function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws: WebSocket): void { function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws: WebSocket): void {
let dmChannelId = (event.dmChannelId as string) || null; let dmChannelId = (event.dmChannelId as string) || null;
const federatedCallId = (event.federatedCallId as string) || null; const federatedCallId = (event.federatedCallId as string) || null;
console.log(`[DM_CALL_ACCEPT] userId=${userId} dmChannelId=${dmChannelId} federatedCallId=${federatedCallId}`);
if (!dmChannelId && !federatedCallId) { if (!dmChannelId && !federatedCallId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId or federatedCallId is required' }); connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId or federatedCallId is required' });
return; return;
@@ -1462,22 +1460,17 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws:
.get(); .get();
if (ch) { if (ch) {
dmChannelId = ch.id; dmChannelId = ch.id;
console.log(`[DM_CALL_ACCEPT] Resolved federatedId → dmChannelId=${ch.id}`);
} else {
console.log(`[DM_CALL_ACCEPT] No channel found for federatedId=${federatedCallId}`);
} }
} }
// Path 1: Local room (we're the host) — only possible with dmChannelId // Path 1: Local room (we're the host) — only possible with dmChannelId
if (dmChannelId) { if (dmChannelId) {
if (!isDmMember(dmChannelId, userId)) { if (!isDmMember(dmChannelId, userId)) {
console.log(`[DM_CALL_ACCEPT] NOT a member of dmChannelId=${dmChannelId}`);
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
return; return;
} }
const room = connectionManager.getRoom(dmChannelId); const room = connectionManager.getRoom(dmChannelId);
console.log(`[DM_CALL_ACCEPT] Path1: getRoom(${dmChannelId}) → ${room ? `found (type=${room.roomType}, state=${(room.metadata as any).state})` : 'NOT FOUND'}`);
if (room && room.roomType === 'dm') { if (room && room.roomType === 'dm') {
const meta = room.metadata as DmRoomMeta; const meta = room.metadata as DmRoomMeta;
@@ -1498,14 +1491,20 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws:
if (acceptorLeft) broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); if (acceptorLeft) broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId);
connectionManager.joinRoom(dmChannelId, userId); connectionManager.joinRoom(dmChannelId, userId);
connectionManager.setVoiceWs(userId, ws); connectionManager.setVoiceWs(userId, ws);
connectionManager.sendToDmMembers(dmChannelId, { type: 'dm_call_accepted', dmChannelId }); // Look up federatedId for the broadcast so all clients (including remote) can match
const fedIdRow = getDb().select({ federatedId: schema.dmChannels.federatedId })
.from(schema.dmChannels).where(eq(schema.dmChannels.id, dmChannelId)).get();
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_accepted',
dmChannelId,
federatedCallId: fedIdRow?.federatedId ?? undefined,
} as ServerEvent);
connectionManager.sendToDmMembers(dmChannelId, { connectionManager.sendToDmMembers(dmChannelId, {
type: 'voice_state_update', type: 'voice_state_update',
channelId: dmChannelId, channelId: dmChannelId,
userId, userId,
action: 'join', action: 'join',
}); });
console.log(`[DM_CALL_ACCEPT] Path1 HOST SUCCESS: room activated, dm_call_accepted broadcast`);
sendFederatedCallAccept(dmChannelId, userId) sendFederatedCallAccept(dmChannelId, userId)
.catch(err => console.error('[federation] sendFederatedCallAccept error:', err)); .catch(err => console.error('[federation] sendFederatedCallAccept error:', err));
return; return;
@@ -1519,7 +1518,6 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws:
? connectionManager.getFederatedCallByDmChannel(dmChannelId) ? connectionManager.getFederatedCallByDmChannel(dmChannelId)
: undefined; : undefined;
console.log(`[DM_CALL_ACCEPT] Path2: fedCall=${fedCall ? `found (host=${fedCall.federatedCallHost})` : 'NOT FOUND'}`);
if (fedCall) { if (fedCall) {
connectionManager.activateFederatedCall(fedCall.federatedId); connectionManager.activateFederatedCall(fedCall.federatedId);
connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { connectionManager.sendToFederatedCallUsers(fedCall.federatedId, {
@@ -1535,7 +1533,6 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws:
.get(); .get();
const homeUserId = user?.homeUserId || userId; const homeUserId = user?.homeUserId || userId;
console.log(`[DM_CALL_ACCEPT] Path2 RELAY to host ${fedCall.federatedCallHost}`);
sendCallRelay(fedCall.federatedCallHost, [{ sendCallRelay(fedCall.federatedCallHost, [{
eventType: 'dm_call_accept', eventType: 'dm_call_accept',
messageId: generateSnowflake(), messageId: generateSnowflake(),
@@ -1545,13 +1542,10 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string, ws:
call: { call: {
acceptor: { homeUserId, homeInstance: getOurOrigin() }, acceptor: { homeUserId, homeInstance: getOurOrigin() },
}, },
}]).then(result => { }]).catch(err => console.error('[federation] Failed to send dm_call_accept:', err));
console.log(`[DM_CALL_ACCEPT] Relay result: ${JSON.stringify(result)}`);
}).catch(err => console.error('[federation] Failed to send dm_call_accept:', err));
return; return;
} }
console.log(`[DM_CALL_ACCEPT] FALLTHROUGH — no call found for userId=${userId}`);
connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' });
} }
@@ -24,7 +24,9 @@ export function SoundController() {
const prevScreenShareUserIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId))); const prevScreenShareUserIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId)));
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null); const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const incomingCallLoading = useRef(false); // sync guard for async playSound
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null); const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const outgoingCallLoading = useRef(false);
useEffect(() => { useEffect(() => {
// Set initial mount flag to false after first run // Set initial mount flag to false after first run
@@ -109,24 +111,43 @@ export function SoundController() {
prevParticipantIds.current = currentParticipantIds; prevParticipantIds.current = currentParticipantIds;
prevScreenShareUserIds.current = currentScreenShareUserIds; prevScreenShareUserIds.current = currentScreenShareUserIds;
// Incoming Call (Ringing) // Incoming Call (Ringing) — sync guard prevents multiple playSound during async load
if (state.incomingCall && !incomingCallLoop.current) { if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) {
incomingCallLoading.current = true;
audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => { audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => {
incomingCallLoop.current = source; // If call was cancelled while sound was loading, stop immediately
if (!useVoiceStore.getState().incomingCall) {
source?.stop();
} else {
incomingCallLoop.current = source;
}
incomingCallLoading.current = false;
}); });
} else if (!state.incomingCall && incomingCallLoop.current) { } else if (!state.incomingCall) {
incomingCallLoop.current.stop(); if (incomingCallLoop.current) {
incomingCallLoop.current = null; incomingCallLoop.current.stop();
incomingCallLoop.current = null;
}
incomingCallLoading.current = false;
} }
// Outgoing Call (Calling) // Outgoing Call (Calling) — same sync guard pattern
if (state.outgoingCall && !outgoingCallLoop.current) { if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
outgoingCallLoading.current = true;
audioManager.playSound('call_calling', { loop: true, volume: getSfxVolume() }).then(source => { audioManager.playSound('call_calling', { loop: true, volume: getSfxVolume() }).then(source => {
outgoingCallLoop.current = source; if (!useVoiceStore.getState().outgoingCall) {
source?.stop();
} else {
outgoingCallLoop.current = source;
}
outgoingCallLoading.current = false;
}); });
} else if (!state.outgoingCall && outgoingCallLoop.current) { } else if (!state.outgoingCall) {
outgoingCallLoop.current.stop(); if (outgoingCallLoop.current) {
outgoingCallLoop.current = null; outgoingCallLoop.current.stop();
outgoingCallLoop.current = null;
}
outgoingCallLoading.current = false;
} }
}); });
+4 -5
View File
@@ -332,11 +332,10 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (call.federatedCallId) { if (call.federatedCallId) {
setFederatedCallId(call.federatedCallId); setFederatedCallId(call.federatedCallId);
} }
if (connectFn && callDmId) { // PASSIVE: do NOT auto-connect to LiveKit on ready.
connectFn(callDmId, true).catch((err) => { // The user must click "Join" or re-accept. Auto-connecting causes
console.error('[WS] DM call reconnect failed:', err); // identity conflicts when the same user has multiple sessions —
}); // both sessions fight for the same LiveKit identity slot.
}
break; break;
} else if (call.state === 'ringing' && call.callerId !== myId) { } else if (call.state === 'ringing' && call.callerId !== myId) {
const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId); const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId);