From 41f687358e9905eed7e2476a3b65c74e10876a64 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 07:45:25 +0000 Subject: [PATCH] fix(desktop): retry host SFU connect instead of failing on first ICE miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a flaky multi-homed host the publisher's first ICE negotiation often fails ("could not establish pc connection") even though a retry immediately lands on a working candidate pair — the SFU logs show exactly this (first attempt dropped, second "participant active" via udp relay). Previously that first failure went straight to the catch and surfaced a fatal error toast, even though reconnecting would have worked. startHosting now retries room.connect() up to 3x with backoff, disconnecting between attempts (those intentional drops are suppressed so they don't flash the "Disconnected" toast). The mic publish is also made non-fatal — a transient publisher-PC hiccup no longer aborts hosting. Only a genuine exhaustion of all retries surfaces the error. Co-Authored-By: Claude Opus 4.8 --- .../hooks/useWebRTCHostSFUAPI.test.ts | 49 +++++++++++++++++ .../src/renderer/hooks/useWebRTCHostSFUAPI.ts | 53 +++++++++++++++---- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts index 8d86a6e..8899e79 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts @@ -255,4 +255,53 @@ describe('useWebRTCHostSFUAPI', () => { expect(result.current.error).toBeNull(); expect(result.current.isHosting).toBe(true); }); + + it('retries the host connection and succeeds on a later attempt (no error toast)', async () => { + vi.useFakeTimers(); + mockConnect + .mockRejectedValueOnce(new Error('could not establish pc connection')) + .mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => + useWebRTCHostSFUAPI({ sessionId: 'session-1', hostId: 'host-1', localStream: null }) + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.startHosting(); + }); + await act(async () => { + await vi.runAllTimersAsync(); + await pending; + }); + + expect(mockConnect).toHaveBeenCalledTimes(2); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + expect(result.current.isHosting).toBe(true); + expect(result.current.error).toBeNull(); + vi.useRealTimers(); + }); + + it('surfaces an error only after exhausting connect retries', async () => { + vi.useFakeTimers(); + mockConnect.mockRejectedValue(new Error('could not establish pc connection')); + + const { result } = renderHook(() => + useWebRTCHostSFUAPI({ sessionId: 'session-1', hostId: 'host-1', localStream: null }) + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.startHosting(); + }); + await act(async () => { + await vi.runAllTimersAsync(); + await pending; + }); + + expect(mockConnect).toHaveBeenCalledTimes(3); + expect(result.current.isHosting).toBe(false); + expect(result.current.error).toBe('could not establish pc connection'); + vi.useRealTimers(); + }); }); diff --git a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts index 7c8a2ea..63411b9 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts @@ -344,8 +344,12 @@ export function useWebRTCHostSFUAPI({ setError(null); }); + // We deliberately disconnect between connect retries below; suppress the + // fatal "Disconnected" toast for those intentional drops. + let connecting = true; room.on(RoomEvent.ConnectionStateChanged, (state: LKConnectionState) => { if (state === LKConnectionState.Disconnected) { + if (connecting) return; // Terminal: livekit only reaches Disconnected after exhausting its // own reconnect attempts (transient blips emit Reconnecting instead). setIsHosting(false); @@ -356,25 +360,54 @@ export function useWebRTCHostSFUAPI({ } }); - // Connect. rtcConfig carries the TURN relay (and, when the user enables - // "Force relay", iceTransportPolicy='relay') so publishing survives - // multi-homed / dead-secondary-NIC machines. - await room.connect(data.url || LIVEKIT_URL, data.token, { - rtcConfig: buildSfuRtcConfig(data.iceServers), - }); + // Connect with retries. On a flaky multi-homed host the first ICE + // negotiation often fails ("could not establish pc connection") while a + // retry lands on a working candidate pair — the SFU logs show exactly + // this (first attempt dropped, second "participant active" via udp relay). + // rtcConfig carries the TURN relay (and, with "Force relay" on, + // iceTransportPolicy='relay') so publishing survives the dead NIC. + const rtcConfig = buildSfuRtcConfig(data.iceServers); + const livekitUrl = data.url || LIVEKIT_URL; + const maxConnectAttempts = 3; + let connected = false; + for (let attempt = 1; attempt <= maxConnectAttempts && !connected; attempt++) { + try { + await room.connect(livekitUrl, data.token, { rtcConfig }); + connected = true; + } catch (connectErr) { + console.warn( + `[WebRTCHostSFUAPI] connect attempt ${String(attempt)}/${String(maxConnectAttempts)} failed:`, + connectErr + ); + try { + await room.disconnect(); + } catch { + // room may already be torn down — ignore + } + if (attempt >= maxConnectAttempts) throw connectErr; + await new Promise((resolve) => setTimeout(resolve, 800 * attempt)); + } + } + connecting = false; // Track existing participants for (const participant of room.remoteParticipants.values()) { addViewer(participant); } - // Publish host mic to the room + // Publish host mic. A transient publisher-PC hiccup here must NOT fail + // hosting with a fatal toast — the connection is up and livekit recovers; + // the mic is non-essential vs the screen share (published separately). const micStream = hostMicStreamRef.current; if (micStream) { for (const track of micStream.getAudioTracks()) { - await room.localParticipant.publishTrack(track, { - source: Track.Source.Microphone, - }); + try { + await room.localParticipant.publishTrack(track, { + source: Track.Source.Microphone, + }); + } catch (micErr) { + console.warn('[WebRTCHostSFUAPI] mic publish hiccup (will recover):', micErr); + } } }