From 76a6eb7f852f4b1dd5ac5919d253011c84dbaf55 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:18:05 +0800 Subject: [PATCH] feat(nat): add opt-in NAT candidate experiment (#366) --- docs/README.md | 2 + ...nection-local-nat-prediction-experiment.md | 67 ++++ docs/product/routing-transport.md | 6 + docs/project-memory.md | 10 +- docs/research/nat-traversal.md | 54 ++-- docs/status.md | 5 +- docs/todo.md | 20 +- src/client/components/living/hints/index.tsx | 4 +- src/client/components/living/hints/set4.tsx | 47 +++ src/client/locales/en.ts | 3 + src/client/locales/zh.ts | 3 + src/client/media/host-provisional-child.ts | 3 + src/client/pages/HostPage.tsx | 21 ++ src/client/webrtc/host-peer.ts | 103 ++++-- src/client/webrtc/nat-prediction.ts | 300 ++++++++++++++++++ tests/host-peer-tracks.test.ts | 85 ++++- tests/nat-prediction.test.ts | 139 ++++++++ 17 files changed, 807 insertions(+), 65 deletions(-) create mode 100644 docs/adr/0009-connection-local-nat-prediction-experiment.md create mode 100644 src/client/webrtc/nat-prediction.ts create mode 100644 tests/nat-prediction.test.ts diff --git a/docs/README.md b/docs/README.md index 13b1a3a..b80889c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ ordinary implementation and UI detail. owned media adaptation. - [ADR-0008](./adr/0008-window-scoped-audio-capture.md): Browser/native window audio boundary. +- [ADR-0009](./adr/0009-connection-local-nat-prediction-experiment.md): opt-in + connection-local NAT candidate experiment. ## Evidence diff --git a/docs/adr/0009-connection-local-nat-prediction-experiment.md b/docs/adr/0009-connection-local-nat-prediction-experiment.md new file mode 100644 index 0000000..74caa03 --- /dev/null +++ b/docs/adr/0009-connection-local-nat-prediction-experiment.md @@ -0,0 +1,67 @@ +# ADR-0009: Connection-Local NAT Candidate Experiment + +- Status: accepted as an opt-in Browser experiment; default off +- Date: 2026-09-01 + +## Context + +The self-hosted deployment has two auxiliary STUN-only listeners for controlled +NAT measurements. A disposable namespace lab showed that a sequential, +endpoint-dependent mapping can sometimes be reached when the remote peer +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. + +## 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. +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 + bounded candidates around the latest observation (four in each direction). + Every ordinary candidate is retained; no ordinary candidate is rejected or + replaced. A failed or inconclusive experiment uses the existing ICE and SFU + fallback unchanged. +4. Candidate observations remain in memory for that connection only. No NAT + label, raw address, port, score, hard candidate skip, periodic probe, or + route-controller branch is introduced. + +## Consequences + +Positive: + +- operators can test the measured hole-punching mechanism without changing the + default media path; +- normal ICE remains available, so an unsuccessful prediction cannot remove + the existing direct or SFU fallback; and +- the experiment is bounded to one connection generation and one small + candidate set. + +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. + +The controlled lab result (14/15 sequential-to-restricted runs with a bounded +prediction set) is mechanism evidence, not a production success guarantee. +Target-network prevalence, candidate direction, and long-run resource impact +remain acceptance work. + +## Primary Sources + +- [RFC 8445: Interactive Connectivity Establishment](https://www.rfc-editor.org/rfc/rfc8445) +- [RFC 4787: UDP NAT behavioral requirements](https://www.rfc-editor.org/rfc/rfc4787) +- [RFC 5780: NAT Behavior Discovery Using STUN](https://www.rfc-editor.org/rfc/rfc5780) +- [W3C WebRTC candidate model](https://www.w3.org/TR/webrtc/) diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index 66c4434..0262522 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -33,6 +33,12 @@ share generation: it before sharing; when disabled, quality evidence stays diagnostic and availability routing is unchanged. +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 policy requires stopping the current share and starting another generation. diff --git a/docs/project-memory.md b/docs/project-memory.md index dfda9d6..a26ffa2 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -1,6 +1,6 @@ # Project Memory -Last updated: 2026-08-28 +Last updated: 2026-09-01 Screener is private, low-latency game screen sharing for one Host and up to 20 authenticated friends. The current product surface is Web Host, Web Viewer, and @@ -30,9 +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. 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 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. - 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; diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md index 7edf4fd..1f3227c 100644 --- a/docs/research/nat-traversal.md +++ b/docs/research/nat-traversal.md @@ -1,6 +1,6 @@ # Browser NAT Traversal -Last reviewed: 2026-08-31 +Last reviewed: 2026-09-01 This document owns evidence for improving direct Browser ICE without adding a new relay or a custom transport. The current product contract remains standard @@ -45,24 +45,28 @@ second proves candidate syntax only; it does not prove that a predicted NAT mapping receives an authenticated connectivity check. The probe retained no raw address or port. -This preflight also leaves the proposed field mapper without an ordered port +This preflight also leaves the Browser-only mapper without an ordered port delta. Candidate events expose response completion, while configured STUN order -does not specify the NAT allocation order. A Browser-only mapper may retain a -positive varies-by-destination observation and unordered port gaps, but cannot -label a signed `P + d` sequence without controlled external evidence. Trying -both signs consumes more checklist budget and still needs the same lab gate. - -The full prediction gate therefore remains unrun. It requires a disposable -Linux runner with network namespaces and packet-filter authority, controlled -STUN destinations, and two real Browser ICE agents. The production host is not -that runner, and an unexecuted nftables script is not accepted evidence. +does not specify the NAT allocation order. A disposable namespace matrix then +provided the controlled gate: its four-by-four baseline matched the expected +reachability classes, and bounded prediction improved sequential-to-restricted +connectivity in 14/15 runs. Sequential-to-sequential pairs still depended on +candidate ordering and remained sensitive to intervening mappings. The result +supports an opt-in mechanism, not a default or a participant-wide classifier. ## Current Decision -Do not change the flagship STUN configuration in this phase. A second endpoint -does not itself traverse a hard NAT and does not justify a permanent external -metadata dependency. The self-hosted STUN endpoint remains the production -discovery service. +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. + +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 +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: @@ -95,12 +99,11 @@ Consequently Browser observations can support a positive `varies` diagnostic, but cannot produce the proposed `eim | edm-sequential | edm-random` participant fact. They cannot safely remove a Peer candidate or save its five-second window. -Port prediction remains the only reachable Browser experiment that might add -direct paths beyond stock ICE. It requires a controlled sequential/random NAT -matrix and two real Browser ICE agents before any product design. No such -controlled Browser matrix is currently available, and the production server is -not an acceptable network-emulation lab. No candidate injection ships without -that gate. +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. ## Rejected Product Inference @@ -118,11 +121,10 @@ type: Therefore NAT samples must not skip a P2P candidate, alter the committed graph, or become a persistent participant capability. Successful `addIceCandidate()` -parsing would prove only candidate syntax; it would not prove that a predicted -mapping receives authenticated ICE checks. Port prediction and synthetic -candidate injection remain experiments until controlled Browser and target- -network measurements prove both benefit and bounded false rejection. They do -not enter the current protocol or controller. +parsing proves only candidate syntax; it does not prove that a predicted mapping +receives authenticated ICE checks. The accepted experiment keeps this boundary: +it adds no protocol fields or controller state and falls back to ordinary ICE +when its shape test is inconclusive. QUIC on UDP 443 does not change this Browser boundary. WebRTC's ICE agent owns its UDP sockets and candidate checks; the Web application cannot replace that diff --git a/docs/status.md b/docs/status.md index 59341e0..2a3fb09 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Current Status -Last updated: 2026-08-31 +Last updated: 2026-09-01 This is the current execution index. Git history owns completed timelines; [verification status](./verification-status.md) owns evidence boundaries. @@ -75,6 +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. - 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 853a19f..1f596f7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -81,18 +81,14 @@ 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.** Keep multi-STUN observations - diagnostic until controlled Browser and target-network evidence proves a - connection-scoped classifier with bounded false rejection. Do not hard-skip - Peer candidates from a participant-wide NAT label or inject predicted ports - into production ICE without a new accepted decision and a measured gate. See - [Browser NAT traversal](./research/nat-traversal.md). The Browser preflight - proves only positive mapping variation and synthetic-candidate syntax; the - isolated Linux namespace lab now covers the controlled Browser gate. A - self-hosted same-IP two/three-port endpoint is available for an approved - aggregate-only field survey; keep observations diagnostic until stable - sequential allocation is common enough to justify a new decision. Do not - inject predicted candidates yet. +14. **NAT inference and predictive candidates.** The default-off Host-only + 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. 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/components/living/hints/index.tsx b/src/client/components/living/hints/index.tsx index b2cb740..ae1dad1 100644 --- a/src/client/components/living/hints/index.tsx +++ b/src/client/components/living/hints/index.tsx @@ -51,7 +51,8 @@ export type Set4Kind = | "hint-theater" | "hint-theater-exit" | "hint-route-p2p" - | "hint-route-sfu"; + | "hint-route-sfu" + | "hint-nat-prediction"; export type HintKind = Set1Kind | Set2Kind | Set3Kind | Set4Kind; @@ -88,6 +89,7 @@ export const HINT_KINDS: readonly HintKind[] = [ "hint-theater-exit", "hint-route-p2p", "hint-route-sfu", + "hint-nat-prediction", ]; export type HintScene = (props: { theme: ComicTheme }) => ReactNode; diff --git a/src/client/components/living/hints/set4.tsx b/src/client/components/living/hints/set4.tsx index 1af1bd4..0fa55d5 100644 --- a/src/client/components/living/hints/set4.tsx +++ b/src/client/components/living/hints/set4.tsx @@ -492,6 +492,52 @@ ${rmBlock( ); +/* hint-nat-prediction: [one direct path] -> [a small bounded fan of + alternate direct paths]. The dots stay lightweight so the scene describes + extra ICE chances without suggesting a media relay. */ +const HintNatPrediction: HintScene = ({ theme }) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + +); + export const SET4_SCENES: Record = { "hint-topology": HintTopology, "hint-close": HintClose, @@ -504,4 +550,5 @@ export const SET4_SCENES: Record = { "hint-theater-exit": HintTheaterExit, "hint-route-p2p": HintRouteP2p, "hint-route-sfu": HintRouteSfu, + "hint-nat-prediction": HintNatPrediction, }; diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index 7dd69be..b7b7b12 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -157,6 +157,9 @@ export const en: Record = { "host.advanced.route.peerOnly": "Privacy mode", "host.advanced.route.peerOnlyHint": "Use P2P routes only; viewers without a P2P path cannot watch", + "host.advanced.route.natPrediction": "NAT traversal experiment", + "host.advanced.route.natPredictionHint": + "Try extra self-hosted STUN candidates for direct links; ordinary ICE remains available", "host.advanced.codec": "Video codec", "host.advanced.codec.auto": "Auto", "host.advanced.codec.autoHint": "Pick automatically", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index 795fdb6..fef44b2 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -155,6 +155,9 @@ export const zh = { "host.advanced.route.peerOnly": "隐私模式", "host.advanced.route.peerOnlyHint": "仅使用 P2P 路线,不经过媒体服务器;无可用 P2P 路线时无法观看", + "host.advanced.route.natPrediction": "NAT 打洞实验", + "host.advanced.route.natPredictionHint": + "为直连尝试额外的自托管 STUN 候选;失败时仍使用普通连接", "host.advanced.codec": "视频编码", "host.advanced.codec.auto": "自动", "host.advanced.codec.autoHint": "自动选择", diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts index 73119ab..d0ef555 100644 --- a/src/client/media/host-provisional-child.ts +++ b/src/client/media/host-provisional-child.ts @@ -23,6 +23,7 @@ interface HostProvisionalPrepareInput extends HostProvisionalInput { stream: MediaStream; profile: QualityProfile; videoCodec: BrowserVideoCodecPreference; + natPredictionEnabled?: boolean; } interface HostProvisionalChildEvents { @@ -96,6 +97,7 @@ export class HostProvisionalChild { input: Pick< HostProvisionalPrepareInput, "iceConfig" | "stream" | "profile" | "videoCodec" + | "natPredictionEnabled" >, ): void { let peer: HostPeer; @@ -126,6 +128,7 @@ export class HostProvisionalChild { }, input.videoCodec, candidate.connectionId, + input.natPredictionEnabled ?? false, ); this.signalingPeer = peer; this.prepared = { diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 89b14b8..f63bd59 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -384,6 +384,7 @@ 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] = @@ -514,6 +515,7 @@ 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); @@ -1172,6 +1174,14 @@ 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 { @@ -1453,6 +1463,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { stream, profile: qualitySettingsRef.current, videoCodec: videoCodecRef.current, + natPredictionEnabled: natPredictionEnabledRef.current, }); } @@ -1529,6 +1540,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { }, }, videoCodecRef.current, + undefined, + natPredictionEnabledRef.current, ); peersRef.current.set(peerId, peer); let started: boolean; @@ -3820,6 +3833,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { note={t("host.advanced.route.peerOnlyHint")} hint="hint-route-p2p" /> +
diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index a8b4d8f..8e3c795 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -37,13 +37,14 @@ import { type BrowserVideoCodecPreference, VP8_ONLY_VIDEO_CODEC, } from "./video-codec"; +import { + iceServersWithNatPrediction, + NatPredictionCandidateBatch, + type SignalCandidate, +} from "./nat-prediction"; const MAX_PENDING_CANDIDATES = 64; type PeerIceConfig = Pick; -type SignalCandidate = Extract< - SignalPayload, - { kind: "candidate" } ->["candidate"]; interface HostPeerEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; @@ -55,6 +56,10 @@ 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 statsAccumulator = createStatsAccumulator(); private senderVideoTrack: MediaStreamTrack | null; private replacementVideoTrack: MediaStreamTrack | null = null; @@ -94,8 +99,10 @@ export class HostPeer { private readonly videoCodec: BrowserVideoCodecPreference = VP8_ONLY_VIDEO_CODEC, connectionId = createOpaqueId(), + natPredictionEnabled = false, ) { this.connectionId = connectionId; + this.natPredictionEnabled = natPredictionEnabled; const sourceVideoTrack = stream.getVideoTracks()[0] ?? null; this.paused = sourceVideoTrack?.enabled === false; this.senderVideoTrack = sourceVideoTrack @@ -103,7 +110,10 @@ export class HostPeer { : null; this.startupVideoProfilePending = needsStartupVideoProfile(desiredProfile); this.connection = new RTCPeerConnection({ - iceServers: iceConfig.iceServers, + iceServers: iceServersWithNatPrediction( + iceConfig.iceServers, + this.natPredictionEnabled, + ), }); this.snapshot = { peerId, @@ -399,7 +409,10 @@ export class HostPeer { } try { this.connection.setConfiguration({ - iceServers: iceConfig.iceServers, + iceServers: iceServersWithNatPrediction( + iceConfig.iceServers, + this.natPredictionEnabled, + ), }); } catch (error) { this.setError(error, say("host.err.createConnection")); @@ -418,24 +431,61 @@ export class HostPeer { this.statsTimer = null; } this.connection.close(); + this.natCandidateBatch?.discard(); + this.natCandidateBatch = null; + this.natCandidateUsernameFragment = null; + this.natCandidateEndSent = false; this.senderVideoTrack?.stop(); this.senderVideoTrack = null; } private bindConnectionEvents(): void { this.connection.addEventListener("icecandidate", (event) => { - this.events.sendSignal(this.peerId, { - kind: "candidate", - connectionId: this.connectionId, - candidate: event.candidate - ? { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex, - usernameFragment: event.candidate.usernameFragment, - } - : null, - }); + 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.connection.addEventListener("icegatheringstatechange", () => { + if ( + this.natPredictionEnabled && + this.connection.iceGatheringState === "complete" + ) { + this.completeNatCandidateBatch(); + } }); this.connection.addEventListener("connectionstatechange", () => { const state = this.connection.connectionState; @@ -457,6 +507,23 @@ export class HostPeer { this.connection.addEventListener("iceconnectionstatechange", () => this.emit()); } + private sendIceCandidate(candidate: SignalCandidate | null): void { + this.events.sendSignal(this.peerId, { + kind: "candidate", + connectionId: this.connectionId, + candidate, + }); + } + + 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 new file mode 100644 index 0000000..9e9b758 --- /dev/null +++ b/src/client/webrtc/nat-prediction.ts @@ -0,0 +1,300 @@ +import type { SignalPayload } from "../../shared/protocol"; + +export type SignalCandidate = Extract< + SignalPayload, + { kind: "candidate" } +>["candidate"]; + +const BASE_STUN_PORT = 3478; +const AUXILIARY_STUN_PORTS = [3479, 3480] as const; +const MIN_PREDICTABLE_PORT = 1; +const MAX_PREDICTABLE_PORT = 65_535; + +/** Keep the experiment bounded so normal ICE candidates retain their budget. */ +export const NAT_PREDICTION_STEPS = 4; +export const MAX_NAT_PREDICTION_CANDIDATES = NAT_PREDICTION_STEPS * 2; + +interface SrflxObservation { + candidate: SignalCandidate; + fields: string[]; + address: string; + port: number; + groupKey: string; +} + +function urlsOf(server: RTCIceServer): readonly string[] { + return Array.isArray(server.urls) ? server.urls : [server.urls]; +} + +function cloneIceServer(server: RTCIceServer): RTCIceServer { + return { + ...server, + urls: Array.isArray(server.urls) ? [...server.urls] : server.urls, + }; +} + +function auxiliaryUrlsFor(url: string): readonly string[] { + if (!/^stun:/i.test(url)) { + return []; + } + const authority = url.slice(url.indexOf(":") + 1); + let parsed: URL; + try { + parsed = new URL(`http://${authority}`); + } catch { + return []; + } + if ( + !parsed.hostname || + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + return []; + } + const port = parsed.port ? Number(parsed.port) : BASE_STUN_PORT; + if (port !== BASE_STUN_PORT) { + return []; + } + const hostname = parsed.hostname.startsWith("[") + ? parsed.hostname + : parsed.hostname.includes(":") + ? `[${parsed.hostname}]` + : parsed.hostname; + return AUXILIARY_STUN_PORTS.map( + (auxiliaryPort) => `stun:${hostname}:${auxiliaryPort}`, + ); +} + +/** + * Add the two optional same-host survey listeners without changing the + * server-provided STUN list. The caller opts into this per connection. + */ +export function iceServersWithNatPrediction( + iceServers: readonly RTCIceServer[] | undefined, + enabled: boolean, +): RTCIceServer[] { + const base = (iceServers ?? []).map(cloneIceServer); + if (!enabled) { + return base; + } + + const additions = new Set(); + const existing = new Set( + (iceServers ?? []).flatMap((server) => urlsOf(server)), + ); + for (const server of iceServers ?? []) { + for (const url of urlsOf(server)) { + for (const auxiliaryUrl of auxiliaryUrlsFor(url)) { + if (!existing.has(auxiliaryUrl)) { + additions.add(auxiliaryUrl); + } + } + if (additions.size >= AUXILIARY_STUN_PORTS.length) { + break; + } + } + if (additions.size >= AUXILIARY_STUN_PORTS.length) { + break; + } + } + + return [ + ...base, + ...Array.from(additions, (url) => ({ urls: url })), + ]; +} + +function parseSrflxCandidate( + candidate: SignalCandidate, +): SrflxObservation | null { + if (!candidate || !candidate.candidate.trim()) { + return null; + } + const fields = candidate.candidate.trim().split(/\s+/); + const typeIndex = fields.findIndex( + (field) => field.toLowerCase() === "typ", + ); + if ( + fields.length < 8 || + !fields[0]?.toLowerCase().startsWith("candidate:") || + fields[2]?.toLowerCase() !== "udp" || + typeIndex < 0 || + fields[typeIndex + 1]?.toLowerCase() !== "srflx" + ) { + return null; + } + const address = fields[4]; + const port = Number(fields[5]); + if ( + !address || + !Number.isInteger(port) || + port < MIN_PREDICTABLE_PORT || + port > MAX_PREDICTABLE_PORT + ) { + return null; + } + const groupKey = [ + candidate.sdpMid ?? "", + candidate.sdpMLineIndex ?? "", + address, + ].join("/"); + return { candidate, fields, address, port, groupKey }; +} + +function distinctObservations( + observations: readonly SrflxObservation[], +): SrflxObservation[] { + const seen = new Set(); + return observations.filter((observation) => { + const key = `${observation.groupKey}/${observation.port}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function stableStep(observations: readonly SrflxObservation[]): number | null { + if (observations.length < 3) { + return null; + } + const sorted = [...observations].sort((left, right) => left.port - right.port); + const step = sorted[1]!.port - sorted[0]!.port; + if ( + step <= 0 || + sorted.some( + (observation, index) => + index > 0 && observation.port - sorted[index - 1]!.port !== step, + ) + ) { + return null; + } + return step; +} + +function predictedFrom( + anchor: SrflxObservation, + step: number, + occupiedPorts: ReadonlySet, +): SignalCandidate[] { + const predicted: SignalCandidate[] = []; + const seenPorts = new Set(occupiedPorts); + for (const direction of [1, -1] as const) { + for (let index = 1; index <= NAT_PREDICTION_STEPS; index += 1) { + const port = anchor.port + direction * step * index; + if (port < MIN_PREDICTABLE_PORT || port > MAX_PREDICTABLE_PORT) { + continue; + } + if (seenPorts.has(port)) { + continue; + } + seenPorts.add(port); + const fields = [...anchor.fields]; + fields[0] = `candidate:s${direction > 0 ? "p" : "m"}${index}`; + fields[5] = String(port); + predicted.push({ + ...anchor.candidate, + candidate: fields.join(" "), + }); + } + } + return predicted; +} + +/** + * Return a bounded, connection-local prediction only for a clear arithmetic + * srflx shape. No participant classification or candidate rejection happens + * here; callers append the result to the ordinary ICE candidate set. + */ +export function predictSrflxCandidates( + candidates: readonly SignalCandidate[], +): SignalCandidate[] { + const groups = new Map(); + for (const candidate of candidates) { + const observation = parseSrflxCandidate(candidate); + if (!observation) { + continue; + } + const group = groups.get(observation.groupKey) ?? []; + group.push(observation); + groups.set(observation.groupKey, group); + } + + for (const group of groups.values()) { + const distinct = distinctObservations(group); + const step = stableStep(distinct); + if (step === null) { + continue; + } + const anchor = distinct.at(-1)!; + return predictedFrom( + anchor, + step, + new Set(distinct.map((observation) => observation.port)), + ).slice(0, MAX_NAT_PREDICTION_CANDIDATES); + } + return []; +} + +/** + * Holds only srflx candidates until the mapping shape is known, then emits + * predictions before the held ordinary candidates. Non-srflx candidates keep + * their normal trickle timing. A new instance belongs to one ICE generation. + */ +export class NatPredictionCandidateBatch { + private readonly pendingSrflx: SignalCandidate[] = []; + private predictionsSent = false; + private completed = false; + + constructor(private readonly send: (candidate: SignalCandidate | null) => void) {} + + add(candidate: SignalCandidate): void { + if (this.completed) { + return; + } + if (!parseSrflxCandidate(candidate)) { + this.send(candidate); + return; + } + this.pendingSrflx.push(candidate); + this.trySendPredictions(); + } + + complete(): void { + if (this.completed) { + return; + } + this.trySendPredictions(); + for (const candidate of this.pendingSrflx.splice(0)) { + this.send(candidate); + } + this.send(null); + this.completed = true; + } + + discard(): void { + this.completed = true; + this.pendingSrflx.length = 0; + } + + private trySendPredictions(): void { + if (this.predictionsSent) { + return; + } + const predictions = predictSrflxCandidates(this.pendingSrflx); + if (predictions.length === 0) { + return; + } + for (const candidate of predictions) { + this.send(candidate); + } + this.predictionsSent = true; + for (const candidate of this.pendingSrflx.splice(0)) { + this.send(candidate); + } + } +} diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 67dabd0..ecb05c5 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -17,6 +17,7 @@ import { ViewerRelay } from "../src/client/webrtc/viewer-relay.ts"; import type { IceConfig, ParticipantRouteAssignment, + SignalPayload, } from "../src/shared/protocol.ts"; const codecPreflight = vi.hoisted(() => ({ @@ -109,12 +110,16 @@ class FakePeerConnection { private releaseRemoteDescription: (() => void) | null = null; connectionState: RTCPeerConnectionState = "new"; iceConnectionState: RTCIceConnectionState = "new"; + iceGatheringState: RTCIceGatheringState = "new"; signalingState: RTCSignalingState = "stable"; localDescription: RTCSessionDescription | null = null; remoteDescription: RTCSessionDescription | null = null; readonly addedIceCandidates: Array = []; readonly statsReports: Array> = []; - private readonly eventListeners = new Map void>>(); + private readonly eventListeners = new Map< + string, + Array<(event: Event) => void> + >(); constructor(configuration?: RTCConfiguration) { FakePeerConnection.latest = this; @@ -160,7 +165,7 @@ class FakePeerConnection { } as unknown as RTCRtpTransceiver; } - addEventListener(type: string, listener: () => void): void { + addEventListener(type: string, listener: (event: Event) => void): void { const listeners = this.eventListeners.get(type) ?? []; listeners.push(listener); this.eventListeners.set(type, listeners); @@ -168,7 +173,7 @@ class FakePeerConnection { dispatchEvent(event: Event): boolean { for (const listener of this.eventListeners.get(event.type) ?? []) { - listener(); + listener(event); } return true; } @@ -417,6 +422,7 @@ function createPeer( iceConfig: IceConfig = { iceServers: [] }, profile: QualityProfile = QUALITY_PROFILES["720p30"], videoCodec: BrowserVideoCodecPreference = VP8_ONLY_VIDEO_CODEC, + natPredictionEnabled = false, ): HostPeer { return new HostPeer( "viewer-peer", @@ -428,6 +434,8 @@ function createPeer( onUpdate, }, videoCodec, + undefined, + natPredictionEnabled, ); } @@ -712,6 +720,77 @@ describe("HostPeer source replacement", () => { } }); + it("keeps ordinary candidates while opting into bounded NAT predictions", () => { + const signals: SignalPayload[] = []; + const peer = new HostPeer( + "viewer-peer", + { iceServers: [{ urls: "stun:share.example.test:3478" }] }, + createStream(createTrack("video", "video"), null), + QUALITY_PROFILES["720p30"], + { + sendSignal: (_peerId, payload) => { + signals.push(payload); + return true; + }, + onUpdate: () => undefined, + }, + VP8_ONLY_VIDEO_CODEC, + undefined, + true, + ); + const connection = FakePeerConnection.latest!; + 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 | null): void => { + const event = new Event("icecandidate"); + Object.defineProperty(event, "candidate", { + value: + port === null + ? null + : { + 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")); + emitCandidate(null); + + const candidateSignals = signals.filter( + (signal): signal is Extract => + signal.kind === "candidate", + ); + const predictionIndex = candidateSignals.findIndex((signal) => + signal.candidate?.candidate.includes("candidate:s"), + ); + const ordinaryIndex = candidateSignals.findIndex((signal) => + signal.candidate?.candidate.startsWith("candidate:base"), + ); + expect(predictionIndex).toBeGreaterThanOrEqual(0); + expect(predictionIndex).toBeLessThan(ordinaryIndex); + expect( + candidateSignals.filter((signal) => + signal.candidate?.candidate.startsWith("candidate:base"), + ), + ).toHaveLength(3); + expect(candidateSignals.at(-1)?.candidate).toBeNull(); + expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1); + peer.dispose(); + }); + it("reserves send-only video and audio senders and replaces both tracks", async () => { const oldVideo = createTrack("video", "old-video"); const oldAudio = createTrack("audio", "old-audio"); diff --git a/tests/nat-prediction.test.ts b/tests/nat-prediction.test.ts new file mode 100644 index 0000000..514475b --- /dev/null +++ b/tests/nat-prediction.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { + iceServersWithNatPrediction, + MAX_NAT_PREDICTION_CANDIDATES, + NatPredictionCandidateBatch, + predictSrflxCandidates, + type SignalCandidate, +} from "../src/client/webrtc/nat-prediction.ts"; + +function candidate(port: number, address = "203.0.113.7"): SignalCandidate { + return { + candidate: + `candidate:base 1 udp 2122260223 ${address} ${port} ` + + "typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test", + sdpMid: "0", + sdpMLineIndex: 0, + usernameFragment: "test", + }; +} + +function hostCandidate(): SignalCandidate { + return { + candidate: + "candidate:host 1 udp 2122260223 192.0.2.7 50000 typ host generation 0 ufrag test", + sdpMid: "0", + sdpMLineIndex: 0, + usernameFragment: "test", + }; +} + +function portOf(value: SignalCandidate): number { + return Number(value?.candidate.trim().split(/\s+/)[5]); +} + +describe("NAT prediction ICE adapter", () => { + it("leaves the configured servers unchanged when disabled", () => { + const servers = [ + { urls: ["stun:share.example.test:3478"] }, + { urls: "stun:other.example.test:5349" }, + ]; + + expect(iceServersWithNatPrediction(servers, false)).toEqual(servers); + }); + + it("derives only the two optional same-host survey listeners", () => { + const servers = [ + { urls: ["stun:share.example.test:3478", "stun:share.example.test:3478"] }, + { urls: "stun:other.example.test:3478" }, + ]; + + expect(iceServersWithNatPrediction(servers, true)).toEqual([ + ...servers, + { urls: "stun:share.example.test:3479" }, + { urls: "stun:share.example.test:3480" }, + ]); + }); + + it("does not duplicate already configured auxiliary listeners", () => { + const servers = [ + { urls: "stun:share.example.test:3478" }, + { urls: "stun:share.example.test:3479" }, + ]; + + expect(iceServersWithNatPrediction(servers, true)).toEqual([ + ...servers, + { urls: "stun:share.example.test:3480" }, + ]); + }); + + it("predicts a bounded two-sided window for a stable arithmetic shape", () => { + const predictions = predictSrflxCandidates([ + candidate(40_000), + candidate(40_003), + candidate(40_006), + ]); + + expect(predictions.length).toBeLessThanOrEqual( + MAX_NAT_PREDICTION_CANDIDATES, + ); + expect(predictions.map(portOf)).toEqual( + expect.arrayContaining([40_009, 39_997]), + ); + expect(predictions.every((value) => value?.sdpMid === "0")).toBe(true); + }); + + it("does not predict an unstable or incomplete mapping shape", () => { + expect(predictSrflxCandidates([candidate(40_000), candidate(40_001)])).toEqual( + [], + ); + expect( + predictSrflxCandidates([ + candidate(40_000), + candidate(40_003), + candidate(40_011), + ]), + ).toEqual([]); + }); + + it("sends predictions before held srflx candidates while retaining every one", () => { + const sent: Array = []; + const batch = new NatPredictionCandidateBatch((value) => sent.push(value)); + const first = candidate(40_000); + const second = candidate(40_003); + const third = candidate(40_006); + + batch.add(hostCandidate()); + batch.add(first); + batch.add(second); + batch.add(third); + batch.complete(); + batch.complete(); + + expect(sent[0]).toEqual(hostCandidate()); + const firstOriginal = sent.indexOf(first); + const firstPrediction = sent.findIndex( + (value) => value?.candidate.includes("candidate:s") ?? false, + ); + expect(firstPrediction).toBeGreaterThanOrEqual(1); + expect(firstPrediction).toBeLessThan(firstOriginal); + expect(sent.filter((value) => value === first)).toHaveLength(1); + expect(sent.filter((value) => value === second)).toHaveLength(1); + expect(sent.filter((value) => value === third)).toHaveLength(1); + expect(sent.at(-1)).toBeNull(); + }); + + it("flushes ordinary candidates without inventing a prediction", () => { + const sent: Array = []; + const batch = new NatPredictionCandidateBatch((value) => sent.push(value)); + const first = candidate(40_000); + const second = candidate(40_007); + + batch.add(first); + batch.add(second); + batch.complete(); + + expect(sent).toEqual([first, second, null]); + }); +});