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
49 changes: 49 additions & 0 deletions apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> = 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<void> = 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();
});
});
53 changes: 43 additions & 10 deletions apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
}
}

Expand Down
Loading