From aae0b1a74e5dc786d28ebe72b5b11560b8b3334b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:12:45 +0200 Subject: [PATCH] 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. --- packages/server/src/routes/federation.ts | 9 ++-- packages/server/src/routes/livekit.ts | 2 - packages/server/src/ws/events.ts | 24 ++++------ .../src/components/voice/SoundController.tsx | 45 ++++++++++++++----- packages/web/src/hooks/useWebSocket.ts | 9 ++-- 5 files changed, 49 insertions(+), 40 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 7eebfcdb..c55a4989 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3561,7 +3561,6 @@ function processDmCallAcceptEvent( accepted: string[], rejected: Array<{ messageId: string; reason: string }>, ): void { - console.log(`[S2S_CALL_ACCEPT] from=${sourceInstance} federatedId=${event.federatedId} acceptor=${JSON.stringify(event.call?.acceptor)}`); if (!event.call?.acceptor || !event.federatedId) { rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' }); return; @@ -3577,11 +3576,9 @@ function processDmCallAcceptEvent( .where(eq(schema.dmChannels.federatedId, event.federatedId)) .get(); 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) 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') { const meta = room.metadata as DmRoomMeta; @@ -3600,12 +3597,12 @@ function processDmCallAcceptEvent( }); } - // Broadcast accepted locally - console.log(`[S2S_CALL_ACCEPT] HOST path: broadcasting dm_call_accepted to dmChannelId=${dmChannelId}`); + // Broadcast accepted locally — include federatedCallId so all clients can match connectionManager.sendToDmMembers(dmChannelId!, { type: 'dm_call_accepted', dmChannelId: dmChannelId!, - }); + federatedCallId: event.federatedId, + } as ServerEvent); // Fan out to ALL other remote instances (exclude the one that sent the accept) const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`; diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index c5f5948f..c2c6ad64 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -48,7 +48,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise { } 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 let roomName: string; @@ -119,7 +118,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise { const jwt = await token.toJwt(); const livekitUrl = config.livekit.url ?? ''; - console.log(`[LIVEKIT_TOKEN] ISSUED room=${roomName} identity=${identity} url=${livekitUrl}`); const response: LiveKitTokenResponse = { token: jwt, diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 2a98ae62..61c8d6b8 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -1444,8 +1444,6 @@ function handleDmCallStart(event: Record, userId: string, usern function handleDmCallAccept(event: Record, userId: string, ws: WebSocket): void { let dmChannelId = (event.dmChannelId as string) || null; const federatedCallId = (event.federatedCallId as string) || null; - console.log(`[DM_CALL_ACCEPT] userId=${userId} dmChannelId=${dmChannelId} federatedCallId=${federatedCallId}`); - if (!dmChannelId && !federatedCallId) { connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId or federatedCallId is required' }); return; @@ -1462,22 +1460,17 @@ function handleDmCallAccept(event: Record, userId: string, ws: .get(); if (ch) { 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 if (dmChannelId) { 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' }); return; } 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') { const meta = room.metadata as DmRoomMeta; @@ -1498,14 +1491,20 @@ function handleDmCallAccept(event: Record, userId: string, ws: if (acceptorLeft) broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); connectionManager.joinRoom(dmChannelId, userId); 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, { type: 'voice_state_update', channelId: dmChannelId, userId, action: 'join', }); - console.log(`[DM_CALL_ACCEPT] Path1 HOST SUCCESS: room activated, dm_call_accepted broadcast`); sendFederatedCallAccept(dmChannelId, userId) .catch(err => console.error('[federation] sendFederatedCallAccept error:', err)); return; @@ -1519,7 +1518,6 @@ function handleDmCallAccept(event: Record, userId: string, ws: ? connectionManager.getFederatedCallByDmChannel(dmChannelId) : undefined; - console.log(`[DM_CALL_ACCEPT] Path2: fedCall=${fedCall ? `found (host=${fedCall.federatedCallHost})` : 'NOT FOUND'}`); if (fedCall) { connectionManager.activateFederatedCall(fedCall.federatedId); connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { @@ -1535,7 +1533,6 @@ function handleDmCallAccept(event: Record, userId: string, ws: .get(); const homeUserId = user?.homeUserId || userId; - console.log(`[DM_CALL_ACCEPT] Path2 RELAY to host ${fedCall.federatedCallHost}`); sendCallRelay(fedCall.federatedCallHost, [{ eventType: 'dm_call_accept', messageId: generateSnowflake(), @@ -1545,13 +1542,10 @@ function handleDmCallAccept(event: Record, userId: string, ws: call: { acceptor: { homeUserId, homeInstance: getOurOrigin() }, }, - }]).then(result => { - console.log(`[DM_CALL_ACCEPT] Relay result: ${JSON.stringify(result)}`); - }).catch(err => console.error('[federation] Failed to send dm_call_accept:', err)); + }]).catch(err => console.error('[federation] Failed to send dm_call_accept:', err)); 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' }); } diff --git a/packages/web/src/components/voice/SoundController.tsx b/packages/web/src/components/voice/SoundController.tsx index 78611dfa..b94fef5a 100644 --- a/packages/web/src/components/voice/SoundController.tsx +++ b/packages/web/src/components/voice/SoundController.tsx @@ -24,7 +24,9 @@ export function SoundController() { const prevScreenShareUserIds = useRef>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId))); const incomingCallLoop = useRef(null); + const incomingCallLoading = useRef(false); // sync guard for async playSound const outgoingCallLoop = useRef(null); + const outgoingCallLoading = useRef(false); useEffect(() => { // Set initial mount flag to false after first run @@ -109,24 +111,43 @@ export function SoundController() { prevParticipantIds.current = currentParticipantIds; prevScreenShareUserIds.current = currentScreenShareUserIds; - // Incoming Call (Ringing) - if (state.incomingCall && !incomingCallLoop.current) { + // Incoming Call (Ringing) — sync guard prevents multiple playSound during async load + if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) { + incomingCallLoading.current = true; 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) { - incomingCallLoop.current.stop(); - incomingCallLoop.current = null; + } else if (!state.incomingCall) { + if (incomingCallLoop.current) { + incomingCallLoop.current.stop(); + incomingCallLoop.current = null; + } + incomingCallLoading.current = false; } - // Outgoing Call (Calling) - if (state.outgoingCall && !outgoingCallLoop.current) { + // Outgoing Call (Calling) — same sync guard pattern + if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) { + outgoingCallLoading.current = true; 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) { - outgoingCallLoop.current.stop(); - outgoingCallLoop.current = null; + } else if (!state.outgoingCall) { + if (outgoingCallLoop.current) { + outgoingCallLoop.current.stop(); + outgoingCallLoop.current = null; + } + outgoingCallLoading.current = false; } }); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 31e55b5e..a8910445 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -332,11 +332,10 @@ function handleEvent(origin: string, event: ServerEvent): void { if (call.federatedCallId) { setFederatedCallId(call.federatedCallId); } - if (connectFn && callDmId) { - connectFn(callDmId, true).catch((err) => { - console.error('[WS] DM call reconnect failed:', err); - }); - } + // PASSIVE: do NOT auto-connect to LiveKit on ready. + // The user must click "Join" or re-accept. Auto-connecting causes + // identity conflicts when the same user has multiple sessions — + // both sessions fight for the same LiveKit identity slot. break; } else if (call.state === 'ringing' && call.callerId !== myId) { const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId);