Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ const mockGetUserMedia = vi.fn();
describe('useWebRTCHostSFUAPI', () => {
beforeEach(() => {
vi.clearAllMocks();
// clearAllMocks resets call history but not implementations; a prior test's
// mockRejectedValue would otherwise leak into the next. Reset connect/disconnect.
mockConnect.mockReset().mockResolvedValue(undefined);
mockDisconnect.mockReset().mockResolvedValue(undefined);
mockRemoteParticipants.clear();
mockTrackPublications.clear();

Expand Down Expand Up @@ -304,4 +308,46 @@ describe('useWebRTCHostSFUAPI', () => {
expect(result.current.error).toBe('could not establish pc connection');
vi.useRealTimers();
});

it('ignores a concurrent startHosting call (no duplicate connection storm)', async () => {
const { result } = renderHook(() =>
useWebRTCHostSFUAPI({ sessionId: 'session-1', hostId: 'host-1', localStream: null })
);

// Two overlapping starts (as the auto-start effect can do) — the second
// must bail on the re-entrancy guard rather than build a second Room.
await act(async () => {
await Promise.all([result.current.startHosting(), result.current.startHosting()]);
await Promise.resolve();
});

expect(mockConnect).toHaveBeenCalledTimes(1);
expect(result.current.isHosting).toBe(true);
});

it('can re-host after a terminal disconnect (guard released)', async () => {
const { result } = renderHook(() =>
useWebRTCHostSFUAPI({ sessionId: 'session-1', hostId: 'host-1', localStream: null })
);

await act(async () => {
await result.current.startHosting();
await Promise.resolve();
});
expect(mockConnect).toHaveBeenCalledTimes(1);

// Terminal disconnect — must release the room ref.
act(() => {
mockRoomInstance.emit('connectionStateChanged', 'disconnected');
});
expect(result.current.isHosting).toBe(false);

// A fresh start now proceeds instead of bailing on the (stale) guard.
await act(async () => {
await result.current.startHosting();
await Promise.resolve();
});
expect(mockConnect).toHaveBeenCalledTimes(2);
expect(result.current.isHosting).toBe(true);
});
});
23 changes: 23 additions & 0 deletions apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function useWebRTCHostSFUAPI({
const [hostMicStream, setHostMicStream] = useState<MediaStream | null>(null);

const roomRef = useRef<Room | null>(null);
const startingRef = useRef(false);
const viewersRef = useRef<Map<string, ViewerConnection>>(new Map());
const authTokenRef = useRef<string | null>(null);
const hostMicStreamRef = useRef<MediaStream | null>(null);
Expand Down Expand Up @@ -219,6 +220,15 @@ export function useWebRTCHostSFUAPI({

// Start hosting (sets up LiveKit room and voice -- screen sharing is optional)
const startHosting = useCallback(async () => {
// Re-entrancy guard. The auto-start effect re-fires startHosting whenever
// isHosting is false — which stays false through the connect retries below.
// Without this, overlapping startHosting calls each build their own Room and
// connect/disconnect on top of each other (a DUPLICATE_IDENTITY /
// CLIENT_REQUEST_LEAVE storm), leaving the screen share published on a
// connection that then gets torn down → camera shows but presentation is
// black. One attempt at a time; the effect retries sequentially after.
if (startingRef.current || roomRef.current) return;
startingRef.current = true;
try {
// Capture host microphone before connecting
try {
Expand Down Expand Up @@ -354,6 +364,9 @@ export function useWebRTCHostSFUAPI({
// own reconnect attempts (transient blips emit Reconnecting instead).
setIsHosting(false);
setError('Disconnected from server');
// Release the dead room so the auto-start effect can re-host cleanly
// (the re-entrancy guard keys off roomRef).
roomRef.current = null;
} else if (state === LKConnectionState.Connected) {
// Back to healthy — drop any error left over from a reconnect.
setError(null);
Expand Down Expand Up @@ -416,6 +429,16 @@ export function useWebRTCHostSFUAPI({
} catch (err) {
console.error('[WebRTCHostSFUAPI] Failed to start hosting:', err);
setError(err instanceof Error ? err.message : 'Failed to start hosting');
// Tear the failed room down so the next (sequential) attempt starts clean
// rather than bailing on the roomRef guard forever.
try {
await roomRef.current?.disconnect();
} catch {
// already gone — ignore
}
roomRef.current = null;
} finally {
startingRef.current = false;
}
}, [sessionId, hostId, addViewer, removeViewer, handleDataReceived]);

Expand Down
Loading