fix(nat): apply NAT experiment room-wide (#367)
This commit is contained in:
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user