diff --git a/docs/adr/0009-connection-local-nat-prediction-experiment.md b/docs/adr/0009-connection-local-nat-prediction-experiment.md index 74caa03..5485b4f 100644 --- a/docs/adr/0009-connection-local-nat-prediction-experiment.md +++ b/docs/adr/0009-connection-local-nat-prediction-experiment.md @@ -12,19 +12,20 @@ receives a small set of adjacent server-reflexive candidates. The result is network- and generation-specific; it is not a reliable NAT type or participant capability. -The current WebRTC signaling contract already carries ICE candidates. A safe -experiment therefore needs no new message shape, route state, or media path. +The existing route-policy and ICE candidate owners are sufficient. Making the +experiment room-wide requires one strict protocol revision for the added policy +field, but no new message type, route-controller state, or media path. ## Decision 1. Host Advanced settings exposes `NAT traversal experiment`, disabled by - default and locked while sharing. The choice is local to that page and is - read when each new connection is created; it is not persisted or broadcast - as a room policy. -2. When enabled, Host-originated direct `HostPeer` connections add ports 3479 - and 3480 for the same STUN authority already supplied by the server. The - server-provided STUN entries remain unchanged. SFU, ViewerPeer, and Viewer - relay connections keep the stock ICE configuration. + default and locked while sharing. It is part of the current share's room + policy and is broadcast to every authenticated Viewer; it is not persisted + beyond that share generation. +2. When enabled, every Browser P2P connection adds ports 3479 and 3480 for the + same STUN authority already supplied by the server. This covers Host direct, + Viewer upstream, and Viewer relay connections. SFU PeerConnections remain + unchanged. 3. For one ICE gathering generation, the adapter observes only UDP `srflx` candidates. If three or more distinct candidates for one media section and public address form an arithmetic port sequence, it appends at most eight @@ -51,8 +52,8 @@ Negative: - the Browser may still reject synthetic candidates or the NAT may not have a matching mapping; and -- the current implementation covers Host-originated direct edges only. It does - not claim to improve Viewer relay edges or two endpoint-dependent NATs. +- two endpoint-dependent NATs remain sensitive to candidate scheduling and + intervening mappings, so room-wide coverage is not a success guarantee. The controlled lab result (14/15 sequential-to-restricted runs with a bounded prediction set) is mechanism evidence, not a production success guarantee. diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index 0262522..5160189 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -31,15 +31,13 @@ share generation: failure; and - native-edge topology convergence is enabled by default. The Host may disable it before sharing; when disabled, quality evidence stays diagnostic and - availability routing is unchanged. + availability routing is unchanged; +- the default-off NAT traversal experiment augments direct ICE candidates on + every Browser P2P edge in that share. It is not a participant capability, + route score, or SFU preference. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md) + owns its bounded behavior and evidence boundary. -Host Advanced settings also contain an independent, default-off NAT traversal -experiment. It only augments Host-originated direct ICE candidates for the -current share generation; it is not a route policy, participant capability, or -SFU preference. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md) -owns its bounded behavior and evidence boundary. - -These are route-policy gates, not new routing algorithms. Changing either +These are route-policy gates, not new routing algorithms. Changing any policy requires stopping the current share and starting another generation. ## Endpoint And Server Capacity diff --git a/docs/project-memory.md b/docs/project-memory.md index a26ffa2..4609d27 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -30,11 +30,11 @@ implementation and routine UI detail. lease. Production enables optional SQLite stable room authority; live participants, routes, and media remain process-only. - Media is automatic and distributed. Ordinary peers are STUN-only and prefer - direct/peer UDP. Host may opt into a bounded, connection-local NAT candidate - experiment; the default path remains stock ICE. The only application - fallback is one bounded LiveKit SFU/UDP publication; Screener configures no - TURN or TCP media route. A pre-share peer-only policy can exclude that SFU - suffix for one share generation. + direct/peer UDP. Host may opt the current share into a bounded, room-wide, + connection-local NAT candidate experiment; the default path remains stock + ICE. The only application fallback is one bounded LiveKit SFU/UDP + publication; Screener configures no TURN or TCP media route. A pre-share + peer-only policy can exclude that SFU suffix for one share generation. - Every endpoint shares one steady outbound-copy cap, default `2` and limited to `1..3`. One room controller owns one committed graph, one reconcile loop, and one serial child operation. First decoded frame commits availability work; @@ -57,7 +57,7 @@ implementation and routine UI detail. ## Current Snapshot -Canonical source uses the strict `screener-v17` Browser/server contract with +Canonical source uses the strict `screener-v18` Browser/server contract with committed media readiness, bounded candidate-relative progress and one-shot Host-root convergence. It retains optional SQLite room authority and the H.264/VP8 sender gate. [Status](./status.md) owns the compact current execution diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md index 1f3227c..2eb41c7 100644 --- a/docs/research/nat-traversal.md +++ b/docs/research/nat-traversal.md @@ -58,14 +58,15 @@ supports an opt-in mechanism, not a default or a participant-wide classifier. The flagship STUN configuration remains unchanged. The self-hosted endpoint is still the ordinary discovery service; ports 3479 and 3480 are optional -STUN-only listeners for an explicitly enabled Host experiment. +STUN-only listeners for an explicitly enabled room experiment. The accepted experiment is deliberately connection-local and additive. It derives the two auxiliary ports from the existing STUN authority, waits for a clear three-point arithmetic `srflx` shape in one ICE generation, and appends a small two-sided candidate window. All ordinary candidates remain available; there is no NAT label, hard candidate skip, route-controller input, or SFU -preference. The UI switch is default off and locks while sharing. Its exact +preference. The Host switch is default off, locks while sharing, and applies to +Host, Viewer upstream, and Viewer relay P2P connections. Its exact scope is recorded in [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md). The Browser-only alternatives do not yet have an accepted implementation: @@ -102,8 +103,8 @@ fact. They cannot safely remove a Peer candidate or save its five-second window. Port prediction remains an experiment rather than a default path. The controlled namespace matrix supports the bounded mechanism, but field prevalence, direction, and resource impact are not established. The experiment -therefore stays opt-in, Host-only, and additive until target-network evidence -justifies a wider decision. +therefore stays opt-in and additive until target-network evidence justifies +enabling it by default. ## Rejected Product Inference diff --git a/docs/status.md b/docs/status.md index 2a3fb09..5d3dcb4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -7,7 +7,7 @@ This is the current execution index. Git history owns completed timelines; ## Production -- `https://share.bonfire.icu` runs the strict `screener-v17` Browser/server +- `https://share.bonfire.icu` runs the strict `screener-v18` Browser/server contract. Exact revision, release, artifact, manifest, and asset identity are retained by the immutable release descriptor, runtime `REVISION`, and deployment record rather than copied into this source snapshot. @@ -49,7 +49,7 @@ This is the current execution index. Git history owns completed timelines; ## Current Source -- Canonical source uses the strict `screener-v17` Browser/server contract. +- Canonical source uses the strict `screener-v18` Browser/server contract. Optional SQLite stable authority, non-expiring local preferred code, atomic room replacement and the Host codec selector remain implemented; production selects stable storage. Graceful restart, crash, timeout and network loss use @@ -75,9 +75,9 @@ This is the current execution index. Git history owns completed timelines; sender windows retain the bounded Peer operation; failed exact candidates stay consumed until the sender or candidate opportunity changes. The pre-share gate defaults on and locks while sharing. -- Host Advanced settings include a default-off, Host-originated NAT traversal - experiment. It adds only bounded, connection-local predicted ICE candidates; - ordinary candidates and the existing SFU fallback remain unchanged. +- Host Advanced settings include a default-off, per-share NAT traversal + experiment. Every Browser P2P role uses the same bounded, connection-local + candidate adapter; ordinary candidates and SFU fallback remain unchanged. - Every direct, Browser-relay, and Host SFU video sender owns one clone of its source track; the original remains presentation and source authority only. Replacement, rollback, unpublish, physical LiveKit sender recreation, and diff --git a/docs/todo.md b/docs/todo.md index 1f596f7..e0478c8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -81,14 +81,13 @@ until their stated boundary is met. `MediaStream` output contract. Reopen only for a registered custom receiver acting as an ordinary Viewer after a named Browser and physical receiver pass the [platform-output gate](./research/platform-output.md). -14. **NAT inference and predictive candidates.** The default-off Host-only +14. **NAT inference and predictive candidates.** The default-off room-wide, connection-local experiment is accepted under [ADR-0009](./adr/0009-connection-local-nat-prediction-experiment.md). Keep multi-STUN observations and injected candidates additive and diagnostic; do not make them a participant-wide NAT label, hard candidate skip, route score, or SFU preference. Validate target-network success, candidate - direction, and bounded resource impact before enabling it by default or - extending it to Viewer relay edges. + direction, and bounded resource impact before enabling it by default. 15. **Unresolved route-state ownership claims.** Reopen lower-revision reauthentication, active SFU failure during an unrelated prepare, and multi-child relay-evidence ownership only from an exact current-wire diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts index d0ef555..fec21ee 100644 --- a/src/client/media/host-provisional-child.ts +++ b/src/client/media/host-provisional-child.ts @@ -23,7 +23,7 @@ interface HostProvisionalPrepareInput extends HostProvisionalInput { stream: MediaStream; profile: QualityProfile; videoCodec: BrowserVideoCodecPreference; - natPredictionEnabled?: boolean; + natPredictionEnabled: boolean; } interface HostProvisionalChildEvents { @@ -128,7 +128,7 @@ export class HostProvisionalChild { }, input.videoCodec, candidate.connectionId, - input.natPredictionEnabled ?? false, + input.natPredictionEnabled, ); this.signalingPeer = peer; this.prepared = { diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index f63bd59..b130b5e 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -384,7 +384,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [routePolicy, setRoutePolicy] = useState( DEFAULT_ROUTE_POLICY, ); - const [natPredictionEnabled, setNatPredictionEnabled] = useState(false); const [videoCodecMode, setVideoCodecMode] = useState("auto"); const [resolvedVideoCodec, setResolvedVideoCodec] = @@ -515,7 +514,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const pendingQualityChangeRef = useRef(null); const qualitySettingsRef = useRef(DEFAULT_QUALITY_SETTINGS); const routePolicyRef = useRef(DEFAULT_ROUTE_POLICY); - const natPredictionEnabledRef = useRef(false); const advancedQualityRef = useRef(advancedQuality); const videoCodecModeRef = useRef(videoCodecMode); const roomMutationRef = useRef(null); @@ -1174,14 +1172,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setRoutePolicy(next); } - function changeNatPrediction(enabled: boolean): void { - if (phase === "starting" || phase === "live") { - return; - } - natPredictionEnabledRef.current = enabled; - setNatPredictionEnabled(enabled); - } - function changeAdvancedQuality( patch: Partial, ): void { @@ -1463,7 +1453,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { stream, profile: qualitySettingsRef.current, videoCodec: videoCodecRef.current, - natPredictionEnabled: natPredictionEnabledRef.current, + natPredictionEnabled: routePolicyRef.current.natPrediction, }); } @@ -1541,7 +1531,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { }, videoCodecRef.current, undefined, - natPredictionEnabledRef.current, + routePolicyRef.current.natPrediction, ); peersRef.current.set(peerId, peer); let started: boolean; @@ -1697,6 +1687,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { "sfuStandbyUrl" in message ? message.sfuStandbyUrl : null, ); setMaxViewers(message.maxViewers); + routePolicyRef.current = { ...message.routePolicy }; + setRoutePolicy({ ...message.routePolicy }); setRoom((current) => mergeAuthenticatedHostRoom( current, @@ -1709,8 +1701,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { "mediaMode" in message && message.mediaMode === "peer-assisted" ) { - routePolicyRef.current = { ...message.routePolicy }; - setRoutePolicy({ ...message.routePolicy }); const currentQualitySettings = pendingQualitySettings ?? message.qualitySettings; activeRouteRevisionRef.current = message.routeRevision; @@ -3834,9 +3824,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { hint="hint-route-p2p" /> + changeRoutePolicy({ natPrediction: checked }) + } label={t("host.advanced.route.natPrediction")} note={t("host.advanced.route.natPredictionHint")} hint="hint-nat-prediction" diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 26eefd2..dcab782 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -890,6 +890,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { }, }, endpointMediaCopyCapacity, + currentRoutePolicy.natPrediction, ); viewerRelay.setChildren(currentAssignment.childPeerIds); return viewerRelay; @@ -1465,6 +1466,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { : true; }, }, + currentRoutePolicy.natPrediction, ); probe.peer = peer; return peer; @@ -1555,6 +1557,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return true; }, }, + currentRoutePolicy.natPrediction, ); peerRef.current = peer; return peer; @@ -1579,6 +1582,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { viewerRelay?.updateCapacity(endpointMediaCopyCapacity); viewerAuthorizationGeneration = message.viewerAuthorizationGeneration; + currentRoutePolicy = message.routePolicy; setSfuStandbyUrl( "sfuStandbyUrl" in message ? message.sfuStandbyUrl : null, ); @@ -1644,7 +1648,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { }); if (nextPeerAssisted && "qualitySettings" in message) { currentQualitySettings = message.qualitySettings; - currentRoutePolicy = message.routePolicy; currentShareGeneration = message.shareGeneration; void viewerRelay?.updateProfile(currentQualitySettings); const route = ensureViewerSfuRoute(); @@ -1783,9 +1786,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } if (message.type === "route-policy") { if ( - peerAssisted && - (currentShareGeneration === null || - currentShareGeneration === message.shareGeneration) + currentShareGeneration === null || + currentShareGeneration === message.shareGeneration ) { currentShareGeneration = message.shareGeneration; currentRoutePolicy = message.routePolicy; diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 8e3c795..db1aeee 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -39,7 +39,7 @@ import { } from "./video-codec"; import { iceServersWithNatPrediction, - NatPredictionCandidateBatch, + NatPredictionCandidateEmitter, type SignalCandidate, } from "./nat-prediction"; @@ -57,9 +57,7 @@ export class HostPeer { private readonly connection: RTCPeerConnection; private readonly pendingCandidates: SignalCandidate[] = []; private readonly natPredictionEnabled: boolean; - private natCandidateBatch: NatPredictionCandidateBatch | null = null; - private natCandidateUsernameFragment: string | null = null; - private natCandidateEndSent = false; + private readonly localIceCandidates: NatPredictionCandidateEmitter; private statsAccumulator = createStatsAccumulator(); private senderVideoTrack: MediaStreamTrack | null; private replacementVideoTrack: MediaStreamTrack | null = null; @@ -115,6 +113,10 @@ export class HostPeer { this.natPredictionEnabled, ), }); + this.localIceCandidates = new NatPredictionCandidateEmitter( + this.natPredictionEnabled, + (candidate) => this.sendIceCandidate(candidate), + ); this.snapshot = { peerId, connectionId: this.connectionId, @@ -431,60 +433,21 @@ export class HostPeer { this.statsTimer = null; } this.connection.close(); - this.natCandidateBatch?.discard(); - this.natCandidateBatch = null; - this.natCandidateUsernameFragment = null; - this.natCandidateEndSent = false; + this.localIceCandidates.discard(); this.senderVideoTrack?.stop(); this.senderVideoTrack = null; } private bindConnectionEvents(): void { this.connection.addEventListener("icecandidate", (event) => { - const candidate = event.candidate - ? { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex, - usernameFragment: event.candidate.usernameFragment, - } - : null; - if (!this.natPredictionEnabled) { - this.sendIceCandidate(candidate); - return; - } - if (candidate === null) { - this.completeNatCandidateBatch(); - if (!this.natCandidateEndSent) { - this.sendIceCandidate(null); - this.natCandidateEndSent = true; - } - return; - } - if ( - this.natCandidateBatch && - this.natCandidateUsernameFragment && - candidate.usernameFragment && - candidate.usernameFragment !== this.natCandidateUsernameFragment - ) { - this.natCandidateBatch.complete(); - this.natCandidateBatch = null; - } - if (!this.natCandidateBatch) { - this.natCandidateUsernameFragment = candidate.usernameFragment ?? null; - this.natCandidateEndSent = false; - this.natCandidateBatch = new NatPredictionCandidateBatch((next) => - this.sendIceCandidate(next), - ); - } - this.natCandidateBatch.add(candidate); + this.localIceCandidates.add(event.candidate); }); this.connection.addEventListener("icegatheringstatechange", () => { if ( this.natPredictionEnabled && this.connection.iceGatheringState === "complete" ) { - this.completeNatCandidateBatch(); + this.localIceCandidates.gatheringComplete(); } }); this.connection.addEventListener("connectionstatechange", () => { @@ -515,15 +478,6 @@ export class HostPeer { }); } - private completeNatCandidateBatch(): void { - if (this.natCandidateBatch) { - this.natCandidateBatch.complete(); - this.natCandidateBatch = null; - this.natCandidateUsernameFragment = null; - this.natCandidateEndSent = true; - } - } - private enqueueSenderMutation(operation: () => Promise): Promise { const result = this.senderMutationTail.then(operation); this.senderMutationTail = result.then( diff --git a/src/client/webrtc/nat-prediction.ts b/src/client/webrtc/nat-prediction.ts index 9e9b758..6520604 100644 --- a/src/client/webrtc/nat-prediction.ts +++ b/src/client/webrtc/nat-prediction.ts @@ -4,6 +4,7 @@ export type SignalCandidate = Extract< SignalPayload, { kind: "candidate" } >["candidate"]; +type ConcreteSignalCandidate = NonNullable; const BASE_STUN_PORT = 3478; const AUXILIARY_STUN_PORTS = [3479, 3480] as const; @@ -298,3 +299,78 @@ export class NatPredictionCandidateBatch { } } } + +function signalCandidate(candidate: RTCIceCandidate): ConcreteSignalCandidate { + return { + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex, + usernameFragment: candidate.usernameFragment, + }; +} + +/** Own one local ICE candidate generation for any Browser P2P role. */ +export class NatPredictionCandidateEmitter { + private batch: NatPredictionCandidateBatch | null = null; + private usernameFragment: string | null = null; + private endSent = false; + + constructor( + private readonly enabled: boolean, + private readonly send: (candidate: SignalCandidate | null) => void, + ) {} + + add(candidate: RTCIceCandidate | null): void { + if (!this.enabled) { + this.send(candidate ? signalCandidate(candidate) : null); + return; + } + if (!candidate) { + this.completeBatch(); + if (!this.endSent) { + this.send(null); + this.endSent = true; + } + return; + } + + const next = signalCandidate(candidate); + if ( + this.batch && + this.usernameFragment && + next.usernameFragment && + next.usernameFragment !== this.usernameFragment + ) { + this.completeBatch(); + } + if (!this.batch) { + this.usernameFragment = next.usernameFragment ?? null; + this.endSent = false; + this.batch = new NatPredictionCandidateBatch(this.send); + } + this.batch.add(next); + } + + gatheringComplete(): void { + if (this.enabled) { + this.completeBatch(); + } + } + + discard(): void { + this.batch?.discard(); + this.batch = null; + this.usernameFragment = null; + this.endSent = false; + } + + private completeBatch(): void { + if (!this.batch) { + return; + } + this.batch.complete(); + this.batch = null; + this.usernameFragment = null; + this.endSent = true; + } +} diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index b385f30..dc2ae5a 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -12,6 +12,10 @@ import { } from "./stats"; import { preferScreenAudioStereo } from "./screen-audio-sdp"; import { observeDecodedFrameProof } from "../media/decoded-frame-proof"; +import { + iceServersWithNatPrediction, + NatPredictionCandidateEmitter, +} from "./nat-prediction"; const MAX_PENDING_CANDIDATES = 64; const MAX_AUTOMATIC_RECOVERY_REQUESTS = 2; @@ -60,12 +64,14 @@ export class ViewerPeer { private recoveryExhaustedReported = false; private disposed = false; private currentIceConfig: PeerIceConfig; + private localIceCandidates: NatPredictionCandidateEmitter | null = null; private snapshot: PeerSnapshot | null = null; private descriptionTail: Promise = Promise.resolve(); constructor( iceConfig: PeerIceConfig, private readonly events: ViewerPeerEvents, + private readonly natPredictionEnabled = false, ) { this.currentIceConfig = iceConfig; } @@ -106,7 +112,10 @@ export class ViewerPeer { } try { this.connection.setConfiguration({ - iceServers: iceConfig.iceServers, + iceServers: iceServersWithNatPrediction( + iceConfig.iceServers, + this.natPredictionEnabled, + ), }); } catch (error) { this.setError(error, say("host.fail.connection")); @@ -193,9 +202,26 @@ export class ViewerPeer { this.statsAccumulator = createStatsAccumulator(); const connection = new RTCPeerConnection({ - iceServers: this.currentIceConfig.iceServers, + iceServers: iceServersWithNatPrediction( + this.currentIceConfig.iceServers, + this.natPredictionEnabled, + ), }); + const localIceCandidates = new NatPredictionCandidateEmitter( + this.natPredictionEnabled, + (candidate) => { + if (this.connection !== connection) { + return; + } + this.events.sendSignal(parentPeerId, { + kind: "candidate", + connectionId, + candidate, + }); + }, + ); this.connection = connection; + this.localIceCandidates = localIceCandidates; this.snapshot = { peerId: parentPeerId, connectionId, @@ -206,21 +232,18 @@ export class ViewerPeer { }; connection.addEventListener("icecandidate", (event) => { - if (!this.parentPeerId || this.connection !== connection) { + if (this.connection !== connection) { return; } - this.events.sendSignal(parentPeerId, { - kind: "candidate", - connectionId, - candidate: event.candidate - ? { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex, - usernameFragment: event.candidate.usernameFragment, - } - : null, - }); + localIceCandidates.add(event.candidate); + }); + connection.addEventListener("icegatheringstatechange", () => { + if ( + this.connection === connection && + connection.iceGatheringState === "complete" + ) { + localIceCandidates.gatheringComplete(); + } }); connection.addEventListener("track", (event) => { if (this.connection !== connection) { @@ -608,6 +631,8 @@ export class ViewerPeer { window.clearInterval(this.statsTimer); this.statsTimer = null; } + this.localIceCandidates?.discard(); + this.localIceCandidates = null; this.connection?.close(); this.connection = null; this.connectionId = null; diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index e772013..2492f0f 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -50,6 +50,7 @@ export class ViewerRelay { private desiredProfile: QualityProfile, private readonly events: ViewerRelayEvents, private maxMediaEdges = MAX_ENDPOINT_MEDIA_CHILDREN, + private readonly natPredictionEnabled = false, ) {} getSnapshot(childPeerId?: string): PeerSnapshot | null { @@ -549,6 +550,7 @@ export class ViewerRelay { }, this.videoCodec, connectionId, + this.natPredictionEnabled, ); return peer; } diff --git a/src/server/signaling.ts b/src/server/signaling.ts index d1c946c..832fc6f 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -671,6 +671,9 @@ export class SignalingServer { if (!this.isHybridMediaEnabled()) { this.fillOrdinaryHostChildren(participant.roomId); } + const routePolicy = + this.routePolicyByRoom.get(participant.roomId) ?? DEFAULT_ROUTE_POLICY; + const hasRoutePolicy = this.routePolicyByRoom.has(participant.roomId); const authenticatedMessageBase = { type: "authenticated" as const, @@ -694,6 +697,7 @@ export class SignalingServer { ? [...this.ordinaryHostChildPeerIds(participant.roomId)] : [...participant.viewerPeerIds], iceConfig: this.iceConfig(), + routePolicy, codeEntryPolicy: participant.codeEntryPolicy, viewerAuthorizationGeneration: participant.viewerAuthorizationGeneration, @@ -712,9 +716,6 @@ export class SignalingServer { ...authenticatedMessageBase, role: "viewer" as const, }; - const routePolicy = - this.routePolicyByRoom.get(participant.roomId) ?? DEFAULT_ROUTE_POLICY; - const hasRoutePolicy = this.routePolicyByRoom.has(participant.roomId); if (hybridState) { this.send(socket, { ...authenticatedMessage, @@ -726,7 +727,6 @@ export class SignalingServer { qualitySettings: this.qualitySettingsByRoom.get(participant.roomId) ?? DEFAULT_QUALITY_SETTINGS, - routePolicy, ...(this.options.sfuFallback && hasRoutePolicy && !routePolicy.peerOnly ? { sfuStandbyUrl: this.options.sfuFallback.url } : {}), diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 327ba14..c13b8e3 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -6,7 +6,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 20; export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; -export const SIGNALING_PROTOCOL = "screener-v17"; +export const SIGNALING_PROTOCOL = "screener-v18"; export const SIGNAL_CLOSE_CODES = { serviceRestart: 1012, sessionReplaced: 4001, @@ -218,12 +218,14 @@ export const routePolicySchema = z .object({ peerOnly: z.boolean(), topologyOptimization: z.boolean(), + natPrediction: z.boolean(), }) .strict(); export type RoutePolicy = z.infer; export const DEFAULT_ROUTE_POLICY = { peerOnly: false, topologyOptimization: true, + natPrediction: false, } as const satisfies RoutePolicy; export const relayDownstreamEdgesSchema = z @@ -921,6 +923,7 @@ const authenticatedMessageShape = { connectionId: opaqueIdSchema.nullable(), viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT), iceConfig: iceConfigSchema, + routePolicy: routePolicySchema.default(DEFAULT_ROUTE_POLICY), codeEntryPolicy: codeEntryPolicySchema, viewerAuthorizationGeneration: opaqueIdSchema, }; @@ -942,7 +945,6 @@ const peerAssistedAuthenticatedShape = { routeRevision: mediaRouteRevisionSchema, routeAssignment: participantRouteAssignmentSchema, qualitySettings: qualitySettingsSchema, - routePolicy: routePolicySchema, sfuStandbyUrl: liveKitWebSocketUrlSchema.optional(), }; diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index ecb05c5..243c56c 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -14,6 +14,7 @@ import { VP8_ONLY_VIDEO_CODEC, } from "../src/client/webrtc/video-codec.ts"; import { ViewerRelay } from "../src/client/webrtc/viewer-relay.ts"; +import { setCopy } from "../src/client/ui/copy.ts"; import type { IceConfig, ParticipantRouteAssignment, @@ -473,6 +474,7 @@ async function completeVideoStartup( } beforeEach(() => { + setCopy({ lang: "zh", vis: false }); codecPreflight.probe.mockReset(); codecPreflight.probe.mockResolvedValue("vp8"); FakePeerConnection.latest = null; @@ -1699,6 +1701,7 @@ function hostProvisionalInput( stream, profile: QUALITY_PROFILES["720p30"], videoCodec: VP8_ONLY_VIDEO_CODEC, + natPredictionEnabled: false, }; } @@ -1823,6 +1826,28 @@ describe("Host provisional child runtime ownership", () => { }); describe("ViewerRelay downstream ownership", () => { + it("applies the room NAT policy to downstream Peer connections", async () => { + const relay = new ViewerRelay( + { iceServers: [{ urls: "stun:share.example.test:3478" }] }, + QUALITY_PROFILES["720p30"], + { sendSignal: () => true }, + 2, + true, + ); + + relay.setChildren(["nat-child"]); + relay.setStream( + createStream(createTrack("video", "nat-relay-source"), null), + ); + await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull()); + expect(FakePeerConnection.latest!.configurations[0]?.iceServers).toEqual([ + { urls: "stun:share.example.test:3478" }, + { urls: "stun:share.example.test:3479" }, + { urls: "stun:share.example.test:3480" }, + ]); + relay.dispose(); + }); + it("gives each downstream sender its own video clone", async () => { const sourceVideo = createTrack("video", "relay-source"); const relay = new ViewerRelay( diff --git a/tests/hybrid-media-router.test.ts b/tests/hybrid-media-router.test.ts index d60c073..c90f8d9 100644 --- a/tests/hybrid-media-router.test.ts +++ b/tests/hybrid-media-router.test.ts @@ -750,6 +750,7 @@ describe("HybridMediaRouter v9 runtime", () => { const host = connectHost(store, room, { peerOnly: false, topologyOptimization: true, + natPrediction: false, }); complete(router, host); const first = connectViewer(store, room, "quality-first"); @@ -906,6 +907,7 @@ describe("HybridMediaRouter v9 runtime", () => { const host = connectHost(store, room, { peerOnly: false, topologyOptimization: true, + natPrediction: false, }); complete(router, host); const viewer = connectViewer(store, room, "quality-regeneration"); @@ -987,6 +989,7 @@ describe("HybridMediaRouter v9 runtime", () => { const host = connectHost(store, room, { peerOnly: true, topologyOptimization: false, + natPrediction: false, }); complete(router, host); const first = connectViewer(store, room, "peer-only-first"); @@ -1035,6 +1038,7 @@ describe("HybridMediaRouter v9 runtime", () => { const host = connectHost(store, room, { peerOnly: false, topologyOptimization: true, + natPrediction: false, }); complete(router, host); const viewer = connectViewer(store, room, "quality-sfu-denied"); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index a71d9a1..603c166 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -97,7 +97,7 @@ describe("client signaling protocol", () => { "utf8", ); - expect(SIGNALING_PROTOCOL).toBe("screener-v17"); + expect(SIGNALING_PROTOCOL).toBe("screener-v18"); expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/); }); @@ -276,6 +276,7 @@ describe("client signaling protocol", () => { expect(DEFAULT_ROUTE_POLICY).toEqual({ peerOnly: false, topologyOptimization: true, + natPrediction: false, }); expect( decodeClientMessage( @@ -320,6 +321,7 @@ describe("client signaling protocol", () => { routePolicy: { peerOnly: false, topologyOptimization: true, + natPrediction: false, }, }); expect( diff --git a/tests/quality.test.ts b/tests/quality.test.ts index d20187a..a9a2c83 100644 --- a/tests/quality.test.ts +++ b/tests/quality.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { audioSenderParameterWarning, @@ -16,6 +16,7 @@ import { setMediaPaused, startupVideoProfile, } from "../src/client/media/quality.ts"; +import { setCopy } from "../src/client/ui/copy.ts"; function createVideoStream() { const videoTrack = { @@ -36,6 +37,10 @@ function createVideoStream() { return { stream, videoTrack, audioTrack }; } +beforeEach(() => { + setCopy({ lang: "zh", vis: false }); +}); + afterEach(() => { vi.unstubAllGlobals(); }); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index b5d1bb7..720b02c 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; import { + DEFAULT_ROUTE_POLICY, MAX_SIGNAL_BYTES, SIGNALING_PROTOCOL, decodeServerMessage, @@ -477,6 +478,7 @@ describe("WebSocket signaling", () => { const routePolicy = { peerOnly: true, topologyOptimization: true, + natPrediction: true, } as const; const authenticated = peerAssisted( await authenticate( @@ -498,6 +500,48 @@ describe("WebSocket signaling", () => { }); }); + it("broadcasts the NAT experiment policy in lightweight rooms", async () => { + const harness = await startHarness({ peerAssistedMedia: false }); + const waitingViewer = await openClient(harness.webSocketUrl); + const waiting = await authenticate( + waitingViewer, + harness.room, + "viewer", + "ordinary-nat-waiting-viewer", + null, + ); + expect(waiting.routePolicy.natPrediction).toBe(false); + + const host = await openClient(harness.webSocketUrl); + const routePolicy = { + ...DEFAULT_ROUTE_POLICY, + natPrediction: true, + }; + const hostAuth = await authenticate( + host, + harness.room, + "host", + "ordinary-nat-host", + null, + "ordinary_nat_share_generation_12345678", + { routePolicy }, + ); + expect(hostAuth.routePolicy).toEqual(routePolicy); + expect(await waitingViewer.inbox.next("route-policy")).toMatchObject({ + routePolicy, + }); + + const joiningViewer = await openClient(harness.webSocketUrl); + const joined = await authenticate( + joiningViewer, + harness.room, + "viewer", + "ordinary-nat-joining-viewer", + null, + ); + expect(joined.routePolicy).toEqual(routePolicy); + }); + it("rebuilds a route when a Viewer reconnects before the Host after restart", async () => { const directory = mkdtempSync(join(tmpdir(), "screener-route-restart-")); const path = join(directory, "rooms.sqlite"); diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index d33ff42..aaa2603 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -6,6 +6,7 @@ import { SfuPublisher } from "../src/client/sfu/publisher.ts"; import { SfuSubscriber } from "../src/client/sfu/subscriber.ts"; import type { ConnectionMetrics } from "../src/client/types.ts"; import { mergeStatsReports } from "../src/client/webrtc/stats.ts"; +import { setCopy } from "../src/client/ui/copy.ts"; type EventHandler = (...args: unknown[]) => void; @@ -535,6 +536,7 @@ function deferred(): { } beforeEach(() => { + setCopy({ lang: "zh", vis: false }); livekit.state.rooms.length = 0; livekit.state.connectGate = null; livekit.state.nextSenderParameterError = null; diff --git a/tests/viewer-peer-generation.test.ts b/tests/viewer-peer-generation.test.ts index b65eb73..84ad98c 100644 --- a/tests/viewer-peer-generation.test.ts +++ b/tests/viewer-peer-generation.test.ts @@ -60,6 +60,7 @@ class FakePeerConnection extends EventTarget { readonly configurations: RTCConfiguration[] = []; connectionState: RTCPeerConnectionState = "new"; iceConnectionState: RTCIceConnectionState = "new"; + iceGatheringState: RTCIceGatheringState = "new"; remoteDescription: RTCSessionDescription | null = null; localDescription: RTCSessionDescription | null = null; statsGate: Promise | null = null; @@ -173,6 +174,7 @@ function createPeer( snapshots: PeerSnapshot[], signalPeers: string[] = [], iceConfig: IceConfig = { iceServers: [] }, + natPrediction = false, ): ViewerPeer { return new ViewerPeer( iceConfig, @@ -186,6 +188,7 @@ function createPeer( onStream: () => undefined, onUpdate: (snapshot) => snapshots.push(snapshot), }, + natPrediction, ); } @@ -333,6 +336,61 @@ describe("ViewerPeer connection generations", () => { } }); + it("uses the room NAT policy for Viewer-originated ICE candidates", async () => { + const signals: SignalPayload[] = []; + const peer = createPeer( + signals, + [], + [], + { iceServers: [{ urls: "stun:share.example.test:3478" }] }, + true, + ); + await peer.acceptSignal("host", offer("nat-room-policy")); + const connection = FakePeerConnection.instances[0]!; + expect(connection.configurations[0]?.iceServers).toEqual([ + { urls: "stun:share.example.test:3478" }, + { urls: "stun:share.example.test:3479" }, + { urls: "stun:share.example.test:3480" }, + ]); + + const emitCandidate = (port: number): void => { + const event = new Event("icecandidate"); + Object.defineProperty(event, "candidate", { + value: { + candidate: + `candidate:base 1 udp 2122260223 203.0.113.7 ${port} ` + + "typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test", + sdpMid: "0", + sdpMLineIndex: 0, + usernameFragment: "test", + }, + }); + connection.dispatchEvent(event); + }; + emitCandidate(40_000); + emitCandidate(40_003); + emitCandidate(40_006); + connection.iceGatheringState = "complete"; + connection.dispatchEvent(new Event("icegatheringstatechange")); + + const candidates = signals.filter( + (signal): signal is Extract => + signal.kind === "candidate", + ); + expect( + candidates.some((signal) => + signal.candidate?.candidate.startsWith("candidate:sp"), + ), + ).toBe(true); + expect( + candidates.filter((signal) => + signal.candidate?.candidate.startsWith("candidate:base"), + ), + ).toHaveLength(3); + expect(candidates.filter((signal) => signal.candidate === null)).toHaveLength(1); + peer.dispose(); + }); + it("restarts ICE when an answered initial connection stays stuck", async () => { const restartRequests: Array<{ peerId: string;