feat(realtime): block TCP ICE candidates by default#151
Conversation
Production telemetry over 48h shows ~10.5% of successfully-connected
clients land on TCP-direct to LiveKit `:7881` instead of UDP, even
though 90% of them had a working `udp srflx` candidate. TCP fallback
carries media under head-of-line blocking, causing the freezes and
choppy playback customers report.
This patch wraps `globalThis.RTCPeerConnection` while a session is open
and drops TCP candidates at three points (defence in depth):
1. `RTCConfiguration.iceServers` — drop `?transport=tcp` and `turns:`
URLs so the browser never gathers TURN-TCP/TLS candidates.
2. `setRemoteDescription` — strip `a=candidate ... TCP ...` lines
from the SFU's SDP so its TCP host candidate is never paired.
3. `addIceCandidate` — drop trickled TCP candidates as a guard.
The patch is reference-counted so concurrent sessions cooperate and
the original `RTCPeerConnection` constructor is restored on disconnect.
Exposed as `allowTcpIce?: boolean` on `RealTimeClientConnectOptions`
and `SubscribeOptions`. Default `false` (TCP blocked). Set to `true`
only for clients with UDP fully blocked outbound (~4% in our data,
who would otherwise fail entirely without the TCP path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit: |
- Drop "defence in depth" wording in module header — it's a quality-of-experience filter, not a threat mitigation. - Tighten the rationale paragraph and the three-paths-into-PC list. - Lead the connect-option JSDoc with "quality choice" framing and describe the opt-in path for callers who genuinely need TCP. - Trim the now-redundant MediaChannelConfig doc to point at the public option. No behaviour change. Tests + typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b48ae5f. Configure here.
| } | ||
| } | ||
| }; | ||
| } |
There was a problem hiding this comment.
allowTcpIce: true silently ignored when concurrent session filters
Medium Severity
When allowTcpIce: true is passed but another concurrent session has already installed the filter with allowTcpIce: false, installIceFilter returns noop at line 64 without checking if the global RTCPeerConnection is currently the filtered proxy. The allowTcpIce: true session then creates its Room using the still-patched global, so TCP candidates are silently filtered despite the explicit opt-in. For example, a publisher using default allowTcpIce: false would cause a concurrent subscriber's allowTcpIce: true to be ignored entirely.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit b48ae5f. Configure here.


Summary
allowTcpIce?: booleantoRealTimeClientConnectOptionsandSubscribeOptions. Callers who insist on TCP (e.g. enterprise networks where outbound UDP is blocked) opt in by passingtrue.Motivation
Last 48h of
livekit-server "participant active" @participant:client-*::7881)Inside the 10.5% TCP-direct slice we sampled 473 sessions and parsed
publisherCandidates:udp srflxcandidate (STUN responded over UDP) — so the client's network does carry UDP. The SFU-specific UDP path failed connectivity checks, and ICE fell back to TCP. The session then carried real-time media under TCP HOL blocking — exactly the choppy-playback pattern reported by customers.allowTcpIce: true.Implementation
packages/sdk/src/realtime/webrtc-ice-filter.ts— pure helpers + a reference-counted install that swapsglobalThis.RTCPeerConnectionfor aProxywhile a session is open. TCP candidates can reach a PC through three independent paths, so the filter closes each:RTCConfiguration.iceServers— strip?transport=tcpURLs and anyturns:(TLS-over-TCP).setRemoteDescriptionSDP — stripa=candidate:N M TCP ...lines so the SFU's TCP host candidate is never paired against ours.addIceCandidate— drop trickled TCP candidates arriving after the initial SDP.Threaded through
client.ts→stream-session.ts→media-channel.ts(publisher) and directly insubscribe-client.ts(subscriber).Test plan
pnpm typecheckcleanpnpm test— 235/235 passing (22 new unit tests cover URL parsing, candidate-string parsing, SDP filtering, RTCConfiguration filtering, ref-counted install/uninstall, the constructor proxy,addIceCandidatefiltering, andsetRemoteDescriptionSDP munging)pnpm lint+pnpm format:checkcleanpackages/sdk/index.htmlagainst staging, connect, and verify inchrome://webrtc-internalsthat the selected candidate pair is UDP and that no TCP candidates appear in the gathered list.allowTcpIce: true, confirm TCP candidates reappear (opt-in works).Rollout considerations
allowTcpIce: true.turns:turn.decart.ai:443from the gathered list doesn't regress any live traffic — but it removes the theoretical "UDP blocked everywhere → TURN-TLS:443" fallback for callers who don't passallowTcpIce: true.globalThis.RTCPeerConnection— apps embedding this SDK alongside their own WebRTC code will see the patch active during a realtime session. The ref count restores the original constructor when the last session disconnects.livekit.rtc.tcp_port: 0on the server) so old SDK versions and other clients also stop seeing the SFU's TCP host candidate. This SDK change protects new builds and gives callers the per-sessionallowTcpIceopt-in.🤖 Generated with Claude Code