fix(nat): apply NAT experiment room-wide (#367)

This commit is contained in:
AAA authored and GitHub committed 2026-09-01 15:09:20 +08:00
1 parent 76a6eb7f85
commit 32c3f95c58
22 files changed
+329 -137

No files matched your search

@@ -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.
+6 -8
View File
@@ -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
+6 -6
View File
@@ -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
+5 -4
View File
@@ -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
+5 -5
View File
@@ -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
+2 -3
View File
@@ -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
+2 -2
View File
@@ -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 = {
+8 -16
View File
@@ -384,7 +384,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const [routePolicy, setRoutePolicy] = useState<RoutePolicy>(
DEFAULT_ROUTE_POLICY,
);
const [natPredictionEnabled, setNatPredictionEnabled] = useState(false);
const [videoCodecMode, setVideoCodecMode] =
useState<BrowserVideoCodecMode>("auto");
const [resolvedVideoCodec, setResolvedVideoCodec] =
@@ -515,7 +514,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const pendingQualityChangeRef = useRef<QualitySettings | null>(null);
const qualitySettingsRef = useRef<QualitySettings>(DEFAULT_QUALITY_SETTINGS);
const routePolicyRef = useRef<RoutePolicy>(DEFAULT_ROUTE_POLICY);
const natPredictionEnabledRef = useRef(false);
const advancedQualityRef = useRef<QualitySettings>(advancedQuality);
const videoCodecModeRef = useRef<BrowserVideoCodecMode>(videoCodecMode);
const roomMutationRef = useRef<object | null>(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<QualitySettings>,
): 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"
/>
<SwitchItem
checked={natPredictionEnabled}
checked={routePolicy.natPrediction}
disabled={phase === "starting" || phase === "live"}
onChange={changeNatPrediction}
onChange={(checked) =>
changeRoutePolicy({ natPrediction: checked })
}
label={t("host.advanced.route.natPrediction")}
note={t("host.advanced.route.natPredictionHint")}
hint="hint-nat-prediction"
+6 -4
View File
@@ -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;
+9 -55
View File
@@ -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<T>(operation: () => Promise<T>): Promise<T> {
const result = this.senderMutationTail.then(operation);
this.senderMutationTail = result.then(
+76
View File
@@ -4,6 +4,7 @@ export type SignalCandidate = Extract<
SignalPayload,
{ kind: "candidate" }
>["candidate"];
type ConcreteSignalCandidate = NonNullable<SignalCandidate>;
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;
}
}
+40 -15
View File
@@ -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<void> = 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;
+2
View File
@@ -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;
}
+4 -4
View File
@@ -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 }
: {}),
+4 -2
View File
@@ -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<typeof routePolicySchema>;
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(),
};
+25
View File
@@ -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(
+4
View File
@@ -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");
+3 -1
View File
@@ -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(
+6 -1
View File
@@ -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();
});
+44
View File
@@ -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");
+2
View File
@@ -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;
+58
View File
@@ -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<RTCStatsReport> | 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<SignalPayload, { kind: "candidate" }> =>
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;