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
32 changes: 32 additions & 0 deletions apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ vi.mock('livekit-client', () => ({
TrackUnsubscribed: 'trackUnsubscribed',
DataReceived: 'dataReceived',
ConnectionStateChanged: 'connectionStateChanged',
Reconnecting: 'reconnecting',
Reconnected: 'reconnected',
},
Track: {
Kind: { Video: 'video', Audio: 'audio' },
Expand Down Expand Up @@ -223,4 +225,34 @@ describe('useWebRTCHostSFUAPI', () => {
expect(viewer?.audioElement).toBeNull();
expect(mockAudioPause).toHaveBeenCalled();
});

it('clears the error and stays hosting after livekit reconnects from a transient blip', async () => {
const { result } = renderHook(() =>
useWebRTCHostSFUAPI({
sessionId: 'session-1',
hostId: 'host-1',
localStream: null,
})
);

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

// A terminal-looking disconnect surfaces the error toast...
act(() => {
mockRoomInstance.emit('connectionStateChanged', 'disconnected');
});
expect(result.current.error).toBe('Disconnected from server');
expect(result.current.isHosting).toBe(false);

// ...but when livekit recovers on its own, the toast clears and hosting resumes.
act(() => {
mockRoomInstance.emit('reconnected');
});
expect(result.current.error).toBeNull();
expect(result.current.isHosting).toBe(true);
});
});
58 changes: 44 additions & 14 deletions apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,28 @@ export function useWebRTCHostSFUAPI({

room.on(RoomEvent.DataReceived, handleDataReceived);

// livekit-client recovers from transient ICE/consent blips on its own
// (common on multi-homed hosts whose dead secondary NIC fails consent
// freshness ~30s in). While it reconnects the stream keeps flowing, so do
// NOT surface a fatal error — just clear any stale toast once recovered.
room.on(RoomEvent.Reconnecting, () => {
console.warn('[WebRTCHostSFUAPI] Reconnecting to SFU (transient ICE blip)...');
});
room.on(RoomEvent.Reconnected, () => {
console.log('[WebRTCHostSFUAPI] Reconnected to SFU');
setIsHosting(true);
setError(null);
});

room.on(RoomEvent.ConnectionStateChanged, (state: LKConnectionState) => {
if (state === LKConnectionState.Disconnected) {
// Terminal: livekit only reaches Disconnected after exhausting its
// own reconnect attempts (transient blips emit Reconnecting instead).
setIsHosting(false);
setError('Disconnected from server');
} else if (state === LKConnectionState.Connected) {
// Back to healthy — drop any error left over from a reconnect.
setError(null);
}
});

Expand Down Expand Up @@ -377,20 +395,32 @@ export function useWebRTCHostSFUAPI({
}

for (const track of stream.getTracks()) {
if (track.kind === 'video') {
track.contentHint = 'detail';
await room.localParticipant.publishTrack(track, {
source: Track.Source.ScreenShare,
simulcast: false,
videoEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
});
} else if (track.kind === 'audio') {
await room.localParticipant.publishTrack(track, {
source: Track.Source.ScreenShareAudio,
});
try {
if (track.kind === 'video') {
track.contentHint = 'detail';
await room.localParticipant.publishTrack(track, {
source: Track.Source.ScreenShare,
simulcast: false,
videoEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
});
} else if (track.kind === 'audio') {
await room.localParticipant.publishTrack(track, {
source: Track.Source.ScreenShareAudio,
});
}
} catch (err) {
// A transient ICE/consent blip can reject an in-flight publish ("publication
// of local track timed out") even though livekit reconnects and the track
// ends up published. Only treat it as fatal if the room is actually gone;
// otherwise swallow it so it never bubbles up as an uncaught rejection /
// scary "Streaming error" toast while the stream is still live. The cast
// widens room.state back to the full enum — it mutates across the await,
// so the narrowing from the early-return guard no longer holds here.
if ((room.state as LKConnectionState) === LKConnectionState.Disconnected) throw err;
console.warn('[WebRTCHostSFUAPI] publishTrack hiccup (room recovering):', err);
}
}
}, []);
Expand Down
Loading