fix: prevent presence race condition during WebRTC handshake

Add /joining and /joined endpoints to track user join intent before
WebRTC handshake completes. This prevents meetings from being
deactivated while users are still connecting.

- Add pending_joins Redis module with 30s TTL
- Add /joining endpoint (called before WebRTC handshake)
- Add /joined endpoint (called after connection established)
- Check for pending joins before deactivating meetings in worker
- Frontend integration with connectionId per browser tab
This commit is contained in:
Igor Loskutov
2026-02-05 20:58:34 -05:00
parent 1ce1c7a910
commit 0c4c1387c1
9 changed files with 1041 additions and 10 deletions

View File

@@ -25,6 +25,8 @@ import { useConsentDialog } from "../../lib/consent";
import {
useRoomJoinMeeting,
useMeetingStartRecording,
useMeetingJoining,
useMeetingJoined,
} from "../../lib/apiHooks";
import { omit } from "remeda";
import {
@@ -187,8 +189,14 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const joinMutation = useRoomJoinMeeting();
const startRecordingMutation = useMeetingStartRecording();
const joiningMutation = useMeetingJoining();
const joinedMutation = useMeetingJoined();
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
// Generate a stable connection ID for this component instance
// Used to track pending joins per browser tab (prevents key collision for anonymous users)
const connectionId = useMemo(() => crypto.randomUUID(), []);
// Generate deterministic instanceIds so all participants use SAME IDs
const cloudInstanceId = parseNonEmptyString(meeting.id);
const rawTracksInstanceId = parseNonEmptyString(
@@ -249,6 +257,28 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
);
const handleFrameJoinMeeting = useCallback(() => {
// Signal that WebRTC connection is established
// This clears the pending join intent, confirming successful connection
joinedMutation.mutate(
{
params: {
path: {
room_name: roomName,
meeting_id: meeting.id,
},
},
body: {
connection_id: connectionId,
},
},
{
onError: (error: unknown) => {
// Non-blocking: log but don't fail - this is cleanup, not critical
console.warn("Failed to signal joined:", error);
},
},
);
if (meeting.recording_type === "cloud") {
console.log("Starting dual recording via REST API", {
cloudInstanceId,
@@ -310,6 +340,9 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
}, [
meeting.recording_type,
meeting.id,
roomName,
connectionId,
joinedMutation,
startRecordingMutation,
cloudInstanceId,
rawTracksInstanceId,
@@ -328,8 +361,28 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
useEffect(() => {
if (!frame || !roomUrl) return;
frame
.join({
const joinRoom = async () => {
// Signal intent to join before WebRTC handshake starts
// This prevents race condition where meeting is deactivated during handshake
try {
await joiningMutation.mutateAsync({
params: {
path: {
room_name: roomName,
meeting_id: meeting.id,
},
},
body: {
connection_id: connectionId,
},
});
} catch (error) {
// Non-blocking: log but continue with join
console.warn("Failed to signal joining intent:", error);
}
await frame.join({
url: roomUrl,
sendSettings: {
video: {
@@ -341,9 +394,13 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
},
// Note: screenVideo intentionally not configured to preserve full quality for screen shares
},
})
.catch(console.error.bind(console, "Failed to join daily room:"));
}, [frame, roomUrl]);
});
};
joinRoom().catch(console.error.bind(console, "Failed to join daily room:"));
// joiningMutation excluded from deps - it's a stable hook reference
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [frame, roomUrl, roomName, meeting.id, connectionId]);
useEffect(() => {
setCustomTrayButton(

View File

@@ -807,6 +807,26 @@ export function useRoomJoinMeeting() {
);
}
// Presence race fix endpoints (not yet in OpenAPI spec)
// These signal join intent to prevent race conditions during WebRTC handshake
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useMeetingJoining(): any {
return ($api as any).useMutation(
"post",
"/v1/rooms/{room_name}/meetings/{meeting_id}/joining",
{},
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useMeetingJoined(): any {
return ($api as any).useMutation(
"post",
"/v1/rooms/{room_name}/meetings/{meeting_id}/joined",
{},
);
}
export function useRoomIcsSync() {
const { setError } = useError();