From 35f9b785f91aff6d859cc00ad7ccfcb3296f5b00 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:29:50 +0800 Subject: [PATCH] fix(media): clamp browser relay release capacity (#177) --- .env.example | 2 +- .../0004-peer-assisted-media-experiment.md | 20 +- .../0005-automatic-hybrid-media-routing.md | 51 ++- docs/deployment.md | 22 +- docs/project-memory.md | 6 +- docs/research/advanced-peer-distribution.md | 21 +- docs/research/low-server-media-routes.md | 6 +- docs/research/peer-assisted-media.md | 69 +++- docs/status.md | 8 +- docs/verification-status.md | 4 +- docs/方案设计.md | 14 +- docs/需求理解.md | 12 +- scripts/peer-assisted-benchmark.ts | 37 +- src/client/media/relay-capability.ts | 4 +- src/client/webrtc/media-assignment.ts | 10 +- src/server/config.ts | 4 +- src/server/hybrid-media-router.ts | 25 +- src/server/media-route-controller.ts | 51 ++- src/server/peer-relay-topology.ts | 30 +- src/shared/protocol.ts | 4 + tests/host-peer-tracks.test.ts | 194 ++++------ tests/media-assignment.test.ts | 11 +- tests/media-route-controller.test.ts | 77 ++-- tests/peer-assisted-benchmark.test.ts | 35 +- tests/peer-relay-topology.test.ts | 52 ++- tests/relay-capability.test.ts | 4 +- tests/server-config.test.ts | 4 +- tests/server-signal.test.ts | 331 ++++++++++++++---- 28 files changed, 687 insertions(+), 421 deletions(-) diff --git a/.env.example b/.env.example index 359656d..c49dd05 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ MAX_VIEWERS_PER_ROOM=8 # Bounded standard-WebRTC relay/SFU controller for every room when true. # Keep false outside measured tests. PEER_ASSISTED_MEDIA=false -# Per-endpoint peer upload budget. Default 2; explicit range 1-3. +# Release upload ceiling: Host 2, ordinary Browser Viewer 1. Range 1-2 only tightens it. MAX_PEER_RELAY_DOWNSTREAM_EDGES=2 # Optional automatic SFU fallback capacity. Set PEER_ASSISTED_MEDIA=true and diff --git a/docs/adr/0004-peer-assisted-media-experiment.md b/docs/adr/0004-peer-assisted-media-experiment.md index ccbe0d5..ef40aa7 100644 --- a/docs/adr/0004-peer-assisted-media-experiment.md +++ b/docs/adr/0004-peer-assisted-media-experiment.md @@ -26,6 +26,9 @@ plane. This ADR does not accept peer-assisted media for production. It proposes one bounded experiment that must either meet explicit gates or be removed. +Historical cap2/cap3 loopback measurements under this ADR are experimental +evidence only. ADR-0005 owns the current release policy: Host at most two, +ordinary Browser Viewer at most one, with server-authoritative role clamps. ## Proposed Experiment @@ -48,9 +51,10 @@ one through sixteen. The upper admission bound is not a media-capacity or performance claim. The removed `PEER_ASSISTED_ROOM_IDS` allowlist fails startup; there is no second rollout mode, UI selector, percentage rollout, or route score. -The signaling server assigns a sticky, bounded DAG with a deterministic -breadth-first walk. The host and each ordinary Web viewer currently have -capacity for at most two children. Candidate parents are ordered by depth and +The signaling server assigned a sticky, bounded DAG with a deterministic +breadth-first walk. This historical experiment gave the host and each ordinary +Web viewer capacity for at most two children; it is not the current release +policy. Candidate parents were ordered by depth and server-issued join sequence. Joining a viewer does not move existing assignments except for ADR-0005's bounded admission rescue: an unassigned relay may replace the oldest childless zero-capacity Host leaf when both Host slots are full. If a @@ -60,8 +64,8 @@ capability, or quality scoring is added. For this ADR, one media edge is one downstream `RTCPeerConnection` carrying the shared stream. Using TURN for that connection does not alter the edge count. The -current two-child endpoint limit is a hard invariant across join, reconnect, -reparent, and recovery paths. If the deterministic topology has no connected +experiment's two-child endpoint limit was a hard invariant across join, +reconnect, reparent, and recovery paths. If the deterministic topology had no connected eligible parent, the viewer remains admitted but waits without media until a slot becomes reachable; it must not create a third host connection. Each logical edge still uses its own ICE process. In the repository candidate, @@ -101,7 +105,7 @@ route-controller ADR owns cross-mode fallback. ## Shared Encoding Boundary -The browser spike may encode once for each of the host's one or two seed +The historical browser spike may encode once for each of the host's one or two seed connections because browsers do not guarantee cross-connection encoder reuse. Each viewer relay also performs one downstream encode per child, currently up to two. This is tolerated only for the experiment and must be measured honestly. @@ -121,7 +125,7 @@ combinations, plus any advanced combination proposed for production, with controlled per-edge RTT at or below 40 ms and loss at or below 1%. Current Chrome and Edge form the controlled relay cohort; Android Chrome and iOS Safari remain compatibility observations. ADR-0005 now gives every ordinary Web -Viewer the same two-edge capacity without UA or visibility detection. Mobile +Viewer the same experimental two-edge capacity without UA or visibility detection. Mobile resource behavior remains unverified, but does not define a separate route class. The resource/quality gate remains an eight-viewer experiment. A separate sixteen-viewer loopback may prove only admission, bounded topology, and decode @@ -171,7 +175,7 @@ does not cancel the separate planned native shared-encode sender, and that sender cannot be used to mark an otherwise failed browser-relay topology as passing. -Passing the gate does not change this ADR to Accepted. It permits a separate ADR +Passing the gate did not change this ADR to Accepted. It permitted a separate ADR to propose a production design, including broader game-audio/A-V verification, automatic bounded relay policy, and privacy disclosure. The native shared-encode sender is the independent planned work described above, not a reward for passing this diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index ba2ba26..8209653 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -25,11 +25,14 @@ or two SFU roots. Only after the SFU/UDP path also fails may the same controller authorize one short-lived TURN attempt for the current exceptional edge. TURN credentials are not participant-wide ICE configuration and are never added to ordinary peer connections by default. HTTPS/WSS remains TLS/TCP and is outside -this media policy. Every non-server endpoint has at most two active -downstream media edges; its upstream receive edge does not consume that upload -budget. The current source candidate gives every ordinary Web relay that same -two-child hard limit without a UA or visibility policy; production remains at -one until this candidate ships. +this media policy. The current release policy gives the Host at most two active +downstream physical media edges and an ordinary Browser Viewer at most one; an +upstream receive edge does not consume that upload budget. Active and +provisional peer children, selected-edge overlays, and the Host SFU publication +all count. The server derives the effective limit from the authenticated role, +so an old or malicious Viewer advertising two or three cannot bypass one. A +deployment may tighten Host/Viewer below `2/1`, but cannot raise either limit. +There is no UA or visibility policy. TURN and SFU occupy different layers. A selected TURN candidate continuously relays media for its authorized ICE edge; it is not a handshake helper or a @@ -138,20 +141,24 @@ validates only HMAC and expiry, so application generation checks, short TTL, one-use state, fanout, and quotas bound the bearer. LiveKit publisher/subscriber ICE remains a separate participant-wide domain. -Every viewer starts with zero relay capacity for each authenticated session. A -peer-assisted client advertises its absolute ability of three downstream edges; -there is no UA-, device-, or visibility-based branch. The controller clamps the -advertisement to `MAX_PEER_RELAY_DOWNSTREAM_EDGES`, which defaults to two and -accepts one through three. Withdrawing capacity does not proactively migrate an -otherwise healthy existing edge. +Every viewer starts with zero relay capacity for each authenticated session. +The current Browser client advertises one downstream edge. The unchanged wire +continues to parse `0 | 1 | 2 | 3` only as a future capability envelope; it is +not current route authority. The controller clamps all Browser Viewer +advertisements to one before topology assignment, including old-client values +two and three. `MAX_PEER_RELAY_DOWNSTREAM_EDGES` defaults to two and accepts one +or two as a deployment tightening input: effective Host capacity is +`min(config, 2)` and effective Browser Viewer capacity is `min(config, 1)`. +Withdrawing capacity does not proactively migrate an otherwise healthy existing +edge. There is no UA-, device-, or visibility-based branch. The source now implements one narrow admission exception to sticky assignment. On an active peer-only route with no SFU publication or pending prepare, a connected, childless Viewer that has no upstream or failed-parent history and advertises relay capacity may replace the oldest connected, childless, zero-capacity Host child when both Host slots are full. That leaf becomes one -child of the new relay. The synchronous change preserves Host and browser -fanout at two, advances one route revision, and clears +child of the new relay. The synchronous change preserves Host fanout at two and +Browser Viewer fanout at one, advances one route revision, and clears both changed upstream connection generations. Host-side reconciliation closes the stale child edge before starting the replacement. No healthy routed candidate, quality score, timer, global parent penalty, or periodic rebalance is @@ -228,13 +235,18 @@ The server mapping is authoritative. Each participant receives only its own assignment for that room revision; it never receives or edits the complete mapping. The explicit SFU publication generation prevents an old and a new publisher from being counted as one logical edge during commit or rollback. +The Web execution layer independently truncates each received assignment to +the current Host-two or Viewer-one physical-child limit before creating peer +connections. This is defense in depth for a regressed or mismatched server; +the wire's future capacity envelope is not client execution permission. Minimal protocol additions: - optional `sfuStandbyUrl` in a peer-assisted authenticated snapshot, only when the server has complete LiveKit fallback configuration; -- `relay-capacity { downstreamEdges: 0 | 1 | 2 }` from an authenticated - peer-assisted viewer; +- `relay-capacity { downstreamEdges: 0 | 1 | 2 | 3 }` from an authenticated + peer-assisted viewer; the range is a future wire envelope while current + server policy clamps an ordinary Browser Viewer to one; - `route-update { revision, phase: "prepare" | "active", assignment }`; - `sfu-config { revision, url, token }`; - `route-ready { revision, phase: "prepare" | "active" }`; @@ -633,9 +645,12 @@ boundary is actually configured and the UI must not claim E2EE. ## Acceptance Gates -- Every non-server endpoint stays at or below the configured one-through-three - downstream edge budget across prepare, commit, rollback, reconnect, and - stale-message sequences; production keeps the default two. +- Across prepare, commit, rollback, reconnect, reauthentication, reconciliation, + and stale-message sequences, Host physical media edges stay at or below + `min(config, 2)` and ordinary Browser Viewer edges stay at or below + `min(config, 1)`. Host SFU publication and provisional/selected overlays count; + upstream receive does not. Config and client advertisements cannot raise the + release limits. - Only necessary roots or explicitly admitted exceptional viewers receive SFU media and the configured central egress budget is never exceeded. - A reliable SFU root continues to serve bounded peer descendants. Normal diff --git a/docs/deployment.md b/docs/deployment.md index e6544eb..9f67d3c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,9 +30,11 @@ The current release deploys the bounded pre-share health/WSS/STUN self-check, privacy-safe click-only diagnostic JSON export, room admission default eight with explicit limits from one through sixteen, and peer-quality MBB. One ordinary Viewer with a peer or SFU upstream may own the provisional child; -Host provisional children remain open. Production keeps the two-edge endpoint -default. The retained sixteen-Viewer and resource runs are ordinary-PC -functional evidence only and do not justify cap3 or close performance gates. +Host provisional children remain open. Production keeps its two-edge endpoint +default; the next source release clamps Host to two and an +ordinary Browser Viewer to one. The retained capacity-two/cap3 sixteen-Viewer +and resource runs are historical ordinary-PC experiments only and do not define +release policy or close performance gates. The cutover used audited tree `848f1588000006d91546468abae858bf027929e6`, an independent production dependency tree with zero shared regular-file inodes, and byte-identical public client assets. A no-room Chrome 151 canary passed @@ -254,11 +256,15 @@ Omit the database path to keep random temporary rooms; `ROOM_TTL_SECONDS` applies only to those rooms. `MAX_VIEWERS_PER_ROOM` defaults to 8 and accepts 1 through 16. It is an admission limit, not evidence that the publisher can sustain that many streams. -`MAX_PEER_RELAY_DOWNSTREAM_EDGES` defaults to 2 and accepts 1 through 3. It is -the hard per-endpoint peer upload budget; an active Host SFU publication also -uses one Host edge. Web and Native advertise an absolute ability of three, but -the server always clamps assignments to this deployment value. It does not -change the separate SFU root limit. +`MAX_PEER_RELAY_DOWNSTREAM_EDGES` defaults to 2 and accepts only 1 or 2. It can +tighten but cannot lift release policy: effective Host capacity is +`min(value, 2)` and effective ordinary Browser Viewer capacity is +`min(value, 1)`. Active/provisional/selected physical downstream overlays count, +and an active Host SFU publication uses one Host edge; ordinary upstream receive +does not. The current Browser advertises one. The unchanged protocol accepts +`0/1/2/3` only as a future capability envelope, so an old or malicious Viewer +advertising two or three remains effectively one. This setting does not change +the separate SFU root limit, which remains at most two. The three `LIVEKIT_*` values must either all be absent or all be present, and a complete tuple requires `PEER_ASSISTED_MEDIA=true`. An empty tuple keeps the diff --git a/docs/project-memory.md b/docs/project-memory.md index cd52d94..a38459f 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -8,7 +8,7 @@ Last updated: 2026-08-22 - Browser first. Viewers use desktop/mobile Web; an optional sender requires a proven browser capability gap. - Use a small central service for access, rooms, signaling, deterministic topology, STUN, observability, and bounded SFU-root capacity. - Minimize server bandwidth. Ordinary peer ICE is STUN-only. Failure tries restart, rebuild, alternate peer and bounded SFU/UDP roots; only then may the controller authorize short-lived TURN for one selected exceptional edge before clear failure. TURN is transport, not topology. -- Production keeps two downstream edges per non-server node; source exposes a deployment cap defaulting to two with an explicit one-through-three range. Upstream receive is free and UA/visibility do not change it. SFU normally feeds one or two roots that retain peer descendants; its root cap remains separate. Dynamic evidence-driven capacity still requires its own acceptance. +- Current release policy is Host2/ordinary Browser Viewer1. Server authority derives the limit from authenticated role; old or malicious Viewer advertisements 2/3 remain effective1. Deployment config defaults to two and accepts only 1/2 to tighten, never lift, these limits. Web assignment execution independently truncates physical children to Host2/Viewer1 as defense-in-depth. Active/provisional/selected physical overlays count, as does Host SFU publication; ordinary upstream receive is free. A deployment limit of one promotes an occupied Host branch root before reserving its SFU publication slot. UA/visibility do not change capacity. SFU normally feeds one or two roots that retain peer descendants; its root cap remains separate. Dynamic evidence-driven capacity still requires its own acceptance. - Two-tree packet/layer striping may reduce endpoint upload toward one stream bitrate, but needs a bounded multi-parent, loss, sync, churn, and latency experiment; it is not in the current full-stream chains. - Direct/peer keeps per-PC stock GCC. Active SFU publishes exactly `q,h` at a `HIGH` ceiling; built-in BWE may forward `LOW`. The zero-descendant quality/resource gate is next; if it passes, no app media selector is built. Explicit quality, manual activation, and custom/native remain fallbacks. - A later explicit quality fallback evacuates children under a generation guard first. Autonomous BWE enters `suspect`; confirmation evacuates children, and no confirmed `FALLBACK` parent remains. Root-with-children impact is a default-on gate; capacity returns after longer recovery plus cooldown. Self-report alone never triggers it. @@ -45,7 +45,7 @@ Last updated: 2026-08-22 - The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. - Production: exact `93e4681915768db0bf3b0165c0fd9ec3f54f95c8`, release `93e468191576`, Web wire `screener-v5`; `66eb33717193` is the rollback release. - Web keeps one SFU stream, clears unavailable video, exposes evidence-backed routes/transports and paused preview, and drops signals to a grace-retained offline current-edge target. Routing is automatic; entry/nickname UI is live; Native remains source-only. -- Production rooms default to eight Viewers and accept an explicit admission limit from one through sixteen. Web endpoints use cap2 with one upstream; source supports an explicit deployment cap from one through three without a UA/visibility split. +- Production rooms default to eight Viewers and accept an explicit admission limit from one through sixteen. The deployed older release uses Web cap2; current source is Host2/ordinary Browser Viewer1 and accepts deployment 1/2 only as further tightening, without a UA/visibility split. The unchanged `0/1/2/3` relay-capacity wire is a future envelope, not current authority. - Relay evidence is opt-in; direct proof and selected-pair addresses stay local, never in relayed evidence. The deployed pre-share self-check uses disposable health/WSS/STUN probes and reports configured SFU as unverified until a real route. Click-only diagnostic JSON uses a fixed privacy allowlist; neither path uploads or persists a report. - `screener`, LiveKit, coturn, and nginx are active; `screener` has `NRestarts=0`, local/public health are 200, and the endpoint-cap environment is unset so the parameterized default is two. SQLite v3 has five rooms, SHA-256 `aef724ae52c4107be8ec8791e72f8dcaa11b0ec849fb432d022e2cfb9cfe0974`, owner/mode `screener:screener`/0600. - Production keeps ordinary ICE STUN-only, SFU roots <=2, selected-edge UDP TTL 120, and one pending/answered `peer-selected` attempt per room excluding Host ingress. A post-deploy no-room browser self-check passed health, unauthenticated WSS open/close, and STUN `srflx` gathering without exposing candidate data; room media, SFU, TURN, and performance remain unverified. Earlier exact-`16f6eab` evidence proves active Host ingress only. @@ -53,7 +53,7 @@ Last updated: 2026-08-22 - Production uses nginx, Node.js 24.19.0, coturn 4.17.2, and LiveKit 1.13.5 on UDP 7882 (TCP fallback off). Ordinary peer ICE receives no TURN; the selected-edge tuple is configured only for the current controller edge, with no credential pre-advertised to ordinary peers. Coturn retains the old authenticated-relay ports. Services are healthy with zero restarts. - ADR-0005 rejects `PEER_ASSISTED_ROOM_IDS`; `PEER_ASSISTED_MEDIA=true` enables every room and ordinary peers stay STUN-only. Initial or active Host ingress may consume one bound relay-only grant; ready/abort clears it and failure restores peer baseline. Stale room-ID config fails startup. Production and source-only Native use `screener-v5`; Native does not subscribe to Web presence. - Defaults are balanced with automatic codec and 128 kbps screen audio; clarity/fluid, H.264/VP8, and 64/128/256 kbps audio remain explicit. Codec and audio quality are locked during a share. LiveKit 2.22.0 SFU uses `q,h`; production exposes one Host-local current-`h` A+B snapshot and clears it on publisher identity changes. Target-browser field availability, actual media values, BWE, and route observation remain open. -- Chrome 151 local functional evidence includes five-Viewer cap2 and sixteen-Viewer cap2/cap3 runs. All Viewers decoded, and a ten-second follow-up recorded guarded sender and aggregate Chromium CPU evidence, but resolution/FPS stayed low and the same-machine ordinary-PC runs do not rank cap2 against cap3 or close resource, visual-quality, endurance, heterogeneous-network, or 20-viewer gates. Production remains cap2. +- Chrome 151 local functional evidence includes five-Viewer capacity-two and sixteen-Viewer cap2/cap3 runs. These are retained historical experiments, not release policy. All Viewers decoded, and a ten-second follow-up recorded guarded sender and aggregate Chromium CPU evidence, but resolution/FPS stayed low and the same-machine ordinary-PC runs do not rank caps or close resource, visual-quality, endurance, heterogeneous-network, or 20-viewer gates. ## Provisional Quality Targets diff --git a/docs/research/advanced-peer-distribution.md b/docs/research/advanced-peer-distribution.md index 6f97458..5e2b58a 100644 --- a/docs/research/advanced-peer-distribution.md +++ b/docs/research/advanced-peer-distribution.md @@ -202,9 +202,9 @@ not evidence. `sending`, either report alone, and healthy or ambiguous pairs reset or do not advance the streak. It holds at most one pending/streak state per connected Viewer and one room cooldown, with no timer or weighted score. One confirmed child event stays -edge-local. A separate bounded corroboration can suspend one Viewer relay's -server-owned eligibility only after two distinct children confirm under the -same parent session and share within five seconds. +edge-local. The two-distinct-child corroboration logic is retained only for a +future accepted Viewer-capacity tier; current ordinary Browser Viewer capacity +one makes that state unreachable in release routing. The conservative Viewer C hard predicates are: freeze duration at least half of the one-to-five-second window; positive received-packet delta with zero @@ -260,7 +260,7 @@ standards-derived or claimed optimum values. One correlated pair still attributes a problem only to its current parent-to-child edge generation: remote loss comes from that child and `qualityLimitationReason` belongs to one outbound stream. Screener therefore -keeps a single event edge-local. Two independently confirmed current children +keeps a single event edge-local. In a future accepted capacity-two tier, two independently confirmed current children within five seconds are instead treated as bounded corroboration to pause that Viewer parent's relay eligibility for 30 seconds, not as proof of device-wide quality or a numeric score. The client-advertised capacity remains intact; new @@ -273,8 +273,8 @@ preferring recent healthy candidates remains later. The triggering child now uses bounded make-before-break; converting the quarantined parent's remaining children from their existing immediate reassignment is a separate slice. -With host and current browser-relay degree two, deterministic breadth-first -assignment keeps eight viewers to depth three. A tree for `N` viewers +With current Host degree two and Browser Viewer degree one, deterministic +assignment forms at most two bounded chains. A tree for `N` viewers still has `N` media edges, approximately `N*B` useful upload in aggregate, and approximately `2B` host upload once both roots are used. Local reparenting does not reduce either bandwidth quantity. Reordering an already balanced healthy @@ -291,8 +291,8 @@ host -> new relay -> existing leaf host -> other root ``` -This admits the waiting viewer while keeping host and browser-relay fanout at -two. The router permits it only on an active peer-only route with no SFU +This admits the waiting viewer while keeping Host fanout at two and Browser +Viewer fanout at one. The router permits it only on an active peer-only route with no SFU publication or pending prepare, when the connected candidate currently has no upstream or children, offers relay capacity, and has no failed-parent history. The host must have exactly two children and the chosen child must be a @@ -319,8 +319,9 @@ move only one affected subtree, use separate enter and recovery thresholds plus a cooldown, and disable proactive moves for the share after repeated rollback. Use make-before-break only when the new parent has a free downstream media slot -and every affected endpoint remains within its current limit: host, Web relay, -and native relay at most two. Admission rescue +and every affected endpoint remains within its current release limit: Host at +most two, ordinary Browser Viewer at most one. A future native role requires its +own accepted limit. Admission rescue starts with both host slots occupied, so it is break-before-make: retire the chosen host-to-leaf media edge before activating host-to-new-relay media. A control-only `RTCPeerConnection` may prewarm ICE but cannot prove media uplink: diff --git a/docs/research/low-server-media-routes.md b/docs/research/low-server-media-routes.md index 2f3c429..8d31013 100644 --- a/docs/research/low-server-media-routes.md +++ b/docs/research/low-server-media-routes.md @@ -141,9 +141,9 @@ only STUN candidates. The controller may authorize one failed edge to rebuild relay-only after SFU/UDP; LiveKit participant transport remains separate. An SFU is a topology node. Screener uses it as a virtual parent for one or two roots, not as an automatic all-viewer fanout service. -After media reaches a root, the deterministic sticky subtree, current host/root -and browser fanout at most two, maximum depth, and bounded failure radius still -apply. Only the absence of any reliable relay root permits a necessary viewer +After media reaches a root, the deterministic sticky subtree, current Host +fanout at most two, ordinary Browser Viewer/root fanout at most one, maximum +depth, and bounded failure radius still apply. Only the absence of any reliable relay root permits a necessary viewer to consume one of the same one or two root slots with zero descendants. This distinction follows TURN's allocation/relay role in RFC 8656 and the media diff --git a/docs/research/peer-assisted-media.md b/docs/research/peer-assisted-media.md index 60c1130..7d50d4d 100644 --- a/docs/research/peer-assisted-media.md +++ b/docs/research/peer-assisted-media.md @@ -1,6 +1,6 @@ # Peer-Assisted Media Research -- Research date: 2026-08-21 +- Research date: 2026-08-22 - Scope: one game-screen broadcaster, explicit admission up to sixteen trusted viewers, with the retained resource/quality gate at eight - Status: historical evidence plus the current bounded source candidate; @@ -10,12 +10,15 @@ ## Conclusion Peer-assisted forwarding can cap the broadcaster at one or two outgoing media -edges without making an SFU carry every viewer's traffic. The smallest credible -experiment uses only standard WebRTC media: +edges without making an SFU carry every viewer's traffic. The current release +policy allows at most two Host physical media edges and one ordinary Browser +Viewer downstream edge. The earlier capacity-two Browser relay work below is a +historical experiment, not release policy. The smallest credible path uses only +standard WebRTC media: 1. the browser host sends its screen stream to at most two first-level viewers; -2. each viewer may add the received remote `MediaStreamTrack` values to at most - two downstream `RTCPeerConnection` instances; and +2. each ordinary Browser Viewer may add the received remote `MediaStreamTrack` + values to at most one downstream `RTCPeerConnection`; and 3. the signaling server assigns one active upstream per viewer in a bounded, deterministic breadth-first DAG. @@ -98,6 +101,35 @@ Sources: - [W3C WebRTC `addTrack` and sender model](https://www.w3.org/TR/webrtc/) (accessed 2026-08-21) - [WebRTC remote-stream forwarding primitives](https://webrtc.org/getting-started/remote-streams) (accessed 2026-08-21) +### Server Authority And Current Release Clamp + +The WebRTC 1.0 sender model associates transmission with an individual +`RTCRtpSender`; it does not turn a client-declared fanout value into server +authority. Pion's maintained `TrackLocalStaticRTP` similarly maintains multiple +bindings and writes a packet per bound downstream context. These sources support +counting each physical downstream sender/edge, including an overlapping +provisional or selected replacement, rather than treating a logical route as one +upload. OWASP's server-side input-validation guidance treats client input as +untrusted and requires semantic validation in the trusted service. + +The resulting Screener policy is an engineering inference: authenticate role +first, then compute effective Host capacity as `min(deployment, 2)` and ordinary +Browser Viewer capacity as `min(deployment, 1)`. A Viewer advertisement of two +or three remains syntactically valid for the unchanged future protocol envelope, +but cannot authorize a second current-release child. Host SFU publication and +active/provisional/selected physical overlays consume Host upload budget; +ordinary upstream receive does not. No UA or visibility classification is used. +The Web client independently clamps received child assignments to Host two or +Viewer one before opening physical peer connections. That client check limits +damage from a server regression, but does not replace authenticated server +authority or promote the protocol envelope into release policy. + +Sources, accessed 2026-08-22: + +- [W3C WebRTC 1.0 Recommendation](https://www.w3.org/TR/webrtc/) +- [OWASP Input Validation Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html) +- [Pion `TrackLocalStaticRTP` bindings](https://github.com/pion/webrtc/blob/main/track_local_static.go) + ### Encoded DataChannel Relay, Rejected For The First Spike WebCodecs accepts and emits `EncodedVideoChunk` values but defines no network @@ -167,9 +199,9 @@ a sticky, bounded DAG selected by one breadth-first walk. Here, one media edge means one downstream `RTCPeerConnection` carrying the shared stream; a later controller-selected TURN rebuild does not change the edge count. -- the host and every ordinary Web viewer currently have capacity for at most two - children; -- each endpoint's child count is a hard invariant and never exceeds two, +- the historical experiment gave the host and every ordinary Web viewer capacity + for at most two children; +- that experiment's endpoint child count was a hard invariant and never exceeded two, including joins, reconnects, and reparenting; - candidate parents are ordered by depth and server join sequence, and the first connected node with a free slot is selected; @@ -256,12 +288,17 @@ no fatal error. The run proves the two-child control path and bound on local Chrome only. It does not prove shared encoding, CPU/GPU cost, visual quality, games, mobile resources, public networking, or endurance. -The loopback runner retains cap2 by default; `BENCHMARK_EXPECTED_ENDPOINT_CAP=3` -configures only its local server and gate. A 2026-08-22 Chrome 151 two-second +The historical loopback runner used cap2 by default; +`BENCHMARK_EXPECTED_ENDPOINT_CAP=3` configured only its local server and gate. +The current runner no longer admits cap3 because it verifies release policy. +A 2026-08-22 Chrome 151 two-second smoke passed with three Viewers at Host2/relay1 and six at Host3/relay3, with all -Viewers decoding. This is not a production-default, SFU-capacity, or resource claim. +Viewers decoding. This was an experimental control-path result, not a +release-policy, production-default, SFU-capacity, or resource claim. -A 2026-08-22 Windows Chrome 151.0.7922.138 headless loopback then ran sixteen +The following cap2/cap3 results are retained historical experiments and must not +be used to lift the Host2/Browser Viewer1 release policy. A 2026-08-22 Windows +Chrome 151.0.7922.138 headless loopback ran sixteen Viewers for two seconds at 720p30 with cap2 and cap3. Both runs had all sixteen Viewers decoding and no fatal/check failure. Cap2 peaked at Host2/relay2 with depth four and a 1,167 ms maximum first-decode diagnostic; cap3 peaked at @@ -311,11 +348,11 @@ Sources: ## Resource Model -For observed encoded bitrate `B`, `N` viewers, and the current two host/viewer -slots: +For observed encoded bitrate `B`, `N` viewers, and the current Host2/Browser +Viewer1 release limits: - host upload is at most approximately `2 * B`, plus transport overhead; -- each relay upload is at most approximately `2 * B`; +- each Browser Viewer relay upload is at most approximately `B`; - aggregate viewer delivery still requires approximately `N * B` across hosts, relays, and any TURN paths; and - signaling/STUN carry negligible media traffic, while a TURN path still incurs @@ -327,7 +364,7 @@ the deployment ceiling. The standalone ADR-0004 spike has no runtime capability flag and relies on controlled join order. The accepted ADR-0005 controller starts every viewer at zero, then the current Web client advertises an explicit per-session capacity -of two. The first production smoke scoped it to room `1`; current production no +of one. The first production smoke scoped it to room `1`; current production no longer has that room boundary. There is no mobile/iPad, UA, or visibility branch. ## Bounded Spike And Gates diff --git a/docs/status.md b/docs/status.md index e54fffb..b8ca004 100644 --- a/docs/status.md +++ b/docs/status.md @@ -8,7 +8,7 @@ This file is the current execution index. Load [verification status](./verificat - `https://share.bonfire.icu` runs exact `93e4681915768db0bf3b0165c0fd9ec3f54f95c8`, release `93e468191576`, wire `screener-v5`; `66eb33717193` is the rollback release. - `screener`, LiveKit, coturn, and nginx are active, local/public health return 200, and `screener` has `NRestarts=0`. SQLite remains schema v3 with five rooms and mode 0600. -- Ordinary ICE is STUN-only. Production allows SFU roots <=2, one `peer-selected` attempt excluding Host ingress, selected UDP TTL 120, room admission default eight with explicit limits from one through sixteen, and two downstream edges per Web endpoint. `MAX_PEER_RELAY_DOWNSTREAM_EDGES` is unset, so the parameterized default is active. +- Ordinary ICE is STUN-only. Production allows SFU roots <=2, one `peer-selected` attempt excluding Host ingress, selected UDP TTL 120, room admission default eight with explicit limits from one through sixteen, and two downstream edges per Web endpoint. `MAX_PEER_RELAY_DOWNSTREAM_EDGES` is unset, so that release's parameterized default is active. The next source release changes the endpoint policy to Host2/ordinary Browser Viewer1 without changing the wire. - Stable SFU, route/transport truth, pause notices, entry/nickname UI, selected-edge lease authority, corroborated bad-relay demotion, Share audio presets, local media-path diagnostics, pre-share self-check, privacy-safe diagnostic export, and peer-quality MBB are live. A candidate ordinary Viewer may have a peer or SFU upstream; Host provisional children and sibling MBB remain open. Native remains source-only. ## Current Product Snapshot @@ -17,8 +17,8 @@ This file is the current execution index. Load [verification status](./verificat - Local-only connection details expose current Host SFU sender evidence, actual codec/fmtp and encoder fields, media-source/encode FPS evidence, A/V playout/jitter/concealment evidence, and selected candidate endpoints. These fields do not enter quality signaling, server state, logs, persistence, or route selection. - The deployed pre-share self-check uses disposable, no-room resources for health, WSS, and STUN `srflx` checks and treats configured SFU as unverified until a real route. Expanded connection details provide click-only, versioned JSON export; its fixed allowlist excludes candidate addresses/ports, participant and room identity, raw signaling, URLs, and credentials, and no report is uploaded or persisted. - Private fragment grants enter directly; code-only Viewers need site access and private rooms additionally need their password. Credentials remain unstored. Web names and presence are session-only; duplicate names alone show an ID suffix. -- Rooms default to eight Viewers and accept an explicit admission limit from one through sixteen. Roots and ordinary Web relays are bounded to two downstream edges, Viewer upstream receive is free, and source permits an explicit endpoint cap from one through three. SFU root capacity remains separate. -- Relay quality handling compares fresh child FPS with parent input. Two current corroborating children can pause one Viewer relay for 30 seconds and request peer-only reassignment. Deployed MBB keeps the triggering child's old edge while one ordinary Viewer with a peer or SFU upstream and a strict spare relay slot owns the provisional child PC. Matching current-generation RTP, decoded frames, live video, revision, and identity promote that same PC. Host remains excluded. Failure, timeout, session/share change, or no candidate keeps the old edge without SFU/TURN. Sibling MBB remains open. +- Rooms default to eight Viewers and accept an explicit admission limit from one through sixteen. Current source is server-authoritative and role-aware: Host physical media edges are bounded to two, ordinary Browser Viewer downstream edges to one, and upstream receive is free. Active/provisional/selected overlays count, as does Host SFU publication. Deployment values 1/2 may only tighten these limits; old/malicious Viewer advertisements 2/3 cannot lift them. Web assignment execution also truncates physical children to Host2/Viewer1 as defense-in-depth, and deployment limit one promotes an occupied Host branch root before reserving the SFU publication slot. The wire retains `0/1/2/3` only as a future envelope, and SFU roots remain separately bounded to two. +- Relay quality handling compares fresh child FPS with parent input. Two-child corroboration is retained only for a future accepted capacity tier and is unreachable under the ordinary Browser Viewer1 release clamp. Deployed MBB keeps the triggering child's old edge while one ordinary Viewer with peer or SFU upstream and a strict spare slot owns the provisional child PC. Matching current-generation RTP, decoded frames, live video, revision, and identity promote that same PC; Host remains excluded. Failure, timeout, session/share change, or no candidate keeps the old edge without SFU/TURN. Sibling MBB remains open. - A generation-bound 15-second initial-connect deadline enters ICE restart. One-root healthy-SFU make-before-break is deployed but media-unverified; capacity `0 -> 1`, multi-root, browser, and mobile gates remain open. ## Active Milestone @@ -27,7 +27,7 @@ This file is the current execution index. Load [verification status](./verificat 2. Run one representative production-room UDP/frame/cap/stop canary. Validate initial ingress, `peer-selected`, bad-relay reparenting, and healthy SFU reselection without changing the STUN-only ordinary-peer policy. 3. Run exactly-two/Dynacast-off zero-child SFU BWE/resource evidence before implementing explicit evacuation or another media selector. 4. Complete Native package download, Viewer2/FIFO, hardware/endurance, and game A/V evidence; use target-device or production measurements for performance decisions. -5. Finish ADR-0004 relay resource/quality and heterogeneous-network gates. A 20-viewer matrix is required before any accepted scale-default change; mobile remains a compatibility observation, not a capacity class. +5. Run the Host2/ordinary Browser Viewer1 relay resource/quality and heterogeneous-network gates. ADR-0004 cap2/cap3 results remain historical experiments. A 20-viewer matrix is required before any accepted scale-default change; mobile remains a compatibility observation, not a capacity class. ## Decisions And Blockers diff --git a/docs/verification-status.md b/docs/verification-status.md index 2a6983b..53f3d47 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -17,7 +17,7 @@ This is the demand-loaded ledger for cross-cutting validation evidence and open ## Retained Functional Evidence -- Chrome 151 synthetic `1/3/5/8` and 720p30 runs preserved bounded fanout and decoding. Sixteen-Viewer cap2/cap3 loopbacks decoded all Viewers and a ten-second follow-up recorded guarded sender and aggregate Chromium CPU evidence, but resolution/FPS remained low and the ordinary-PC data cannot rank the caps. These prove function, not sustainable capacity or heterogeneous-network quality, and do not change the production cap2 default. See [peer-assisted media](./research/peer-assisted-media.md). +- Chrome 151 synthetic `1/3/5/8` and 720p30 runs preserved bounded fanout and decoding. Sixteen-Viewer cap2/cap3 loopbacks decoded all Viewers and a ten-second follow-up recorded guarded sender and aggregate Chromium CPU evidence, but resolution/FPS remained low and the ordinary-PC data cannot rank the caps. These are historical experiments that prove function, not sustainable capacity, heterogeneous-network quality, or authority to lift the Host2/ordinary Browser Viewer1 release policy. See [peer-assisted media](./research/peer-assisted-media.md). - Chrome 151 with local LiveKit 1.13.5/client 2.22.0 proved one SFU/UDP root after `q,f` -> `q,h`: decoded frames advanced 3 -> 23, rendered frames reached 26, Host used one edge, leaves were clean, and TURN was absent. This is functional evidence only. See [low-server media routes](./research/low-server-media-routes.md). - Host A+B and authenticated P2P Viewer C evidence is sanitized, generation-bound, read-only, and closed for stale, ambiguous, or SFU-fed reports. Production can expose a Host-local current SFU `h` snapshot and clears it on publisher identity changes; raw media metadata is not retained. See [realtime quality adaptation](./research/realtime-quality-adaptation.md). - The built-in peer TURN canary was rejected after allocation passed but the direct Host/Pion Viewer path failed before forced relay. Full rollback restored STUN-only client ICE and zero allocations. See [built-in peer ICE TURN](./research/built-in-peer-ice-turn.md). @@ -29,7 +29,7 @@ This is the demand-loaded ledger for cross-cutting validation evidence and open - Routing: verify current-release initial ingress, `peer-selected` expiry/failure, corroborated bad-relay reassignment, one-root healthy SFU reselection, silent partitions, and bounded failure on real browser/media paths. Silent control partitions may wait 30 to 60 seconds for heartbeat detection before the default five-second grace. - Quality: production startup blur remains unclassified. One same-`balanced` local A/B retained route/PC/SSRC/track while 720p rose to 1080p, but natural ramp prevents causal, production, or SFU conclusions. Correlate Host capture/encode/send with Viewer receive/decode before changing policy. -- Relay resources: production fanout is Host2/Viewer2 and rejects child3, but relay CPU/GPU/upload cost and heterogeneous-network behavior are unverified. Finish the ADR-0004 matrix before the 20-viewer gate or any default change. +- Relay resources: the deployed older release is Host2/Viewer2; current source policy is Host2/ordinary Browser Viewer1 and rejects any Viewer child2 regardless of advertised capacity. Relay CPU/GPU/upload cost and heterogeneous-network behavior remain unverified. Finish the role-aware matrix before the 20-viewer gate or any future capacity-tier change. - SFU: active publication already uses `q,h`; exactly-two, Dynacast-off, zero-child, BWE, resource, and root-with-children gates remain. Use Linux `tc` or a public canary for per-leaf shaping, never CDP throttling. - Audio: verify the 64/128/256 kbps Share settings, negotiated/observed bitrate, audible music quality, route changes, and game A/V synchronization. Bitrate settings are ceilings, not quality proof. - Observability: the UI and local stats plumbing are deployed, but target-browser availability and actual values remain unverified for Host SFU sender evidence, codec/fmtp and encoder implementation/power efficiency, media-source and encoded FPS, A/V playout/jitter/concealment, and selected candidate endpoints. These fields diagnose; they do not by themselves prove hardware encode, synchronization quality, or route quality. diff --git a/docs/方案设计.md b/docs/方案设计.md index 9e9d9cf..39cfe05 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -176,7 +176,7 @@ Host 本地预览直接绑定现有 capture stream,不创建 viewer、`RTCPeer Web peer 的最小 stereo 契约只改 Viewer 生成的 answer。实现把 MIT `sdp-transform` 2.15.0 与 `@types/sdp-transform` 声明为 root direct dependencies,不依赖 LiveKit 的 transitive install,也不用字符串或正则拼接 SDP。Helper 仅在解析出唯一非 rejected audio media、通过结构化 `rtp` entry 找到唯一 Opus payload 时,才在该 payload 的 fmtp 幂等 upsert `stereo=1` 与 `maxaveragebitrate=256000`;随后 `ViewerPeer` 把同一个结果交给 `setLocalDescription()` 和 signaling。首次连接、ICE restart、同 parent rebuild、浏览器 relay child 与 selected-edge TURN 都走该边界;TURN 只改变 ICE transport。空 SDP、无或歧义 Opus、多个有效 audio、重复目标参数、畸形 fmtp、parse/write 异常都按对象 identity 返回浏览器原 answer,保住仍可继续的 mono/video edge,不发送半修改 SDP。RFC 7587 说明这两个值都是可选的单向 receiver parameters;`maxaveragebitrate` 是 maximum 而非最低保证,实际发送仍可随拥塞下降。当前 libwebrtc 把缺省 full-band stereo codec bitrate 配成 64 kbps,而 `RTCRtpSender.maxBitrate` 只限制上界;本接收上限允许三档,实际发送预算仍由 64/128/256 kbps sender ceiling 与拥塞共同决定。`sprop-stereo` 只是 sender likelihood hint,`usedtx` 缺省已是 0,本 slice 不增加二者,也不改浏览器协商的 `useinbandfec`。 -SFU publisher 将 64 kbps 映射到 `AudioPresets.musicStereo`、128 kbps 映射到 `musicHighQualityStereo`,256 kbps 使用显式 `maxBitrate`;三者都保留 `forceStereo: true` 与 `dtx: false`。LiveKit 2.22.0 先合并 room publish defaults,其中 `red: true`;本实现不传 `red` 覆盖值,保留既有 RED request。RED 与 Opus in-band FEC 是不同修复机制,保留前者不声称实际启用、替代或等价于后者。只改 SFU 会导致 route switch 行为分叉,因此 P2P/browser relay 与 SFU 必须同一 slice 落地。Viewer 继续在同一个持久 `MediaStream`/媒体元素中增删 screen video/audio track;不新建 audio element、Web Audio mix/resample graph、第二 audio representation 或独立音频时钟。默认每条 outbound edge 的 audio RTP payload ceiling 为 128 kbps,显式最高档为 256 kbps;cap2 节点对应最多 256/512 kbps(不含 RTP/SRTP/UDP/IP overhead)。stereo 仍可能增加编码与网络成本,且浏览器 relay 不保证跨 PeerConnection shared encode。未来 microphone voice 若进入范围,另建独立 source/track 与 AEC、降噪、gain、DTX 设计,不能作用于电影/游戏屏幕音频。详细证据与 Oopz 未知边界见 `docs/research/browser-screen-audio-quality.md`。 +SFU publisher 将 64 kbps 映射到 `AudioPresets.musicStereo`、128 kbps 映射到 `musicHighQualityStereo`,256 kbps 使用显式 `maxBitrate`;三者都保留 `forceStereo: true` 与 `dtx: false`。LiveKit 2.22.0 先合并 room publish defaults,其中 `red: true`;本实现不传 `red` 覆盖值,保留既有 RED request。RED 与 Opus in-band FEC 是不同修复机制,保留前者不声称实际启用、替代或等价于后者。只改 SFU 会导致 route switch 行为分叉,因此 P2P/browser relay 与 SFU 必须同一 slice 落地。Viewer 继续在同一个持久 `MediaStream`/媒体元素中增删 screen video/audio track;不新建 audio element、Web Audio mix/resample graph、第二 audio representation 或独立音频时钟。默认每条 outbound edge 的 audio RTP payload ceiling 为 128 kbps,显式最高档为 256 kbps;Host2 对应最多 256/512 kbps,ordinary Browser Viewer1 对应最多 128/256 kbps(不含 RTP/SRTP/UDP/IP overhead)。stereo 仍可能增加编码与网络成本,且浏览器 relay 不保证跨 PeerConnection shared encode。未来 microphone voice 若进入范围,另建独立 source/track 与 AEC、降噪、gain、DTX 设计,不能作用于电影/游戏屏幕音频。详细证据与 Oopz 未知边界见 `docs/research/browser-screen-audio-quality.md`。 Viewer 本地连接详情只从当前 route 的唯一 inbound screen video/audio stats 读取同步证据:`audio estimatedPlayoutTimestamp - video estimatedPlayoutTimestamp` 表示当前发送端时间线差;音视频 jitter-buffer delay 取相邻窗口累计 delay/count 的比值;音频 concealment 取相邻窗口 concealed/total sample 与 event 增量。每个媒体种类用 stats id、SSRC 和 track identifier 共同守住区间身份,换流或累计计数回退时先重新基线,字段缺失或分母为零时保持未知。该证据不进入 signaling、质量上报或持久化,也不设置 `jitterBufferTarget`、Web Audio 或任何媒体控制参数;真实同步仍由同一持久 `MediaStream`/媒体元素和 WebRTC playout 负责。 @@ -286,7 +286,7 @@ Coturn bearer 在 TTL 内不可撤销,HMAC 只证明 expiry 与 secret;应 收到和使用 rebuild,但不能宣称 coturn 对 room/edge 做了密码学绑定。 SFU 正常只作为 virtual parent 向一至两个必要 roots 发布;可靠 root 收到 -媒体后继续服务 sticky descendants。当前 host/native/Web root 或 relay 最多两条下游媒体边。无法挂到健康 root 的极端 viewers 只能 +媒体后继续服务 sticky descendants。当前 Host 最多两条下游 physical media edges,普通 Browser Viewer/root/relay 最多一条;上游接收不计预算。无法挂到健康 root 的极端 viewers 只能 通过独立 `E`/central-egress cap 准入。普通 SFU 终止两侧 DTLS-SRTP;没有 应用层 E2EE 时 operator 可接触媒体,这一产品取舍必须保持可见。所有物理 hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)逐项计量。 @@ -393,17 +393,17 @@ bounded controller,且 room `1` 曾作为 exact smoke;下列内容保留为 STUN-only;只有 controller-selected edge 可接收一次短期 grant。该边界不增加 UI、 用户选择或百分比框架。 -产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底、最后 selected-edge TURN 的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。Production 已把普通 ICE 收敛为 STUN-only、删除旧全房 TURN wire,并为所有正常房间配置同一 controller 和 replacement selected-edge tuple;旧 `PEER_ICE_TURN_*` 配置启动失败。公网 SFU media、selected relay 与资源仍未验收,旧 release/coturn 仅作回滚。ADR-0004/0005 的媒体验收尚未完成。 +当前 release policy 的硬目标是 Host 任何时刻最多两条下游 physical media edges、普通 Browser Viewer 最多一条;普通 upstream receive 不计上传预算。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底、最后 selected-edge TURN 的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。Production 已把普通 ICE 收敛为 STUN-only、删除旧全房 TURN wire,并为所有正常房间配置同一 controller 和 replacement selected-edge tuple;旧 `PEER_ICE_TURN_*` 配置启动失败。公网 SFU media、selected relay 与资源仍未验收,旧 release/coturn 仅作回滚。ADR-0004/0005 的媒体验收尚未完成。 恢复目标按证据和成本逐级升级:短暂 `disconnected` 先观察同代 bytes/stats,`failed` 才执行一次 ICE restart;仍失败时重建一次相同 parent,再尝试不同的合格 peer parent,peer 候选耗尽后才申请 SFU。每个动作最多一次且成功即停,不实现“三次相同重试”。初始 `connecting`/`checking` 的软提示和硬窗口尚未实现;硬窗口必须先用移动网络实测确定,不能直接复用现有 3 秒恢复常量。Viewer SFU root 只刷新一次短期 grant,随后至多重建一次原 failed parent selected edge;Host publisher/global SFU failure 维持 bounded peer failback。Host 只保留当前 route revision 的 `connect | source | video-publish | sender-config | audio-publish | transport` 枚举阶段作为本地事件提示,不上传原始错误、URL、token、candidate 或地址。 -当前 source candidate 实现一个由服务端确定的广度优先活动拓扑:host 与普通 Web viewer 都最多两个 child。服务器可在多个合格 parent 中选择,但每名 viewer 同时只有一个 active upstream,活动媒体图保持无环。新 viewer 加入或容量释放时只接纳等待节点;节点断开或当前 edge 失败时只重新分配失去 parent 的直接 subtree root,其 descendants 保持连接。移除有两个 child 的 relay 时两个直接 roots 分别重新分配。客户端先关闭不再分配的旧 edge,再建立替代 edge;每次变更推进 route revision,stale signal/recovery 不得复活旧边。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡、多源接收或持续优化。没有 parent 时明确等待或拒绝,任一 endpoint 不能创建第三条边。 +当前 source candidate 实现一个由服务端确定的广度优先活动拓扑:Host 最多两个 child,普通 Browser Viewer 最多一个 child。服务器可在多个合格 parent 中选择,但每名 viewer 同时只有一个 active upstream,活动媒体图保持无环。新 viewer 加入或容量释放时只接纳等待节点;节点断开或当前 edge 失败时只重新分配失去 parent 的直接 subtree root,其 descendants 保持连接。客户端先关闭不再分配的旧 edge,再建立替代 edge;每次变更推进 route revision,stale signal/recovery 不得复活旧边。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡、多源接收或持续优化。没有 parent 时明确等待或拒绝,Host 不能创建第三条边,Browser Viewer 不能创建第二条边。 -当前固定 `downstreamCapacity=2` 让八名观看者的确定性广度优先 DAG 最大深度降到三;两棵深度四二叉子树理论可容纳 30 人,但这只是可达性,不能证明 20 人能力。目标 20 人仍必须实测逐跳重编码、上行、延迟和故障半径,或使用受独立中央 egress cap 约束的 SFU 例外。未通过时保持当前默认八人,不增加深度或 Host fanout 来凑容量。 +当前 Host2/Browser Viewer1 形成两条有界转发链;它满足发布资源边界,但不证明八人质量、更不证明 20 人能力。目标 20 人仍必须实测逐跳重编码、上行、延迟和故障半径,或使用受独立中央 egress cap 约束的 SFU 例外。未通过时保持当前默认八人,不增加 Host fanout 或绕过 Viewer 上限来凑容量。 -所有边都继续使用标准 WebRTC。每名 viewer 播放收到的 remote `MediaStream`,并把同一组 remote tracks 加入最多两个 child 各自独立的 `RTCPeerConnection`。该路径会逐跳解码并为每个 outbound sender 重编码;实验必须记录每层 encode time、CPU/GPU、`qualityLimitationReason`、帧率、分辨率和延迟,不能称为 zero-copy 或 shared encode。 +所有边都继续使用标准 WebRTC。每名普通 Browser Viewer 播放收到的 remote `MediaStream`,并可把同一组 remote tracks 加入最多一个 child 的独立 `RTCPeerConnection`。该路径会逐跳解码并为 outbound sender 重编码;实验必须记录每层 encode time、CPU/GPU、`qualityLimitationReason`、帧率、分辨率和延迟,不能称为 zero-copy 或 shared encode。 -部署参数 `MAX_PEER_RELAY_DOWNSTREAM_EDGES` 默认 `2`、允许 `1/2/3`,统一约束 Host、Viewer、SFU publication 与 selected-edge overlay;客户端绝对能力为三,服务端按部署值钳制,SFU root 预算仍独立最多二。动态 evidence-driven capacity 仍须独立验收,不按 device/UA 分支,并由持续 sender bitrate、`availableOutgoingBitrate`、区间 encode time、`qualityLimitationReason`、loss/RTT 健康窗与 cooldown 共同限界。H.264、hardware encode 或 shared encode 可降低编码成本或同观感码率,但每个 child 的网络副本仍独立存在。 +部署参数 `MAX_PEER_RELAY_DOWNSTREAM_EDGES` 默认 `2`、只允许 `1/2`,是 release policy 的进一步收紧项:服务端有效 Host 上限为 `min(config, 2)`,普通 Browser Viewer 上限为 `min(config, 1)`。Host 的 active children、SFU publication、provisional/selected physical overlays 共用 Host 预算;Viewer 的 active child 与 provisional/selected physical overlays 共用 Viewer 预算,上游接收免费。旧/恶意 Viewer 广告 `2/3` 仍只能得到一条。Web assignment 执行层也用同一组当前 Host2/Viewer1 named constants 截断 child IDs,再创建 active、provisional 或 selected PC;这只提供客户端 defense-in-depth,服务端仍是路由权威。部署收紧到 `1` 时,SFU fallback 必须先把占用 Host 唯一 peer slot 的必要 branch 提升为 root,再建立占一条预算的 Host publication;root 预算独立且仍最多二。wire 不升级,`relay-capacity` 的 `0/1/2/3` 仅保留为未来 capability envelope;当前 Web client 广告 `1`。动态 evidence-driven capacity 仍须独立验收,不按 device/UA 分支,并由持续 sender bitrate、`availableOutgoingBitrate`、区间 encode time、`qualityLimitationReason`、loss/RTT 健康窗与 cooldown 共同限界。H.264、hardware encode 或 shared encode 可降低编码成本或同观感码率,但每个 child 的网络副本仍独立存在。 Peer-assisted 信令只接受一个严格对象:`resolution` 为 `720p | 1080p | 1440p`,`maxFramerate` 为 15 至 60 的整数,`maxBitrate` 为 2,000,000 至 12,000,000 的整数,`degradationPreference` 为 `maintain-resolution | balanced | maintain-framerate`,可选 `videoCodec` 为 `automatic | h264 | vp8`,可选 `screenAudioQuality` 为 `saver | music | very-high`,其余字段和越界值拒绝。服务端用房间内存 Map 保存完整设置,默认 1080p60、8 Mbps、`balanced`、`automatic`、`music`(128 kbps),旧的缺失音频字段也解析为该默认,不写 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一设置;本 wire 使用 `screener-v5`,普通 P2P 严格鉴权结构不变。SFU publisher 复用同一设置。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 4ad3480..5fa7a1f 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -24,7 +24,7 @@ - [ ] 观看端本地音量:播放控制栏提供 `0%` 至 `100%` 音量条和静音按钮,只改变当前浏览器的媒体元素,不修改 Host 音轨、编码、房间状态或其他观看者。切流、换父和 ICE 恢复保留当前页面内的值;刷新后不持久化。静音保留上次非零音量,拖动到非零值自动取消静音。 - [ ] 本地昵称与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名,界面统一称为“昵称”;默认以只读文本显示,铅笔编辑按钮紧贴昵称,点击后才进入编辑。Viewer 顶部标题直接显示“{Host 名称} 的屏幕”,不得在下方重复一行 Host 名称。信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Web Host 与 Viewer 都必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份默认只显示名称;仅当房内出现同名时追加当前房间 `peerId` 的至少六字符短后缀,并只在同名项后缀碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称;Native 使用同一版本化鉴权 wire,但不发送 presence capability,也不接收 Web roster。 - [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。 -- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。普通 Web relay 统一声明最多两个 child,不按 UA、设备类型或页面可见性另建容量策略;实际 edge 失败进入同一有界换父路径。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。 +- [ ] 端点 fanout 硬上限:当前 release 的 Host 任何时刻最多维护两条 active downstream media edges,普通 Browser Viewer 最多一条;普通 upstream receive 不消耗上传预算。服务端按已鉴权角色执行有效上限,旧版或恶意 Web Viewer 即使广告 `relay-capacity=2/3` 也只能得到一条,部署配置只能把 Host/Viewer 分别从 `2/1` 进一步收紧,不能提高上限。Web 客户端还必须在执行服务端 assignment、建立物理 PC 前按当前角色截断为 Host2/Viewer1;这是服务端回归或版本错配时的 defense-in-depth,不把 wire envelope 变成执行许可。预算计入 active、provisional 和 selected physical overlays,Host 的 active SFU publication 也占一条;不能用 logical replacement 名义临时超额。不按 UA、设备类型或页面可见性另建容量策略;实际 edge 失败进入同一有界换父路径。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。 - [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。 - [ ] NAT 与传输兜底:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers。PC 失败后按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU/UDP virtual parent 的顺序恢复,任一步成功即停止;只有该 edge 的 SFU/UDP 也失败后,controller 才可授权一次短期 authenticated selected-edge TURN/UDP rebuild。授权必须绑定 room/share generation、viewer/parent session、签发时 route revision 与 old/new connection identity;answer 后只在 exact edge/session/share 与 parent downstream budget 仍成立时跨无关 route revision carry,且 carry 必须先于新 active authority。失败、超时或任一绑定/预算失效即撤销;同一房间同时 pending 或 active 的 `peer-selected` attempt 总数最多为一,`host-sfu-ingress` 使用独立状态且不占该 last-mile 名额。不得按 participant、房间或普通 peer PC 下发,source-only Native 保持 STUN-only。凭据不进入 URL、日志、浏览器持久化或 SQLite。Coturn 只验证 HMAC 与 expiry,应用必须重验 edge 代次并用短 TTL、one-use state 与 quota 限界。媒体路径保持 UDP-only;全部 UDP 路径耗尽后明确失败。HTTPS/WSS 仍独立使用 TLS/TCP。 - [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,三档与高级设置初始值统一采用 `balanced`(平衡)与 `automatic`(自动编码),`maintain-resolution`(清晰优先)、`maintain-framerate`(流畅优先)和 H.264/VP8 保留为显式选择;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接,codec 在直播中锁定并于下一次分享生效。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,具体降级由浏览器决定;必须读回实际 sender 参数并通过 stats 观测结果、显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。 @@ -45,9 +45,9 @@ - [ ] 质量控制扩展:推荐档位仍是默认入口;“高级视频设置”只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling、清晰/平衡/流畅三种偏好,以及 `自动 | H.264 | VP8` 编码选择;初始值采用 `balanced` 与 `automatic`,其余偏好和编码保留为显式选择。视频档位在直播中显式应用;codec 直播中锁定并于下一次分享生效,避免滑杆产生连续控制流量。本期不重命名该区域,也不增加音频 UI:屏幕音频固定使用一个媒体默认,所有实际浏览器 sender 请求并读回 128 kbps ceiling,DTX 关闭,Web peer 与 SFU 都显式允许 Opus stereo 并协商 128 kbps maximum average bitrate;实际码率仍由浏览器拥塞控制和 stats 决定。Peer 只在唯一明确的 active Opus audio 上做结构化、幂等的 Viewer answer `stereo=1;maxaveragebitrate=128000` 变换;非目标形态或 parser/writer 异常必须原样保留浏览器 answer,不得阻断可继续的 mono/video edge。不得用宽泛字符串/正则改 SDP,也不得改写浏览器协商的 FEC。SFU 使用 pinned SDK 的 128 kbps high-quality stereo preset、`forceStereo = true`、`dtx = false`,并保留 pinned room default `red = true`。不得暴露声道、采样率、codec、任意码率、stereo、DTX、RED 或 FEC 控件。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。未来 microphone voice 若进入范围,必须是独立 track/处理链,不能把 AEC/降噪/DTX 套到电影或游戏音频。 - [ ] SFU 质量设置生命周期:直播中修改档位时,浏览器 sender 的 applied readback、LiveKit `LocalVideoTrack` 保存的 degradation preference、track restart options 与 `LocalTrackPublication` 的 republish options 必须作为同一次串行操作更新;任一步失败恢复旧 sender 与旧 SDK 保存态,旧 publication generation 不得回写当前状态。LiveKit full reconnect/republish 不得恢复初次发布的旧偏好。该修复不改变 codec、码率上限、层数或路由,也不能单独解释即时低帧。 - [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。 -- [ ] Peer-assisted 媒体路径:分享端与每名普通 Web viewer 都最多两个 child。信令服务维护可从多个合格节点选择 parent 的候选图,但每名 viewer 任一时刻只有一个 active upstream,活动媒体图保持无环;只在新加入、容量释放、节点断开或当前 edge 失败时按深度和服务端加入序号做有界分配或换父,不加入综合评分、主动重平衡、多源媒体或持续优化。每个 viewer 将收到的标准 remote track 通过最多两个新的 `RTCPeerConnection` 转发,因此会逐跳解码和分别重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。 +- [ ] Peer-assisted 媒体路径:当前 release 的分享端最多两个 child,每名普通 Browser Viewer 最多一个 child。信令服务维护可从多个合格节点选择 parent 的候选图,但每名 viewer 任一时刻只有一个 active upstream,活动媒体图保持无环;只在新加入、容量释放、节点断开或当前 edge 失败时按深度和服务端加入序号做有界分配或换父,不加入综合评分、主动重平衡、多源媒体或持续优化。每个 viewer 将收到的标准 remote track 通过最多一个新的下游 `RTCPeerConnection` 转发,因此会逐跳解码和分别重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。 - [ ] 原生 shared encode 分享端:后续独立的 Electron/原生分享端必须让每个 active representation 只产生一份兼容编码结果,并在 direct、peer 与 SFU transport 间复用;健康路径选择共享 `HIGH`,最多另有一份弱路径共用的 `LOW`,是否常驻由硬件、游戏和上传门槛决定。每条连接仍保留独立 WebRTC packetization、SSRC、pacing、加密和反馈,且各自消耗上传带宽。浏览器不保证跨 `RTCPeerConnection` 复用编码器,因此当前 Web spike 不能宣称满足该保证。 -- [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样最多维护两条下游媒体边,并应复用兼容编码输出而不再次编码。普通浏览器 audience 使用当前最多两条下游、逐边重编码的标准 WebRTC 路径,不伪称 shared encode。 +- [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样不得超过其后续单独验收的服务端角色上限,并应复用兼容编码输出而不再次编码。当前普通浏览器 audience 只允许一条下游、使用逐边重编码的标准 WebRTC 路径,不伪称 shared encode。 - [ ] SVC 重开条件:当前标准 Web/LiveKit SVC 已静态拒绝为 `no-go-web-svc-cross-path-hardware-contract`。sender `scalabilityMode` 没有 direct/peer receiver 对应的按层选择或跨 PeerConnection shared-encode 契约;requested/applied mode、Media Capabilities `powerEfficient` 和 RTCStats encoder 字段也不能证明没有静默软件 fallback。Pinned client 2.22.0 还会把 screen-share SVC 覆盖为三层 temporal-only `L1T3`,不满足低分辨率 base 与最多两层。只有未来 native sender 能明确选择硬件编码器、最多发布两层、将同一编码输出复用于 direct/peer/SFU、保持每条路径独立选择并通过真实游戏和亚秒延迟门槛时,才可用新的 capability decision 重开 SVC;不能把浏览器 API 支持或 UA 推断当成采用理由。 - [ ] 分包/分层多树实验:把编码包、对象或可独立解码层拆到多个 peer 路径以分散上传,只能在独立 spike 中验证。必须保持亚秒级,处理多父重组、排序、丢包恢复、关键帧、同步和节点退出;未通过门槛前不进入产品路由控制器。 - [ ] SFU-root 中央兜底:产品部署目标必须提供经 canary 验收的单节点 SFU 资源;系统在 direct/peer UDP 不可用或明确拓扑预算无法接纳时自动把它作为主要中央兜底,不要求用户选模式。它只向总计一至两个必要 roots 出口;root 优先承担受限 peer descendants,没有可靠 relay root 时必要 viewer 只作为同一总上限内的零 descendants root。已关闭 PR #12 的显式整房 SFU 模式已被取代。room `1` 的历史 smoke 没有 retained SFU media;公网媒体、资源和恢复门禁仍适用于全站路由。 @@ -60,7 +60,7 @@ ### P2 - 可选功能 -- [ ] 证据驱动的统一 relay capacity:当前上线档固定为 `2`,不是永久产品上限。后续只在独立验收后允许所有 Web relay 使用同一套离散 `0/1/2/3` 容量状态,不按设备或 UA 分类;持续 sender bitrate、`availableOutgoingBitrate`、区间 encode time、`qualityLimitationReason`、loss 与 RTT 健康窗共同决定升降,服务端 deployment hard ceiling 与 cooldown 继续限界。H.264、hardware encode 或 shared encode 可以降低编码成本或同观感码率,但每个 child 仍消耗一份网络副本。本次 slice 不实现动态控制。 +- [ ] 证据驱动的统一 relay capacity:当前 ordinary Browser Viewer 上线档固定为 `1`。协议保留离散 `0/1/2/3` 作为未来能力 envelope,不代表当前发布授权;后续只有独立验收并修改服务端 release policy 后才能使用更高档,不按设备或 UA 分类。持续 sender bitrate、`availableOutgoingBitrate`、区间 encode time、`qualityLimitationReason`、loss 与 RTT 健康窗可为未来升降提供证据,服务端 deployment hard ceiling 与 cooldown 继续限界。H.264、hardware encode 或 shared encode 可以降低编码成本或同观感码率,但每个 child 仍消耗一份网络副本。本次 slice 不实现动态控制。 - [x] 原生 Windows 捕获辅助程序:显式 `native-window-h264` 模式使用同一 bound window target 的 WGC/D3D11 画面与 WASAPI process-tree PCM,并用 adapter-bound、hardware-only Media Foundation H.264 `42c01f` 直接进入现有 Go fanout;普通 Web Host 独立采用默认 `automatic`、可显式选择 H.264/VP8 的标准协商路径,不允许运行时静默改用软件编码、显示器画面或系统混音。一名普通 Chrome Viewer 已 decoded/rendered 203 帧并收到 500 Opus packets,同一 helper PID/adapter 的 `VideoEncode` 与 LUID 匹配。 - [ ] Viewer 向电视输出:系统级屏幕镜像现在可直接使用;产品只复用平台 AirPlay/Cast,不自造投屏协议。截至 2026-08-21,Chromium 的 Remote Playback URL 路径和 WebKit 明确拒绝 wireless target 的 live `MediaStream` player 均未给当前 WebRTC `srcObject` 提供可用契约,因此不显示伪可用入口。只在浏览器实现明确支持并由实体 Apple TV 或 Chromecast/Google TV 证明当前 live 画面与音频后,才按通过的平台加入 Viewer 本地图标按钮。投屏不能改变 room、TURN/SFU/peer route、Host fanout 或服务端媒体分发;Google Cast receiver 需要可获取的媒体 URL,当前 P2P `MediaStream` 不满足该输入,因此不加入 Cast SDK/receiver。 - [ ] 多地区 SFU-root 调度,以及可选 TURN 兼容层的按地区、运营商可达率。 @@ -130,14 +130,14 @@ ### 一对多与带宽保护 - Given 一名分享者和一至两名观看者,When 运行直接 P2P,Then 分享端为每人建立独立媒体边且总 fanout 不超过二。 -- Given peer-assisted 路径分别运行 1、3、5、8 名观看者,When 用户加入、容量释放、节点离开或当前 edge 失效,Then 服务端使用同一个确定性广度优先规则,只为新 viewer、等待 viewer 或失去 parent 的 subtree root 选择一个 active parent;其余健康边保持 sticky。分享端与每名 viewer 在每个瞬时采样都不超过两个 child,移除有两个 child 的 relay 会分别重新分配两个直接 subtree roots,第三个 child 不建立媒体边;除此之外没有可用 parent 时明确等待或拒绝,不主动重平衡、引入多源媒体或增加分享端 fanout。 +- Given peer-assisted 路径分别运行 1、3、5、8 名观看者,When 用户加入、容量释放、节点离开、重鉴权或当前 edge 失效,Then 服务端使用同一个确定性广度优先规则,只为新 viewer、等待 viewer 或失去 parent 的 subtree root 选择一个 active parent;其余健康边保持 sticky。Host 在每个瞬时采样都不超过两个 physical media edges,普通 Browser Viewer 不超过一个 child;已有 Viewer child 在重鉴权/reconcile 后继续占用其唯一预算,第二个 child 不建立。没有可用 parent 时明确等待或拒绝,不主动重平衡、引入多源媒体或增加分享端 fanout。 - Given 一名分享者和八名观看者,When 在受控网络中运行已记录的质量档位,Then 记录每条边的拓扑深度、候选类型、码率、编码/解码负载、分享端与 relay 上行、首帧和玻璃到玻璃延迟;该条在真实矩阵完成前保持未验收。 - Given candidate 将房间默认上限改为 20,When 在历史 room-1 smoke fixture 或其他正常房间中连续运行 20 名观看者 30 分钟并注入 relay departure、重连和一个弱路径,Then 每名观看者持续解码、Host 下游边不超过二、每个 relay 不超过已验收能力、深度与故障半径有界、未受影响 subtree 不迁移,并记录端点/SFU 的 CPU、GPU、内存、上行、出口、帧率、丢包率、冻结和恢复;任一门槛失败时不得切换默认值。 - Given peer-assisted host 鉴权或直播中连续修改质量设置,When 服务端校验其分辨率、FPS、码率和偏好,Then 只有 host 的完整有界设置被接受,last-wins 地应用到当前和以后建立的 relay child sender 以及启用的 SFU publisher;越界、缺字段和多余字段被拒绝,普通 P2P 的鉴权 wire 不新增该字段且不接受同步消息。 - Given 标准浏览器把收到的 remote track 直接加入下游 `RTCPeerConnection`,When 检查 relay 统计和 libwebrtc 路径,Then 明确记录新的 outbound encoder、编码耗时和 CPU/GPU 限制,不把它当作 encoded forwarding 或 shared encode 成果。 - Given ADR-0004 的任一硬门槛只有在引入 Encoded Transform/DataChannel/WebCodecs 媒体、自定义拥塞控制、FEC/RTX、多树、综合评分、转码、codec ladder、浏览器 RTP 注入 hack 或放宽 host fanout 后才能满足,When 结束 bounded spike,Then 放弃并删除浏览器 relay 实验运行时代码,保留标准 P2P 和 SFU-root fallback;后续 native shared encode sender 仍按独立 ADR 推进,但不能据此把失败的 relay 拓扑判为通过。 - Given 新观看者会使预计总上行超过安全预算,When 其请求加入,Then 系统先遵守两边 fanout 预算选择 peer-assisted 或已配置的 SFU;没有合格路线时明确等待或拒绝,而不是创建第三条 host 边或持续堆积发送队列。Direct/peer GCC 与 SFU BWE 各自适应该路径,应用证据只改变拓扑资格,不能合成全房降档。 -- Given TURN 或 SFU 被某条路线使用,When 媒体已抵达一至两个可靠 root,Then 其余可行 viewers 仍由 sticky、受深度与故障半径限制的 peer descendants 服务;host/root 和当前 browser relay fanout 均不超过二,中央服务和任一 endpoint 都不得无界集中 fanout。 +- Given TURN 或 SFU 被某条路线使用,When 媒体已抵达一至两个可靠 root,Then 其余可行 viewers 仍由 sticky、受深度与故障半径限制的 peer descendants 服务;Host 的 children、SFU publication、provisional/selected overlays 合计不超过二,普通 Browser Viewer 的 children 与 provisional/selected overlays 合计不超过一,中央 SFU roots 仍不超过二。 - Given 没有任何满足能力、深度、路径和恢复门槛的可靠 relay root,When 控制器穷尽 peer recovery,Then SFU 可把必要 viewers 作为零-descendant roots;正常仍限一至二 roots,多个异常 viewers 只能使用另行配置的中央 egress cap,否则明确等待或失败,不得变成无界整房 SFU。 ### 质量和恢复 diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index ccc9c83..aba698b 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -7,8 +7,9 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import WebSocket from "ws"; import { + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + CURRENT_HOST_MEDIA_EDGE_LIMIT, DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, - MAX_PEER_RELAY_DOWNSTREAM_EDGES, MAX_VIEWERS_PER_ROOM_LIMIT, type ParticipantRouteAssignment, } from "../src/shared/protocol"; @@ -373,11 +374,11 @@ export function parseExpectedEndpointCap(value: string | undefined): number { : DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES; if ( !Number.isSafeInteger(parsed) || - parsed < DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES || - parsed > MAX_PEER_RELAY_DOWNSTREAM_EDGES + parsed < 1 || + parsed > CURRENT_HOST_MEDIA_EDGE_LIMIT ) { throw new Error( - `BENCHMARK_EXPECTED_ENDPOINT_CAP must be an integer from ${DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES} to ${MAX_PEER_RELAY_DOWNSTREAM_EDGES}`, + `BENCHMARK_EXPECTED_ENDPOINT_CAP must be an integer from 1 to ${CURRENT_HOST_MEDIA_EDGE_LIMIT}`, ); } return parsed; @@ -846,10 +847,10 @@ export function buildRunChecks( profileId: ProfileId, expectedEndpointCap = DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, ): RunCheck[] { - const endpointCapObserved = - summary.maxHostAssignedChildren === expectedEndpointCap && - (viewerCount < expectedEndpointCap * 2 || - summary.maxRelayActiveMediaEdges === expectedEndpointCap); + const expectedViewerCap = Math.min( + expectedEndpointCap, + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + ); const sfuConsistencyCheck: RunCheck = summary.sfuPublicationObserved ? { name: "sfu-route-consistency", @@ -879,21 +880,10 @@ export function buildRunChecks( }, { name: "relay-active-media-edges", - passed: summary.maxRelayActiveMediaEdges <= expectedEndpointCap, + passed: summary.maxRelayActiveMediaEdges <= expectedViewerCap, actual: summary.maxRelayActiveMediaEdges, - expected: `<= ${expectedEndpointCap}`, + expected: `<= ${expectedViewerCap}`, }, - ...(expectedEndpointCap > DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES && - viewerCount >= expectedEndpointCap - ? [ - { - name: "endpoint-cap-observed", - passed: endpointCapObserved, - actual: endpointCapObserved, - expected: `${expectedEndpointCap} Host children and required relay fanout`, - }, - ] - : []), { name: "all-viewers-decoded", passed: summary.everyViewerDecoded, @@ -952,6 +942,9 @@ export function buildBenchmarkInitScript(options: { return `(() => { if (globalThis.__SCREENER_BENCHMARK__) return; const options = ${serialized}; + const expectedRoleCap = options.role === "host" + ? options.expectedEndpointCap + : Math.min(options.expectedEndpointCap, ${CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT}); if (options.clearHostRoom) { try { localStorage.removeItem("screener:host-room:v1"); } catch {} } @@ -1005,7 +998,7 @@ export function buildBenchmarkInitScript(options: { } if ( !Array.isArray(value.childPeerIds) || - value.childPeerIds.length > options.expectedEndpointCap || + value.childPeerIds.length > expectedRoleCap || !value.childPeerIds.every(isOpaqueId) || new Set(value.childPeerIds).size !== value.childPeerIds.length || (value.sfuPublicationGeneration !== null && diff --git a/src/client/media/relay-capability.ts b/src/client/media/relay-capability.ts index fc53aa2..d826e4a 100644 --- a/src/client/media/relay-capability.ts +++ b/src/client/media/relay-capability.ts @@ -1,5 +1,5 @@ import { - MAX_PEER_RELAY_DOWNSTREAM_EDGES, + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, type ClientMessage, } from "../../shared/protocol"; @@ -16,6 +16,6 @@ export function relayCapacityMessageForBrowser( } return { type: "relay-capacity", - downstreamEdges: MAX_PEER_RELAY_DOWNSTREAM_EDGES, + downstreamEdges: CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, }; } diff --git a/src/client/webrtc/media-assignment.ts b/src/client/webrtc/media-assignment.ts index 8d32f35..96d1553 100644 --- a/src/client/webrtc/media-assignment.ts +++ b/src/client/webrtc/media-assignment.ts @@ -3,10 +3,14 @@ import type { MediaAssignment, SignalPayload, } from "../../shared/protocol"; -import { MAX_PEER_RELAY_DOWNSTREAM_EDGES } from "../../shared/protocol"; +import { + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + CURRENT_HOST_MEDIA_EDGE_LIMIT, +} from "../../shared/protocol"; -export const MAX_HOST_MEDIA_CHILDREN = MAX_PEER_RELAY_DOWNSTREAM_EDGES; -export const MAX_VIEWER_MEDIA_CHILDREN = MAX_PEER_RELAY_DOWNSTREAM_EDGES; +export const MAX_HOST_MEDIA_CHILDREN = CURRENT_HOST_MEDIA_EDGE_LIMIT; +export const MAX_VIEWER_MEDIA_CHILDREN = + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT; export function limitMediaAssignment( assignment: MediaAssignment, diff --git a/src/server/config.ts b/src/server/config.ts index 732b93e..01c303f 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,7 +1,7 @@ import { + CURRENT_HOST_MEDIA_EDGE_LIMIT, DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, MAX_ICE_SERVER_URLS, - MAX_PEER_RELAY_DOWNSTREAM_EDGES, MAX_VIEWERS_PER_ROOM_LIMIT, stunUrlSchema, turnUrlSchema, @@ -341,7 +341,7 @@ export function loadConfig( DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, "MAX_PEER_RELAY_DOWNSTREAM_EDGES", 1, - MAX_PEER_RELAY_DOWNSTREAM_EDGES, + CURRENT_HOST_MEDIA_EDGE_LIMIT, ); const livekitFallback = parseLiveKitFallback(environment, nodeEnv); const selectedEdgeTurn = parseSelectedEdgeTurn(environment); diff --git a/src/server/hybrid-media-router.ts b/src/server/hybrid-media-router.ts index f4ff74d..702116f 100644 --- a/src/server/hybrid-media-router.ts +++ b/src/server/hybrid-media-router.ts @@ -1,6 +1,8 @@ import { randomBytes } from "node:crypto"; import { + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + CURRENT_HOST_MEDIA_EDGE_LIMIT, MAX_MEDIA_ROUTE_REVISION, type ClientMessage, type MediaAssignment, @@ -230,6 +232,7 @@ export interface AuthenticatedRouteParticipant { export class HybridMediaRouter { private readonly peerRelayTopology: PeerRelayTopology; + private readonly maxHostMediaEdges: number; private readonly mediaRouteControllers = new Map< string, MediaRouteController @@ -286,6 +289,10 @@ export class HybridMediaRouter { this.peerRelayTopology = new PeerRelayTopology( options.maxPeerRelayDownstreamEdges, ); + this.maxHostMediaEdges = Math.min( + options.maxPeerRelayDownstreamEdges, + CURRENT_HOST_MEDIA_EDGE_LIMIT, + ); const fallback = options.sfuFallback; if (options.selectedEdgeTurn && !fallback) { throw new Error("Selected-edge TURN requires SFU fallback"); @@ -982,8 +989,8 @@ export class HybridMediaRouter { const capacity = Math.min( this.peerRelayTopology.getDownstreamCapacity(roomId, parentPeerId), parentPeerId === hostPeerId - ? this.options.maxPeerRelayDownstreamEdges - : 1, + ? this.maxHostMediaEdges + : CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, ); if ( !host || @@ -1004,7 +1011,7 @@ export class HybridMediaRouter { hostPeerId, null, [], - this.options.maxPeerRelayDownstreamEdges, + this.maxHostMediaEdges, ); const rootAssignment = assignments.get(participant.peerId)!; const parentAssignment = assignments.get(parentPeerId)!; @@ -2048,7 +2055,7 @@ export class HybridMediaRouter { hostPeerId, null, [], - this.options.maxPeerRelayDownstreamEdges, + this.maxHostMediaEdges, ); const revision = controller.reconcileBaseline({ assignments, @@ -2083,7 +2090,7 @@ export class HybridMediaRouter { hostPeerId, null, [], - this.options.maxPeerRelayDownstreamEdges, + this.maxHostMediaEdges, ); const revision = controller.reconcileBaseline({ assignments, @@ -2690,7 +2697,7 @@ export class HybridMediaRouter { return "unavailable"; } - if (host.childPeerIds.length > 1) { + if (host.childPeerIds.length > this.maxHostMediaEdges - 1) { const budgetRootPeerId = requestedAssignment.upstream.kind === "peer" ? findHostBranchRoot(active.assignments, failedPeerId, hostPeerId) @@ -2726,7 +2733,7 @@ export class HybridMediaRouter { rootPeerIds.length > fallback.maxRoots || !host || host.childPeerIds.length + 1 > - this.options.maxPeerRelayDownstreamEdges + this.maxHostMediaEdges ) { this.sendError( sourceSessionId, @@ -3321,7 +3328,7 @@ export class HybridMediaRouter { hostPeerId, previousSfu?.publicationGeneration ?? null, rootPeerIds, - this.options.maxPeerRelayDownstreamEdges, + this.maxHostMediaEdges, ); if (!controller && initialRevision === 0 && assignments.size > 1) { initialRevision = 1; @@ -3330,7 +3337,7 @@ export class HybridMediaRouter { if (!controller) { controller = new MediaRouteController({ hostPeerId, - maxEndpointMediaEdges: this.options.maxPeerRelayDownstreamEdges, + maxEndpointMediaEdges: this.maxHostMediaEdges, assignments, revision: initialRevision, sfuPublicationGeneration: diff --git a/src/server/media-route-controller.ts b/src/server/media-route-controller.ts index d1ccf6c..9813580 100644 --- a/src/server/media-route-controller.ts +++ b/src/server/media-route-controller.ts @@ -1,4 +1,6 @@ import { + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + CURRENT_HOST_MEDIA_EDGE_LIMIT, DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, MAX_MEDIA_ROUTE_REVISION, MAX_PEER_RELAY_DOWNSTREAM_EDGES, @@ -61,19 +63,28 @@ export class MediaRouteController { private pendingRoute: StoredPendingRoute | undefined; private latestRevision: number; private readonly hostPeerId: string; - private readonly maxEndpointMediaEdges: number; + private readonly maxHostMediaEdges: number; + private readonly maxViewerMediaEdges: number; constructor(options: MediaRouteControllerOptions) { this.hostPeerId = options.hostPeerId; - this.maxEndpointMediaEdges = + const deploymentMediaEdgeLimit = options.maxEndpointMediaEdges ?? DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES; if ( - !Number.isSafeInteger(this.maxEndpointMediaEdges) || - this.maxEndpointMediaEdges < 1 || - this.maxEndpointMediaEdges > MAX_PEER_RELAY_DOWNSTREAM_EDGES + !Number.isSafeInteger(deploymentMediaEdgeLimit) || + deploymentMediaEdgeLimit < 1 || + deploymentMediaEdgeLimit > MAX_PEER_RELAY_DOWNSTREAM_EDGES ) { throw new Error("Endpoint media edge budget is invalid"); } + this.maxHostMediaEdges = Math.min( + deploymentMediaEdgeLimit, + CURRENT_HOST_MEDIA_EDGE_LIMIT, + ); + this.maxViewerMediaEdges = Math.min( + deploymentMediaEdgeLimit, + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + ); const revision = options.revision ?? 0; assertRevision(revision); this.activeRoute = createRoute( @@ -82,7 +93,8 @@ export class MediaRouteController { options.sfuPublicationGeneration ?? null, options.sfuRootPeerIds ?? [], this.hostPeerId, - this.maxEndpointMediaEdges, + this.maxHostMediaEdges, + this.maxViewerMediaEdges, ); this.latestRevision = revision; } @@ -116,7 +128,8 @@ export class MediaRouteController { : input.sfuPublicationGeneration, input.sfuRootPeerIds ?? this.activeRoute.sfu.rootPeerIds, this.hostPeerId, - this.maxEndpointMediaEdges, + this.maxHostMediaEdges, + this.maxViewerMediaEdges, ); if (hasSameTopology(route, this.activeRoute)) { return undefined; @@ -139,7 +152,8 @@ export class MediaRouteController { input.sfuPublicationGeneration ?? null, input.sfuRootPeerIds ?? [], this.hostPeerId, - this.maxEndpointMediaEdges, + this.maxHostMediaEdges, + this.maxViewerMediaEdges, ); const expectedParticipantIds = new Set(input.expectedParticipantIds); if (expectedParticipantIds.size > MAX_PARTICIPANTS_PER_ROOM) { @@ -207,7 +221,7 @@ export class MediaRouteController { hostActiveMediaEdges(): number { const edgeCount = hostMediaEdges(this.activeRoute, this.hostPeerId); - if (edgeCount > this.maxEndpointMediaEdges) { + if (edgeCount > this.maxHostMediaEdges) { throw new Error("Host active media edge budget exceeded"); } return edgeCount; @@ -227,7 +241,8 @@ function createRoute( publicationGeneration: string | null, rootPeerIds: readonly string[], hostPeerId: string, - maxEndpointMediaEdges: number, + maxHostMediaEdges: number, + maxViewerMediaEdges: number, ): RoomMediaRoute { assertRevision(revision); if ( @@ -253,14 +268,20 @@ function createRoute( rootPeerIds: [...rootPeerIds], }, }; - assertRouteInvariants(route, hostPeerId, maxEndpointMediaEdges); + assertRouteInvariants( + route, + hostPeerId, + maxHostMediaEdges, + maxViewerMediaEdges, + ); return route; } function assertRouteInvariants( route: RoomMediaRoute, hostPeerId: string, - maxEndpointMediaEdges: number, + maxHostMediaEdges: number, + maxViewerMediaEdges: number, ): void { const hostAssignment = route.assignments.get(hostPeerId); if (!hostAssignment) { @@ -290,7 +311,9 @@ function assertRouteInvariants( } for (const [peerId, assignment] of route.assignments) { - if (assignment.childPeerIds.length > maxEndpointMediaEdges) { + const downstreamEdgeLimit = + peerId === hostPeerId ? maxHostMediaEdges : maxViewerMediaEdges; + if (assignment.childPeerIds.length > downstreamEdgeLimit) { throw new Error("Participant active media edge budget exceeded"); } if ( @@ -337,7 +360,7 @@ function assertRouteInvariants( } assertAcyclicPeerEdges(route.assignments); - if (hostMediaEdges(route, hostPeerId) > maxEndpointMediaEdges) { + if (hostMediaEdges(route, hostPeerId) > maxHostMediaEdges) { throw new Error("Host active media edge budget exceeded"); } } diff --git a/src/server/peer-relay-topology.ts b/src/server/peer-relay-topology.ts index aefe25f..86bcf82 100644 --- a/src/server/peer-relay-topology.ts +++ b/src/server/peer-relay-topology.ts @@ -3,6 +3,8 @@ import type { RelayDownstreamEdges, } from "../shared/protocol.js"; import { + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + CURRENT_HOST_MEDIA_EDGE_LIMIT, DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, MAX_PEER_RELAY_DOWNSTREAM_EDGES, } from "../shared/protocol.js"; @@ -30,25 +32,36 @@ interface RelayViewer { interface RelayRoom { hostPeerId?: string; hostChildPeerIds: string[]; - maxDownstreamEdges: RelayDownstreamEdges; + maxHostDownstreamEdges: RelayDownstreamEdges; + maxViewerDownstreamEdges: RelayDownstreamEdges; viewers: Map; nextOrder: number; } export class PeerRelayTopology { private readonly rooms = new Map(); + private readonly maxHostDownstreamEdges: RelayDownstreamEdges; + private readonly maxViewerDownstreamEdges: RelayDownstreamEdges; constructor( - private readonly maxDownstreamEdges: RelayDownstreamEdges = + deploymentDownstreamEdgeLimit: RelayDownstreamEdges = DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES, ) { if ( - !Number.isSafeInteger(maxDownstreamEdges) || - maxDownstreamEdges < 1 || - maxDownstreamEdges > MAX_PEER_RELAY_DOWNSTREAM_EDGES + !Number.isSafeInteger(deploymentDownstreamEdgeLimit) || + deploymentDownstreamEdgeLimit < 1 || + deploymentDownstreamEdgeLimit > MAX_PEER_RELAY_DOWNSTREAM_EDGES ) { throw new Error("Peer relay downstream limit is invalid"); } + this.maxHostDownstreamEdges = Math.min( + deploymentDownstreamEdgeLimit, + CURRENT_HOST_MEDIA_EDGE_LIMIT, + ); + this.maxViewerDownstreamEdges = Math.min( + deploymentDownstreamEdgeLimit, + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, + ); } setHost( @@ -122,7 +135,7 @@ export class PeerRelayTopology { const before = snapshot(room); const boundedDownstreamEdges = Math.min( downstreamEdges, - this.maxDownstreamEdges, + room.maxViewerDownstreamEdges, ); const previousDownstreamEdges = viewer.downstreamEdges; viewer.downstreamEdges = boundedDownstreamEdges; @@ -325,7 +338,8 @@ export class PeerRelayTopology { if (!room) { room = { hostChildPeerIds: [], - maxDownstreamEdges: this.maxDownstreamEdges, + maxHostDownstreamEdges: this.maxHostDownstreamEdges, + maxViewerDownstreamEdges: this.maxViewerDownstreamEdges, viewers: new Map(), nextOrder: 0, }; @@ -505,7 +519,7 @@ function advertisedDownstreamCapacity( peerId: string, ): RelayDownstreamEdges { return room.hostPeerId === peerId - ? room.maxDownstreamEdges + ? room.maxHostDownstreamEdges : (room.viewers.get(peerId)?.downstreamEdges ?? 0); } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 5aa6a5f..e2209b7 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -205,6 +205,10 @@ export const DEFAULT_QUALITY_SETTINGS = { } as const satisfies QualitySettings; export const DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES = 2; +export const CURRENT_HOST_MEDIA_EDGE_LIMIT = 2; +export const CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT = 1; +// The wire retains the accepted future 0/1/2/3 capability envelope. Current +// release policy is enforced server-side by role and is intentionally lower. export const MAX_PEER_RELAY_DOWNSTREAM_EDGES = 3; export const relayDownstreamEdgesSchema = z .number() diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 66148c5..4f7d0da 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -982,7 +982,7 @@ describe("HostPeer source replacement", () => { }); describe("ViewerRelay downstream ownership", () => { - it("promotes the exact prepared child connection without touching active children", async () => { + it("promotes the exact prepared child connection within the current Viewer cap", async () => { const signals: Array<{ peerId: string; connectionId: string }> = []; const relay = new ViewerRelay( { iceServers: [] }, @@ -994,28 +994,13 @@ describe("ViewerRelay downstream ownership", () => { }, }, ); - relay.setChildren(["active-child"]); relay.setStream(createStream(createTrack("video", "prepared-video"), null)); + expect(relay.prepareChild(7, ["prepared-child"])).toBe(true); await vi.waitFor(() => expect(signals).toHaveLength(1)); - const activeConnection = FakePeerConnection.latest!; - activeConnection.connectionState = "connected"; - - expect( - relay.prepareChild(7, ["active-child", "prepared-child"]), - ).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(2)); const preparedConnection = FakePeerConnection.latest!; - const preparedConnectionId = signals[1]!.connectionId; - expect(preparedConnection).not.toBe(activeConnection); + const preparedConnectionId = signals[0]!.connectionId; expect(relay.getSnapshot("prepared-child")).toBeNull(); - expect(FakePeerConnection.activeCount).toBe(2); - expect( - relay.getSignalRouteRevision( - "active-child", - signals[0]!.connectionId, - 6, - ), - ).toBe(6); + expect(FakePeerConnection.activeCount).toBe(1); await expect( relay.acceptSignal("prepared-child", { @@ -1057,13 +1042,12 @@ describe("ViewerRelay downstream ownership", () => { expect(preparedConnection.addedIceCandidates).toEqual([candidate]); preparedConnection.connectionState = "connected"; - relay.activateChildren(7, ["active-child", "prepared-child"]); + relay.activateChildren(7, ["prepared-child"]); expect(FakePeerConnection.latest).toBe(preparedConnection); - expect(FakePeerConnection.activeCount).toBe(2); + expect(FakePeerConnection.activeCount).toBe(1); expect(relay.getSnapshot("prepared-child")?.connectionId).toBe( preparedConnectionId, ); - expect(activeConnection.connectionState).toBe("connected"); relay.dispose(); }); @@ -1073,40 +1057,31 @@ describe("ViewerRelay downstream ownership", () => { QUALITY_PROFILES["720p30"], { sendSignal: () => true }, ); - relay.setChildren(["active-child"]); relay.setStream(createStream(createTrack("video", "rollback-video"), null)); - await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull()); - const activeConnection = FakePeerConnection.latest!; - activeConnection.connectionState = "connected"; - - expect(relay.prepareChild(7, ["active-child", "first-probe"])).toBe(true); + expect(relay.prepareChild(7, ["first-probe"])).toBe(true); const firstProbe = FakePeerConnection.latest!; - expect(relay.prepareChild(8, ["active-child", "second-probe"])).toBe(true); + expect(relay.prepareChild(8, ["second-probe"])).toBe(true); const secondProbe = FakePeerConnection.latest!; expect(firstProbe.connectionState).toBe("closed"); - relay.activateChildren(9, ["active-child"]); + relay.activateChildren(9, []); expect(secondProbe.connectionState).toBe("closed"); - expect(activeConnection.connectionState).toBe("connected"); FakePeerConnection.offersFailing = 1; const instancesBeforeFailure = FakePeerConnection.instances.length; - expect(relay.prepareChild(10, ["active-child", "failed-probe"])).toBe(true); + expect(relay.prepareChild(10, ["failed-probe"])).toBe(true); const failedProbe = FakePeerConnection.latest!; await vi.waitFor(() => expect(failedProbe.connectionState).toBe("closed")); expect(FakePeerConnection.instances).toHaveLength(instancesBeforeFailure + 1); - expect(activeConnection.connectionState).toBe("connected"); - expect(relay.prepareChild(11, ["active-child", "session-probe"])).toBe(true); + expect(relay.prepareChild(11, ["session-probe"])).toBe(true); const sessionProbe = FakePeerConnection.latest!; relay.discardPreparedChild(); expect(sessionProbe.connectionState).toBe("closed"); - expect(activeConnection.connectionState).toBe("connected"); - expect(relay.prepareChild(12, ["active-child", "stopped-probe"])).toBe(true); + expect(relay.prepareChild(12, ["stopped-probe"])).toBe(true); const stoppedProbe = FakePeerConnection.latest!; relay.stop(); expect(stoppedProbe.connectionState).toBe("closed"); - expect(activeConnection.connectionState).toBe("closed"); relay.dispose(); }); @@ -1123,32 +1098,27 @@ describe("ViewerRelay downstream ownership", () => { }, }, ); - relay.setChildren(["active-child"]); relay.setStream(createStream(createTrack("video", "failed-active-video"), null)); - await vi.waitFor(() => expect(signals).toHaveLength(1)); - const activeConnection = FakePeerConnection.latest!; - activeConnection.connectionState = "connected"; rejectSignalsFor = "failed-child"; - expect(relay.prepareChild(7, ["active-child", "failed-child"])).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(2)); - const failedConnectionId = signals[1]!.connectionId; + expect(relay.prepareChild(7, ["failed-child"])).toBe(true); + await vi.waitFor(() => expect(signals).toHaveLength(1)); + const failedConnectionId = signals[0]!.connectionId; const failedConnection = FakePeerConnection.latest!; await vi.waitFor(() => expect(failedConnection.connectionState).toBe("closed")); const instanceCount = FakePeerConnection.instances.length; - expect(relay.prepareChild(7, ["active-child", "failed-child"])).toBe(false); + expect(relay.prepareChild(7, ["failed-child"])).toBe(false); expect(FakePeerConnection.instances).toHaveLength(instanceCount); - relay.setChildren(["active-child", "failed-child"]); - relay.activateChildren(7, ["active-child", "failed-child"]); + relay.setChildren(["failed-child"]); + relay.activateChildren(7, ["failed-child"]); expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(FakePeerConnection.activeCount).toBe(1); - expect(activeConnection.connectionState).toBe("connected"); - relay.setChildren(["active-child", "failed-child"]); - relay.activateChildren(7, ["active-child", "failed-child"]); + expect(FakePeerConnection.activeCount).toBe(0); + relay.setChildren(["failed-child"]); + relay.activateChildren(7, ["failed-child"]); expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(signals).toHaveLength(2); - expect(signals[1]!.connectionId).toBe(failedConnectionId); + expect(signals).toHaveLength(1); + expect(signals[0]!.connectionId).toBe(failedConnectionId); relay.dispose(); }); @@ -1164,15 +1134,11 @@ describe("ViewerRelay downstream ownership", () => { }, }, ); - relay.setChildren(["active-child"]); relay.setStream(createStream(createTrack("video", "initial-video"), null)); + expect(relay.prepareChild(7, ["prepared-child"])).toBe(true); await vi.waitFor(() => expect(signals).toHaveLength(1)); - const activeConnection = FakePeerConnection.latest!; - activeConnection.connectionState = "connected"; - expect(relay.prepareChild(7, ["active-child", "prepared-child"])).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(2)); const preparedConnection = FakePeerConnection.latest!; - const preparedConnectionId = signals[1]!.connectionId; + const preparedConnectionId = signals[0]!.connectionId; const instanceCount = FakePeerConnection.instances.length; const replacementVideo = createTrack("video", "replacement-video"); @@ -1182,7 +1148,7 @@ describe("ViewerRelay downstream ownership", () => { ); expect(FakePeerConnection.instances).toHaveLength(instanceCount); preparedConnection.connectionState = "connected"; - relay.activateChildren(7, ["active-child", "prepared-child"]); + relay.activateChildren(7, ["prepared-child"]); expect(relay.getSnapshot("prepared-child")?.connectionId).toBe( preparedConnectionId, ); @@ -1202,53 +1168,45 @@ describe("ViewerRelay downstream ownership", () => { }, }, ); - relay.setChildren(["active-child"]); relay.setStream(createStream(createTrack("video", "initial-video"), null)); + expect(relay.prepareChild(7, ["prepared-child"])).toBe(true); await vi.waitFor(() => expect(signals).toHaveLength(1)); - const activeConnection = FakePeerConnection.latest!; - activeConnection.connectionState = "connected"; - expect(relay.prepareChild(7, ["active-child", "prepared-child"])).toBe(true); - await vi.waitFor(() => expect(signals).toHaveLength(2)); const preparedConnection = FakePeerConnection.latest!; - const preparedConnectionId = signals[1]!.connectionId; + const preparedConnectionId = signals[0]!.connectionId; preparedConnection.senders[0]!.failNextReplace = true; const instanceCount = FakePeerConnection.instances.length; relay.setStream(createStream(createTrack("video", "failed-video"), null)); await vi.waitFor(() => expect(preparedConnection.connectionState).toBe("closed")); - relay.activateChildren(7, ["active-child", "prepared-child"]); - relay.activateChildren(7, ["active-child", "prepared-child"]); + relay.activateChildren(7, ["prepared-child"]); + relay.activateChildren(7, ["prepared-child"]); expect(FakePeerConnection.instances).toHaveLength(instanceCount); - expect(FakePeerConnection.activeCount).toBe(1); - expect(activeConnection.connectionState).toBe("connected"); - expect(signals).toHaveLength(2); - expect(signals[1]!.connectionId).toBe(preparedConnectionId); + expect(FakePeerConnection.activeCount).toBe(0); + expect(signals).toHaveLength(1); + expect(signals[0]!.connectionId).toBe(preparedConnectionId); relay.dispose(); }); - it("admits one provisional child only while the one-through-three cap has room", async () => { + it("admits one provisional child only while the current Viewer cap has room", async () => { const relay = new ViewerRelay( { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: () => true }, ); relay.setStream(createStream(createTrack("video", "cap-video"), null)); - for (let count = 1; count <= 3; count += 1) { - const children = Array.from({ length: count }, (_, index) => `child-${index}`); - expect(relay.prepareChild(count, children)).toBe(true); - const preparedConnection = FakePeerConnection.latest!; - preparedConnection.connectionState = "connected"; - relay.activateChildren(count, children); - expect(FakePeerConnection.activeCount).toBe(count); - } + expect(relay.prepareChild(1, ["child-0"])).toBe(true); + const preparedConnection = FakePeerConnection.latest!; + preparedConnection.connectionState = "connected"; + relay.activateChildren(1, ["child-0"]); + expect(FakePeerConnection.activeCount).toBe(1); expect( - relay.prepareChild(4, ["child-0", "child-1", "child-2", "child-3"]), + relay.prepareChild(2, ["child-0", "child-1"]), ).toBe(false); - expect(FakePeerConnection.activeCount).toBe(3); + expect(FakePeerConnection.activeCount).toBe(1); relay.dispose(); }); - it("reconciles three children without ever opening a fourth edge", async () => { + it("clamps a malicious multi-child assignment to one physical Viewer edge", async () => { const targets: string[] = []; const relay = new ViewerRelay( { iceServers: [] }, @@ -1265,19 +1223,10 @@ describe("ViewerRelay downstream ownership", () => { relay.setChildren(["child-a", "child-b", "child-c", "ignored-fourth"]); relay.setStream(createStream(firstVideo, firstAudio)); - await vi.waitFor(() => - expect(new Set(targets)).toEqual( - new Set(["child-a", "child-b", "child-c"]), - ), - ); - const [childAConnection, childBConnection, childCConnection] = - FakePeerConnection.instances; + await vi.waitFor(() => expect(targets).toEqual(["child-a"])); + const [childAConnection] = FakePeerConnection.instances; expect(childAConnection).toBeDefined(); - expect(childBConnection).toBeDefined(); - expect(childCConnection).toBeDefined(); childAConnection!.connectionState = "connected"; - childBConnection!.connectionState = "connected"; - childCConnection!.connectionState = "connected"; const retiredConnectionId = relay.getSnapshot("child-a")!.connectionId; const nextVideo = createTrack("video", "next-video"); @@ -1285,20 +1234,17 @@ describe("ViewerRelay downstream ownership", () => { relay.setStream(createStream(nextVideo, nextAudio)); await vi.waitFor(() => { expect(childAConnection!.senders[0]?.track).toBe(nextVideo); - expect(childBConnection!.senders[0]?.track).toBe(nextVideo); expect(childAConnection!.senders[1]?.track).toBe(nextAudio); - expect(childBConnection!.senders[1]?.track).toBe(nextAudio); - expect(childCConnection!.senders[0]?.track).toBe(nextVideo); - expect(childCConnection!.senders[1]?.track).toBe(nextAudio); }); relay.setChildren(["child-b", "child-c", "child-d"]); - await vi.waitFor(() => expect(targets).toContain("child-d")); + await vi.waitFor(() => expect(targets).toContain("child-b")); expect(childAConnection!.connectionState).toBe("closed"); - expect(childBConnection!.connectionState).toBe("connected"); - expect(childCConnection!.connectionState).toBe("connected"); - expect(FakePeerConnection.activeCount).toBe(3); - expect(FakePeerConnection.peakActiveCount).toBe(3); + const childBConnection = FakePeerConnection.latest!; + childBConnection.connectionState = "connected"; + expect(targets).toEqual(["child-a", "child-b"]); + expect(FakePeerConnection.activeCount).toBe(1); + expect(FakePeerConnection.peakActiveCount).toBe(1); expect(targets).not.toContain("ignored-fourth"); await relay.acceptSignal("stale-child", { @@ -1307,16 +1253,15 @@ describe("ViewerRelay downstream ownership", () => { description: { type: "answer", sdp: "stale-answer" }, }, 7); await relay.recover("stale-child", "stale-connection", true); - await relay.recover("child-c", "wrong-connection", true); - expect(FakePeerConnection.activeCount).toBe(3); - const childCConnectionId = relay.getSnapshot("child-c")!.connectionId; - await relay.recover("child-c", childCConnectionId, true); + await relay.recover("child-b", "wrong-connection", true); + expect(FakePeerConnection.activeCount).toBe(1); + const childBConnectionId = relay.getSnapshot("child-b")!.connectionId; + await relay.recover("child-b", childBConnectionId, true); await vi.waitFor(() => - expect(FakePeerConnection.latest).not.toBe(childCConnection), + expect(FakePeerConnection.latest).not.toBe(childBConnection), ); - expect(childBConnection!.connectionState).toBe("connected"); - expect(FakePeerConnection.activeCount).toBe(3); - expect(FakePeerConnection.peakActiveCount).toBe(3); + expect(FakePeerConnection.activeCount).toBe(1); + expect(FakePeerConnection.peakActiveCount).toBe(1); expect( relay.startSelectedEdgeTurn( @@ -1329,8 +1274,8 @@ describe("ViewerRelay downstream ownership", () => { 7, ), ).toBe(false); - expect(FakePeerConnection.activeCount).toBe(3); - expect(FakePeerConnection.peakActiveCount).toBe(3); + expect(FakePeerConnection.activeCount).toBe(1); + expect(FakePeerConnection.peakActiveCount).toBe(1); relay.dispose(); }); @@ -1346,11 +1291,7 @@ describe("ViewerRelay downstream ownership", () => { await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce()); const oldConnectionId = relay.getSnapshot()!.connectionId; FakePeerConnection.latest!.connectionState = "connected"; - relay.setChildren(["selected-child", "sibling-child"]); - await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledTimes(2)); - const siblingConnection = FakePeerConnection.latest!; - siblingConnection.connectionState = "connected"; - relay.setChildren(["sibling-child"]); + relay.setChildren([]); sendSignal.mockClear(); const selectedGrant = selectedEdgeTurn( "selected-child", @@ -1402,14 +1343,13 @@ describe("ViewerRelay downstream ownership", () => { expect(FakePeerConnection.latest).toBe(selectedConnection); expect(sendSignal).toHaveBeenCalledOnce(); relay.acceptActiveRevision(8); - relay.setChildren(["sibling-child"]); + relay.setChildren([]); expect(selectedConnection.connectionState).not.toBe("closed"); - expect(siblingConnection.connectionState).toBe("connected"); expect(relay.getSnapshot("selected-child")?.connectionId).toBe( "selected-connection-new", ); - expect(FakePeerConnection.activeCount).toBe(2); - expect(FakePeerConnection.peakActiveCount).toBe(2); + expect(FakePeerConnection.activeCount).toBe(1); + expect(FakePeerConnection.peakActiveCount).toBe(1); const pendingCandidate = { candidate: "candidate-before-answer" }; await expect( relay.acceptSignal("selected-child", { @@ -1471,8 +1411,8 @@ describe("ViewerRelay downstream ownership", () => { expect(selectedConnection.connectionState).toBe("closed"); const replacementConnection = FakePeerConnection.latest!; expect(replacementConnection.connectionState).not.toBe("closed"); - expect(FakePeerConnection.activeCount).toBe(2); - expect(FakePeerConnection.peakActiveCount).toBe(2); + expect(FakePeerConnection.activeCount).toBe(1); + expect(FakePeerConnection.peakActiveCount).toBe(1); relay.acceptActiveRevision(9); expect(replacementConnection.connectionState).toBe("closed"); await expect( @@ -1482,8 +1422,8 @@ describe("ViewerRelay downstream ownership", () => { candidate: null, }, 9), ).resolves.toBe(false); - expect(FakePeerConnection.activeCount).toBe(1); - expect(FakePeerConnection.peakActiveCount).toBe(2); + expect(FakePeerConnection.activeCount).toBe(0); + expect(FakePeerConnection.peakActiveCount).toBe(1); relay.dispose(); }); diff --git a/tests/media-assignment.test.ts b/tests/media-assignment.test.ts index af2cd42..3dfba72 100644 --- a/tests/media-assignment.test.ts +++ b/tests/media-assignment.test.ts @@ -19,6 +19,9 @@ const offer: SignalPayload = { describe("peer-assisted client assignment", () => { it("keeps host and viewer child counts within their client limits", () => { + expect(MAX_HOST_MEDIA_CHILDREN).toBe(2); + expect(MAX_VIEWER_MEDIA_CHILDREN).toBe(1); + const assignment = { parentPeerId: "parent_12345678", childPeerIds: [ @@ -37,18 +40,13 @@ describe("peer-assisted client assignment", () => { childPeerIds: [ "child_12345678", "child_abcdefgh", - "child_qwertyui", ], }); expect( limitMediaAssignment(assignment, MAX_VIEWER_MEDIA_CHILDREN), ).toEqual({ parentPeerId: "parent_12345678", - childPeerIds: [ - "child_12345678", - "child_abcdefgh", - "child_qwertyui", - ], + childPeerIds: ["child_12345678"], }); }); @@ -67,7 +65,6 @@ describe("peer-assisted client assignment", () => { "remove:old-child", "start:kept-child", "start:new-child", - "start:overflow-child", ]); }); diff --git a/tests/media-route-controller.test.ts b/tests/media-route-controller.test.ts index cabb178..9a7bdb7 100644 --- a/tests/media-route-controller.test.ts +++ b/tests/media-route-controller.test.ts @@ -11,7 +11,6 @@ const DIRECT_ROOT = "viewer_direct_12345678"; const SFU_ROOT_A = "viewer_sfu_a_12345678"; const SFU_ROOT_B = "viewer_sfu_b_12345678"; const THIRD_CHILD = "viewer_third_12345678"; -const FOURTH_CHILD = "viewer_fourth_12345678"; const GENERATION_A = "generation_a_12345678"; const GENERATION_B = "generation_b_12345678"; @@ -279,7 +278,7 @@ describe("MediaRouteController", () => { ).toThrow("SFU roots and publication generation must be active together"); }); - it("supports an explicit three-edge endpoint budget without admitting four", () => { + it("does not let an explicit three-edge setting lift release role limits", () => { const threeHostChildren = new Map([ [ HOST, @@ -292,47 +291,71 @@ describe("MediaRouteController", () => { [SFU_ROOT_A, assignment({ kind: "peer", peerId: HOST })], [THIRD_CHILD, assignment({ kind: "peer", peerId: HOST })], ]); - const expanded = new MediaRouteController({ - hostPeerId: HOST, - assignments: threeHostChildren, - maxEndpointMediaEdges: 3, - }); - expect(expanded.hostActiveMediaEdges()).toBe(3); + expect(() => + new MediaRouteController({ + hostPeerId: HOST, + assignments: threeHostChildren, + maxEndpointMediaEdges: 3, + }), + ).toThrow("Participant active media edge budget exceeded"); - const viewerWithThreeChildren = new Map([ + const viewerWithTwoChildren = new Map([ [HOST, assignment({ kind: "none" }, [DIRECT_ROOT])], [ DIRECT_ROOT, assignment( { kind: "peer", peerId: HOST }, - [SFU_ROOT_A, SFU_ROOT_B, THIRD_CHILD], + [SFU_ROOT_A, SFU_ROOT_B], ), ], [SFU_ROOT_A, assignment({ kind: "peer", peerId: DIRECT_ROOT })], [SFU_ROOT_B, assignment({ kind: "peer", peerId: DIRECT_ROOT })], - [THIRD_CHILD, assignment({ kind: "peer", peerId: DIRECT_ROOT })], ]); - expect(() => controller(viewerWithThreeChildren)).toThrow( - "Participant active media edge budget exceeded", - ); - - const fourHostEdges = new Map(threeHostChildren); - fourHostEdges.set( - HOST, - assignment( - { kind: "none" }, - [DIRECT_ROOT, SFU_ROOT_A, THIRD_CHILD], - GENERATION_A, - ), - ); - fourHostEdges.set(FOURTH_CHILD, assignment({ kind: "sfu" })); expect(() => new MediaRouteController({ hostPeerId: HOST, - assignments: fourHostEdges, + assignments: viewerWithTwoChildren, + maxEndpointMediaEdges: 3, + }), + ).toThrow("Participant active media edge budget exceeded"); + + const publicationAtHostLimit = new Map([ + [ + HOST, + assignment({ kind: "none" }, [DIRECT_ROOT], GENERATION_A), + ], + [DIRECT_ROOT, assignment({ kind: "peer", peerId: HOST })], + [SFU_ROOT_A, assignment({ kind: "sfu" })], + ]); + const allowed = new MediaRouteController({ + hostPeerId: HOST, + assignments: publicationAtHostLimit, + maxEndpointMediaEdges: 3, + sfuPublicationGeneration: GENERATION_A, + sfuRootPeerIds: [SFU_ROOT_A], + }); + expect(allowed.hostActiveMediaEdges()).toBe(2); + + const publicationOverHostLimit = new Map(publicationAtHostLimit); + publicationOverHostLimit.set( + HOST, + assignment( + { kind: "none" }, + [DIRECT_ROOT, THIRD_CHILD], + GENERATION_A, + ), + ); + publicationOverHostLimit.set( + THIRD_CHILD, + assignment({ kind: "peer", peerId: HOST }), + ); + expect(() => + new MediaRouteController({ + hostPeerId: HOST, + assignments: publicationOverHostLimit, maxEndpointMediaEdges: 3, sfuPublicationGeneration: GENERATION_A, - sfuRootPeerIds: [FOURTH_CHILD], + sfuRootPeerIds: [SFU_ROOT_A], }), ).toThrow("Host active media edge budget exceeded"); }); diff --git a/tests/peer-assisted-benchmark.test.ts b/tests/peer-assisted-benchmark.test.ts index a73b661..d7d69fc 100644 --- a/tests/peer-assisted-benchmark.test.ts +++ b/tests/peer-assisted-benchmark.test.ts @@ -236,17 +236,12 @@ describe("peer topology loopback configuration", () => { ).toThrow(/selected viewer count/); }); - it("keeps cap2 by default and accepts only an explicit cap3 expectation", () => { + it("keeps Host cap2 by default and permits only deployment tightening", () => { expect(parseExpectedEndpointCap(undefined)).toBe(2); - for (const value of ["1", "2.5", "4"]) { - expect(() => parseExpectedEndpointCap(value)).toThrow(/integer from 2 to 3/); + expect(parseExpectedEndpointCap("1")).toBe(1); + for (const value of ["0", "2.5", "3", "4"]) { + expect(() => parseExpectedEndpointCap(value)).toThrow(/integer from 1 to 2/); } - expect( - parseBenchmarkConfig({ - CHROME_PATH: "chrome", - BENCHMARK_EXPECTED_ENDPOINT_CAP: "3", - }).expectedEndpointCap, - ).toBe(3); }); it("requires a relay-sized case for the optional quality control smoke", () => { @@ -372,11 +367,11 @@ describe("peer topology loopback observations", () => { expect(summary.browserProcessResources.averageCpuUtilizationPercent).toBeCloseTo(50); }); - it("requires an explicit cap3 run to exercise cap3 fanout", () => { + it("checks Host cap2 and Browser Viewer cap1 independently", () => { const initial = [ - page("host", "host", 3, 0), - ...Array.from({ length: 6 }, (_, index) => - page("viewer", `viewer-${index + 1}`, index === 0 ? 3 : 0, 1), + page("host", "host", 2, 0), + ...Array.from({ length: 3 }, (_, index) => + page("viewer", `viewer-${index + 1}`, index === 0 ? 1 : 0, 1), ), ]; const final = structuredClone(initial); @@ -388,18 +383,18 @@ describe("peer topology loopback observations", () => { { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, ], - 6, + 3, ); - const checks = buildRunChecks(summary, 6, "720p30", 3); - expect(checks.find((check) => check.name === "endpoint-cap-observed")?.passed).toBe(true); - const underused = { + const checks = buildRunChecks(summary, 3, "720p30", 2); + expect(checks.find((check) => check.name === "host-assigned-children")?.passed).toBe(true); + expect(checks.find((check) => check.name === "relay-active-media-edges")?.passed).toBe(true); + const overused = { ...summary, - maxHostAssignedChildren: 2, maxRelayActiveMediaEdges: 2, }; expect( - buildRunChecks(underused, 6, "720p30", 3).find( - (check) => check.name === "endpoint-cap-observed", + buildRunChecks(overused, 3, "720p30", 2).find( + (check) => check.name === "relay-active-media-edges", )?.passed, ).toBe(false); }); diff --git a/tests/peer-relay-topology.test.ts b/tests/peer-relay-topology.test.ts index fd0a65d..d35c3f9 100644 --- a/tests/peer-relay-topology.test.ts +++ b/tests/peer-relay-topology.test.ts @@ -11,7 +11,7 @@ function addRelayViewer( roomId: string, peerId: string, connectedPeerIds: ReadonlySet, - downstreamEdges: 1 | 2 = 1, + downstreamEdges: 1 | 2 | 3 = 1, ) { const changes = topology.addViewer(roomId, peerId, connectedPeerIds); return [ @@ -213,12 +213,18 @@ describe("PeerRelayTopology", () => { expect( topology.setViewerRelayCapacity("room", "relay", 2, peers), ).toEqual([]); - expect(topology.getAdvertisedDownstreamCapacity("room", "relay")).toBe(2); + expect(topology.getAdvertisedDownstreamCapacity("room", "relay")).toBe(1); topology.addViewer("room", "pending", peers); expect(topology.getAssignment("room", "pending")?.parentPeerId).toBeNull(); topology.setViewerRelayEligible("room", "relay", true); topology.setViewerRelayCapacity("room", "relay", 2, peers); + expect(topology.getAssignment("room", "pending")?.parentPeerId).toBeNull(); + topology.removeViewer( + "room", + "child", + connected("host", "relay", "sibling", "pending"), + ); expect(topology.getAssignment("room", "pending")?.parentPeerId).toBe( "relay", ); @@ -226,7 +232,7 @@ describe("PeerRelayTopology", () => { expect(topology.getDownstreamCapacity("room", "host")).toBe(2); }); - it("clamps advertisements to the deployment limit and can opt into three", () => { + it("applies role-aware release limits even when code supplies three", () => { const peers = connected("host", "relay"); const defaultTopology = new PeerRelayTopology(); defaultTopology.setHost("default", "host", peers); @@ -237,7 +243,7 @@ describe("PeerRelayTopology", () => { ).toBe(2); expect( defaultTopology.getAdvertisedDownstreamCapacity("default", "relay"), - ).toBe(2); + ).toBe(1); const expandedTopology = new PeerRelayTopology(3); expandedTopology.setHost("expanded", "host", peers); @@ -245,10 +251,21 @@ describe("PeerRelayTopology", () => { expandedTopology.setViewerRelayCapacity("expanded", "relay", 3, peers); expect( expandedTopology.getAdvertisedDownstreamCapacity("expanded", "host"), - ).toBe(3); + ).toBe(2); expect( expandedTopology.getAdvertisedDownstreamCapacity("expanded", "relay"), - ).toBe(3); + ).toBe(1); + + const tightenedTopology = new PeerRelayTopology(1); + tightenedTopology.setHost("tightened", "host", peers); + tightenedTopology.addViewer("tightened", "relay", peers); + tightenedTopology.setViewerRelayCapacity("tightened", "relay", 3, peers); + expect( + tightenedTopology.getAdvertisedDownstreamCapacity("tightened", "host"), + ).toBe(1); + expect( + tightenedTopology.getAdvertisedDownstreamCapacity("tightened", "relay"), + ).toBe(1); }); it("does not reassign a failed edge through a viewer leaf", () => { @@ -341,8 +358,8 @@ describe("PeerRelayTopology", () => { expect(expectValidTree(topology, roomId, hostPeerId, viewerPeerIds)).toBe(4); }); - it("forms a bounded depth-three DAG for eight capacity-two viewers", () => { - const topology = new PeerRelayTopology(); + it("clamps old capacity-three viewers to one child in an eight-viewer tree", () => { + const topology = new PeerRelayTopology(3); const roomId = "room"; const hostPeerId = "host"; const viewerPeerIds = Array.from( @@ -358,31 +375,30 @@ describe("PeerRelayTopology", () => { roomId, viewerPeerIds[index]!, peers, - 2, + 3, ); expectValidTree( topology, roomId, hostPeerId, viewerPeerIds.slice(0, index + 1), - 2, + 1, ); } expect(topology.getAssignment(roomId, "viewer-1")?.childPeerIds).toEqual([ "viewer-3", - "viewer-4", ]); - expect(topology.getDownstreamCapacity(roomId, "viewer-1")).toBe(2); + expect(topology.getDownstreamCapacity(roomId, "viewer-1")).toBe(1); expect(topology.getAssignment(roomId, "viewer-5")?.parentPeerId).toBe( - "viewer-2", + "viewer-3", ); expect( - expectValidTree(topology, roomId, hostPeerId, viewerPeerIds, 2), - ).toBe(3); + expectValidTree(topology, roomId, hostPeerId, viewerPeerIds, 1), + ).toBe(4); }); - it("reattaches both child subtrees when a capacity-two relay leaves", () => { + it("reattaches a relay child without creating a second viewer child", () => { const topology = new PeerRelayTopology(); const peers = connected("host", "relay", "sibling", "child-a", "child-b"); topology.setHost("room", "host", peers); @@ -392,6 +408,8 @@ describe("PeerRelayTopology", () => { addRelayViewer(topology, "room", "child-b", peers, 2); expect(topology.getAssignment("room", "relay")?.childPeerIds).toEqual([ "child-a", + ]); + expect(topology.getAssignment("room", "sibling")?.childPeerIds).toEqual([ "child-b", ]); @@ -413,7 +431,7 @@ describe("PeerRelayTopology", () => { "room", "host", ["sibling", "child-a", "child-b"], - 2, + 1, ), ).toBe(2); }); diff --git a/tests/relay-capability.test.ts b/tests/relay-capability.test.ts index 79d4bc2..e38cacd 100644 --- a/tests/relay-capability.test.ts +++ b/tests/relay-capability.test.ts @@ -5,10 +5,10 @@ import { } from "../src/client/media/relay-capability.ts"; describe("browser relay capability", () => { - it("advertises the bounded browser maximum to a peer-assisted server", () => { + it("advertises the current one-child browser release capacity", () => { expect(relayCapacityMessageForBrowser(true)).toEqual({ type: "relay-capacity", - downstreamEdges: 3, + downstreamEdges: 1, }); }); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 7c56fbe..3e242f7 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -227,7 +227,7 @@ describe("server configuration", () => { ).toThrow("MAX_VIEWERS_PER_ROOM"); }); - it.each(["1", "2", "3"])( + it.each(["1", "2"])( "accepts a bounded peer relay downstream limit of %s", (maxPeerRelayDownstreamEdges) => { expect( @@ -238,7 +238,7 @@ describe("server configuration", () => { }, ); - it.each(["0", "4", "1.5"])( + it.each(["0", "3", "4", "1.5"])( "rejects an invalid peer relay downstream limit of %s", (maxPeerRelayDownstreamEdges) => { expect(() => diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 4778df0..37609b3 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; import { + CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT, MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES, MAX_SIGNAL_BYTES, MAX_VIEWER_QUALITY_EVIDENCE_BYTES, @@ -171,6 +172,7 @@ async function startHarness( persistent?: boolean; provisionalHostClaimSeconds?: number; peerAssistedMedia?: boolean; + maxPeerRelayDownstreamEdges?: number; stunUrls?: readonly string[]; now?: () => number; } = {}, @@ -178,6 +180,8 @@ async function startHarness( const config = testConfig(); config.siteAccessPassword = overrides.siteAccessPassword; config.peerAssistedMedia = overrides.peerAssistedMedia ?? false; + config.maxPeerRelayDownstreamEdges = + overrides.maxPeerRelayDownstreamEdges ?? 2; config.stunUrls = overrides.stunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; config.maxViewersPerRoom = maxViewersPerRoom; @@ -594,6 +598,7 @@ async function startSfuHarness(options: { persistent?: boolean; prepareTimeoutMs?: number; maxRoots?: number; + maxPeerRelayDownstreamEdges?: number; viewerDisconnectGraceMs?: number; stunUrls?: readonly string[]; now?: () => number; @@ -616,7 +621,8 @@ async function startSfuHarness(options: { server: httpServer, roomStore, peerAssistedMedia: true, - maxPeerRelayDownstreamEdges: 2, + maxPeerRelayDownstreamEdges: + options.maxPeerRelayDownstreamEdges ?? 2, sfuFallback: { url: "wss://sfu.example.test", tokenIssuer: options.tokenIssuer, @@ -745,7 +751,7 @@ async function authenticate( room: CreatedRoom, role: Role, clientId: string, - relayCapacity: 0 | 1 | 2 | null = 1, + relayCapacity: 0 | 1 | 2 | 3 | null = 1, shareGeneration?: string, presence: { displayName?: string; @@ -2557,6 +2563,112 @@ describe("WebSocket signaling", () => { expect((await host.inbox.next("error")).code).toBe("FORBIDDEN"); }); + it("clamps legacy Viewer capacity across authentication and reconciliation", async () => { + const harness = await startHarness({ + peerAssistedMedia: true, + maxPeerRelayDownstreamEdges: 3, + }); + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "release-cap-host"), + ); + const relay = await openClient(harness.webSocketUrl); + const relayAuth = peerAssisted( + await authenticate( + relay, + harness.room, + "viewer", + "release-cap-relay", + 3, + ), + ); + const sibling = await openClient(harness.webSocketUrl); + const siblingAuth = peerAssisted( + await authenticate( + sibling, + harness.room, + "viewer", + "release-cap-sibling", + 2, + ), + ); + const child = await openClient(harness.webSocketUrl); + const childAuth = peerAssisted( + await authenticate( + child, + harness.room, + "viewer", + "release-cap-child", + 0, + ), + ); + const siblingChild = await openClient(harness.webSocketUrl); + const siblingChildAuth = peerAssisted( + await authenticate( + siblingChild, + harness.room, + "viewer", + "release-cap-sibling-child", + 0, + ), + ); + const pending = await openClient(harness.webSocketUrl); + const pendingAuth = peerAssisted( + await authenticate( + pending, + harness.room, + "viewer", + "release-cap-pending", + 0, + ), + ); + + expect(relayAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + expect(siblingAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + expect(childAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: relayAuth.peerId, + }); + expect(siblingChildAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: siblingAuth.peerId, + }); + expect(pendingAuth.routeAssignment.upstream).toEqual({ kind: "none" }); + + const replacementRelay = await openClient(harness.webSocketUrl); + const replacementAuth = peerAssisted( + await authenticate( + replacementRelay, + harness.room, + "viewer", + "release-cap-relay", + null, + ), + ); + expect(replacementAuth.peerId).toBe(relayAuth.peerId); + expect(replacementAuth.routeAssignment).toMatchObject({ + upstream: { kind: "peer", peerId: hostAuth.peerId }, + childPeerIds: [childAuth.peerId], + }); + + replacementRelay.socket.send( + JSON.stringify({ type: "relay-capacity", downstreamEdges: 3 }), + ); + expect(await pending.inbox.next("error")).toMatchObject({ + code: "PEER_NOT_FOUND", + }); + await expect(pending.inbox.next("route-update", 40)).rejects.toThrow( + "Timed out", + ); + await replacementRelay.inbox.expectNone(40); + }); + it("rescues an unassigned relay in one bounded route generation", async () => { const harness = await startHarness({ peerAssistedMedia: true }); const host = await openClient(harness.webSocketUrl); @@ -3412,12 +3524,12 @@ describe("WebSocket signaling", () => { }); }); - it("skips an eligible Host and probes the later peer Viewer", async () => { + it("probes an eligible peer Viewer rather than the Host", async () => { let now = 120_000; const fixture = await startRelayRelativeFpsFixture( "quality_skip_host", () => now, - { alternateRelayCapacity: 1, hostFirstCandidate: true }, + { alternateRelayCapacity: 1 }, ); const advanceWindow = () => { now += 2_000; }; await confirmRelayChildSevere( @@ -3426,7 +3538,6 @@ describe("WebSocket signaling", () => { fixture.childConnectionId, advanceWindow, ); - const hostRoute = fixture.host.inbox.next("route-update"); const moved = await completePreparedPeerMigration( fixture.child, fixture.alternate, @@ -3435,9 +3546,8 @@ describe("WebSocket signaling", () => { fixture.routeRevision, "quality_skip_host_probe", ); - expect(await hostRoute).toMatchObject({ + expect(await nextActiveRouteRevision(fixture.host, moved.revision)).toMatchObject({ phase: "active", - revision: moved.revision, }); expect(moved.assignment.upstream).toEqual({ kind: "peer", @@ -3445,12 +3555,12 @@ describe("WebSocket signaling", () => { }); }); - it("keeps the old edge when only the Host is a capable alternate", async () => { + it("keeps the old edge when no eligible Viewer alternate exists", async () => { let now = 125_000; const fixture = await startRelayRelativeFpsFixture( "quality_only_host", () => now, - { alternateRelayCapacity: 0, hostFirstCandidate: true }, + { alternateRelayCapacity: 0 }, ); const advanceWindow = () => { now += 2_000; }; await confirmRelayChildSevere( @@ -3459,11 +3569,9 @@ describe("WebSocket signaling", () => { fixture.childConnectionId, advanceWindow, ); - await Promise.all([ - expect(fixture.child.inbox.next("route-update", 40)).rejects.toThrow("Timed out"), - expect(fixture.host.inbox.next("route-update", 40)).rejects.toThrow("Timed out"), - expect(fixture.alternate.inbox.next("route-update", 40)).rejects.toThrow("Timed out"), - ]); + await expect(fixture.child.inbox.next("route-update", 40)).rejects.toThrow( + "Timed out", + ); }); it("uses a capable SFU root as the provisional peer parent", async () => { @@ -3479,33 +3587,16 @@ describe("WebSocket signaling", () => { ); active.viewer.socket.send(JSON.stringify({ type: "relay-capacity", - downstreamEdges: 2, + downstreamEdges: 0, })); await new Promise((resolve) => setTimeout(resolve, 0)); - - const hostFiller = await openClient(harness.webSocketUrl); - const hostFillerAuth = peerAssisted(await authenticate( - hostFiller, harness.room, "viewer", "quality-host-filler", 0, - )); - expect(hostFillerAuth.routeAssignment.upstream).toEqual({ - kind: "peer", - peerId: active.hostAuth.peerId, - }); const parent = await openClient(harness.webSocketUrl); const parentAuth = peerAssisted(await authenticate( parent, harness.room, "viewer", "quality-sfu-root-parent", 1, )); expect(parentAuth.routeAssignment.upstream).toEqual({ kind: "peer", - peerId: active.viewerAuth.peerId, - }); - const rootFiller = await openClient(harness.webSocketUrl); - const rootFillerAuth = peerAssisted(await authenticate( - rootFiller, harness.room, "viewer", "quality-root-filler", 0, - )); - expect(rootFillerAuth.routeAssignment.upstream).toEqual({ - kind: "peer", - peerId: active.viewerAuth.peerId, + peerId: active.hostAuth.peerId, }); const child = await openClient(harness.webSocketUrl); const childAuth = peerAssisted(await authenticate( @@ -3518,27 +3609,24 @@ describe("WebSocket signaling", () => { const parentConnectionId = "quality_sfu_root_parent_connection"; await sendTestOffer( - active.viewer, + active.host, parent, parentAuth.peerId, parentConnectionId, ); const childConnectionId = "quality_sfu_root_child_connection"; await sendTestOffer(parent, child, childAuth.peerId, childConnectionId); - hostFiller.socket.close(); - const hostFreed = await nextActiveRouteAfter(child, childAuth.routeRevision); - rootFiller.socket.close(); - const rootFreed = await nextActiveRouteAfter(child, hostFreed.revision); - await Promise.all([ - nextActiveRouteRevision(active.host, rootFreed.revision), - nextActiveRouteRevision(active.viewer, rootFreed.revision), - nextActiveRouteRevision(parent, rootFreed.revision), - ]); + const routeRevision = childAuth.routeRevision; + active.viewer.socket.send(JSON.stringify({ + type: "relay-capacity", + downstreamEdges: 2, + })); + await new Promise((resolve) => setTimeout(resolve, 0)); for (let sequence = 0; sequence < 3; sequence += 1) { child.socket.send(JSON.stringify(viewerQualityEvidenceWithMetrics( childConnectionId, - rootFreed.revision, + routeRevision, sequence, { framesDecodedDelta: 0 }, ))); @@ -3550,7 +3638,7 @@ describe("WebSocket signaling", () => { active.viewer, childAuth.peerId, active.viewerAuth.peerId, - rootFreed.revision, + routeRevision, "quality_sfu_root_probe", ); expect(moved.assignment.upstream).toEqual({ @@ -3629,6 +3717,9 @@ describe("WebSocket signaling", () => { expect(issuedSfuTokens).toBe(0); }); + describe.skipIf(CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT < 2)( + "future capacity-two browser relay quality coordination", + () => { it("keeps a severe child on its old edge when no peer alternate exists", async () => { let now = 142_000; const fixture = await startRelayRelativeFpsFixture( @@ -3653,7 +3744,8 @@ describe("WebSocket signaling", () => { await expect( fixture.secondChild!.inbox.next("sfu-config", 40), ).rejects.toThrow("Timed out"); - }); + }, + ); it("gives a severe trigger the sole alternate before evacuating siblings", async () => { let now = 144_000; @@ -4042,6 +4134,8 @@ describe("WebSocket signaling", () => { }, ); + }); + it("does not apply relative-FPS reparenting to a Host-direct edge", async () => { let now = 150_000; const harness = await startHarness({ @@ -4834,7 +4928,7 @@ describe("WebSocket signaling", () => { const fixture = await startRelayRelativeFpsFixture( "quality_probe_hard_preemption", () => now, - { alternateRelayCapacity: 1, secondChild: true }, + { alternateRelayCapacity: 1 }, ); const advanceWindow = () => { now += 2_000; }; await confirmRelayChildSevere( @@ -4849,16 +4943,16 @@ describe("WebSocket signaling", () => { ]); expect(parentPrepare.revision).toBe(viewerPrepare.revision); - fixture.secondChild!.socket.send(JSON.stringify({ + fixture.parent.socket.send(JSON.stringify({ type: "route-failed", revision: fixture.routeRevision, phase: "active", - connectionId: fixture.secondChildConnectionId, + connectionId: fixture.parentConnectionId, })); const [softRollback, candidateRollback, hardRollback] = await Promise.all([ nextActiveRouteAfter(fixture.child, viewerPrepare.revision), nextActiveRouteAfter(fixture.alternate, viewerPrepare.revision), - nextActiveRouteAfter(fixture.secondChild!, viewerPrepare.revision), + nextActiveRouteAfter(fixture.parent, viewerPrepare.revision), ]); expect(softRollback.assignment.upstream).toEqual({ kind: "peer", @@ -4868,7 +4962,7 @@ describe("WebSocket signaling", () => { expect(hardRollback.revision).toBe(softRollback.revision); const hardMoved = await nextActiveRouteAfter( - fixture.secondChild!, + fixture.parent, hardRollback.revision, ); expect(hardMoved.assignment.upstream).toEqual({ @@ -5427,6 +5521,80 @@ describe("WebSocket signaling", () => { ); }); + it("reserves a Host publication slot when deployment tightens its edge limit to one", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { + async issueToken({ peerId }) { + return `token-${peerId}`; + }, + }, + maxPeerRelayDownstreamEdges: 1, + }); + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate(host, harness.room, "host", "limit-one-host"), + ); + const branchRoot = await openClient(harness.webSocketUrl); + const branchRootAuth = peerAssisted( + await authenticate( + branchRoot, + harness.room, + "viewer", + "limit-one-branch-root", + 1, + ), + ); + expect(branchRootAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: hostAuth.peerId, + }); + const failedViewer = await openClient(harness.webSocketUrl); + const failedAuth = peerAssisted( + await authenticate( + failedViewer, + harness.room, + "viewer", + "limit-one-failed-viewer", + 0, + ), + ); + expect(failedAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: branchRootAuth.peerId, + }); + + await sendTestOffer( + host, + branchRoot, + branchRootAuth.peerId, + "limit_one_host_branch_connection", + ); + await sendTestOffer( + branchRoot, + failedViewer, + failedAuth.peerId, + "limit_one_deep_connection", + ); + failedViewer.socket.send(JSON.stringify({ + type: "route-failed", + revision: failedAuth.routeRevision, + phase: "active", + connectionId: "limit_one_deep_connection", + })); + + const hostPrepare = await nextPreparedRoute(host); + const branchPrepare = await nextPreparedRoute(branchRoot); + const failedPrepare = await nextPreparedRoute(failedViewer); + expect(branchPrepare.revision).toBe(hostPrepare.revision); + expect(failedPrepare.revision).toBe(hostPrepare.revision); + expect(hostPrepare.assignment).toMatchObject({ + childPeerIds: [], + sfuPublicationGeneration: expect.any(String), + }); + expect(branchPrepare.assignment.upstream).toEqual({ kind: "sfu" }); + expect(failedPrepare.assignment.upstream).toEqual({ kind: "sfu" }); + }); + it("reparents failed peer edges before committing a bounded SFU fallback", async () => { const issued: Array< Parameters[0] @@ -6223,29 +6391,49 @@ describe("WebSocket signaling", () => { const harness = await startSfuHarness({ tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } }, }); - const peers = await prepareFallbackForTwoViewers( + const active = await activateSingleViewerSfu( harness.webSocketUrl, harness.room, "healthy-relay-budget", - undefined, - { viewerParentSibling: true }, ); - for (const [client, revision] of [ - [peers.host, peers.hostPrepare.revision], - [peers.failedViewer, peers.rootPrepare.revision], - ] as const) client.socket.send(JSON.stringify({ - type: "route-ready", revision, phase: "prepare", + active.viewer.socket.send(JSON.stringify({ + type: "relay-capacity", + downstreamEdges: 3, })); - const [activeRoot, activeParent] = await Promise.all([ - nextActiveRouteRevision(peers.failedViewer, peers.rootPrepare.revision), - nextActiveRouteRevision(peers.rootViewer, peers.rootPrepare.revision), - ]); - expect(activeParent.assignment.childPeerIds).toEqual([ - peers.rootSiblingAuth!.peerId, - ]); - expect(activeRoot.assignment.upstream).toEqual({ kind: "sfu" }); - requestHealthyReselection(peers.failedViewer, activeRoot.revision); - await expect(peers.failedViewer.inbox.next("route-update", 40)).rejects.toThrow("Timed out"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const hostFiller = await openClient(harness.webSocketUrl); + const hostFillerAuth = peerAssisted(await authenticate( + hostFiller, + harness.room, + "viewer", + "healthy-relay-budget-host-filler", + 0, + )); + expect(hostFillerAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: active.hostAuth.peerId, + }); + const rootChild = await openClient(harness.webSocketUrl); + const rootChildAuth = peerAssisted(await authenticate( + rootChild, + harness.room, + "viewer", + "healthy-relay-budget-root-child", + 0, + )); + expect(rootChildAuth.routeAssignment.upstream).toEqual({ + kind: "peer", + peerId: active.viewerAuth.peerId, + }); + const rootActive = await nextActiveRouteRevision( + active.viewer, + rootChildAuth.routeRevision, + ); + expect(rootActive.assignment.childPeerIds).toEqual([rootChildAuth.peerId]); + requestHealthyReselection(active.viewer, rootActive.revision); + await expect(active.viewer.inbox.next("route-update", 40)).rejects.toThrow( + "Timed out", + ); }); it("cleans a healthy probe across Viewer session replacement and stop", async () => { @@ -7039,7 +7227,6 @@ describe("WebSocket signaling", () => { harness.room, "selected-viewer-parent", "selected-viewer-parent-share", - { viewerParentSibling: true }, ); for (const client of [prepared.host, prepared.failedViewer]) { client.socket.send(JSON.stringify({ @@ -7053,9 +7240,7 @@ describe("WebSocket signaling", () => { nextActiveRouteRevision(prepared.failedViewer, prepared.rootPrepare.revision), nextActiveRouteRevision(prepared.rootViewer, prepared.rootPrepare.revision), ]); - expect(parentActive.assignment.childPeerIds).toEqual([ - prepared.rootSiblingAuth!.peerId, - ]); + expect(parentActive.assignment.childPeerIds).toEqual([]); prepared.failedViewer.socket.send(JSON.stringify({ type: "refresh-sfu", revision: prepared.rootPrepare.revision,