fix(nat): close the browser NAT experiment loop (#370)
This commit is contained in:
1 parent
4c081f4f88
commit
b2f6d33630
37 files changed
+612
-67
No files matched your search
@@ -40,9 +40,14 @@ LIVEKIT_API_SECRET=
|
||||
# Separate multiple URLs with commas.
|
||||
STUN_URLS=
|
||||
|
||||
# Optional independent STUN observations used only when a Host enables the
|
||||
# default-off NAT traversal experiment. Maximum two comma-separated URLs.
|
||||
NAT_PREDICTION_STUN_URLS=
|
||||
|
||||
# Minimal production example (use independent untracked secrets):
|
||||
# SITE_ACCESS_PASSWORD=replace-with-an-independent-access-key
|
||||
# STUN_URLS=stun:stun.example.com:3478
|
||||
# NAT_PREDICTION_STUN_URLS=stun:stun-observer.example.com:3478
|
||||
# Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not
|
||||
# replace STUN above):
|
||||
# LIVEKIT_URL=wss://share.example.com
|
||||
|
||||
@@ -12,9 +12,10 @@ 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 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.
|
||||
The existing route-policy and ICE candidate owners are sufficient. The current
|
||||
strict protocol carries the room policy, bounded independent observation URLs,
|
||||
and anonymous selected-path provenance, but adds no NAT label, route-controller
|
||||
state, or media path.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -25,17 +26,25 @@ field, but no new message type, route-controller state, or media path.
|
||||
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.
|
||||
unchanged. A deployment may also provide at most two independent `stun:`
|
||||
observation URLs. They are added only inside the enabled experiment and are
|
||||
never part of ordinary room ICE.
|
||||
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 sequence's high endpoint (four in each
|
||||
direction). Every ordinary candidate keeps normal Trickle ICE timing; no
|
||||
ordinary candidate is delayed, rejected, or replaced. A failed or
|
||||
inconclusive experiment uses the existing ICE and SFU fallback unchanged.
|
||||
candidates whose Browser-reported STUN URL belongs to the self-hosted
|
||||
3478/3479/3480 survey set. Independent observations remain ordinary ICE
|
||||
candidates and cannot enter the arithmetic sequence. If three or more
|
||||
distinct survey candidates for one media section and public address form an
|
||||
arithmetic port sequence, the adapter appends at most eight bounded
|
||||
candidates around the sequence's high endpoint. Every ordinary candidate
|
||||
keeps normal Trickle ICE timing; no ordinary candidate is delayed, rejected,
|
||||
or replaced. A Browser that does not expose candidate source URLs keeps
|
||||
ordinary ICE but does not mix independent observations into prediction.
|
||||
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.
|
||||
route-controller branch is introduced. Sanitized diagnostics record only
|
||||
whether a signaled candidate was ordinary, predicted, or end-of-candidates,
|
||||
and whether the selected remote foundation was ordinary, predicted, or
|
||||
unknown.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -47,12 +56,16 @@ Positive:
|
||||
listener or unsuccessful prediction cannot delay the existing direct or SFU
|
||||
fallback; and
|
||||
- the experiment is bounded to one connection generation and one small
|
||||
candidate set.
|
||||
candidate set; and
|
||||
- selected-foundation attribution can validate actual field benefit without
|
||||
uploading candidate endpoints.
|
||||
|
||||
Negative:
|
||||
|
||||
- the Browser may still reject synthetic candidates or the NAT may not have a
|
||||
matching mapping; and
|
||||
matching mapping;
|
||||
- every independent STUN operator observes connection metadata and supplies no
|
||||
availability promise unless its own service contract says otherwise; and
|
||||
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
|
||||
intervening mappings, so room-wide coverage is not a success guarantee.
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@ These same-IP auxiliary ports measure destination-port mapping behavior. They
|
||||
are not a full RFC 5780 alternate-address deployment; that requires a second
|
||||
public IPv4.
|
||||
|
||||
An operator may configure at most two independent observation URLs through
|
||||
`NAT_PREDICTION_STUN_URLS`. They are contacted only by Browser P2P connections
|
||||
in an opted-in room, remain ordinary ICE candidates, and require no local
|
||||
listener or firewall change. Verify them with a real Browser from the target
|
||||
network before use. DNS resolution alone is not a STUN availability check, and
|
||||
an unaffiliated public endpoint has no implied SLA.
|
||||
|
||||
## Operational Verification
|
||||
|
||||
- `/healthz` returns `{"status":"ok"}` locally and through the public origin.
|
||||
|
||||
@@ -34,8 +34,11 @@ share generation:
|
||||
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.
|
||||
route score, or SFU preference. A deployment may add a bounded independent
|
||||
STUN observation list only inside that experiment. Candidate generation and
|
||||
selected-pair diagnostics retain only `ordinary | predicted | unknown`
|
||||
provenance. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md)
|
||||
owns the bounded behavior and evidence boundary.
|
||||
|
||||
These are route-policy gates, not new routing algorithms. Changing any
|
||||
policy requires stopping the current share and starting another generation.
|
||||
|
||||
@@ -31,8 +31,10 @@ implementation and routine UI detail.
|
||||
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 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
|
||||
connection-local NAT candidate experiment; a deployment may add two
|
||||
independent observation STUN URLs only inside that experiment, and anonymous
|
||||
provenance distinguishes ordinary from predicted selected paths. 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
|
||||
@@ -57,7 +59,7 @@ implementation and routine UI detail.
|
||||
|
||||
## Current Snapshot
|
||||
|
||||
Canonical source uses the strict `screener-v18` Browser/server contract with
|
||||
Canonical source uses the strict `screener-v19` 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
|
||||
|
||||
@@ -20,6 +20,7 @@ service secret store or an untracked access-restricted environment file.
|
||||
| `PEER_ASSISTED_MEDIA` | Enables the all-room bounded Peer/SFU controller. |
|
||||
| `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. |
|
||||
| `STUN_URLS` | Comma-separated `stun:` URLs; at least one is required in production. |
|
||||
| `NAT_PREDICTION_STUN_URLS` | Optional comma-separated independent `stun:` observations, maximum `2`; used only by rooms that enable the default-off NAT experiment. |
|
||||
|
||||
Automatic SFU fallback is enabled only when `PEER_ASSISTED_MEDIA=true` and all
|
||||
four values below are present:
|
||||
@@ -68,6 +69,11 @@ a cloud port without a listener has no effect. No new DNS record is required.
|
||||
Keep both listeners available while the experiment is offered and remove them
|
||||
only after disabling it. Same-IP ports expose destination-port allocation
|
||||
behavior; a full RFC 5780 alternate-address test requires a second public IPv4.
|
||||
Optional `NAT_PREDICTION_STUN_URLS` add independent ordinary candidates without
|
||||
changing `STUN_URLS`. Their operators receive client address, port, time, and
|
||||
protocol metadata when the Host enables the experiment. They need no inbound
|
||||
server port or DNS change, but should be omitted when that dependency is not
|
||||
accepted.
|
||||
|
||||
## Bounds
|
||||
|
||||
|
||||
@@ -71,6 +71,23 @@ 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).
|
||||
|
||||
### Independent Observation And Attribution Preflight
|
||||
|
||||
On 2026-09-01, Chrome 151 on the flagship Windows network gathered one `srflx`
|
||||
candidate independently from the self-hosted endpoint, Xiaomi, Bilibili, and
|
||||
Cloudflare within a five-second bound. `stun.qq.com:3478` produced none in the
|
||||
same ordered run. Candidate events and local candidate stats preserved the exact
|
||||
STUN `url`, so independent destinations can remain ordinary candidates while
|
||||
only self-hosted 3478/3479/3480 observations feed prediction. These are measured
|
||||
reachability results, not third-party availability commitments.
|
||||
|
||||
A second real-Chrome loopback rewrote only one remote candidate foundation to
|
||||
`sp1`, established the data channel, and read `sp1` from the selected remote
|
||||
candidate stats. The product can therefore report `predicted | ordinary |
|
||||
unknown` selection without retaining or uploading addresses, ports, foundations,
|
||||
or STUN URLs. Field acceptance still requires target-network outcomes; this
|
||||
preflight proves the attribution mechanism, not prediction prevalence.
|
||||
|
||||
The Browser-only alternatives do not yet have an accepted implementation:
|
||||
|
||||
- WebRTC ICE already exchanges observed candidates and performs coordinated
|
||||
@@ -148,3 +165,4 @@ a new Peer route.
|
||||
- [libp2p Browser and DCUtR hole-punching boundary](https://github.com/libp2p/specs/blob/master/connections/hole-punching.md)
|
||||
- [coturn listener and auxiliary endpoint reference](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf)
|
||||
- [Cloudflare Realtime STUN service](https://developers.cloudflare.com/realtime/turn/)
|
||||
- [WebRTC selected candidate stats](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats-foundation)
|
||||
+9
-6
@@ -7,7 +7,7 @@ This is the current execution index. Git history owns completed timelines;
|
||||
|
||||
## Production
|
||||
|
||||
- `https://share.bonfire.icu` runs the strict `screener-v18` Browser/server
|
||||
- `https://share.bonfire.icu` runs the strict `screener-v19` 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.
|
||||
@@ -23,9 +23,9 @@ This is the current execution index. Git history owns completed timelines;
|
||||
- Site access uses a stateless 24-hour rolling idle cookie. An active
|
||||
site-authorized page renews it hourly through the existing status request;
|
||||
Viewer-grant admission remains independent and cannot create or renew it.
|
||||
- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. During
|
||||
the approved NAT survey, coturn also exposes auxiliary STUN-only UDP 3479
|
||||
and 3480; ordinary `STUN_URLS` and media routes remain unchanged. Web ingress
|
||||
- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. The
|
||||
optional NAT experiment also uses auxiliary STUN-only UDP 3479 and 3480;
|
||||
ordinary `STUN_URLS` and media routes remain unchanged. Web ingress
|
||||
is TCP 80/443; Node 8787 and LiveKit control/signaling 7880 are private. TURN,
|
||||
ICE/TCP, media TCP, TLS relay, port 5349, and relay ranges are disabled.
|
||||
- Browser video uses the content-independent H.264 sender gate with VP8
|
||||
@@ -49,7 +49,7 @@ This is the current execution index. Git history owns completed timelines;
|
||||
|
||||
## Current Source
|
||||
|
||||
- Canonical source uses the strict `screener-v18` Browser/server contract.
|
||||
- Canonical source uses the strict `screener-v19` 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
|
||||
@@ -77,7 +77,10 @@ This is the current execution index. Git history owns completed timelines;
|
||||
defaults on and locks while sharing.
|
||||
- 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.
|
||||
candidate adapter. At most two deployment-supplied independent STUN
|
||||
observations are contacted only inside opted-in rooms; candidate and selected
|
||||
path logs retain anonymous `ordinary | predicted | unknown` provenance.
|
||||
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
|
||||
|
||||
+3
-2
@@ -86,8 +86,9 @@ until their stated boundary is met.
|
||||
[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.
|
||||
score, or SFU preference. Candidate generation and selected-path provenance
|
||||
are now observable without endpoint data. Validate target-network success,
|
||||
candidate 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
|
||||
|
||||
@@ -99,7 +99,7 @@ async function main(): Promise<void> {
|
||||
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
|
||||
siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000,
|
||||
maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2,
|
||||
peerAssistedMedia: false, stunUrls: [],
|
||||
peerAssistedMedia: false, stunUrls: [], natPredictionStunUrls: [],
|
||||
};
|
||||
server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") });
|
||||
server.httpServer.prependListener("request", (request, response) => {
|
||||
|
||||
@@ -102,6 +102,7 @@ export function senderQualityEvidenceFromSnapshot(
|
||||
routeRevision,
|
||||
state,
|
||||
diagnostics: {
|
||||
natTraversalPath: snapshot.metrics.natTraversalPath,
|
||||
reason: state === "unknown" ? null : snapshot.metrics.qualityLimitationReason,
|
||||
framesPerSecond: snapshot.metrics.framesPerSecond,
|
||||
bitrateKbps: snapshot.metrics.bitrateKbps,
|
||||
@@ -166,6 +167,7 @@ export function sfuPublisherQualityEvidenceFromMetrics(
|
||||
state,
|
||||
sampleTimestampMs,
|
||||
diagnostics: {
|
||||
natTraversalPath: metrics.natTraversalPath,
|
||||
reason: state === "unknown" ? null : metrics.qualityLimitationReason,
|
||||
framesPerSecond: metrics.framesPerSecond,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
|
||||
@@ -97,6 +97,7 @@ export function qualityEvidenceWindowFromMetrics(
|
||||
? { width, height }
|
||||
: { width: null, height: null };
|
||||
const candidate: ViewerQualityEvidenceMetrics = {
|
||||
natTraversalPath: metrics.natTraversalPath,
|
||||
...dimensions,
|
||||
framesPerSecond: boundedNumber(metrics.framesPerSecond, 240),
|
||||
bitrateKbps: boundedNumber(metrics.bitrateKbps, 100_000),
|
||||
@@ -502,6 +503,7 @@ export function metricsFromQualityEvidence(
|
||||
const metrics = evidence.metrics;
|
||||
return {
|
||||
...EMPTY_METRICS,
|
||||
natTraversalPath: metrics.natTraversalPath,
|
||||
sampleWindowMs: evidence.windowMs,
|
||||
framesPerSecond: metrics.framesPerSecond,
|
||||
frameWidth: metrics.width,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NatTraversalPath } from "../shared/nat-candidate";
|
||||
|
||||
export type SignalConnectionState =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
@@ -28,6 +30,7 @@ export interface ConnectionMetrics {
|
||||
captureFramesPerSecond: number | null;
|
||||
mediaSourceFramesPerSecond: number | null;
|
||||
path: MediaPath;
|
||||
natTraversalPath: NatTraversalPath;
|
||||
iceProtocol: string | null;
|
||||
localCandidateType: string | null;
|
||||
remoteCandidateType: string | null;
|
||||
@@ -114,6 +117,7 @@ export const EMPTY_METRICS: ConnectionMetrics = {
|
||||
captureFramesPerSecond: null,
|
||||
mediaSourceFramesPerSecond: null,
|
||||
path: "unknown",
|
||||
natTraversalPath: "unknown",
|
||||
iceProtocol: null,
|
||||
localCandidateType: null,
|
||||
remoteCandidateType: null,
|
||||
|
||||
@@ -39,12 +39,15 @@ import {
|
||||
} from "./video-codec";
|
||||
import {
|
||||
iceServersWithNatPrediction,
|
||||
natPredictionSurveyUrls,
|
||||
NatPredictionCandidateEmitter,
|
||||
type SignalCandidate,
|
||||
} from "./nat-prediction";
|
||||
|
||||
const MAX_PENDING_CANDIDATES = 64;
|
||||
type PeerIceConfig = Pick<RTCConfiguration, "iceServers">;
|
||||
type PeerIceConfig = Pick<RTCConfiguration, "iceServers"> & {
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
};
|
||||
|
||||
interface HostPeerEvents {
|
||||
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
|
||||
@@ -111,11 +114,15 @@ export class HostPeer {
|
||||
iceServers: iceServersWithNatPrediction(
|
||||
iceConfig.iceServers,
|
||||
this.natPredictionEnabled,
|
||||
iceConfig.natPredictionStunUrls,
|
||||
),
|
||||
});
|
||||
this.localIceCandidates = new NatPredictionCandidateEmitter(
|
||||
this.natPredictionEnabled,
|
||||
(candidate) => this.sendIceCandidate(candidate),
|
||||
natPredictionSurveyUrls(
|
||||
iceConfig.iceServers,
|
||||
),
|
||||
);
|
||||
this.snapshot = {
|
||||
peerId,
|
||||
@@ -414,8 +421,14 @@ export class HostPeer {
|
||||
iceServers: iceServersWithNatPrediction(
|
||||
iceConfig.iceServers,
|
||||
this.natPredictionEnabled,
|
||||
iceConfig.natPredictionStunUrls,
|
||||
),
|
||||
});
|
||||
this.localIceCandidates.setSurveyUrls(
|
||||
natPredictionSurveyUrls(
|
||||
iceConfig.iceServers,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.setError(error, say("host.err.createConnection"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { SignalPayload } from "../../shared/protocol";
|
||||
import {
|
||||
MAX_NAT_PREDICTION_STUN_URLS,
|
||||
type SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export type SignalCandidate = Extract<
|
||||
SignalPayload,
|
||||
@@ -69,6 +72,57 @@ function auxiliaryUrlsFor(url: string): readonly string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizedStunUrl(url: string): string | null {
|
||||
if (!/^stun:/i.test(url)) {
|
||||
return null;
|
||||
}
|
||||
const authority = url.slice(url.indexOf(":") + 1);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(`http://${authority}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!parsed.hostname ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.pathname !== "/" ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const hostname = parsed.hostname.startsWith("[")
|
||||
? parsed.hostname.toLowerCase()
|
||||
: parsed.hostname.includes(":")
|
||||
? `[${parsed.hostname.toLowerCase()}]`
|
||||
: parsed.hostname.toLowerCase();
|
||||
return `stun:${hostname}:${parsed.port || BASE_STUN_PORT}`;
|
||||
}
|
||||
|
||||
/** URLs whose same-socket observations may form the controlled port sequence. */
|
||||
export function natPredictionSurveyUrls(
|
||||
iceServers: readonly RTCIceServer[] | undefined,
|
||||
): ReadonlySet<string> {
|
||||
for (const server of iceServers ?? []) {
|
||||
for (const url of urlsOf(server)) {
|
||||
const auxiliaryUrls = auxiliaryUrlsFor(url);
|
||||
const baseUrl = normalizedStunUrl(url);
|
||||
if (baseUrl && auxiliaryUrls.length > 0) {
|
||||
return new Set([
|
||||
baseUrl,
|
||||
...auxiliaryUrls.flatMap((entry) => {
|
||||
const normalized = normalizedStunUrl(entry);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the two optional same-host survey listeners without changing the
|
||||
* server-provided STUN list. The caller opts into this per connection.
|
||||
@@ -76,6 +130,7 @@ function auxiliaryUrlsFor(url: string): readonly string[] {
|
||||
export function iceServersWithNatPrediction(
|
||||
iceServers: readonly RTCIceServer[] | undefined,
|
||||
enabled: boolean,
|
||||
externalStunUrls: readonly string[] = [],
|
||||
): RTCIceServer[] {
|
||||
const base = (iceServers ?? []).map(cloneIceServer);
|
||||
if (!enabled) {
|
||||
@@ -84,13 +139,17 @@ export function iceServersWithNatPrediction(
|
||||
|
||||
const additions = new Set<string>();
|
||||
const existing = new Set(
|
||||
(iceServers ?? []).flatMap((server) => urlsOf(server)),
|
||||
(iceServers ?? [])
|
||||
.flatMap((server) => urlsOf(server))
|
||||
.map((url) => normalizedStunUrl(url) ?? url),
|
||||
);
|
||||
for (const server of iceServers ?? []) {
|
||||
for (const url of urlsOf(server)) {
|
||||
for (const auxiliaryUrl of auxiliaryUrlsFor(url)) {
|
||||
if (!existing.has(auxiliaryUrl)) {
|
||||
const normalized = normalizedStunUrl(auxiliaryUrl) ?? auxiliaryUrl;
|
||||
if (!existing.has(normalized)) {
|
||||
additions.add(auxiliaryUrl);
|
||||
existing.add(normalized);
|
||||
}
|
||||
}
|
||||
if (additions.size >= AUXILIARY_STUN_PORTS.length) {
|
||||
@@ -102,6 +161,14 @@ export function iceServersWithNatPrediction(
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of externalStunUrls.slice(0, MAX_NAT_PREDICTION_STUN_URLS)) {
|
||||
const normalized = normalizedStunUrl(url);
|
||||
if (normalized && !existing.has(normalized)) {
|
||||
additions.add(url);
|
||||
existing.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...base,
|
||||
...Array.from(additions, (url) => ({ urls: url })),
|
||||
@@ -255,7 +322,7 @@ export class NatPredictionCandidateBatch {
|
||||
|
||||
constructor(private readonly send: (candidate: SignalCandidate | null) => void) {}
|
||||
|
||||
add(candidate: SignalCandidate): void {
|
||||
add(candidate: SignalCandidate, predictionEligible = true): void {
|
||||
if (this.completed) {
|
||||
return;
|
||||
}
|
||||
@@ -263,8 +330,10 @@ export class NatPredictionCandidateBatch {
|
||||
this.send(candidate);
|
||||
return;
|
||||
}
|
||||
this.observedSrflx.push(candidate);
|
||||
this.trySendPredictions();
|
||||
if (predictionEligible) {
|
||||
this.observedSrflx.push(candidate);
|
||||
this.trySendPredictions();
|
||||
}
|
||||
this.send(candidate);
|
||||
}
|
||||
|
||||
@@ -311,11 +380,24 @@ export class NatPredictionCandidateEmitter {
|
||||
private batch: NatPredictionCandidateBatch | null = null;
|
||||
private usernameFragment: string | null = null;
|
||||
private endSent = false;
|
||||
private surveyUrls = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly enabled: boolean,
|
||||
private readonly send: (candidate: SignalCandidate | null) => void,
|
||||
) {}
|
||||
surveyUrls: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
this.setSurveyUrls(surveyUrls);
|
||||
}
|
||||
|
||||
setSurveyUrls(urls: ReadonlySet<string>): void {
|
||||
this.surveyUrls = new Set(
|
||||
[...urls].flatMap((url) => {
|
||||
const normalized = normalizedStunUrl(url);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
add(candidate: RTCIceCandidate | null): void {
|
||||
if (!this.enabled) {
|
||||
@@ -345,7 +427,16 @@ export class NatPredictionCandidateEmitter {
|
||||
this.endSent = false;
|
||||
this.batch = new NatPredictionCandidateBatch(this.send);
|
||||
}
|
||||
this.batch.add(next);
|
||||
const serverUrl = (
|
||||
candidate as RTCIceCandidate & { readonly url?: string | null }
|
||||
).url;
|
||||
const candidateUrl = serverUrl
|
||||
? normalizedStunUrl(serverUrl)
|
||||
: null;
|
||||
this.batch.add(
|
||||
next,
|
||||
candidateUrl !== null && this.surveyUrls.has(candidateUrl),
|
||||
);
|
||||
}
|
||||
|
||||
gatheringComplete(): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "../types";
|
||||
import { packetLossPercentFromDeltas } from "../../shared/packet-loss";
|
||||
import { deriveVideoCodecEvidence } from "../../shared/video-codec-evidence";
|
||||
import { isPredictedCandidateFoundation } from "../../shared/nat-candidate";
|
||||
|
||||
type StatsRecord = Record<string, unknown> & {
|
||||
id: string;
|
||||
@@ -675,6 +676,13 @@ export function collectConnectionMetricsFromReport(
|
||||
}
|
||||
const localType = stringValue(localCandidate, "candidateType");
|
||||
const remoteType = stringValue(remoteCandidate, "candidateType");
|
||||
const remoteFoundation = stringValue(remoteCandidate, "foundation");
|
||||
const natTraversalPath =
|
||||
remoteFoundation === null
|
||||
? "unknown"
|
||||
: isPredictedCandidateFoundation(remoteFoundation)
|
||||
? "predicted"
|
||||
: "ordinary";
|
||||
const path =
|
||||
localType === "relay" || remoteType === "relay"
|
||||
? "unknown"
|
||||
@@ -1068,6 +1076,7 @@ export function collectConnectionMetricsFromReport(
|
||||
trackIdentifier,
|
||||
selectedCandidatePairId: pair?.id ?? null,
|
||||
path,
|
||||
natTraversalPath,
|
||||
iceProtocol,
|
||||
localCandidateType: localType,
|
||||
remoteCandidateType: remoteType,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { preferScreenAudioStereo } from "./screen-audio-sdp";
|
||||
import { observeDecodedFrameProof } from "../media/decoded-frame-proof";
|
||||
import {
|
||||
iceServersWithNatPrediction,
|
||||
natPredictionSurveyUrls,
|
||||
NatPredictionCandidateEmitter,
|
||||
} from "./nat-prediction";
|
||||
|
||||
@@ -21,7 +22,9 @@ const MAX_PENDING_CANDIDATES = 64;
|
||||
const MAX_AUTOMATIC_RECOVERY_REQUESTS = 2;
|
||||
const INITIAL_CONNECTION_TIMEOUT_MS = 15_000;
|
||||
const AUTOMATIC_RECOVERY_TIMEOUT_MS = 3_000;
|
||||
type PeerIceConfig = Pick<RTCConfiguration, "iceServers">;
|
||||
type PeerIceConfig = Pick<RTCConfiguration, "iceServers"> & {
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
};
|
||||
type SignalCandidate = Extract<
|
||||
SignalPayload,
|
||||
{ kind: "candidate" }
|
||||
@@ -124,8 +127,14 @@ export class ViewerPeer {
|
||||
iceServers: iceServersWithNatPrediction(
|
||||
iceConfig.iceServers,
|
||||
this.natPredictionEnabled,
|
||||
iceConfig.natPredictionStunUrls,
|
||||
),
|
||||
});
|
||||
this.localIceCandidates?.setSurveyUrls(
|
||||
natPredictionSurveyUrls(
|
||||
iceConfig.iceServers,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.setError(error, say("host.fail.connection"));
|
||||
}
|
||||
@@ -225,6 +234,7 @@ export class ViewerPeer {
|
||||
iceServers: iceServersWithNatPrediction(
|
||||
this.currentIceConfig.iceServers,
|
||||
this.natPredictionEnabled,
|
||||
this.currentIceConfig.natPredictionStunUrls,
|
||||
),
|
||||
});
|
||||
const localIceCandidates = new NatPredictionCandidateEmitter(
|
||||
@@ -239,6 +249,9 @@ export class ViewerPeer {
|
||||
candidate,
|
||||
});
|
||||
},
|
||||
natPredictionSurveyUrls(
|
||||
this.currentIceConfig.iceServers,
|
||||
),
|
||||
);
|
||||
this.connection = connection;
|
||||
this.localIceCandidates = localIceCandidates;
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function createScreenerServer(
|
||||
});
|
||||
const iceOptions = {
|
||||
stunUrls: config.stunUrls,
|
||||
natPredictionStunUrls: config.natPredictionStunUrls,
|
||||
};
|
||||
|
||||
let frontendHandler: FrontendHandler | undefined;
|
||||
|
||||
+24
-5
@@ -2,6 +2,7 @@ import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
MAX_ICE_SERVER_URLS,
|
||||
MAX_NAT_PREDICTION_STUN_URLS,
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
stunUrlSchema,
|
||||
} from "../shared/protocol.js";
|
||||
@@ -56,6 +57,7 @@ export interface ServerConfig {
|
||||
endpointMediaCopyCapacity: number;
|
||||
livekitFallback?: LiveKitFallbackConfig;
|
||||
stunUrls: readonly string[];
|
||||
natPredictionStunUrls: readonly string[];
|
||||
}
|
||||
|
||||
function parseBoolean(
|
||||
@@ -228,11 +230,14 @@ function parseUrlList(value: string | undefined, name: string): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
function parseStunUrlList(value: string | undefined): string[] {
|
||||
const name = "STUN_URLS";
|
||||
function parseStunUrlList(
|
||||
value: string | undefined,
|
||||
name: string,
|
||||
maximum = MAX_ICE_SERVER_URLS,
|
||||
): string[] {
|
||||
const values = parseUrlList(value, name);
|
||||
if (values.length > MAX_ICE_SERVER_URLS) {
|
||||
throw new Error(`${name} must contain at most ${MAX_ICE_SERVER_URLS} URLs`);
|
||||
if (values.length > maximum) {
|
||||
throw new Error(`${name} must contain at most ${maximum} URLs`);
|
||||
}
|
||||
return values.map((value) => {
|
||||
if (!stunUrlSchema.safeParse(value).success) {
|
||||
@@ -317,7 +322,20 @@ export function loadConfig(
|
||||
const roomDatabasePath = parseRoomDatabasePath(
|
||||
environment.ROOM_DATABASE_PATH,
|
||||
);
|
||||
const stunUrls = parseStunUrlList(environment.STUN_URLS);
|
||||
const stunUrls = parseStunUrlList(environment.STUN_URLS, "STUN_URLS");
|
||||
const natPredictionStunUrls = parseStunUrlList(
|
||||
environment.NAT_PREDICTION_STUN_URLS,
|
||||
"NAT_PREDICTION_STUN_URLS",
|
||||
MAX_NAT_PREDICTION_STUN_URLS,
|
||||
);
|
||||
if (
|
||||
stunUrls.length + natPredictionStunUrls.length >
|
||||
MAX_ICE_SERVER_URLS
|
||||
) {
|
||||
throw new Error(
|
||||
`STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most ${MAX_ICE_SERVER_URLS} URLs together`,
|
||||
);
|
||||
}
|
||||
const maxViewersPerRoom = parseBoundedInteger(
|
||||
environment.MAX_VIEWERS_PER_ROOM,
|
||||
DEFAULT_MAX_VIEWERS_PER_ROOM,
|
||||
@@ -392,5 +410,6 @@ export function loadConfig(
|
||||
endpointMediaCopyCapacity,
|
||||
livekitFallback,
|
||||
stunUrls,
|
||||
natPredictionStunUrls,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
||||
import { debuglog } from "node:util";
|
||||
|
||||
import { packetLossPercentFromDeltas } from "../shared/packet-loss.js";
|
||||
import type { CandidateSignalOrigin } from "../shared/nat-candidate.js";
|
||||
|
||||
import type {
|
||||
ClientMessage,
|
||||
@@ -324,6 +325,7 @@ export class HybridMediaRouter {
|
||||
input.upstream.kind === "peer"
|
||||
? `p2p:${this.debugPeer(roomId, input.upstream.peerId)}`
|
||||
: "sfu",
|
||||
natTraversalPath: input.metrics.natTraversalPath,
|
||||
framesPerSecond: input.metrics.framesPerSecond,
|
||||
bitrateKbps: input.metrics.bitrateKbps,
|
||||
width: input.metrics.width,
|
||||
@@ -397,6 +399,7 @@ export class HybridMediaRouter {
|
||||
routeRevision: message.routeRevision,
|
||||
...copyContext,
|
||||
state: message.state,
|
||||
natTraversalPath: message.diagnostics.natTraversalPath,
|
||||
reason: message.diagnostics.reason,
|
||||
framesPerSecond: message.diagnostics.framesPerSecond,
|
||||
bitrateKbps: message.diagnostics.bitrateKbps,
|
||||
@@ -683,6 +686,7 @@ export class HybridMediaRouter {
|
||||
sourcePeerId: string;
|
||||
targetPeerId: string;
|
||||
signalKind: "candidate" | "description";
|
||||
candidateOrigin?: CandidateSignalOrigin;
|
||||
descriptionType?: "offer" | "answer";
|
||||
authorization: boolean | "probe" | undefined;
|
||||
}): void {
|
||||
@@ -690,6 +694,7 @@ export class HybridMediaRouter {
|
||||
source: this.debugPeer(input.roomId, input.sourcePeerId),
|
||||
target: this.debugPeer(input.roomId, input.targetPeerId),
|
||||
signalKind: input.signalKind,
|
||||
candidateOrigin: input.candidateOrigin ?? null,
|
||||
descriptionType: input.descriptionType ?? null,
|
||||
authorization:
|
||||
input.authorization === undefined ? "assignment" : input.authorization,
|
||||
|
||||
+4
-2
@@ -1,12 +1,14 @@
|
||||
import type { IceConfig } from "../shared/protocol.js";
|
||||
import type { WireIceConfig } from "../shared/protocol.js";
|
||||
|
||||
export interface IceConfigOptions {
|
||||
stunUrls: readonly string[];
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
}
|
||||
|
||||
export function createIceConfig(options: IceConfigOptions): IceConfig {
|
||||
export function createIceConfig(options: IceConfigOptions): WireIceConfig {
|
||||
return {
|
||||
iceServers:
|
||||
options.stunUrls.length > 0 ? [{ urls: [...options.stunUrls] }] : [],
|
||||
natPredictionStunUrls: [...(options.natPredictionStunUrls ?? [])],
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ServerMessage,
|
||||
type ParticipantPresenceEntry,
|
||||
} from "../shared/protocol.js";
|
||||
import { candidateSignalOrigin } from "../shared/nat-candidate.js";
|
||||
import { assertEndpointMediaCopyCapacity } from "../shared/media-copy-accounting.js";
|
||||
import {
|
||||
RoomStore,
|
||||
@@ -1534,6 +1535,13 @@ export class SignalingServer {
|
||||
sourcePeerId: source.peerId,
|
||||
targetPeerId: target.peerId,
|
||||
signalKind: message.payload.kind,
|
||||
...(message.payload.kind === "candidate"
|
||||
? {
|
||||
candidateOrigin: candidateSignalOrigin(
|
||||
message.payload.candidate?.candidate ?? null,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(description ? { descriptionType: description.type } : {}),
|
||||
authorization: candidateAuthorized,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const NAT_TRAVERSAL_PATHS = [
|
||||
"unknown",
|
||||
"ordinary",
|
||||
"predicted",
|
||||
] as const;
|
||||
|
||||
export type NatTraversalPath = (typeof NAT_TRAVERSAL_PATHS)[number];
|
||||
export type CandidateSignalOrigin = "end" | "ordinary" | "predicted";
|
||||
|
||||
export function isPredictedCandidateFoundation(
|
||||
foundation: string | null,
|
||||
): boolean {
|
||||
return foundation !== null && /^s[pm]\d+$/.test(foundation);
|
||||
}
|
||||
|
||||
export function candidateSignalOrigin(
|
||||
candidate: string | null,
|
||||
): CandidateSignalOrigin {
|
||||
if (candidate === null || candidate.trim() === "") {
|
||||
return "end";
|
||||
}
|
||||
return /^candidate:s[pm]\d+\s/i.test(candidate)
|
||||
? "predicted"
|
||||
: "ordinary";
|
||||
}
|
||||
+13
-2
@@ -2,11 +2,12 @@ import { z } from "zod";
|
||||
|
||||
import { MAX_ENDPOINT_MEDIA_COPY_CAPACITY } from "./media-copy-accounting.js";
|
||||
import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
|
||||
import { NAT_TRAVERSAL_PATHS } from "./nat-candidate.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-v18";
|
||||
export const SIGNALING_PROTOCOL = "screener-v19";
|
||||
export const SIGNAL_CLOSE_CODES = {
|
||||
serviceRestart: 1012,
|
||||
sessionReplaced: 4001,
|
||||
@@ -18,6 +19,7 @@ export const ROOM_CODE_LENGTH = 4;
|
||||
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
|
||||
export const MAX_ICE_SERVER_URLS = 8;
|
||||
export const MAX_NAT_PREDICTION_STUN_URLS = 2;
|
||||
export const MAX_VIEWER_QUALITY_EVIDENCE_BYTES = 2 * 1024;
|
||||
export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000;
|
||||
export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000;
|
||||
@@ -288,9 +290,16 @@ const iceServerSchema = z
|
||||
export const iceConfigSchema = z
|
||||
.object({
|
||||
iceServers: z.array(iceServerSchema).max(8),
|
||||
natPredictionStunUrls: z
|
||||
.array(stunUrlSchema)
|
||||
.max(MAX_NAT_PREDICTION_STUN_URLS),
|
||||
})
|
||||
.strict();
|
||||
export type IceConfig = z.infer<typeof iceConfigSchema>;
|
||||
export type WireIceConfig = z.infer<typeof iceConfigSchema>;
|
||||
export type IceConfig = {
|
||||
iceServers: Array<z.infer<typeof iceServerSchema>>;
|
||||
natPredictionStunUrls?: string[];
|
||||
};
|
||||
|
||||
const sessionDescriptionSchema = z
|
||||
.object({
|
||||
@@ -565,6 +574,7 @@ const nullableSafeEvidenceInteger = z
|
||||
|
||||
export const viewerQualityEvidenceMetricsSchema = z
|
||||
.object({
|
||||
natTraversalPath: z.enum(NAT_TRAVERSAL_PATHS),
|
||||
width: z.number().int().min(1).max(16_384).nullable(),
|
||||
height: z.number().int().min(1).max(16_384).nullable(),
|
||||
framesPerSecond: nullableEvidenceNumber(240),
|
||||
@@ -644,6 +654,7 @@ const viewerQualityEvidenceWindowShape = {
|
||||
|
||||
const senderQualityDiagnosticsSchema = z
|
||||
.object({
|
||||
natTraversalPath: z.enum(NAT_TRAVERSAL_PATHS),
|
||||
reason: z.enum(["none", "bandwidth", "cpu"]).nullable(),
|
||||
framesPerSecond: nullableEvidenceNumber(240),
|
||||
bitrateKbps: nullableEvidenceNumber(100_000),
|
||||
|
||||
@@ -864,7 +864,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostOnline: true,
|
||||
connectionId: null,
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
codeEntryPolicy: "open",
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
}),
|
||||
@@ -1008,7 +1008,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostPaused: true,
|
||||
connectionId: null,
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
codeEntryPolicy: "open",
|
||||
viewerPasswordEnabled: false,
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
@@ -1044,7 +1044,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostPaused: true,
|
||||
connectionId: null,
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
codeEntryPolicy: "open",
|
||||
viewerPasswordEnabled: false,
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
@@ -1157,7 +1157,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostPaused: false,
|
||||
connectionId: null,
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
codeEntryPolicy: "open",
|
||||
viewerPasswordEnabled: false,
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
@@ -1285,7 +1285,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostOnline: true,
|
||||
connectionId: null,
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
codeEntryPolicy: "open",
|
||||
viewerPasswordEnabled: false,
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
@@ -1402,7 +1402,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostOnline: true,
|
||||
connectionId: "connection_12345678",
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
codeEntryPolicy: "open",
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
mediaMode: "peer-assisted",
|
||||
@@ -1483,7 +1483,7 @@ describe("client signaling recovery policy", () => {
|
||||
hostOnline: true,
|
||||
connectionId: null,
|
||||
viewerPeerIds: [],
|
||||
iceConfig: { iceServers: [] },
|
||||
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
|
||||
}),
|
||||
])(
|
||||
"terminates once when a server payload is incompatible with the current wire",
|
||||
@@ -1710,6 +1710,7 @@ describe("WebRTC stats parsing", () => {
|
||||
}),
|
||||
entry("remote-candidate-a", "remote-candidate", {
|
||||
candidateType: "srflx",
|
||||
foundation: "sp1",
|
||||
protocol: "udp",
|
||||
address: "2001:db8::10",
|
||||
port: 50_001,
|
||||
@@ -1804,6 +1805,7 @@ describe("WebRTC stats parsing", () => {
|
||||
mediaSourceFramesPerSecond: 58.5,
|
||||
selectedCandidatePairId: "pair-a",
|
||||
path: "direct",
|
||||
natTraversalPath: "predicted",
|
||||
iceProtocol: "udp",
|
||||
localCandidateAddress: "192.0.2.10",
|
||||
localCandidatePort: 50_000,
|
||||
@@ -1840,6 +1842,12 @@ describe("WebRTC stats parsing", () => {
|
||||
});
|
||||
report.get("remote-candidate-a")!.address = "2001:db8::10";
|
||||
report.get("remote-candidate-a")!.port = 50_001;
|
||||
report.get("remote-candidate-a")!.foundation = "ordinary";
|
||||
expect(
|
||||
await collectConnectionMetrics(connection, "send", accumulator, {
|
||||
trackIdentifier: "capture-track-a",
|
||||
}),
|
||||
).toMatchObject({ natTraversalPath: "ordinary" });
|
||||
|
||||
report.get("transport-a")!.selectedCandidatePairId = "pair-z";
|
||||
const wrongTransportPair = await collectConnectionMetrics(
|
||||
|
||||
@@ -726,7 +726,10 @@ describe("HostPeer source replacement", () => {
|
||||
const signals: SignalPayload[] = [];
|
||||
const peer = new HostPeer(
|
||||
"viewer-peer",
|
||||
{ iceServers: [{ urls: "stun:share.example.test:3478" }] },
|
||||
{
|
||||
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
},
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{
|
||||
@@ -745,9 +748,15 @@ describe("HostPeer source replacement", () => {
|
||||
{ urls: "stun:share.example.test:3478" },
|
||||
{ urls: "stun:share.example.test:3479" },
|
||||
{ urls: "stun:share.example.test:3480" },
|
||||
{ urls: "stun:observer.example.test:3478" },
|
||||
]);
|
||||
|
||||
const emitCandidate = (port: number | null): void => {
|
||||
const surveyUrls = [
|
||||
"stun:share.example.test:3478",
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
];
|
||||
const emitCandidate = (port: number | null, url?: string): void => {
|
||||
const event = new Event("icecandidate");
|
||||
Object.defineProperty(event, "candidate", {
|
||||
value:
|
||||
@@ -760,13 +769,15 @@ describe("HostPeer source replacement", () => {
|
||||
sdpMid: "0",
|
||||
sdpMLineIndex: 0,
|
||||
usernameFragment: "test",
|
||||
url,
|
||||
},
|
||||
});
|
||||
connection.dispatchEvent(event);
|
||||
};
|
||||
emitCandidate(40_000);
|
||||
emitCandidate(40_003);
|
||||
emitCandidate(40_006);
|
||||
emitCandidate(50_000, "stun:observer.example.test:3478");
|
||||
emitCandidate(40_000, surveyUrls[0]);
|
||||
emitCandidate(40_003, surveyUrls[1]);
|
||||
emitCandidate(40_006, surveyUrls[2]);
|
||||
connection.iceGatheringState = "complete";
|
||||
connection.dispatchEvent(new Event("icegatheringstatechange"));
|
||||
emitCandidate(null);
|
||||
@@ -788,7 +799,7 @@ describe("HostPeer source replacement", () => {
|
||||
candidateSignals.filter((signal) =>
|
||||
signal.candidate?.candidate.startsWith("candidate:base"),
|
||||
),
|
||||
).toHaveLength(3);
|
||||
).toHaveLength(4);
|
||||
expect(candidateSignals.at(-1)?.candidate).toBeNull();
|
||||
expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1);
|
||||
peer.dispose();
|
||||
|
||||
@@ -14,6 +14,7 @@ const SHARE_GENERATION = "share_generation_12345678";
|
||||
|
||||
function senderDiagnostics(state: "unknown" | "healthy" | "degraded") {
|
||||
return {
|
||||
natTraversalPath: "unknown",
|
||||
reason:
|
||||
state === "unknown" ? null : state === "healthy" ? "none" : "bandwidth",
|
||||
framesPerSecond: null,
|
||||
@@ -250,6 +251,41 @@ async function establishSfuRoom(
|
||||
}
|
||||
|
||||
describe("HybridMediaRouter v9 runtime", () => {
|
||||
it("logs only aggregate candidate origin for route signals", async () => {
|
||||
const { router } = harness(2);
|
||||
const debug = vi.spyOn(
|
||||
router as unknown as {
|
||||
debug(
|
||||
roomId: string,
|
||||
event: string,
|
||||
details: Record<string, unknown>,
|
||||
): void;
|
||||
},
|
||||
"debug",
|
||||
);
|
||||
try {
|
||||
router.debugPeerSignal({
|
||||
roomId: "1234",
|
||||
sourcePeerId: "source_12345678",
|
||||
targetPeerId: "target_12345678",
|
||||
signalKind: "candidate",
|
||||
candidateOrigin: "predicted",
|
||||
authorization: "probe",
|
||||
});
|
||||
expect(debug).toHaveBeenCalledWith(
|
||||
"1234",
|
||||
"peer-signal",
|
||||
expect.objectContaining({
|
||||
candidateOrigin: "predicted",
|
||||
signalKind: "candidate",
|
||||
authorization: "probe",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await router.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("projects exact endpoint copy context from the current snapshot", async () => {
|
||||
const { router } = harness(2);
|
||||
const internal = router as unknown as {
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ describe("ordinary ICE configuration", () => {
|
||||
"stun:stun-a.example.test:3478",
|
||||
"stun:stun-b.example.test:3478",
|
||||
],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
}),
|
||||
).toEqual({
|
||||
iceServers: [
|
||||
@@ -20,10 +21,13 @@ describe("ordinary ICE configuration", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local development explicit when STUN is absent", () => {
|
||||
expect(createIceConfig({ stunUrls: [] })).toEqual({ iceServers: [] });
|
||||
expect(
|
||||
createIceConfig({ stunUrls: [], natPredictionStunUrls: [] }),
|
||||
).toEqual({ iceServers: [], natPredictionStunUrls: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
candidateSignalOrigin,
|
||||
isPredictedCandidateFoundation,
|
||||
} from "../src/shared/nat-candidate.ts";
|
||||
|
||||
describe("NAT candidate diagnostics", () => {
|
||||
it("recognizes only the bounded synthetic foundation namespace", () => {
|
||||
expect(isPredictedCandidateFoundation("sp1")).toBe(true);
|
||||
expect(isPredictedCandidateFoundation("sm4")).toBe(true);
|
||||
expect(isPredictedCandidateFoundation("base")).toBe(false);
|
||||
expect(isPredictedCandidateFoundation(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies candidate signals without retaining endpoint data", () => {
|
||||
expect(candidateSignalOrigin(null)).toBe("end");
|
||||
expect(candidateSignalOrigin(" ")).toBe("end");
|
||||
expect(
|
||||
candidateSignalOrigin(
|
||||
"candidate:sp2 1 udp 1 203.0.113.1 50000 typ srflx",
|
||||
),
|
||||
).toBe("predicted");
|
||||
expect(
|
||||
candidateSignalOrigin(
|
||||
"candidate:base 1 udp 1 203.0.113.1 50000 typ srflx",
|
||||
),
|
||||
).toBe("ordinary");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
iceServersWithNatPrediction,
|
||||
MAX_NAT_PREDICTION_CANDIDATES,
|
||||
NatPredictionCandidateBatch,
|
||||
NatPredictionCandidateEmitter,
|
||||
natPredictionSurveyUrls,
|
||||
predictSrflxCandidates,
|
||||
type SignalCandidate,
|
||||
} from "../src/client/webrtc/nat-prediction.ts";
|
||||
@@ -33,6 +35,12 @@ function portOf(value: SignalCandidate): number {
|
||||
return Number(value?.candidate.trim().split(/\s+/)[5]);
|
||||
}
|
||||
|
||||
function rtcCandidate(port: number): RTCIceCandidate {
|
||||
return {
|
||||
...candidate(port),
|
||||
} as unknown as RTCIceCandidate;
|
||||
}
|
||||
|
||||
describe("NAT prediction ICE adapter", () => {
|
||||
it("leaves the configured servers unchanged when disabled", () => {
|
||||
const servers = [
|
||||
@@ -40,7 +48,42 @@ describe("NAT prediction ICE adapter", () => {
|
||||
{ urls: "stun:other.example.test:5349" },
|
||||
];
|
||||
|
||||
expect(iceServersWithNatPrediction(servers, false)).toEqual(servers);
|
||||
expect(
|
||||
iceServersWithNatPrediction(
|
||||
servers,
|
||||
false,
|
||||
["stun:observer.example.test:3478"],
|
||||
),
|
||||
).toEqual(servers);
|
||||
});
|
||||
|
||||
it("adds independent observers only inside the enabled experiment", () => {
|
||||
const servers = [{ urls: "stun:share.example.test:3478" }];
|
||||
|
||||
expect(
|
||||
iceServersWithNatPrediction(servers, true, [
|
||||
"stun:observer.example.test:3478",
|
||||
]),
|
||||
).toEqual([
|
||||
...servers,
|
||||
{ urls: "stun:share.example.test:3479" },
|
||||
{ urls: "stun:share.example.test:3480" },
|
||||
{ urls: "stun:observer.example.test:3478" },
|
||||
]);
|
||||
expect(
|
||||
natPredictionSurveyUrls(servers),
|
||||
).toEqual(
|
||||
new Set([
|
||||
"stun:share.example.test:3478",
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
natPredictionSurveyUrls([
|
||||
{ urls: "stun:share.example.test:5349" },
|
||||
]),
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("derives only the two optional same-host survey listeners", () => {
|
||||
@@ -158,4 +201,43 @@ describe("NAT prediction ICE adapter", () => {
|
||||
|
||||
expect(sent).toEqual([first]);
|
||||
});
|
||||
|
||||
it("retains independent observations without using them as sequence evidence", () => {
|
||||
const sent: Array<SignalCandidate | null> = [];
|
||||
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
|
||||
const external = candidate(50_000);
|
||||
|
||||
batch.add(external, false);
|
||||
batch.add(candidate(40_000));
|
||||
batch.add(candidate(40_003));
|
||||
batch.add(candidate(40_006));
|
||||
|
||||
expect(sent[0]).toEqual(external);
|
||||
expect(
|
||||
sent.some((value) => value?.candidate.startsWith("candidate:sp")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not predict when the Browser omits candidate source URLs", () => {
|
||||
const sent: Array<SignalCandidate | null> = [];
|
||||
const emitter = new NatPredictionCandidateEmitter(
|
||||
true,
|
||||
(value) => sent.push(value),
|
||||
new Set([
|
||||
"stun:share.example.test:3478",
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
]),
|
||||
);
|
||||
|
||||
emitter.add(rtcCandidate(40_000));
|
||||
emitter.add(rtcCandidate(40_003));
|
||||
emitter.add(rtcCandidate(40_006));
|
||||
emitter.gatheringComplete();
|
||||
|
||||
expect(
|
||||
sent.some((value) => value?.candidate.startsWith("candidate:sp")),
|
||||
).toBe(false);
|
||||
expect(sent.filter((value) => value !== null)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -56,6 +56,7 @@ const qualityEvidence = {
|
||||
sequence: 0,
|
||||
windowMs: 2_000,
|
||||
metrics: {
|
||||
natTraversalPath: "ordinary",
|
||||
width: 1_920,
|
||||
height: 1_080,
|
||||
framesPerSecond: 59.8,
|
||||
@@ -97,7 +98,7 @@ describe("client signaling protocol", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(SIGNALING_PROTOCOL).toBe("screener-v18");
|
||||
expect(SIGNALING_PROTOCOL).toBe("screener-v19");
|
||||
expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/);
|
||||
});
|
||||
|
||||
@@ -768,6 +769,14 @@ describe("client signaling protocol", () => {
|
||||
|
||||
it("accepts only strict, bounded viewer quality evidence", () => {
|
||||
expect(clientMessageSchema.safeParse(qualityEvidence).success).toBe(true);
|
||||
const { natTraversalPath: _path, ...legacyMetrics } =
|
||||
qualityEvidence.metrics;
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
...qualityEvidence,
|
||||
metrics: legacyMetrics,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
...qualityEvidence,
|
||||
@@ -883,12 +892,21 @@ describe("client signaling protocol", () => {
|
||||
routeRevision: 3,
|
||||
state: "degraded",
|
||||
diagnostics: {
|
||||
natTraversalPath: "ordinary",
|
||||
reason: "bandwidth",
|
||||
framesPerSecond: 24,
|
||||
bitrateKbps: 1_500,
|
||||
},
|
||||
} as const;
|
||||
expect(clientMessageSchema.safeParse(sender).success).toBe(true);
|
||||
const { natTraversalPath: _senderPath, ...legacyDiagnostics } =
|
||||
sender.diagnostics;
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
...sender,
|
||||
diagnostics: legacyDiagnostics,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
...sender,
|
||||
@@ -911,6 +929,7 @@ describe("client signaling protocol", () => {
|
||||
state: "healthy",
|
||||
sampleTimestampMs: 2_000,
|
||||
diagnostics: {
|
||||
natTraversalPath: "ordinary",
|
||||
reason: "none",
|
||||
framesPerSecond: 30,
|
||||
bitrateKbps: 2_000,
|
||||
@@ -1253,6 +1272,7 @@ describe("server signaling protocol", () => {
|
||||
viewerAuthorizationGeneration: "viewer_generation_12345678",
|
||||
iceConfig: {
|
||||
iceServers: [],
|
||||
natPredictionStunUrls: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1275,6 +1295,18 @@ describe("server signaling protocol", () => {
|
||||
expect(serverMessageSchema.safeParse(viewer).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the v19 NAT observation configuration", () => {
|
||||
const message = authenticatedMessage(8);
|
||||
const { natPredictionStunUrls: _urls, ...legacyIceConfig } =
|
||||
message.iceConfig;
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...message,
|
||||
iceConfig: legacyIceConfig,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts dynamic viewer limits within the protocol boundary", () => {
|
||||
expect(MAX_VIEWERS_PER_ROOM_LIMIT).toBe(20);
|
||||
expect(serverMessageSchema.safeParse(authenticatedMessage(1)).success).toBe(
|
||||
|
||||
@@ -182,6 +182,7 @@ describe("native sender quality evidence", () => {
|
||||
routeRevision: 7,
|
||||
state: "healthy",
|
||||
sampleTimestampMs: 2_000,
|
||||
diagnostics: { natTraversalPath: "unknown" },
|
||||
});
|
||||
invalidateSenderQualityEvidence();
|
||||
expect(senderQualityEvidenceFromSnapshot(nextSnapshot, 7)).toMatchObject({
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("server configuration", () => {
|
||||
expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"]));
|
||||
expect(config.roomDatabasePath).toBeUndefined();
|
||||
expect(config.stunUrls).toEqual([]);
|
||||
expect(config.natPredictionStunUrls).toEqual([]);
|
||||
expect(config.maxViewersPerRoom).toBe(8);
|
||||
expect(config.peerAssistedMedia).toBe(false);
|
||||
expect(config.endpointMediaCopyCapacity).toBe(2);
|
||||
@@ -326,6 +327,30 @@ describe("server configuration", () => {
|
||||
).toEqual(["STUN:[2001:db8::1]:3478"]);
|
||||
});
|
||||
|
||||
it("accepts a bounded independent NAT prediction STUN list", () => {
|
||||
expect(
|
||||
loadConfig({
|
||||
NAT_PREDICTION_STUN_URLS:
|
||||
"stun:observer-a.test:3478, stun:observer-b.test:3478",
|
||||
}).natPredictionStunUrls,
|
||||
).toEqual([
|
||||
"stun:observer-a.test:3478",
|
||||
"stun:observer-b.test:3478",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid or excessive NAT prediction STUN URLs", () => {
|
||||
expect(() =>
|
||||
loadConfig({ NAT_PREDICTION_STUN_URLS: "turn:observer.test:3478" }),
|
||||
).toThrow("NAT_PREDICTION_STUN_URLS contains an invalid STUN URL");
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
NAT_PREDICTION_STUN_URLS:
|
||||
"stun:a.test:3478,stun:b.test:3478,stun:c.test:3478",
|
||||
}),
|
||||
).toThrow("NAT_PREDICTION_STUN_URLS must contain at most 2 URLs");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stun:stun.test/path",
|
||||
"stun:stun.test?transport=udp",
|
||||
@@ -377,6 +402,21 @@ describe("server configuration", () => {
|
||||
).toThrow("STUN_URLS must contain at most 8 URLs");
|
||||
});
|
||||
|
||||
it("bounds ordinary and NAT prediction STUN URLs together", () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
STUN_URLS: Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => `stun:ordinary-${index}.test:3478`,
|
||||
).join(","),
|
||||
NAT_PREDICTION_STUN_URLS:
|
||||
"stun:observer-a.test:3478,stun:observer-b.test:3478",
|
||||
}),
|
||||
).toThrow(
|
||||
"STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most 8 URLs together",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires and accepts a bounded production site access password", () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
|
||||
@@ -57,6 +57,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
peerAssistedMedia: false,
|
||||
endpointMediaCopyCapacity: 2,
|
||||
stunUrls: [],
|
||||
natPredictionStunUrls: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ function testConfig(): ServerConfig {
|
||||
peerAssistedMedia: false,
|
||||
endpointMediaCopyCapacity: 2,
|
||||
stunUrls: [],
|
||||
natPredictionStunUrls: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ async function startHarness(
|
||||
peerAssistedMedia?: boolean;
|
||||
endpointMediaCopyCapacity?: number;
|
||||
stunUrls?: readonly string[];
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
now?: () => number;
|
||||
roomStore?: RoomStore;
|
||||
room?: CreatedRoom;
|
||||
@@ -162,6 +164,7 @@ async function startHarness(
|
||||
config.endpointMediaCopyCapacity =
|
||||
overrides.endpointMediaCopyCapacity ?? 2;
|
||||
config.stunUrls = overrides.stunUrls ?? [];
|
||||
config.natPredictionStunUrls = overrides.natPredictionStunUrls ?? [];
|
||||
const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8;
|
||||
config.maxViewersPerRoom = maxViewersPerRoom;
|
||||
const roomStore =
|
||||
@@ -420,6 +423,7 @@ function viewerQualityEvidenceMessage(
|
||||
sequence,
|
||||
windowMs: 2_000,
|
||||
metrics: {
|
||||
natTraversalPath: "ordinary",
|
||||
width: 1_920,
|
||||
height: 1_080,
|
||||
framesPerSecond: 60,
|
||||
@@ -542,6 +546,26 @@ describe("WebSocket signaling", () => {
|
||||
expect(joined.routePolicy).toEqual(routePolicy);
|
||||
});
|
||||
|
||||
it("delivers independent NAT experiment STUN without changing ordinary ICE", async () => {
|
||||
const harness = await startHarness({
|
||||
stunUrls: ["stun:ordinary.example.test:3478"],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
});
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const authenticated = await authenticate(
|
||||
viewer,
|
||||
harness.room,
|
||||
"viewer",
|
||||
"nat-observer-viewer",
|
||||
null,
|
||||
);
|
||||
|
||||
expect(authenticated.iceConfig).toEqual({
|
||||
iceServers: [{ urls: ["stun:ordinary.example.test:3478"] }],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -342,7 +342,10 @@ describe("ViewerPeer connection generations", () => {
|
||||
signals,
|
||||
[],
|
||||
[],
|
||||
{ iceServers: [{ urls: "stun:share.example.test:3478" }] },
|
||||
{
|
||||
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
},
|
||||
true,
|
||||
);
|
||||
await peer.acceptSignal("host", offer("nat-room-policy"));
|
||||
@@ -351,9 +354,15 @@ describe("ViewerPeer connection generations", () => {
|
||||
{ urls: "stun:share.example.test:3478" },
|
||||
{ urls: "stun:share.example.test:3479" },
|
||||
{ urls: "stun:share.example.test:3480" },
|
||||
{ urls: "stun:observer.example.test:3478" },
|
||||
]);
|
||||
|
||||
const emitCandidate = (port: number): void => {
|
||||
const surveyUrls = [
|
||||
"stun:share.example.test:3478",
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
];
|
||||
const emitCandidate = (port: number, url: string): void => {
|
||||
const event = new Event("icecandidate");
|
||||
Object.defineProperty(event, "candidate", {
|
||||
value: {
|
||||
@@ -363,13 +372,14 @@ describe("ViewerPeer connection generations", () => {
|
||||
sdpMid: "0",
|
||||
sdpMLineIndex: 0,
|
||||
usernameFragment: "test",
|
||||
url,
|
||||
},
|
||||
});
|
||||
connection.dispatchEvent(event);
|
||||
};
|
||||
emitCandidate(40_000);
|
||||
emitCandidate(40_003);
|
||||
emitCandidate(40_006);
|
||||
emitCandidate(40_000, surveyUrls[0]!);
|
||||
emitCandidate(40_003, surveyUrls[1]!);
|
||||
emitCandidate(40_006, surveyUrls[2]!);
|
||||
connection.iceGatheringState = "complete";
|
||||
connection.dispatchEvent(new Event("icegatheringstatechange"));
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ function receiveMetrics(
|
||||
...EMPTY_METRICS,
|
||||
sampleTimestampMs: 10_000,
|
||||
sampleWindowMs: 2_000,
|
||||
natTraversalPath: "predicted",
|
||||
framesPerSecond: 60,
|
||||
frameWidth: 1_920,
|
||||
frameHeight: 1_080,
|
||||
@@ -116,6 +117,7 @@ describe("viewer quality evidence", () => {
|
||||
expect(qualityEvidenceWindowFromMetrics(receiveMetrics())).toEqual({
|
||||
windowMs: 2_000,
|
||||
metrics: {
|
||||
natTraversalPath: "predicted",
|
||||
width: 1_920,
|
||||
height: 1_080,
|
||||
framesPerSecond: 60,
|
||||
@@ -175,6 +177,7 @@ describe("viewer quality evidence", () => {
|
||||
}),
|
||||
);
|
||||
expect(bounded?.metrics).toMatchObject({
|
||||
natTraversalPath: "predicted",
|
||||
width: null,
|
||||
height: null,
|
||||
bitrateKbps: null,
|
||||
|
||||
Reference in new issue
Block a user