fix: cross-instance event contamination in federated DM calls

Root cause: sendToFederatedCallUsers used sendToDmMembers when dmChannelId
was set, which broadcast to ALL DM members including the caller's replicated
stub. The caller's multi-instance WS received dm_call_accepted with the
REMOTE instance's dmChannelId, causing token request for a non-existent
channel (403) and preventing the caller from connecting.

Fix 1: sendToFederatedCallUsers always uses ringedUserIds (exact recipients)
instead of sendToDmMembers (all members including caller stub).

Fix 2: dm_call_accepted handler only sets activeDmCall if the client is the
caller (wasOutgoingCall) or already connected to LiveKit. Other instances of
the same user just clear ringing without entering stuck "Connecting..." state.
This commit is contained in:
Jannis Braun
2026-04-08 13:50:25 +02:00
parent 94461f8967
commit 86fe713a7c
2 changed files with 15 additions and 12 deletions
+6 -7
View File
@@ -754,16 +754,15 @@ class ConnectionManager {
} }
} }
/** Send event to users involved in a federated call. Works for both Path A (DM exists) and Path B (no local DM). */ /** Send event to users who were ringed for a federated call.
* ALWAYS uses ringedUserIds, never sendToDmMembers — sendToDmMembers would
* also reach the caller's replicated stub, causing cross-instance event contamination
* (the caller's multi-instance WS gets dm_call_accepted with the wrong dmChannelId). */
sendToFederatedCallUsers(federatedId: string, event: ServerEvent): void { sendToFederatedCallUsers(federatedId: string, event: ServerEvent): void {
const call = this.federatedCalls.get(federatedId); const call = this.federatedCalls.get(federatedId);
if (!call) return; if (!call) return;
if (call.dmChannelId) { for (const uid of call.ringedUserIds) {
this.sendToDmMembers(call.dmChannelId, event); this.sendToUser(uid, event);
} else {
for (const uid of call.ringedUserIds) {
this.sendToUser(uid, event);
}
} }
} }
+9 -5
View File
@@ -802,15 +802,19 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_call_accepted': { case 'dm_call_accepted': {
const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState(); const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState();
// Track whether WE are the caller before clearing state
const wasOutgoingCall = !!outgoingCall; const wasOutgoingCall = !!outgoingCall;
setIncomingCall(null); setIncomingCall(null);
setOutgoingCall(null); setOutgoingCall(null);
// Only enter active call state if:
// - We're the caller (wasOutgoingCall) → will connect via connectFn below
// - We already connected to LiveKit (clicked accept in handleAccept)
// Other instances of the same user must NOT enter call state — they'd show
// "Connecting..." forever with no actual LiveKit connection.
const callDmId = event.dmChannelId || event.federatedCallId || ''; const callDmId = event.dmChannelId || event.federatedCallId || '';
setActiveDmCall({ dmChannelId: callDmId }); if (wasOutgoingCall || isLiveKitConnected) {
// Only auto-connect if we're the CALLER waiting for acceptance. setActiveDmCall({ dmChannelId: callDmId });
// The callee who accepted connects in the click handler (handleAccept). }
// Other instances of the same user should NOT auto-connect.
if (connectFn && !isLiveKitConnected && wasOutgoingCall && callDmId) { if (connectFn && !isLiveKitConnected && wasOutgoingCall && callDmId) {
connectFn(callDmId, true).catch((err: unknown) => { connectFn(callDmId, true).catch((err: unknown) => {
console.error('[WS] DM call connect failed:', err); console.error('[WS] DM call connect failed:', err);