refactor(route): unify endpoint media copy accounting (#200)
This commit is contained in:
1 parent
0af91c7c38
commit
78871db805
39 files changed
+1000
-971
No files matched your search
+4
-3
@@ -19,8 +19,9 @@ 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
|
||||
# Release upload ceiling: Host 2, ordinary Browser Viewer 1. Range 1-2 only tightens it.
|
||||
MAX_PEER_RELAY_DOWNSTREAM_EDGES=2
|
||||
# One steady outbound media-copy capacity for every non-server endpoint.
|
||||
# A peer child or Host SFU publication consumes one copy. Valid values: 1, 2, 3.
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY=2
|
||||
|
||||
# Optional automatic SFU fallback capacity. Set PEER_ASSISTED_MEDIA=true and
|
||||
# supply all three values together; leaving them empty keeps LiveKit completely
|
||||
@@ -51,4 +52,4 @@ STUN_URLS=
|
||||
# LIVEKIT_API_KEY=replace-with-a-livekit-api-key
|
||||
# LIVEKIT_API_SECRET=replace-with-at-least-32-independent-random-bytes
|
||||
# MAX_SFU_ROOTS_PER_ROOM=2
|
||||
# MAX_PEER_RELAY_DOWNSTREAM_EDGES=2
|
||||
# ENDPOINT_MEDIA_COPY_CAPACITY=2
|
||||
@@ -57,7 +57,7 @@ Keep site access and room authorization as two ordered checks:
|
||||
`screener-v1` to `screener-v2`. That migration had no negotiation, v1 parser,
|
||||
dual write, or translator. A v1 first message received the universal fatal
|
||||
refresh outcome before room lookup and did not reconnect. Later atomic
|
||||
migrations advanced the current wire to `screener-v5`.
|
||||
migrations advanced the source wire to `screener-v6`.
|
||||
|
||||
Every room has one of two Viewer policies:
|
||||
|
||||
@@ -98,9 +98,9 @@ material and socket/Host session so stale asynchronous work cannot authorize or
|
||||
commit.
|
||||
|
||||
At this ADR's implementation checkpoint, the wire remained `screener-v2`.
|
||||
Current source and production have since migrated atomically to `screener-v5`;
|
||||
the paragraph below records the v2 compatibility boundary rather than the
|
||||
current wire. Only a Web Host that advertises
|
||||
Current source has since migrated atomically to `screener-v6`, while production
|
||||
remains on `screener-v5`; the paragraph below records the v2 compatibility
|
||||
boundary rather than the current source wire. Only a Web Host that advertises
|
||||
`viewerPasswordSettings: true` may set/remove the password and receive
|
||||
`viewer-password-updated`; Native v2 does not advertise the capability and sees
|
||||
no new message type. Viewer `authenticate` may carry the current password
|
||||
|
||||
@@ -39,9 +39,9 @@ merged as product code from this state.
|
||||
## 2026-08-20 Historical Wire Checkpoint
|
||||
|
||||
This checkpoint targeted the then-current `screener-v2` wire and SQLite schema
|
||||
v2. Current Web and source-only Native use `screener-v5`, and current room
|
||||
persistence is SQLite schema v3. The dated behavior below remains experiment
|
||||
provenance, not a current integration contract.
|
||||
v2. Current Web and source-only Native use `screener-v6`, production remains on
|
||||
`screener-v5`, and current room persistence is SQLite schema v3. The dated
|
||||
behavior below remains experiment provenance, not a current integration contract.
|
||||
|
||||
A clean local delivery candidate now targets only `screener-v2`: it exchanges
|
||||
`SITE_ACCESS_PASSWORD` for the bounded HttpOnly site-access cookie, uses
|
||||
|
||||
+21
-14
@@ -2,9 +2,9 @@
|
||||
|
||||
Last verified against upstream documentation: 2026-08-21.
|
||||
|
||||
This page records exact release `9461e20` deployment behavior and syntax. Current
|
||||
product direction and pending migrations are owned by [project memory](./project-memory.md)
|
||||
and [the TODO ledger](./todo.md).
|
||||
This page records exact release `9461e20` production facts and the current source
|
||||
deployment contract. Product direction and pending migrations are owned by
|
||||
[project memory](./project-memory.md) and [the TODO ledger](./todo.md).
|
||||
|
||||
This section documents the repository's UDP-only deployment candidate: one
|
||||
Node.js process provides the built Web client, room API, and WebSocket signaling
|
||||
@@ -194,7 +194,7 @@ manager or a cross-platform Node command such as:
|
||||
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
||||
```
|
||||
|
||||
Use this production baseline:
|
||||
Use this next-release baseline for the current source candidate:
|
||||
|
||||
```dotenv
|
||||
NODE_ENV=production
|
||||
@@ -207,6 +207,7 @@ ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite
|
||||
ROOM_TTL_SECONDS=14400
|
||||
MAX_ROOMS=1000
|
||||
MAX_VIEWERS_PER_ROOM=8
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY=2
|
||||
|
||||
STUN_URLS=stun:stun.example.com:3478
|
||||
```
|
||||
@@ -215,7 +216,6 @@ To make automatic SFU fallback capacity available, add the complete tuple:
|
||||
|
||||
```dotenv
|
||||
PEER_ASSISTED_MEDIA=true
|
||||
MAX_PEER_RELAY_DOWNSTREAM_EDGES=2
|
||||
LIVEKIT_URL=wss://share.example.com
|
||||
LIVEKIT_API_KEY=<GENERATED_LIVEKIT_API_KEY>
|
||||
LIVEKIT_API_SECRET=<INDEPENDENT_SECRET_OF_AT_LEAST_32_BYTES>
|
||||
@@ -264,15 +264,22 @@ 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.
|
||||
In exact release `9461e20`, `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.
|
||||
`ENDPOINT_MEDIA_COPY_CAPACITY` defaults to 2 and accepts only 1, 2, or 3. It is
|
||||
the single server-authoritative steady outbound media-copy cap for Host and
|
||||
Viewer endpoints; role, browser, UA, and visibility do not create another tier.
|
||||
A peer child or the Host's single SFU publication consumes one copy, upstream
|
||||
receive is free, a committed selected TURN transport for the same child does not
|
||||
consume a second copy, and an uncommitted selected carry does. Transition work
|
||||
may reach only `min(C + 1, 3)`. A Host already sending three copies at `C=3`
|
||||
must fail or wait before a fourth publication token or TURN grant is issued.
|
||||
Supplying the removed `MAX_PEER_RELAY_DOWNSTREAM_EDGES`, even blank, fails
|
||||
startup. The fixed per-room SFU-root and selected-lease guards remain separate
|
||||
temporary server safety boundaries.
|
||||
|
||||
Production release `9461e20` still runs the legacy Host-2/Browser-1 policy on
|
||||
wire `screener-v5`. Deploy the current source server, Web assets, and Native
|
||||
sender atomically on `screener-v6`; restore the exact prior environment and
|
||||
release together when rolling back.
|
||||
|
||||
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
|
||||
|
||||
@@ -21,6 +21,7 @@ Last updated: 2026-08-22
|
||||
## Current Source And Production
|
||||
|
||||
- Canonical source is the clean `main` branch. New branches and worktrees start from its exact current commit after accepted truth is merged.
|
||||
- The Route wave A merge candidate implements the uniform `1/2/3` endpoint cap through one shared accounting guard, atomically advances Web/server/Native to `screener-v6`, rejects v5 before room authority, treats SFU-fed Viewers as leaves without outbound proof, and retains the fixed SFU-root and room-wide selected-lease guards pending later server admission waves.
|
||||
- Production runs exact `9461e207af62b4f38f6b7a8aa16f8beb49e0e4ee`, release `9461e20`, wire `screener-v5`; `21d5cd9f7139` is the rollback release.
|
||||
- Production still enforces Host downstream `2`, ordinary Browser downstream `1`, deployment values `1/2`, a fixed SFU-root limit of `2`, and legacy publication/selected accounting. This is a dated implementation divergence, not current product policy.
|
||||
- Production has automatic routing, bad-relay corroboration, peer-quality make-before-break with Viewer and Host provisional parents, selected-edge transport, stable route/connection details, 64/128/256 audio choices, access controls, self-check, diagnostic export, and signaling watchdog behavior.
|
||||
|
||||
@@ -166,9 +166,9 @@ transient room TTL still caps the room, and restart drops it. A pre-auth
|
||||
connection or authentication failure gets one same-room/token/client-generation
|
||||
retry without a second room POST. SQLite remained v2 and ordinary Web creation
|
||||
was unchanged. Current Web and source-only Native have since moved to
|
||||
`screener-v5`, and current room persistence is SQLite schema v3. Retaining this
|
||||
paragraph records the dated candidate boundary; it does not authorize new work
|
||||
against v2.
|
||||
`screener-v6`, production remains on `screener-v5`, and current room persistence
|
||||
is SQLite schema v3. Retaining this paragraph records the dated candidate
|
||||
boundary; it does not authorize new work against v2.
|
||||
Android stop/capture-failure cleanup keeps the established signaling socket until
|
||||
its single-thread teardown sends `abandon-room` and then performs a normal close;
|
||||
pending-work cancellation only blocks callbacks and cancels active HTTP. No ACK
|
||||
|
||||
@@ -525,7 +525,7 @@ event arrives later. The application therefore needs a small text-message
|
||||
challenge rather than waiting for either protocol Ping/Pong or the old socket's
|
||||
close event.
|
||||
|
||||
The retained compatibility seam is response-only opt-in under `screener-v5`:
|
||||
The retained compatibility seam is response-only opt-in under `screener-v6`:
|
||||
the Web client sends `signaling-challenge { sequence }`, and the server sends
|
||||
the exact `signaling-challenge-response { sequence }` only to that requesting
|
||||
socket. The Native sender's strict decoder rejects unknown server message
|
||||
|
||||
+3
-2
@@ -14,9 +14,10 @@ This is the current execution index. Git history owns completed timelines; [veri
|
||||
## Current Source
|
||||
|
||||
- Canonical source is the clean `main` branch; auxiliary branches and worktrees do not own current truth.
|
||||
- The accepted endpoint direction is one steady outbound media-copy cap for every non-server endpoint: default `2`, static `1/2/3`, a peer child or Host publication consumes one slot, upstream receive is free, selected TURN replaces the same copy's transport, and no Browser/UA/visibility tier exists.
|
||||
- The Route wave A merge candidate implements one steady outbound media-copy cap for every non-server endpoint: default `2`, static `1/2/3`, a peer child or Host publication consumes one slot, upstream receive is free, a committed selected TURN transport replaces the same copy while hidden carry consumes another, and no Browser/UA/visibility tier exists. One shared guard owns the accounting and transition work is bounded by `min(C + 1, 3)`.
|
||||
- The same candidate atomically advances Web/server/Native to `screener-v6`, rejects v5 before room authority, leaves an SFU-fed Viewer childless without independent outbound proof, and keeps quality events child-scoped without a fixed two-child parent drain. Fixed SFU-root and room-wide selected-lease guards remain until their independent server admission owners land.
|
||||
- Automatic P2P-first routing, one active upstream, an acyclic graph, exact identity/generation authority, media-proven make-before-break, and bounded failure remain accepted.
|
||||
- The holistic assisted-route model is accepted: one controller performs allocation, child reparenting, and relay abdication/drain. One Host publication serves SFU subscriptions; TURN is an exact edge transport, not a topology node. Endpoint cap, transition overlap, media-binding generations, and independent SFU/TURN admission are governed by one bounded transaction and remain runtime validation work.
|
||||
- The holistic assisted-route model is accepted: one controller performs allocation, child reparenting, and relay abdication/drain. One Host publication serves SFU subscriptions; TURN is an exact edge transport, not a topology node. Wave A supplies endpoint accounting and the wire boundary without pretending that later controller convergence, media-binding generations, or independent SFU/TURN admission already exist.
|
||||
- Parent selection is deterministic rather than random or score-based: hard eligibility filters, then shallowest result, remaining sender capacity, stable join order, and peer identity; one provisional edge uses standard ICE and media proof at a time. Viewer connection feedback and the strict-NAT emulator gate are accepted follow-up work, not deployed behavior.
|
||||
- Live 64/128/256 audio-ceiling mutation, a source-checkout LAN one-command launcher, and paused-share video codec switching are accepted but not yet implemented on canonical `main`. H.264 startup blur/low-FPS reports remain an evidence-led diagnosis, not a presumed codec defect.
|
||||
|
||||
|
||||
+6
-4
@@ -106,7 +106,7 @@ Web Host 在 `authenticate` 中显式发送 `viewerPresence: true` capability,
|
||||
|
||||
## 信令协议
|
||||
|
||||
WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-access cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v5` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 site access;Web Host 可选发 `viewerPasswordSettings: true` capability,source-only Native 使用同一 wire 版本但不发送该 capability,也不接收密码设置或 presence 新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。
|
||||
WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-access cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v6` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 site access;Web Host 可选发 `viewerPasswordSettings: true` capability,source-only Native 使用同一 wire 版本但不发送该 capability,也不接收密码设置或 presence 新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。
|
||||
|
||||
主要消息:
|
||||
|
||||
@@ -126,7 +126,7 @@ WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-
|
||||
|
||||
Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。
|
||||
|
||||
历史上的授权迁移曾把唯一 literal 从 `screener-v1` 原子改为 `screener-v2`,不做版本协商;后续原子迁移已把当前 wire 推进到 `screener-v5`,旧 v1/v2 parser、双写和 translator 都不存在。不兼容旧页面的首条消息在任何角色进入 room lookup 前得到同一个 fatal refresh 终态并停止自动重连。缺失、过期、跨房、已 rotate 或 revoked 的当前 Viewer grant 使用同一个通用“邀请无效或已失效”结果,不区分房间不存在与凭据错误。
|
||||
历史上的授权迁移曾把唯一 literal 从 `screener-v1` 原子改为 `screener-v2`,不做版本协商;后续不兼容迁移已把当前 source wire 原子推进到 `screener-v6`,旧 parser、双写和 translator 都不存在。包括上一版 `screener-v5` 在内的不兼容旧页面,其首条消息在任何角色进入 room lookup 前得到同一个 fatal refresh 终态并停止自动重连。缺失、过期、跨房、已 rotate 或 revoked 的当前 Viewer grant 使用同一个通用“邀请无效或已失效”结果,不区分房间不存在与凭据错误。
|
||||
|
||||
## WebRTC 协商
|
||||
|
||||
@@ -186,7 +186,7 @@ Viewer 本地连接详情只从当前 route 的唯一 inbound screen video/audio
|
||||
|
||||
质量自适应采用 ADR-0007 的 built-in-first 路径隔离双表示,而不是全房间按最差链路降档。Direct/peer 保留每条 PeerConnection 的 stock WebRTC GCC。每条 SFU path 从 `HIGH` ceiling 开始;优先发布共享 `HIGH+LOW` 并让 LiveKit BWE 独立降层和恢复。若该 gate 通过,Screener 不实现媒体层 selector。应用证据只决定下述 parent/leaf 拓扑资格与诊断。`LOW` 可以常驻;关闭空闲层只是资源优化。active representation/layer 数量硬限制为二,不能按 viewer 增长。标准浏览器不保证跨 PeerConnection 共用一个物理编码器,inactive 层也不保证释放 GPU;实际层数、实例数、CPU/GPU、游戏 FPS/p1 low、区间 encode cost、上传与释放行为必须实测。若没有合格 hardware/power-efficient 路径或额外表示破坏游戏/上传预算,则 fail closed,保留健康路径并明确显示弱路径降级或失败。
|
||||
|
||||
质量证据保持 edge-local,只能影响证据覆盖的 parent/child authority,不能形成全房综合分数。现有实现包含 Viewer 与 Host provisional-parent、媒体证明后 promotion、失败保留旧边、坏 relay corroboration 和 bounded quarantine 等可复用机制;整体模型统一 candidate eligibility、质量 owner 的独立关联证据、sibling migration、migration mutex、capacity reservation 与 fallback 交互。无论策略取值如何,prepare/commit/rollback 都保留 exact session/share/revision/connection/media-binding identity,ICE connected 不能替代 live track、正 RTP 和 decoded-frame progress。
|
||||
质量证据保持 edge-local,只能影响证据覆盖的 parent/child authority,不能形成全房综合分数。现有实现包含 Viewer 与 Host provisional-parent、媒体证明后 promotion、失败保留旧边和 child-scoped reparent;relay-parent suspect/repair/drain 等待独立 evidence owner,不能从固定数量的 child 事件推断 parent failure。整体模型统一 candidate eligibility、质量 owner 的独立关联证据、sibling migration、migration mutex、capacity reservation 与 fallback 交互。无论策略取值如何,prepare/commit/rollback 都保留 exact session/share/revision/connection/media-binding identity,ICE connected 不能替代 live track、正 RTP 和 decoded-frame progress。
|
||||
|
||||
状态决策必须关联同一时间区间和媒体代次的三段证据:
|
||||
|
||||
@@ -329,7 +329,7 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio
|
||||
- RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝;
|
||||
- SQLite 房间库从 `1` 自增、重启恢复、v1/v2 -> v3 单事务迁移、nullable 32-byte grant 与 48-byte password material CHECK、畸形值 fail-closed、rotate/revoke/password 写失败原子性和数据最小化;数据库路径无 site access 时启动失败;
|
||||
- STUN/TURN UDP URL 严格校验、生产 STUN 必需、server-assisted tuple 完整性/TTL/secret 独立性、issuer HMAC/expiry/opaque tag、当前 route/session/revision/connection generation guard,以及 ordinary peer、未授权房间和 Native 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界;
|
||||
- 单一 `screener-v5` 信令 literal、v3 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
|
||||
- 单一 `screener-v6` 信令 literal、v5 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
|
||||
- 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;screen audio SDP helper 覆盖 CRLF、existing/missing fmtp、unknown key preservation、幂等、payload number、rejected/non-audio,以及 empty/no-Opus/ambiguous/parser 异常原对象 fallback,`ViewerPeer` 覆盖 initial/restart/rebuild 时同一结果的 local/signaling 一致性,SFU publisher 精确断言 high-quality stereo/forceStereo/DTX 且不覆盖 RED;
|
||||
- HTTP/WS 集成:nginx per-source site-access `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured site access 与 Host token,Viewer 要么持 exact-room grant,要么先有 site access 再按 room policy;site-access cookie 不绕过 private grant/password,grant/password 不能建房、取得 Host 或跨房。覆盖 fragment/sessionStorage/replaceState、无 site access 的 public/password code-only 统一拒绝、通过 site access 后的 public/password 进入、不含明文于 URL/浏览器持久存储/SQLite/log、Native capability isolation,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝;
|
||||
- 客户端策略与 stats 解析:会话替换不重连,ordinary ICE、LiveKit 与 selected-edge TURN protocol 分离,远端 TURN transport 保持未知;
|
||||
@@ -361,6 +361,8 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio
|
||||
7. 加入 first-frame-driven Viewer connection stages,再整合 source-checkout LAN launcher。Site access、room-scoped Viewer grant 与 SQLite v3 的已部署合同保持不变。
|
||||
8. Windows Native fixed-`HIGH` 保持可选且不改变 Web 默认;shared encode、移动 lifecycle、可分发 sender 和平台输出按 TODO roadmap 后续推进。
|
||||
|
||||
生产仍运行 `screener-v5`;当前 source 的 Wave A capacity slice 使用 `screener-v6`。不兼容 wire/schema 迁移须原子切换并保留迁移前备份。
|
||||
|
||||
任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。
|
||||
|
||||
## 自动路由设计
|
||||
|
||||
@@ -632,7 +632,7 @@ func newAppRemoteServer(t *testing.T, terminal chan<- []byte) *httptest.Server {
|
||||
if _, _, err = connection.Read(request.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
authenticated := []byte(`{"type":"authenticated","protocol":"screener-v5","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`)
|
||||
authenticated := []byte(`{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`)
|
||||
if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -688,7 +688,7 @@ func newDelayedAppRemoteServer(
|
||||
<-releaseAuthentication
|
||||
writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelWrite()
|
||||
_ = connection.Write(writeContext, websocket.MessageText, []byte(`{"type":"authenticated","protocol":"screener-v5","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`))
|
||||
_ = connection.Write(writeContext, websocket.MessageText, []byte(`{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`))
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
signalingProtocol = "screener-v5"
|
||||
signalingProtocol = "screener-v6"
|
||||
maxProtocolViewers = 16
|
||||
maxRouteRevision = int64(1<<53 - 1)
|
||||
maxSignalSDPBytes = 48 << 10
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const validHostAuthenticated = `{"type":"authenticated","protocol":"screener-v5","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`
|
||||
const validHostAuthenticated = `{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`
|
||||
|
||||
const validPeerAssistedHostAuthenticated = `{"type":"authenticated","protocol":"screener-v5","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":["viewer-1","viewer-2","viewer-3"],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678","mediaMode":"peer-assisted","mediaAssignment":{"parentPeerId":null,"childPeerIds":["viewer-1","viewer-2","viewer-3"]},"routeRevision":7,"routeAssignment":{"upstream":{"kind":"none"},"childPeerIds":["viewer-1","viewer-2","viewer-3"],"sfuPublicationGeneration":null},"qualitySettings":{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,"degradationPreference":"maintain-resolution","videoCodec":"automatic"}}`
|
||||
const validPeerAssistedHostAuthenticated = `{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":["viewer-1","viewer-2","viewer-3"],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678","mediaMode":"peer-assisted","mediaAssignment":{"parentPeerId":null,"childPeerIds":["viewer-1","viewer-2","viewer-3"]},"routeRevision":7,"routeAssignment":{"upstream":{"kind":"none"},"childPeerIds":["viewer-1","viewer-2","viewer-3"],"sfuPublicationGeneration":null},"qualitySettings":{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,"degradationPreference":"maintain-resolution","videoCodec":"automatic"}}`
|
||||
|
||||
func TestMarshalHostSignalShapes(t *testing.T) {
|
||||
mid := "0"
|
||||
@@ -139,7 +139,7 @@ func TestDecodePeerAssistedRouteMessages(t *testing.T) {
|
||||
func TestDecodeOrdinaryHostAuthenticationRejectsInvalidVariants(t *testing.T) {
|
||||
tests := map[string]func(map[string]any){
|
||||
"missing-protocol": func(message map[string]any) { delete(message, "protocol") },
|
||||
"wrong-protocol": func(message map[string]any) { message["protocol"] = "legacy-protocol" },
|
||||
"wrong-protocol": func(message map[string]any) { message["protocol"] = "screener-v5" },
|
||||
"missing-max": func(message map[string]any) { delete(message, "maxViewers") },
|
||||
"zero-max": func(message map[string]any) { message["maxViewers"] = 0 },
|
||||
"too-large-max": func(message map[string]any) { message["maxViewers"] = 17 },
|
||||
|
||||
@@ -7,12 +7,13 @@ 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_VIEWERS_PER_ROOM_LIMIT,
|
||||
type ParticipantRouteAssignment,
|
||||
} from "../src/shared/protocol";
|
||||
import {
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../src/shared/media-copy-accounting";
|
||||
import {
|
||||
createScreenerServer,
|
||||
type ScreenerServer,
|
||||
@@ -387,14 +388,14 @@ export function parseBenchmarkCanaryMode(value: string | undefined): BenchmarkCa
|
||||
export function parseExpectedEndpointCap(value: string | undefined): number {
|
||||
const parsed = value?.trim()
|
||||
? Number(value)
|
||||
: DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES;
|
||||
: DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY;
|
||||
if (
|
||||
!Number.isSafeInteger(parsed) ||
|
||||
parsed < 1 ||
|
||||
parsed > CURRENT_HOST_MEDIA_EDGE_LIMIT
|
||||
parsed > MAX_ENDPOINT_MEDIA_COPY_CAPACITY
|
||||
) {
|
||||
throw new Error(
|
||||
`BENCHMARK_EXPECTED_ENDPOINT_CAP must be an integer from 1 to ${CURRENT_HOST_MEDIA_EDGE_LIMIT}`,
|
||||
`BENCHMARK_EXPECTED_ENDPOINT_CAP must be an integer from 1 to ${MAX_ENDPOINT_MEDIA_COPY_CAPACITY}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
@@ -863,12 +864,8 @@ export function buildRunChecks(
|
||||
summary: NonNullable<BenchmarkRun["summary"]>,
|
||||
viewerCount: number,
|
||||
profileId: ProfileId,
|
||||
expectedEndpointCap = DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
expectedEndpointCap = DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
): RunCheck[] {
|
||||
const expectedViewerCap = Math.min(
|
||||
expectedEndpointCap,
|
||||
CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
);
|
||||
const sfuConsistencyCheck: RunCheck = summary.sfuPublicationObserved
|
||||
? {
|
||||
name: "sfu-route-consistency",
|
||||
@@ -898,9 +895,9 @@ export function buildRunChecks(
|
||||
},
|
||||
{
|
||||
name: "relay-active-media-edges",
|
||||
passed: summary.maxRelayActiveMediaEdges <= expectedViewerCap,
|
||||
passed: summary.maxRelayActiveMediaEdges <= expectedEndpointCap,
|
||||
actual: summary.maxRelayActiveMediaEdges,
|
||||
expected: `<= ${expectedViewerCap}`,
|
||||
expected: `<= ${expectedEndpointCap}`,
|
||||
},
|
||||
{
|
||||
name: "all-viewers-decoded",
|
||||
@@ -928,7 +925,7 @@ export function buildRunChecks(
|
||||
|
||||
export function buildRecoveryCheck(
|
||||
recovery: RecoveryResult,
|
||||
expectedEndpointCap = DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
expectedEndpointCap = DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
): RunCheck {
|
||||
const passed =
|
||||
recovery.triggered &&
|
||||
@@ -960,9 +957,7 @@ 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});
|
||||
const expectedRoleCap = options.expectedEndpointCap;
|
||||
if (options.clearHostRoom) {
|
||||
try { localStorage.removeItem("screener:host-room:v1"); } catch {}
|
||||
}
|
||||
@@ -2664,7 +2659,7 @@ export async function main(): Promise<number> {
|
||||
ALLOWED_ORIGINS: baseUrl,
|
||||
ROOM_DATABASE_PATH: "",
|
||||
PEER_ASSISTED_MEDIA: "true",
|
||||
MAX_PEER_RELAY_DOWNSTREAM_EDGES: String(config.expectedEndpointCap),
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY: String(config.expectedEndpointCap),
|
||||
MAX_VIEWERS_PER_ROOM: String(Math.max(
|
||||
...config.viewerCounts,
|
||||
config.canaryMode === "viewer-mbb" ? 3 : 1,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ParticipantRouteAssignment,
|
||||
SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
import { countEndpointMediaCopies } from "../../shared/media-copy-accounting";
|
||||
import { HostPeer } from "../webrtc/host-peer";
|
||||
import type { PeerSnapshot } from "../types";
|
||||
import type { QualityProfile } from "./quality";
|
||||
@@ -220,8 +221,13 @@ export function plannedHostProvisionalChild(
|
||||
input.activeChildPeerIds.some(
|
||||
(candidate) => !childPeerIds.includes(candidate),
|
||||
) ||
|
||||
hostPhysicalMediaEdges(input.assignment, input.selectedPeerId) >
|
||||
input.maxMediaEdges
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: input.assignment.childPeerIds,
|
||||
publicationGeneration: input.assignment.sfuPublicationGeneration,
|
||||
selectedChildPeerIds: input.selectedPeerId
|
||||
? [input.selectedPeerId]
|
||||
: [],
|
||||
}) > input.maxMediaEdges
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -249,16 +255,3 @@ export function resolveHostPreparedChildActivation(
|
||||
}
|
||||
: { kind: "promote", peerId };
|
||||
}
|
||||
|
||||
function hostPhysicalMediaEdges(
|
||||
assignment: ParticipantRouteAssignment,
|
||||
selectedPeerId: string | null,
|
||||
): number {
|
||||
const selectedOverlayEdges =
|
||||
selectedPeerId && !assignment.childPeerIds.includes(selectedPeerId) ? 1 : 0;
|
||||
return (
|
||||
assignment.childPeerIds.length +
|
||||
(assignment.sfuPublicationGeneration ? 1 : 0) +
|
||||
selectedOverlayEdges
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
type ClientMessage,
|
||||
} from "../../shared/protocol";
|
||||
import { MAX_ENDPOINT_MEDIA_COPY_CAPACITY } from "../../shared/media-copy-accounting";
|
||||
import type { ClientMessage } from "../../shared/protocol";
|
||||
|
||||
type RelayCapacityMessage = Extract<
|
||||
ClientMessage,
|
||||
@@ -16,6 +14,6 @@ export function relayCapacityMessageForBrowser(
|
||||
}
|
||||
return {
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
downstreamEdges: MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
};
|
||||
}
|
||||
@@ -114,7 +114,7 @@ import type {
|
||||
} from "../types";
|
||||
import { HostPeer } from "../webrtc/host-peer";
|
||||
import {
|
||||
MAX_HOST_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
reconcileBoundedMediaChildren,
|
||||
} from "../webrtc/media-assignment";
|
||||
import {
|
||||
@@ -970,7 +970,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
assignment,
|
||||
activeChildPeerIds: activeHostChildPeerIdsRef.current,
|
||||
selectedPeerId: selectedHostChildRef.current?.peerId ?? null,
|
||||
maxMediaEdges: MAX_HOST_MEDIA_CHILDREN,
|
||||
maxMediaEdges: MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
iceConfig,
|
||||
stream,
|
||||
profile: qualitySettingsRef.current,
|
||||
@@ -989,7 +989,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
assignment,
|
||||
activeChildPeerIds,
|
||||
selectedPeerId: selectedHostChildRef.current?.peerId ?? null,
|
||||
maxMediaEdges: MAX_HOST_MEDIA_CHILDREN,
|
||||
maxMediaEdges: MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
}) ?? { kind: "ordinary" as const };
|
||||
if (activation.kind === "promote") {
|
||||
hostProvisionalChildRef.current = null;
|
||||
@@ -1180,7 +1180,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
reconcileBoundedMediaChildren(
|
||||
peersRef.current.keys(),
|
||||
effectiveChildPeerIds,
|
||||
MAX_HOST_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
removePeer,
|
||||
(peerId) => {
|
||||
void startPeer(peerId, generation).catch((error: unknown) => {
|
||||
|
||||
@@ -74,7 +74,7 @@ import type {
|
||||
} from "../types";
|
||||
import {
|
||||
limitMediaAssignment,
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
retainSelectedMediaParent,
|
||||
viewerRestartMessage,
|
||||
viewerSignalMessage,
|
||||
@@ -648,7 +648,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
parentPeerId: currentAssignment.parentPeerId,
|
||||
childPeerIds: [...childPeerIds],
|
||||
},
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
);
|
||||
reconcileRelayChildren(previousChildPeerIds, revision);
|
||||
},
|
||||
@@ -685,7 +685,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const previousChildPeerIds = currentAssignment.childPeerIds;
|
||||
currentAssignment = limitMediaAssignment(
|
||||
{ parentPeerId: null, childPeerIds: assignment.childPeerIds },
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
);
|
||||
reconcileRelayChildren(previousChildPeerIds);
|
||||
const relay = ensureViewerRelay();
|
||||
@@ -768,7 +768,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
function applyMediaAssignment(assignment: MediaAssignment, preserveUpstream = false): void {
|
||||
const nextAssignment = limitMediaAssignment(
|
||||
assignment,
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
);
|
||||
const previousParentId = currentAssignment.parentPeerId;
|
||||
const previousChildPeerIds = currentAssignment.childPeerIds;
|
||||
|
||||
@@ -3,14 +3,9 @@ import type {
|
||||
MediaAssignment,
|
||||
SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
import {
|
||||
CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
CURRENT_HOST_MEDIA_EDGE_LIMIT,
|
||||
} from "../../shared/protocol";
|
||||
import { MAX_ENDPOINT_MEDIA_COPY_CAPACITY } from "../../shared/media-copy-accounting";
|
||||
|
||||
export const MAX_HOST_MEDIA_CHILDREN = CURRENT_HOST_MEDIA_EDGE_LIMIT;
|
||||
export const MAX_VIEWER_MEDIA_CHILDREN =
|
||||
CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT;
|
||||
export const MAX_ENDPOINT_MEDIA_CHILDREN = MAX_ENDPOINT_MEDIA_COPY_CAPACITY;
|
||||
|
||||
export function limitMediaAssignment(
|
||||
assignment: MediaAssignment,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../media/quality";
|
||||
import type { PeerSnapshot } from "../types";
|
||||
import { HostPeer } from "./host-peer";
|
||||
import { MAX_VIEWER_MEDIA_CHILDREN } from "./media-assignment";
|
||||
import { MAX_ENDPOINT_MEDIA_CHILDREN } from "./media-assignment";
|
||||
|
||||
interface ViewerRelayEvents {
|
||||
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
|
||||
@@ -99,7 +99,7 @@ export class ViewerRelay {
|
||||
Date.parse(message.expiresAt) <= now ||
|
||||
connectionId !== message.oldConnectionId ||
|
||||
(!retainedChildPeerIds.includes(message.viewerPeerId) &&
|
||||
retainedChildPeerIds.length >= MAX_VIEWER_MEDIA_CHILDREN)
|
||||
retainedChildPeerIds.length >= MAX_ENDPOINT_MEDIA_CHILDREN)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export class ViewerRelay {
|
||||
!stream ||
|
||||
planned.length !== plannedChildPeerIds.length ||
|
||||
planned.length !== this.childPeerIds.length + 1 ||
|
||||
planned.length > MAX_VIEWER_MEDIA_CHILDREN ||
|
||||
planned.length > MAX_ENDPOINT_MEDIA_CHILDREN ||
|
||||
this.childPeerIds.some((peerId) => !planned.includes(peerId)) ||
|
||||
!childPeerId ||
|
||||
this.peers.has(childPeerId)
|
||||
@@ -247,11 +247,11 @@ export class ViewerRelay {
|
||||
}
|
||||
const nextChildPeerIds = [...new Set(childPeerIds)].slice(
|
||||
0,
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
);
|
||||
const selectedPeerId = this.selectedChildConnection?.peerId ?? null;
|
||||
if (selectedPeerId && !nextChildPeerIds.includes(selectedPeerId)) {
|
||||
if (nextChildPeerIds.length >= MAX_VIEWER_MEDIA_CHILDREN) {
|
||||
if (nextChildPeerIds.length >= MAX_ENDPOINT_MEDIA_CHILDREN) {
|
||||
nextChildPeerIds.pop();
|
||||
}
|
||||
nextChildPeerIds.push(selectedPeerId);
|
||||
@@ -631,7 +631,7 @@ export class ViewerRelay {
|
||||
if (peer) {
|
||||
this.retiredConnections.delete(childPeerId);
|
||||
this.retiredConnections.set(childPeerId, peer.connectionId);
|
||||
while (this.retiredConnections.size > MAX_VIEWER_MEDIA_CHILDREN) {
|
||||
while (this.retiredConnections.size > MAX_ENDPOINT_MEDIA_CHILDREN) {
|
||||
const oldestPeerId = this.retiredConnections.keys().next().value;
|
||||
if (typeof oldestPeerId === "string") {
|
||||
this.retiredConnections.delete(oldestPeerId);
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ export async function createScreenerServer(
|
||||
server: httpServer,
|
||||
roomStore,
|
||||
peerAssistedMedia: config.peerAssistedMedia,
|
||||
maxPeerRelayDownstreamEdges: config.maxPeerRelayDownstreamEdges,
|
||||
endpointMediaCopyCapacity: config.endpointMediaCopyCapacity,
|
||||
...(sfuFallback ? { sfuFallback } : {}),
|
||||
...(config.selectedEdgeTurn ? { selectedEdgeTurn: config.selectedEdgeTurn } : {}),
|
||||
ice: iceOptions,
|
||||
|
||||
+15
-10
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
CURRENT_HOST_MEDIA_EDGE_LIMIT,
|
||||
CURRENT_SFU_ROOT_LIMIT,
|
||||
DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
MAX_ICE_SERVER_URLS,
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
stunUrlSchema,
|
||||
turnUrlSchema,
|
||||
} from "../shared/protocol.js";
|
||||
import {
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
|
||||
export type RuntimeEnvironment = "development" | "test" | "production";
|
||||
|
||||
@@ -29,6 +31,7 @@ const REMOVED_ENVIRONMENT_VARIABLES = [
|
||||
"PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS",
|
||||
"PEER_ASSISTED_ROOM_IDS",
|
||||
"HOST_ADMISSION_PASSWORD",
|
||||
"MAX_PEER_RELAY_DOWNSTREAM_EDGES",
|
||||
] as const;
|
||||
|
||||
export interface LiveKitFallbackConfig {
|
||||
@@ -56,7 +59,7 @@ export interface ServerConfig {
|
||||
maxRooms: number;
|
||||
maxViewersPerRoom: number;
|
||||
peerAssistedMedia: boolean;
|
||||
maxPeerRelayDownstreamEdges: number;
|
||||
endpointMediaCopyCapacity: number;
|
||||
livekitFallback?: LiveKitFallbackConfig;
|
||||
selectedEdgeTurn?: SelectedEdgeTurnConfig;
|
||||
stunUrls: readonly string[];
|
||||
@@ -272,7 +275,9 @@ export function loadConfig(
|
||||
for (const name of REMOVED_ENVIRONMENT_VARIABLES) {
|
||||
if (Object.prototype.hasOwnProperty.call(environment, name)) {
|
||||
throw new Error(
|
||||
name === "HOST_ADMISSION_PASSWORD"
|
||||
name === "MAX_PEER_RELAY_DOWNSTREAM_EDGES"
|
||||
? `${name} is no longer supported; use ENDPOINT_MEDIA_COPY_CAPACITY`
|
||||
: name === "HOST_ADMISSION_PASSWORD"
|
||||
? `${name} is no longer supported; use SITE_ACCESS_PASSWORD`
|
||||
: name === "PEER_ASSISTED_ROOM_IDS"
|
||||
? `${name} is no longer supported; peer-assisted media applies to every room when enabled`
|
||||
@@ -336,12 +341,12 @@ export function loadConfig(
|
||||
false,
|
||||
"PEER_ASSISTED_MEDIA",
|
||||
);
|
||||
const maxPeerRelayDownstreamEdges = parseBoundedInteger(
|
||||
environment.MAX_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
"MAX_PEER_RELAY_DOWNSTREAM_EDGES",
|
||||
const endpointMediaCopyCapacity = parseBoundedInteger(
|
||||
environment.ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
"ENDPOINT_MEDIA_COPY_CAPACITY",
|
||||
1,
|
||||
CURRENT_HOST_MEDIA_EDGE_LIMIT,
|
||||
MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
);
|
||||
const livekitFallback = parseLiveKitFallback(environment, nodeEnv);
|
||||
const selectedEdgeTurn = parseSelectedEdgeTurn(environment);
|
||||
@@ -406,7 +411,7 @@ export function loadConfig(
|
||||
maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"),
|
||||
maxViewersPerRoom,
|
||||
peerAssistedMedia,
|
||||
maxPeerRelayDownstreamEdges,
|
||||
endpointMediaCopyCapacity,
|
||||
livekitFallback,
|
||||
selectedEdgeTurn,
|
||||
stunUrls,
|
||||
|
||||
+195
-323
@@ -1,8 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
CURRENT_HOST_MEDIA_EDGE_LIMIT,
|
||||
CURRENT_SFU_ROOT_LIMIT,
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
type ClientMessage,
|
||||
@@ -14,6 +12,11 @@ import {
|
||||
type Role,
|
||||
type ServerMessage,
|
||||
} from "../shared/protocol.js";
|
||||
import {
|
||||
assertEndpointMediaCopyCapacity,
|
||||
countEndpointMediaCopies,
|
||||
endpointMediaCopyCountFits,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
import type { SfuTokenIssuer } from "./livekit-token.js";
|
||||
import type { SelectedEdgeTurnConfig } from "./config.js";
|
||||
import {
|
||||
@@ -160,14 +163,6 @@ interface ViewerQualityEvidenceState {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface RelayParentQualityState {
|
||||
parentSessionId: string;
|
||||
shareGeneration: string;
|
||||
lastChildPeerId: string;
|
||||
lastConfirmedAtMs: number;
|
||||
quarantinedUntilMs: number | null;
|
||||
}
|
||||
|
||||
interface ForwardedViewerQualityEvidence {
|
||||
roomId: string;
|
||||
viewerSessionId: string;
|
||||
@@ -196,7 +191,7 @@ export interface SfuFallbackOptions {
|
||||
|
||||
export interface HybridMediaRouterOptions {
|
||||
roomStore: RoomStore;
|
||||
maxPeerRelayDownstreamEdges: RelayDownstreamEdges;
|
||||
endpointMediaCopyCapacity: number;
|
||||
sfuFallback?: SfuFallbackOptions;
|
||||
selectedEdgeTurn?: SelectedEdgeTurnConfig;
|
||||
sendToSession: (sessionId: string, message: ServerMessage) => void;
|
||||
@@ -233,7 +228,7 @@ export interface AuthenticatedRouteParticipant {
|
||||
|
||||
export class HybridMediaRouter {
|
||||
private readonly peerRelayTopology: PeerRelayTopology;
|
||||
private readonly maxHostMediaEdges: number;
|
||||
private readonly endpointMediaCopyCapacity: number;
|
||||
private readonly mediaRouteControllers = new Map<
|
||||
string,
|
||||
MediaRouteController
|
||||
@@ -251,7 +246,7 @@ export class HybridMediaRouter {
|
||||
Map<string, ViewerRouteIntent>
|
||||
>();
|
||||
private readonly consumedSfuRefreshesByRoom = new Map<string, Set<string>>();
|
||||
private readonly selectedPeerEdgeLeases = new Map<
|
||||
private readonly selectedPeerEdgeLeasesByRoom = new Map<
|
||||
string,
|
||||
SelectedPeerEdgeLease
|
||||
>();
|
||||
@@ -284,16 +279,12 @@ export class HybridMediaRouter {
|
||||
string,
|
||||
DeferredHealthySfuReselection
|
||||
>();
|
||||
private readonly relayParentQualityStates = new Map<string, RelayParentQualityState>();
|
||||
|
||||
constructor(private readonly options: HybridMediaRouterOptions) {
|
||||
assertEndpointMediaCopyCapacity(options.endpointMediaCopyCapacity);
|
||||
this.peerRelayTopology = new PeerRelayTopology(
|
||||
options.maxPeerRelayDownstreamEdges,
|
||||
);
|
||||
this.maxHostMediaEdges = Math.min(
|
||||
options.maxPeerRelayDownstreamEdges,
|
||||
CURRENT_HOST_MEDIA_EDGE_LIMIT,
|
||||
options.endpointMediaCopyCapacity,
|
||||
);
|
||||
this.endpointMediaCopyCapacity = options.endpointMediaCopyCapacity;
|
||||
const fallback = options.sfuFallback;
|
||||
if (options.selectedEdgeTurn && !fallback) {
|
||||
throw new Error("Selected-edge TURN requires SFU fallback");
|
||||
@@ -320,10 +311,10 @@ export class HybridMediaRouter {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pendingRoutePreparations.clear();
|
||||
for (const lease of this.selectedPeerEdgeLeases.values()) {
|
||||
for (const lease of this.selectedPeerEdgeLeasesByRoom.values()) {
|
||||
clearTimeout(lease.timer);
|
||||
}
|
||||
this.selectedPeerEdgeLeases.clear();
|
||||
this.selectedPeerEdgeLeasesByRoom.clear();
|
||||
for (const attempt of this.selectedSfuIngressAttempts.values()) {
|
||||
clearTimeout(attempt.timer);
|
||||
}
|
||||
@@ -344,7 +335,6 @@ export class HybridMediaRouter {
|
||||
this.viewerQualityEvidenceStates.clear();
|
||||
this.heldQualityParentExclusionsByViewer.clear();
|
||||
this.roomQualityMigrationCooldownUntilMs.clear();
|
||||
this.relayParentQualityStates.clear();
|
||||
}
|
||||
|
||||
connectParticipant(input: AuthenticatedRouteParticipant): HybridAuthenticationState {
|
||||
@@ -355,7 +345,6 @@ export class HybridMediaRouter {
|
||||
input.peerId,
|
||||
);
|
||||
this.clearRoomViewerQualityEvidenceStates(input.roomId);
|
||||
this.refreshRelayParentQualityStates(input.roomId);
|
||||
this.clearHeldQualityParentExclusion(input.roomId, input.peerId);
|
||||
this.clearSfuRefreshesForPeer(input.roomId, input.peerId);
|
||||
if (input.role === "viewer") {
|
||||
@@ -457,16 +446,15 @@ export class HybridMediaRouter {
|
||||
signalKind: "candidate" | "description";
|
||||
descriptionType?: "offer" | "answer";
|
||||
}): boolean | "probe" | undefined {
|
||||
const sourceKey = viewerConnectionKey(input.roomId, input.sourcePeerId);
|
||||
const targetKey = viewerConnectionKey(input.roomId, input.targetPeerId);
|
||||
const roomLease = this.selectedPeerEdgeLeasesByRoom.get(input.roomId);
|
||||
const lease =
|
||||
this.selectedPeerEdgeLeases.get(sourceKey)?.parentPeerId ===
|
||||
input.targetPeerId
|
||||
? this.selectedPeerEdgeLeases.get(sourceKey)
|
||||
: this.selectedPeerEdgeLeases.get(targetKey)?.parentPeerId ===
|
||||
input.sourcePeerId
|
||||
? this.selectedPeerEdgeLeases.get(targetKey)
|
||||
: undefined;
|
||||
roomLease &&
|
||||
((roomLease.parentPeerId === input.sourcePeerId &&
|
||||
roomLease.viewerPeerId === input.targetPeerId) ||
|
||||
(roomLease.viewerPeerId === input.sourcePeerId &&
|
||||
roomLease.parentPeerId === input.targetPeerId))
|
||||
? roomLease
|
||||
: undefined;
|
||||
if (!lease) {
|
||||
const pending = this.pendingRoutePreparations.get(input.roomId);
|
||||
const qualityProbe = pending?.peerQualityReselection;
|
||||
@@ -540,10 +528,11 @@ export class HybridMediaRouter {
|
||||
viewerPeerId: string,
|
||||
connectionId: string,
|
||||
): void {
|
||||
const lease = this.selectedPeerEdgeLeases.get(
|
||||
viewerConnectionKey(roomId, viewerPeerId),
|
||||
);
|
||||
if (lease?.newConnectionId === connectionId) {
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (
|
||||
lease?.viewerPeerId === viewerPeerId &&
|
||||
lease.newConnectionId === connectionId
|
||||
) {
|
||||
lease.answered = true;
|
||||
clearTimeout(lease.timer);
|
||||
this.drainViewerRouteIntents(roomId);
|
||||
@@ -726,7 +715,6 @@ export class HybridMediaRouter {
|
||||
correlatedAtMs: now,
|
||||
framesPerSecond: pending.evidence.metrics.framesPerSecond,
|
||||
};
|
||||
this.refreshRelayParentQualityStates(roomId, now);
|
||||
const existingIntent = this.viewerRouteIntentsByRoom
|
||||
.get(roomId)
|
||||
?.get(viewerPeerId);
|
||||
@@ -766,25 +754,11 @@ export class HybridMediaRouter {
|
||||
return;
|
||||
}
|
||||
|
||||
const relayParentBadEvent = this.handleRelayParentBadEvent(
|
||||
roomId,
|
||||
edge.parentPeerId,
|
||||
parentSessionId,
|
||||
viewerPeerId,
|
||||
badWindowKind!,
|
||||
now,
|
||||
);
|
||||
if (relayParentBadEvent === "trigger-moved") {
|
||||
this.viewerQualityEvidenceStates.delete(connectionKey);
|
||||
return;
|
||||
}
|
||||
const cooldownUntil = this.roomQualityMigrationCooldownUntilMs.get(roomId);
|
||||
if (cooldownUntil !== undefined && now < cooldownUntil) {
|
||||
if (relayParentBadEvent === null) {
|
||||
state.badWindowCount = 0;
|
||||
state.badWindowKind = null;
|
||||
return;
|
||||
}
|
||||
state.badWindowCount = 0;
|
||||
state.badWindowKind = null;
|
||||
return;
|
||||
} else if (cooldownUntil !== undefined) {
|
||||
this.roomQualityMigrationCooldownUntilMs.delete(roomId);
|
||||
}
|
||||
@@ -868,7 +842,6 @@ export class HybridMediaRouter {
|
||||
if (participant.role !== "viewer") {
|
||||
return;
|
||||
}
|
||||
this.refreshRelayParentQualityStates(participant.roomId);
|
||||
let advertised = this.relayCapacitySessionsByRoom.get(participant.roomId);
|
||||
if (!advertised) {
|
||||
advertised = new Map();
|
||||
@@ -988,20 +961,24 @@ export class HybridMediaRouter {
|
||||
const host = this.connectedPeer(roomId, hostPeerId);
|
||||
const parent = this.connectedPeer(roomId, parentPeerId);
|
||||
const activeParent = active.assignments.get(parentPeerId);
|
||||
const capacity = Math.min(
|
||||
this.peerRelayTopology.getDownstreamCapacity(roomId, parentPeerId),
|
||||
const parentSupportsOutboundRelay =
|
||||
parentPeerId === hostPeerId
|
||||
? this.maxHostMediaEdges
|
||||
: CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
? activeParent?.upstream.kind === "none"
|
||||
: activeParent?.upstream.kind === "peer";
|
||||
const capacity = this.peerRelayTopology.getDownstreamCapacity(
|
||||
roomId,
|
||||
parentPeerId,
|
||||
);
|
||||
if (
|
||||
!host ||
|
||||
!parent ||
|
||||
!activeParent ||
|
||||
activeParent.childPeerIds.length +
|
||||
(activeParent.sfuPublicationGeneration ? 1 : 0) +
|
||||
1 >
|
||||
capacity ||
|
||||
!parentSupportsOutboundRelay ||
|
||||
!endpointMediaCopyCountFits(
|
||||
this.activeParentMediaEdges(roomId, parentPeerId, activeParent) + 1,
|
||||
capacity,
|
||||
"transition",
|
||||
) ||
|
||||
(parentPeerId !== hostPeerId &&
|
||||
this.relayCapacitySessionsByRoom.get(roomId)?.get(parentPeerId) !==
|
||||
parent.sessionId)
|
||||
@@ -1013,7 +990,7 @@ export class HybridMediaRouter {
|
||||
hostPeerId,
|
||||
null,
|
||||
[],
|
||||
this.maxHostMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
const rootAssignment = assignments.get(participant.peerId)!;
|
||||
const parentAssignment = assignments.get(parentPeerId)!;
|
||||
@@ -1643,11 +1620,8 @@ export class HybridMediaRouter {
|
||||
if (pending?.peerQualityReselection) {
|
||||
return "quality";
|
||||
}
|
||||
return [...this.selectedPeerEdgeLeases.values()].some(
|
||||
(lease) =>
|
||||
lease.roomId === roomId &&
|
||||
(!lease.answered || lease.viewerPeerId === viewerPeerId),
|
||||
)
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
return lease && (!lease.answered || lease.viewerPeerId === viewerPeerId)
|
||||
? "selected"
|
||||
: null;
|
||||
}
|
||||
@@ -1668,21 +1642,16 @@ export class HybridMediaRouter {
|
||||
const controller = this.mediaRouteControllers.get(roomId);
|
||||
const active = controller?.getActiveRoute();
|
||||
const intent = this.viewerRouteIntentsByRoom.get(roomId)?.get(viewerPeerId);
|
||||
const key = viewerConnectionKey(roomId, viewerPeerId);
|
||||
const roomAttempt = this.roomPeerMigrationAttempt(roomId);
|
||||
if (roomAttempt === "healthy" || roomAttempt === "quality") {
|
||||
// The room probe already owns recovery, so suppress concurrent failback.
|
||||
return true;
|
||||
}
|
||||
if (this.selectedPeerEdgeLeases.has(key)) {
|
||||
const roomLease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (roomLease?.viewerPeerId === viewerPeerId) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
roomAttempt === "selected" ||
|
||||
[...this.selectedPeerEdgeLeases.values()].some(
|
||||
(lease) => lease.roomId === roomId,
|
||||
)
|
||||
) {
|
||||
if (roomAttempt === "selected" || roomLease) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -1703,27 +1672,31 @@ export class HybridMediaRouter {
|
||||
const parent = this.connectedPeer(roomId, intent.failedParentPeerId);
|
||||
const shareGeneration = this.options.getShareGeneration(roomId);
|
||||
const parentAssignment = active.assignments.get(intent.failedParentPeerId);
|
||||
const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId);
|
||||
const parentSupportsOutboundRelay =
|
||||
intent.failedParentPeerId === hostPeerId
|
||||
? parentAssignment?.upstream.kind === "none"
|
||||
: parentAssignment?.upstream.kind === "peer";
|
||||
const parentChildLimit =
|
||||
this.peerRelayTopology.getAdvertisedDownstreamCapacity(
|
||||
roomId,
|
||||
intent.failedParentPeerId,
|
||||
);
|
||||
const selectedOverlayEdges = parentAssignment?.childPeerIds.includes(
|
||||
viewerPeerId,
|
||||
)
|
||||
? 0
|
||||
: 1;
|
||||
const parentActiveEdges = parentAssignment
|
||||
? parentAssignment.childPeerIds.length +
|
||||
(parentAssignment.sfuPublicationGeneration ? 1 : 0) +
|
||||
selectedOverlayEdges
|
||||
? countEndpointMediaCopies({
|
||||
childPeerIds: parentAssignment.childPeerIds,
|
||||
publicationGeneration:
|
||||
parentAssignment.sfuPublicationGeneration,
|
||||
selectedChildPeerIds: [viewerPeerId],
|
||||
})
|
||||
: Number.POSITIVE_INFINITY;
|
||||
if (
|
||||
viewer?.sessionId !== intent.sessionId ||
|
||||
parent?.sessionId !== intent.failedParentSessionId ||
|
||||
!parentAssignment ||
|
||||
!parentSupportsOutboundRelay ||
|
||||
parentChildLimit === 0 ||
|
||||
parentActiveEdges > parentChildLimit ||
|
||||
!endpointMediaCopyCountFits(parentActiveEdges, parentChildLimit) ||
|
||||
!shareGeneration
|
||||
) {
|
||||
return false;
|
||||
@@ -1773,7 +1746,7 @@ export class HybridMediaRouter {
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const current = this.selectedPeerEdgeLeases.get(key);
|
||||
const current = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (
|
||||
!current ||
|
||||
current.newConnectionId !== newConnectionId ||
|
||||
@@ -1781,7 +1754,7 @@ export class HybridMediaRouter {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const settled = this.settleSelectedPeerEdgeLease(key);
|
||||
const settled = this.settleSelectedPeerEdgeLease(roomId);
|
||||
if (!settled) {
|
||||
return;
|
||||
}
|
||||
@@ -1817,7 +1790,7 @@ export class HybridMediaRouter {
|
||||
answered: false,
|
||||
timer,
|
||||
};
|
||||
this.selectedPeerEdgeLeases.set(key, lease);
|
||||
this.selectedPeerEdgeLeasesByRoom.set(roomId, lease);
|
||||
// Queue the Viewer grant first. Any parent offer is handled on a later
|
||||
// event-loop turn, so the Viewer's socket observes this grant first.
|
||||
this.options.sendToSession(viewer.sessionId, message);
|
||||
@@ -2037,23 +2010,7 @@ export class HybridMediaRouter {
|
||||
if (message.connectionId === null) {
|
||||
return false;
|
||||
}
|
||||
let key = viewerConnectionKey(participant.roomId, participant.peerId);
|
||||
let lease = this.selectedPeerEdgeLeases.get(key);
|
||||
if (
|
||||
!lease ||
|
||||
lease.viewerPeerId !== participant.peerId ||
|
||||
lease.viewerSessionId !== participant.sessionId
|
||||
) {
|
||||
const parentEntry = [...this.selectedPeerEdgeLeases].find(
|
||||
([, candidate]) =>
|
||||
candidate.roomId === participant.roomId &&
|
||||
candidate.parentPeerId === participant.peerId &&
|
||||
candidate.parentSessionId === participant.sessionId,
|
||||
);
|
||||
if (parentEntry) {
|
||||
[key, lease] = parentEntry;
|
||||
}
|
||||
}
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(participant.roomId);
|
||||
const active = this.mediaRouteControllers
|
||||
.get(participant.roomId)
|
||||
?.getActiveRoute();
|
||||
@@ -2079,7 +2036,7 @@ export class HybridMediaRouter {
|
||||
participant.roomId,
|
||||
lease.viewerPeerId,
|
||||
);
|
||||
this.settleSelectedPeerEdgeLease(key);
|
||||
this.settleSelectedPeerEdgeLease(participant.roomId);
|
||||
this.sendError(
|
||||
participant.sessionId,
|
||||
"PEER_NOT_FOUND",
|
||||
@@ -2108,7 +2065,7 @@ export class HybridMediaRouter {
|
||||
hostPeerId,
|
||||
null,
|
||||
[],
|
||||
this.maxHostMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
const revision = controller.reconcileBaseline({
|
||||
assignments,
|
||||
@@ -2143,7 +2100,7 @@ export class HybridMediaRouter {
|
||||
hostPeerId,
|
||||
null,
|
||||
[],
|
||||
this.maxHostMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
const revision = controller.reconcileBaseline({
|
||||
assignments,
|
||||
@@ -2192,7 +2149,6 @@ export class HybridMediaRouter {
|
||||
}
|
||||
|
||||
private drainViewerRouteIntents(roomId: string): void {
|
||||
this.refreshRelayParentQualityStates(roomId);
|
||||
this.enqueueUnassignedViewerIntents(roomId);
|
||||
const pending = this.pendingRoutePreparations.get(roomId);
|
||||
if (pending) {
|
||||
@@ -2206,15 +2162,13 @@ export class HybridMediaRouter {
|
||||
}
|
||||
const intents = this.viewerRouteIntentsByRoom.get(roomId);
|
||||
if (!intents) {
|
||||
this.evacuateQuarantinedRelayParents(roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [viewerPeerId, intent] of [...intents]) {
|
||||
if (
|
||||
this.selectedPeerEdgeLeases.has(
|
||||
viewerConnectionKey(roomId, viewerPeerId),
|
||||
)
|
||||
this.selectedPeerEdgeLeasesByRoom.get(roomId)?.viewerPeerId ===
|
||||
viewerPeerId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -2412,9 +2366,6 @@ export class HybridMediaRouter {
|
||||
if (intents.size === 0) {
|
||||
this.viewerRouteIntentsByRoom.delete(roomId);
|
||||
}
|
||||
if (this.roomPeerMigrationAttempt(roomId) !== "selected") {
|
||||
this.evacuateQuarantinedRelayParents(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
private startPeerQualityReselection(
|
||||
@@ -2454,13 +2405,15 @@ export class HybridMediaRouter {
|
||||
: this.options.roomStore.getConnectedViewer(roomId, peerId);
|
||||
const supportedParent = isHost
|
||||
? assignment.upstream.kind === "none"
|
||||
: assignment.upstream.kind === "peer" ||
|
||||
assignment.upstream.kind === "sfu";
|
||||
: assignment.upstream.kind === "peer";
|
||||
if (
|
||||
!participant ||
|
||||
!supportedParent ||
|
||||
this.activeParentMediaEdges(roomId, peerId, assignment) + 1 >
|
||||
this.peerRelayTopology.getDownstreamCapacity(roomId, peerId)
|
||||
!endpointMediaCopyCountFits(
|
||||
this.activeParentMediaEdges(roomId, peerId, assignment) + 1,
|
||||
this.peerRelayTopology.getDownstreamCapacity(roomId, peerId),
|
||||
"transition",
|
||||
)
|
||||
) {
|
||||
candidateExclusions.add(peerId);
|
||||
}
|
||||
@@ -2755,7 +2708,15 @@ export class HybridMediaRouter {
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
if (host.childPeerIds.length > this.maxHostMediaEdges - 1) {
|
||||
if (
|
||||
!endpointMediaCopyCountFits(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: host.childPeerIds,
|
||||
publicationGeneration: "candidate",
|
||||
}),
|
||||
this.endpointMediaCopyCapacity,
|
||||
)
|
||||
) {
|
||||
const budgetRootPeerId =
|
||||
requestedAssignment.upstream.kind === "peer"
|
||||
? findHostBranchRoot(active.assignments, failedPeerId, hostPeerId)
|
||||
@@ -2790,8 +2751,13 @@ export class HybridMediaRouter {
|
||||
if (
|
||||
rootPeerIds.length > fallback.maxRoots ||
|
||||
!host ||
|
||||
host.childPeerIds.length + 1 >
|
||||
this.maxHostMediaEdges
|
||||
!endpointMediaCopyCountFits(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: host.childPeerIds,
|
||||
publicationGeneration: "candidate",
|
||||
}),
|
||||
this.endpointMediaCopyCapacity,
|
||||
)
|
||||
) {
|
||||
this.sendError(
|
||||
sourceSessionId,
|
||||
@@ -2801,6 +2767,23 @@ export class HybridMediaRouter {
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
const activeHost = active.assignments.get(hostPeerId);
|
||||
if (
|
||||
!activeHost ||
|
||||
!endpointMediaCopyCountFits(
|
||||
this.activeParentMediaEdges(roomId, hostPeerId, activeHost) + 1,
|
||||
this.endpointMediaCopyCapacity,
|
||||
"transition",
|
||||
)
|
||||
) {
|
||||
this.sendError(
|
||||
sourceSessionId,
|
||||
"PEER_NOT_FOUND",
|
||||
"Endpoint media copy transition capacity is exhausted",
|
||||
);
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
const generation = randomBytes(16).toString("base64url");
|
||||
assignments.set(hostPeerId, {
|
||||
...host,
|
||||
@@ -3056,8 +3039,7 @@ export class HybridMediaRouter {
|
||||
const supportedParent =
|
||||
probe?.newParentPeerId === hostPeerId
|
||||
? parentAssignment?.upstream.kind === "none"
|
||||
: parentAssignment?.upstream.kind === "peer" ||
|
||||
parentAssignment?.upstream.kind === "sfu";
|
||||
: parentAssignment?.upstream.kind === "peer";
|
||||
return Boolean(
|
||||
probe &&
|
||||
this.pendingRoutePreparations.get(roomId) === pending &&
|
||||
@@ -3065,15 +3047,18 @@ export class HybridMediaRouter {
|
||||
this.options.getShareGeneration(roomId) === probe.shareGeneration &&
|
||||
supportedParent &&
|
||||
parentAssignment &&
|
||||
this.activeParentMediaEdges(
|
||||
roomId,
|
||||
probe.newParentPeerId,
|
||||
parentAssignment,
|
||||
) + 1 <=
|
||||
endpointMediaCopyCountFits(
|
||||
this.activeParentMediaEdges(
|
||||
roomId,
|
||||
probe.newParentPeerId,
|
||||
parentAssignment,
|
||||
) + 1,
|
||||
this.peerRelayTopology.getDownstreamCapacity(
|
||||
roomId,
|
||||
probe.newParentPeerId,
|
||||
) &&
|
||||
),
|
||||
"transition",
|
||||
) &&
|
||||
planned?.revision === pending.revision &&
|
||||
plannedUpstream?.kind === "peer" &&
|
||||
plannedUpstream.peerId === probe.newParentPeerId &&
|
||||
@@ -3087,19 +3072,15 @@ export class HybridMediaRouter {
|
||||
parentPeerId: string,
|
||||
assignment: ParticipantRouteAssignment,
|
||||
): number {
|
||||
const selectedOverlayEdges = [...this.selectedPeerEdgeLeases.values()].some(
|
||||
(lease) =>
|
||||
lease.roomId === roomId &&
|
||||
lease.parentPeerId === parentPeerId &&
|
||||
!assignment.childPeerIds.includes(lease.viewerPeerId),
|
||||
)
|
||||
? 1
|
||||
: 0;
|
||||
return (
|
||||
assignment.childPeerIds.length +
|
||||
(assignment.sfuPublicationGeneration ? 1 : 0) +
|
||||
selectedOverlayEdges
|
||||
);
|
||||
const selectedLease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
return countEndpointMediaCopies({
|
||||
childPeerIds: assignment.childPeerIds,
|
||||
publicationGeneration: assignment.sfuPublicationGeneration,
|
||||
selectedChildPeerIds:
|
||||
selectedLease?.parentPeerId === parentPeerId
|
||||
? [selectedLease.viewerPeerId]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
private ownsActiveSfuForHealthyReselection(
|
||||
@@ -3156,9 +3137,8 @@ export class HybridMediaRouter {
|
||||
}
|
||||
if (participant.role === "viewer") {
|
||||
return (
|
||||
!this.selectedPeerEdgeLeases.has(
|
||||
viewerConnectionKey(participant.roomId, participant.peerId),
|
||||
) &&
|
||||
this.selectedPeerEdgeLeasesByRoom.get(participant.roomId)
|
||||
?.viewerPeerId !== participant.peerId &&
|
||||
assignment.upstream.kind === "sfu" &&
|
||||
active.sfu.rootPeerIds.includes(participant.peerId)
|
||||
);
|
||||
@@ -3462,7 +3442,7 @@ export class HybridMediaRouter {
|
||||
hostPeerId,
|
||||
previousSfu?.publicationGeneration ?? null,
|
||||
rootPeerIds,
|
||||
this.maxHostMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
if (!controller && initialRevision === 0 && assignments.size > 1) {
|
||||
initialRevision = 1;
|
||||
@@ -3471,7 +3451,7 @@ export class HybridMediaRouter {
|
||||
if (!controller) {
|
||||
controller = new MediaRouteController({
|
||||
hostPeerId,
|
||||
maxEndpointMediaEdges: this.maxHostMediaEdges,
|
||||
endpointMediaCopyCapacity: this.endpointMediaCopyCapacity,
|
||||
assignments,
|
||||
revision: initialRevision,
|
||||
sfuPublicationGeneration:
|
||||
@@ -3563,14 +3543,18 @@ export class HybridMediaRouter {
|
||||
lease.parentPeerId,
|
||||
);
|
||||
const activeParentAssignment = route.assignments.get(lease.parentPeerId);
|
||||
const selectedOverlayEdges =
|
||||
activeParentAssignment?.childPeerIds.includes(lease.viewerPeerId) === true
|
||||
? 0
|
||||
: 1;
|
||||
const hostPeerId = this.peerRelayTopology.getHostPeerId(roomId);
|
||||
const parentSupportsOutboundRelay =
|
||||
lease.parentPeerId === hostPeerId
|
||||
? activeParentAssignment?.upstream.kind === "none"
|
||||
: activeParentAssignment?.upstream.kind === "peer";
|
||||
const activeParentEdges = activeParentAssignment
|
||||
? activeParentAssignment.childPeerIds.length +
|
||||
(activeParentAssignment.sfuPublicationGeneration ? 1 : 0) +
|
||||
selectedOverlayEdges
|
||||
? countEndpointMediaCopies({
|
||||
childPeerIds: activeParentAssignment.childPeerIds,
|
||||
publicationGeneration:
|
||||
activeParentAssignment.sfuPublicationGeneration,
|
||||
selectedChildPeerIds: [lease.viewerPeerId],
|
||||
})
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return (
|
||||
route.revision === lease.currentRouteRevision &&
|
||||
@@ -3579,13 +3563,16 @@ export class HybridMediaRouter {
|
||||
lease.parentSessionId &&
|
||||
this.options.roomStore.getConnectedViewer(roomId, lease.viewerPeerId)
|
||||
?.sessionId === lease.viewerSessionId &&
|
||||
parentSupportsOutboundRelay &&
|
||||
viewerAssignment?.parentPeerId === lease.parentPeerId &&
|
||||
parentAssignment?.childPeerIds.includes(lease.viewerPeerId) === true &&
|
||||
activeParentEdges <=
|
||||
endpointMediaCopyCountFits(
|
||||
activeParentEdges,
|
||||
this.peerRelayTopology.getAdvertisedDownstreamCapacity(
|
||||
roomId,
|
||||
lease.parentPeerId,
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3593,28 +3580,26 @@ export class HybridMediaRouter {
|
||||
roomId: string,
|
||||
route: RoomMediaRoute,
|
||||
): void {
|
||||
for (const [key, lease] of this.selectedPeerEdgeLeases) {
|
||||
if (lease.roomId !== roomId) {
|
||||
continue;
|
||||
}
|
||||
lease.currentRouteRevision = route.revision;
|
||||
if (this.selectedPeerEdgeLeaseIsCurrent(roomId, lease, route)) {
|
||||
continue;
|
||||
}
|
||||
this.settleSelectedPeerEdgeLease(key);
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (!lease) {
|
||||
return;
|
||||
}
|
||||
lease.currentRouteRevision = route.revision;
|
||||
if (!this.selectedPeerEdgeLeaseIsCurrent(roomId, lease, route)) {
|
||||
this.settleSelectedPeerEdgeLease(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
private settleSelectedPeerEdgeLease(
|
||||
key: string,
|
||||
roomId: string,
|
||||
excludedPeerId?: string,
|
||||
): SelectedPeerEdgeLease | undefined {
|
||||
const lease = this.selectedPeerEdgeLeases.get(key);
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (!lease) {
|
||||
return undefined;
|
||||
}
|
||||
clearTimeout(lease.timer);
|
||||
this.selectedPeerEdgeLeases.delete(key);
|
||||
this.selectedPeerEdgeLeasesByRoom.delete(roomId);
|
||||
|
||||
const active = this.mediaRouteControllers.get(lease.roomId)?.getActiveRoute();
|
||||
if (!active) {
|
||||
@@ -3653,17 +3638,16 @@ export class HybridMediaRouter {
|
||||
if (!peer || !assignment) {
|
||||
return;
|
||||
}
|
||||
for (const lease of this.selectedPeerEdgeLeases.values()) {
|
||||
if (
|
||||
lease.roomId === roomId &&
|
||||
(lease.parentPeerId === peerId || lease.viewerPeerId === peerId) &&
|
||||
this.selectedPeerEdgeLeaseIsCurrent(roomId, lease, route)
|
||||
) {
|
||||
this.options.sendToSession(peer.sessionId, {
|
||||
...lease.grant,
|
||||
revision: route.revision,
|
||||
});
|
||||
}
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (
|
||||
lease &&
|
||||
(lease.parentPeerId === peerId || lease.viewerPeerId === peerId) &&
|
||||
this.selectedPeerEdgeLeaseIsCurrent(roomId, lease, route)
|
||||
) {
|
||||
this.options.sendToSession(peer.sessionId, {
|
||||
...lease.grant,
|
||||
revision: route.revision,
|
||||
});
|
||||
}
|
||||
this.options.sendToSession(peer.sessionId, {
|
||||
type: "route-update",
|
||||
@@ -3795,11 +3779,10 @@ export class HybridMediaRouter {
|
||||
this.mediaRouteControllers.delete(roomId);
|
||||
this.clearRoomFailedParents(roomId);
|
||||
this.viewerRouteIntentsByRoom.delete(roomId);
|
||||
for (const [key, lease] of this.selectedPeerEdgeLeases) {
|
||||
if (lease.roomId === roomId) {
|
||||
clearTimeout(lease.timer);
|
||||
this.selectedPeerEdgeLeases.delete(key);
|
||||
}
|
||||
const selectedLease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (selectedLease) {
|
||||
clearTimeout(selectedLease.timer);
|
||||
this.selectedPeerEdgeLeasesByRoom.delete(roomId);
|
||||
}
|
||||
const ingress = this.selectedSfuIngressAttempts.get(roomId);
|
||||
if (ingress) {
|
||||
@@ -3812,20 +3795,18 @@ export class HybridMediaRouter {
|
||||
this.sfuDisabledRoomIds.delete(roomId);
|
||||
this.clearRoomViewerQualityEvidenceStates(roomId);
|
||||
this.roomQualityMigrationCooldownUntilMs.delete(roomId);
|
||||
this.refreshRelayParentQualityStates(roomId, Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
private clearSelectedEdgeTurnsForParticipant(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
): void {
|
||||
for (const [key, lease] of [...this.selectedPeerEdgeLeases]) {
|
||||
if (
|
||||
lease.roomId === roomId &&
|
||||
(lease.parentPeerId === peerId || lease.viewerPeerId === peerId)
|
||||
) {
|
||||
this.settleSelectedPeerEdgeLease(key, peerId);
|
||||
}
|
||||
const lease = this.selectedPeerEdgeLeasesByRoom.get(roomId);
|
||||
if (
|
||||
lease &&
|
||||
(lease.parentPeerId === peerId || lease.viewerPeerId === peerId)
|
||||
) {
|
||||
this.settleSelectedPeerEdgeLease(roomId, peerId);
|
||||
}
|
||||
const ingress = this.selectedSfuIngressAttempts.get(roomId);
|
||||
if (ingress?.hostPeerId === peerId) {
|
||||
@@ -3965,132 +3946,10 @@ export class HybridMediaRouter {
|
||||
}
|
||||
}
|
||||
|
||||
private handleRelayParentBadEvent(
|
||||
roomId: string,
|
||||
parentPeerId: string,
|
||||
parentSessionId: string,
|
||||
childPeerId: string,
|
||||
badWindowKind: ViewerQualityBadWindowKind,
|
||||
now: number,
|
||||
): ViewerQualityBadWindowKind | "trigger-moved" | null {
|
||||
const shareGeneration = this.options.getShareGeneration(roomId);
|
||||
if (
|
||||
!shareGeneration ||
|
||||
this.options.roomStore.getConnectedViewer(roomId, parentPeerId)
|
||||
?.sessionId !== parentSessionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const key = viewerConnectionKey(roomId, parentPeerId);
|
||||
const previous = this.relayParentQualityStates.get(key);
|
||||
if (previous?.quarantinedUntilMs == null) {
|
||||
if (
|
||||
!previous ||
|
||||
previous.lastChildPeerId === childPeerId ||
|
||||
now - previous.lastConfirmedAtMs > VIEWER_QUALITY_EVIDENCE_GAP_MS
|
||||
) {
|
||||
this.relayParentQualityStates.set(key, {
|
||||
parentSessionId,
|
||||
shareGeneration,
|
||||
lastChildPeerId: childPeerId,
|
||||
lastConfirmedAtMs: now,
|
||||
quarantinedUntilMs: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
previous.lastChildPeerId = childPeerId;
|
||||
previous.lastConfirmedAtMs = now;
|
||||
previous.quarantinedUntilMs =
|
||||
now + VIEWER_QUALITY_REASSIGN_COOLDOWN_MS;
|
||||
this.peerRelayTopology.setViewerRelayEligible(roomId, parentPeerId, false);
|
||||
}
|
||||
return this.resolveActivePeerEdge(roomId, childPeerId)?.parentPeerId ===
|
||||
parentPeerId
|
||||
? badWindowKind
|
||||
: "trigger-moved";
|
||||
}
|
||||
|
||||
private evacuateRelayParentChildren(
|
||||
roomId: string,
|
||||
parentPeerId: string,
|
||||
): void {
|
||||
const childPeerIds =
|
||||
this.peerRelayTopology.getAssignment(roomId, parentPeerId)?.childPeerIds ?? [];
|
||||
for (const childPeerId of childPeerIds) {
|
||||
if (
|
||||
this.heldQualityParentExclusionsByViewer.get(
|
||||
viewerConnectionKey(roomId, childPeerId),
|
||||
)?.parentPeerId === parentPeerId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const failedParents = this.failedParentPeerIdsByViewer.get(
|
||||
viewerConnectionKey(roomId, childPeerId),
|
||||
);
|
||||
const excludedParents = new Set(failedParents);
|
||||
excludedParents.add(parentPeerId);
|
||||
const changes = this.peerRelayTopology.reassignViewer(
|
||||
roomId, childPeerId, this.connectedPeerIds(roomId), excludedParents,
|
||||
MAX_PEER_RELAY_DEPTH,
|
||||
);
|
||||
if (!changes) continue;
|
||||
this.clearChangedConnectionIds(roomId, changes);
|
||||
this.reconcileMediaRoute(roomId);
|
||||
this.sendMediaAssignmentChanges(roomId, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private evacuateQuarantinedRelayParents(roomId: string): void {
|
||||
const prefix = `${roomId}:`;
|
||||
for (const [key, state] of this.relayParentQualityStates) {
|
||||
if (key.startsWith(prefix) && state.quarantinedUntilMs !== null) {
|
||||
this.evacuateRelayParentChildren(roomId, key.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private refreshRelayParentQualityStates(
|
||||
roomId: string,
|
||||
currentTime = this.options.now?.() ?? Date.now(),
|
||||
): void {
|
||||
if (!Number.isFinite(currentTime)) return;
|
||||
const prefix = `${roomId}:`;
|
||||
const shareGeneration = this.options.getShareGeneration(roomId);
|
||||
for (const [key, state] of this.relayParentQualityStates) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const parentPeerId = key.slice(prefix.length);
|
||||
const identityCurrent =
|
||||
state.parentSessionId ===
|
||||
this.options.roomStore.getConnectedViewer(roomId, parentPeerId)
|
||||
?.sessionId && state.shareGeneration === shareGeneration;
|
||||
const active =
|
||||
state.quarantinedUntilMs === null
|
||||
? currentTime - state.lastConfirmedAtMs <=
|
||||
VIEWER_QUALITY_EVIDENCE_GAP_MS
|
||||
: currentTime < state.quarantinedUntilMs;
|
||||
if (
|
||||
identityCurrent &&
|
||||
currentTime >= state.lastConfirmedAtMs &&
|
||||
active
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (state.quarantinedUntilMs !== null) {
|
||||
this.peerRelayTopology.setViewerRelayEligible(roomId, parentPeerId, true);
|
||||
}
|
||||
this.relayParentQualityStates.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private clearViewerQualityStateForParticipant(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
): void {
|
||||
const relayParentKey = viewerConnectionKey(roomId, peerId);
|
||||
if (this.relayParentQualityStates.get(relayParentKey)?.quarantinedUntilMs != null) {
|
||||
this.peerRelayTopology.setViewerRelayEligible(roomId, peerId, true);
|
||||
}
|
||||
this.relayParentQualityStates.delete(relayParentKey);
|
||||
this.clearHeldQualityParentExclusion(roomId, peerId);
|
||||
const intents = this.viewerRouteIntentsByRoom.get(roomId);
|
||||
for (const [viewerPeerId, intent] of [...(intents ?? [])]) {
|
||||
@@ -4217,10 +4076,10 @@ function participantRoutesFromTopology(
|
||||
hostPeerId: string,
|
||||
publicationGeneration: string | null,
|
||||
requestedRootPeerIds: readonly string[],
|
||||
maxHostMediaEdges: number,
|
||||
endpointMediaCopyCapacity: number,
|
||||
): Map<string, ParticipantRouteAssignment> {
|
||||
// Keep the peer tree intact underneath the active SFU overlay so prepare
|
||||
// aborts and one-shot failback never need to reconstruct a lost baseline.
|
||||
// The topology remains the failback baseline. The active SFU overlay detaches
|
||||
// children because an SFU-fed Viewer has no independent outbound proof yet.
|
||||
const assignments = new Map<string, ParticipantRouteAssignment>();
|
||||
for (const [peerId, assignment] of topologyAssignments) {
|
||||
assignments.set(peerId, {
|
||||
@@ -4254,11 +4113,24 @@ function participantRoutesFromTopology(
|
||||
assignments.set(rootPeerId, {
|
||||
...root,
|
||||
upstream: { kind: "sfu" },
|
||||
childPeerIds: [],
|
||||
});
|
||||
for (const childPeerId of root.childPeerIds) {
|
||||
const child = assignments.get(childPeerId);
|
||||
if (
|
||||
child?.upstream.kind === "peer" &&
|
||||
child.upstream.peerId === rootPeerId
|
||||
) {
|
||||
assignments.set(childPeerId, {
|
||||
...child,
|
||||
upstream: { kind: "none" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const host = assignments.get(hostPeerId);
|
||||
if (host && rootPeerIds.length > 0) {
|
||||
const directChildLimit = Math.max(0, maxHostMediaEdges - 1);
|
||||
const directChildLimit = Math.max(0, endpointMediaCopyCapacity - 1);
|
||||
const directChildPeerIds = host.childPeerIds.slice(0, directChildLimit);
|
||||
for (const suppressedPeerId of host.childPeerIds.slice(directChildLimit)) {
|
||||
const suppressed = assignments.get(suppressedPeerId);
|
||||
@@ -4302,7 +4174,7 @@ function promoteSfuRoots(
|
||||
): boolean {
|
||||
for (const rootPeerId of rootPeerIds) {
|
||||
const root = assignments.get(rootPeerId);
|
||||
if (!root) {
|
||||
if (!root || root.childPeerIds.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (root.upstream.kind === "peer") {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import {
|
||||
CURRENT_BROWSER_RELAY_DOWNSTREAM_EDGE_LIMIT,
|
||||
CURRENT_HOST_MEDIA_EDGE_LIMIT,
|
||||
CURRENT_SFU_ROOT_LIMIT,
|
||||
DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
MAX_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
MAX_VIEWERS_PER_ROOM_LIMIT,
|
||||
participantRouteAssignmentSchema,
|
||||
sfuPublicationGenerationSchema,
|
||||
type MediaRoutePhase,
|
||||
type ParticipantRouteAssignment,
|
||||
} from "../shared/protocol.js";
|
||||
import {
|
||||
assertEndpointMediaCopyCapacity,
|
||||
assertEndpointMediaCopyCount,
|
||||
countEndpointMediaCopies,
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
|
||||
const MAX_PARTICIPANTS_PER_ROOM = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
|
||||
|
||||
@@ -37,7 +39,7 @@ export interface MediaRouteControllerOptions {
|
||||
revision?: number;
|
||||
sfuPublicationGeneration?: string | null;
|
||||
sfuRootPeerIds?: readonly string[];
|
||||
maxEndpointMediaEdges?: number;
|
||||
endpointMediaCopyCapacity?: number;
|
||||
}
|
||||
|
||||
export interface PrepareMediaRouteInput {
|
||||
@@ -64,28 +66,14 @@ export class MediaRouteController {
|
||||
private pendingRoute: StoredPendingRoute | undefined;
|
||||
private latestRevision: number;
|
||||
private readonly hostPeerId: string;
|
||||
private readonly maxHostMediaEdges: number;
|
||||
private readonly maxViewerMediaEdges: number;
|
||||
private readonly endpointMediaCopyCapacity: number;
|
||||
|
||||
constructor(options: MediaRouteControllerOptions) {
|
||||
this.hostPeerId = options.hostPeerId;
|
||||
const deploymentMediaEdgeLimit =
|
||||
options.maxEndpointMediaEdges ?? DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES;
|
||||
if (
|
||||
!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,
|
||||
);
|
||||
this.endpointMediaCopyCapacity =
|
||||
options.endpointMediaCopyCapacity ??
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY;
|
||||
assertEndpointMediaCopyCapacity(this.endpointMediaCopyCapacity);
|
||||
const revision = options.revision ?? 0;
|
||||
assertRevision(revision);
|
||||
this.activeRoute = createRoute(
|
||||
@@ -94,8 +82,7 @@ export class MediaRouteController {
|
||||
options.sfuPublicationGeneration ?? null,
|
||||
options.sfuRootPeerIds ?? [],
|
||||
this.hostPeerId,
|
||||
this.maxHostMediaEdges,
|
||||
this.maxViewerMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
this.latestRevision = revision;
|
||||
}
|
||||
@@ -129,8 +116,7 @@ export class MediaRouteController {
|
||||
: input.sfuPublicationGeneration,
|
||||
input.sfuRootPeerIds ?? this.activeRoute.sfu.rootPeerIds,
|
||||
this.hostPeerId,
|
||||
this.maxHostMediaEdges,
|
||||
this.maxViewerMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
if (hasSameTopology(route, this.activeRoute)) {
|
||||
return undefined;
|
||||
@@ -153,8 +139,7 @@ export class MediaRouteController {
|
||||
input.sfuPublicationGeneration ?? null,
|
||||
input.sfuRootPeerIds ?? [],
|
||||
this.hostPeerId,
|
||||
this.maxHostMediaEdges,
|
||||
this.maxViewerMediaEdges,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
const expectedParticipantIds = new Set(input.expectedParticipantIds);
|
||||
if (expectedParticipantIds.size > MAX_PARTICIPANTS_PER_ROOM) {
|
||||
@@ -222,9 +207,10 @@ export class MediaRouteController {
|
||||
|
||||
hostActiveMediaEdges(): number {
|
||||
const edgeCount = hostMediaEdges(this.activeRoute, this.hostPeerId);
|
||||
if (edgeCount > this.maxHostMediaEdges) {
|
||||
throw new Error("Host active media edge budget exceeded");
|
||||
}
|
||||
assertEndpointMediaCopyCount(
|
||||
edgeCount,
|
||||
this.endpointMediaCopyCapacity,
|
||||
);
|
||||
return edgeCount;
|
||||
}
|
||||
|
||||
@@ -242,8 +228,7 @@ function createRoute(
|
||||
publicationGeneration: string | null,
|
||||
rootPeerIds: readonly string[],
|
||||
hostPeerId: string,
|
||||
maxHostMediaEdges: number,
|
||||
maxViewerMediaEdges: number,
|
||||
endpointMediaCopyCapacity: number,
|
||||
): RoomMediaRoute {
|
||||
assertRevision(revision);
|
||||
if (
|
||||
@@ -272,8 +257,7 @@ function createRoute(
|
||||
assertRouteInvariants(
|
||||
route,
|
||||
hostPeerId,
|
||||
maxHostMediaEdges,
|
||||
maxViewerMediaEdges,
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
return route;
|
||||
}
|
||||
@@ -281,8 +265,7 @@ function createRoute(
|
||||
function assertRouteInvariants(
|
||||
route: RoomMediaRoute,
|
||||
hostPeerId: string,
|
||||
maxHostMediaEdges: number,
|
||||
maxViewerMediaEdges: number,
|
||||
endpointMediaCopyCapacity: number,
|
||||
): void {
|
||||
const hostAssignment = route.assignments.get(hostPeerId);
|
||||
if (!hostAssignment) {
|
||||
@@ -312,11 +295,13 @@ function assertRouteInvariants(
|
||||
}
|
||||
|
||||
for (const [peerId, assignment] of route.assignments) {
|
||||
const downstreamEdgeLimit =
|
||||
peerId === hostPeerId ? maxHostMediaEdges : maxViewerMediaEdges;
|
||||
if (assignment.childPeerIds.length > downstreamEdgeLimit) {
|
||||
throw new Error("Participant active media edge budget exceeded");
|
||||
}
|
||||
assertEndpointMediaCopyCount(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: assignment.childPeerIds,
|
||||
publicationGeneration: assignment.sfuPublicationGeneration,
|
||||
}),
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
if (
|
||||
peerId !== hostPeerId &&
|
||||
assignment.sfuPublicationGeneration !== null
|
||||
@@ -351,6 +336,12 @@ function assertRouteInvariants(
|
||||
if (assignment.upstream.kind === "sfu" && !rootPeerIds.has(peerId)) {
|
||||
throw new Error("Every SFU upstream must belong to the root allowlist");
|
||||
}
|
||||
if (
|
||||
assignment.upstream.kind === "sfu" &&
|
||||
assignment.childPeerIds.length > 0
|
||||
) {
|
||||
throw new Error("SFU-fed viewers require outbound relay proof");
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootPeerId of rootPeerIds) {
|
||||
@@ -361,9 +352,10 @@ function assertRouteInvariants(
|
||||
}
|
||||
|
||||
assertAcyclicPeerEdges(route.assignments);
|
||||
if (hostMediaEdges(route, hostPeerId) > maxHostMediaEdges) {
|
||||
throw new Error("Host active media edge budget exceeded");
|
||||
}
|
||||
assertEndpointMediaCopyCount(
|
||||
hostMediaEdges(route, hostPeerId),
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
}
|
||||
|
||||
function assertAcyclicPeerEdges(
|
||||
@@ -384,11 +376,14 @@ function assertAcyclicPeerEdges(
|
||||
}
|
||||
|
||||
function hostMediaEdges(route: RoomMediaRoute, hostPeerId: string): number {
|
||||
const directChildren = route.assignments.get(hostPeerId)?.childPeerIds.length;
|
||||
if (directChildren === undefined) {
|
||||
const hostAssignment = route.assignments.get(hostPeerId);
|
||||
if (!hostAssignment) {
|
||||
throw new Error("Media route is missing its host assignment");
|
||||
}
|
||||
return directChildren + (route.sfu.publicationGeneration === null ? 0 : 1);
|
||||
return countEndpointMediaCopies({
|
||||
childPeerIds: hostAssignment.childPeerIds,
|
||||
publicationGeneration: route.sfu.publicationGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
function hasSameTopology(left: RoomMediaRoute, right: RoomMediaRoute): boolean {
|
||||
|
||||
@@ -3,11 +3,9 @@ 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";
|
||||
assertEndpointMediaCopyCapacity,
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
|
||||
export interface MediaAssignmentChange {
|
||||
peerId: string;
|
||||
@@ -26,42 +24,26 @@ interface RelayViewer {
|
||||
parentPeerId: string | null;
|
||||
childPeerIds: string[];
|
||||
downstreamEdges: RelayDownstreamEdges;
|
||||
relayEligible: boolean;
|
||||
}
|
||||
|
||||
interface RelayRoom {
|
||||
hostPeerId?: string;
|
||||
hostChildPeerIds: string[];
|
||||
maxHostDownstreamEdges: RelayDownstreamEdges;
|
||||
maxViewerDownstreamEdges: RelayDownstreamEdges;
|
||||
endpointMediaCopyCapacity: RelayDownstreamEdges;
|
||||
viewers: Map<string, RelayViewer>;
|
||||
nextOrder: number;
|
||||
}
|
||||
|
||||
export class PeerRelayTopology {
|
||||
private readonly rooms = new Map<string, RelayRoom>();
|
||||
private readonly maxHostDownstreamEdges: RelayDownstreamEdges;
|
||||
private readonly maxViewerDownstreamEdges: RelayDownstreamEdges;
|
||||
private readonly endpointMediaCopyCapacity: RelayDownstreamEdges;
|
||||
|
||||
constructor(
|
||||
deploymentDownstreamEdgeLimit: RelayDownstreamEdges =
|
||||
DEFAULT_PEER_RELAY_DOWNSTREAM_EDGES,
|
||||
endpointMediaCopyCapacity: RelayDownstreamEdges =
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
) {
|
||||
if (
|
||||
!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,
|
||||
);
|
||||
assertEndpointMediaCopyCapacity(endpointMediaCopyCapacity);
|
||||
this.endpointMediaCopyCapacity = endpointMediaCopyCapacity;
|
||||
}
|
||||
|
||||
setHost(
|
||||
@@ -105,7 +87,6 @@ export class PeerRelayTopology {
|
||||
parentPeerId: null,
|
||||
childPeerIds: [],
|
||||
downstreamEdges: 0,
|
||||
relayEligible: true,
|
||||
});
|
||||
room.nextOrder += 1;
|
||||
} else {
|
||||
@@ -135,7 +116,7 @@ export class PeerRelayTopology {
|
||||
const before = snapshot(room);
|
||||
const boundedDownstreamEdges = Math.min(
|
||||
downstreamEdges,
|
||||
room.maxViewerDownstreamEdges,
|
||||
room.endpointMediaCopyCapacity,
|
||||
);
|
||||
const previousDownstreamEdges = viewer.downstreamEdges;
|
||||
viewer.downstreamEdges = boundedDownstreamEdges;
|
||||
@@ -236,17 +217,6 @@ export class PeerRelayTopology {
|
||||
return room ? advertisedDownstreamCapacity(room, peerId) : 0;
|
||||
}
|
||||
|
||||
setViewerRelayEligible(
|
||||
roomId: string,
|
||||
peerId: string,
|
||||
eligible: boolean,
|
||||
): void {
|
||||
const viewer = this.rooms.get(roomId)?.viewers.get(peerId);
|
||||
if (viewer) {
|
||||
viewer.relayEligible = eligible;
|
||||
}
|
||||
}
|
||||
|
||||
getHostPeerId(roomId: string): string | undefined {
|
||||
return this.rooms.get(roomId)?.hostPeerId;
|
||||
}
|
||||
@@ -338,8 +308,7 @@ export class PeerRelayTopology {
|
||||
if (!room) {
|
||||
room = {
|
||||
hostChildPeerIds: [],
|
||||
maxHostDownstreamEdges: this.maxHostDownstreamEdges,
|
||||
maxViewerDownstreamEdges: this.maxViewerDownstreamEdges,
|
||||
endpointMediaCopyCapacity: this.endpointMediaCopyCapacity,
|
||||
viewers: new Map(),
|
||||
nextOrder: 0,
|
||||
};
|
||||
@@ -509,9 +478,7 @@ function downstreamCapacity(
|
||||
room: RelayRoom,
|
||||
peerId: string,
|
||||
): RelayDownstreamEdges {
|
||||
return room.hostPeerId === peerId || room.viewers.get(peerId)?.relayEligible
|
||||
? advertisedDownstreamCapacity(room, peerId)
|
||||
: 0;
|
||||
return advertisedDownstreamCapacity(room, peerId);
|
||||
}
|
||||
|
||||
function advertisedDownstreamCapacity(
|
||||
@@ -519,7 +486,7 @@ function advertisedDownstreamCapacity(
|
||||
peerId: string,
|
||||
): RelayDownstreamEdges {
|
||||
return room.hostPeerId === peerId
|
||||
? room.maxHostDownstreamEdges
|
||||
? room.endpointMediaCopyCapacity
|
||||
: (room.viewers.get(peerId)?.downstreamEdges ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface SignalingOptions {
|
||||
server: HttpServer;
|
||||
roomStore: RoomStore;
|
||||
peerAssistedMedia: boolean;
|
||||
maxPeerRelayDownstreamEdges: number;
|
||||
endpointMediaCopyCapacity: number;
|
||||
sfuFallback?: SfuFallbackOptions;
|
||||
selectedEdgeTurn?: SelectedEdgeTurnConfig;
|
||||
ice: IceConfigOptions;
|
||||
@@ -158,7 +158,7 @@ export class SignalingServer {
|
||||
if (options.peerAssistedMedia) {
|
||||
this.hybridMediaRouter = new HybridMediaRouter({
|
||||
roomStore: options.roomStore,
|
||||
maxPeerRelayDownstreamEdges: options.maxPeerRelayDownstreamEdges,
|
||||
endpointMediaCopyCapacity: options.endpointMediaCopyCapacity,
|
||||
sfuFallback: options.sfuFallback,
|
||||
selectedEdgeTurn: options.selectedEdgeTurn,
|
||||
sendToSession: (sessionId, message) =>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export const DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY = 2;
|
||||
export const MAX_ENDPOINT_MEDIA_COPY_CAPACITY = 3;
|
||||
|
||||
export type EndpointMediaCopyPhase = "steady" | "transition";
|
||||
|
||||
export interface EndpointMediaCopySnapshot {
|
||||
childPeerIds: readonly string[];
|
||||
publicationGeneration?: string | null;
|
||||
selectedChildPeerIds?: Iterable<string>;
|
||||
}
|
||||
|
||||
export function countEndpointMediaCopies(
|
||||
snapshot: EndpointMediaCopySnapshot,
|
||||
): number {
|
||||
const childPeerIds = new Set(snapshot.childPeerIds);
|
||||
const hiddenSelectedChildPeerIds = new Set<string>();
|
||||
for (const childPeerId of snapshot.selectedChildPeerIds ?? []) {
|
||||
if (!childPeerIds.has(childPeerId)) {
|
||||
hiddenSelectedChildPeerIds.add(childPeerId);
|
||||
}
|
||||
}
|
||||
return (
|
||||
snapshot.childPeerIds.length +
|
||||
(snapshot.publicationGeneration ? 1 : 0) +
|
||||
hiddenSelectedChildPeerIds.size
|
||||
);
|
||||
}
|
||||
|
||||
export function endpointMediaCopyLimit(
|
||||
capacity: number,
|
||||
phase: EndpointMediaCopyPhase,
|
||||
): number {
|
||||
assertEndpointMediaCopyCapacity(capacity);
|
||||
return phase === "steady"
|
||||
? capacity
|
||||
: Math.min(capacity + 1, MAX_ENDPOINT_MEDIA_COPY_CAPACITY);
|
||||
}
|
||||
|
||||
export function endpointMediaCopyCountFits(
|
||||
count: number,
|
||||
capacity: number,
|
||||
phase: EndpointMediaCopyPhase = "steady",
|
||||
): boolean {
|
||||
return (
|
||||
Number.isSafeInteger(count) &&
|
||||
count >= 0 &&
|
||||
isEndpointMediaCopyCapacity(capacity) &&
|
||||
count <= endpointMediaCopyLimit(capacity, phase)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEndpointMediaCopyCapacity(capacity: number): boolean {
|
||||
return (
|
||||
Number.isSafeInteger(capacity) &&
|
||||
capacity >= 1 &&
|
||||
capacity <= MAX_ENDPOINT_MEDIA_COPY_CAPACITY
|
||||
);
|
||||
}
|
||||
|
||||
export function assertEndpointMediaCopyCapacity(capacity: number): void {
|
||||
if (!isEndpointMediaCopyCapacity(capacity)) {
|
||||
throw new Error("Endpoint media copy capacity must be 1, 2, or 3");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEndpointMediaCopyCount(
|
||||
count: number,
|
||||
capacity: number,
|
||||
phase: EndpointMediaCopyPhase = "steady",
|
||||
): void {
|
||||
if (!endpointMediaCopyCountFits(count, capacity, phase)) {
|
||||
throw new Error(`Endpoint ${phase} media copy capacity exceeded`);
|
||||
}
|
||||
}
|
||||
+5
-10
@@ -1,11 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { MAX_ENDPOINT_MEDIA_COPY_CAPACITY } from "./media-copy-accounting.js";
|
||||
import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
|
||||
|
||||
export const MAX_VIEWERS_PER_ROOM_LIMIT = 16;
|
||||
export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
|
||||
export const MAX_SIGNAL_BYTES = 64 * 1024;
|
||||
export const SIGNALING_PROTOCOL = "screener-v5";
|
||||
export const SIGNALING_PROTOCOL = "screener-v6";
|
||||
export const ROOM_CODE_LENGTH = 12;
|
||||
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
|
||||
@@ -204,18 +205,12 @@ export const DEFAULT_QUALITY_SETTINGS = {
|
||||
screenAudioQuality: "music",
|
||||
} 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;
|
||||
export const CURRENT_SFU_ROOT_LIMIT = 2;
|
||||
// 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()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_PEER_RELAY_DOWNSTREAM_EDGES);
|
||||
.max(MAX_ENDPOINT_MEDIA_COPY_CAPACITY);
|
||||
export type RelayDownstreamEdges = z.infer<typeof relayDownstreamEdgesSchema>;
|
||||
|
||||
function isValidStunUrl(value: string): boolean {
|
||||
@@ -354,7 +349,7 @@ export const mediaAssignmentSchema = z
|
||||
parentPeerId: opaqueIdSchema.nullable(),
|
||||
childPeerIds: z
|
||||
.array(opaqueIdSchema)
|
||||
.max(MAX_PEER_RELAY_DOWNSTREAM_EDGES),
|
||||
.max(MAX_ENDPOINT_MEDIA_COPY_CAPACITY),
|
||||
})
|
||||
.strict();
|
||||
export type MediaAssignment = z.infer<typeof mediaAssignmentSchema>;
|
||||
@@ -375,7 +370,7 @@ export const participantRouteAssignmentSchema = z
|
||||
upstream: mediaRouteUpstreamSchema,
|
||||
childPeerIds: z
|
||||
.array(opaqueIdSchema)
|
||||
.max(MAX_PEER_RELAY_DOWNSTREAM_EDGES)
|
||||
.max(MAX_ENDPOINT_MEDIA_COPY_CAPACITY)
|
||||
.refine((peerIds) => new Set(peerIds).size === peerIds.length),
|
||||
sfuPublicationGeneration: sfuPublicationGenerationSchema.nullable(),
|
||||
})
|
||||
|
||||
@@ -640,7 +640,7 @@ describe("client signaling recovery policy", () => {
|
||||
Object.defineProperty(authenticated, "data", {
|
||||
value: JSON.stringify({
|
||||
type: "authenticated",
|
||||
protocol: "screener-v5",
|
||||
protocol: "screener-v6",
|
||||
role: "viewer",
|
||||
peerId: "viewer_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -713,7 +713,7 @@ describe("client signaling recovery policy", () => {
|
||||
socket.dispatchEvent(new Event("open"));
|
||||
receive(socket, {
|
||||
type: "authenticated",
|
||||
protocol: "screener-v5",
|
||||
protocol: "screener-v6",
|
||||
role: "host",
|
||||
peerId: "host_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -837,7 +837,7 @@ describe("client signaling recovery policy", () => {
|
||||
sockets[0]!.dispatchEvent(new Event("open"));
|
||||
receive({
|
||||
type: "authenticated",
|
||||
protocol: "screener-v5",
|
||||
protocol: "screener-v6",
|
||||
role: "viewer",
|
||||
peerId: "viewer_12345678",
|
||||
roomExpiresAt: null,
|
||||
@@ -923,7 +923,7 @@ describe("client signaling recovery policy", () => {
|
||||
iceConfig: { iceServers: [] },
|
||||
}),
|
||||
])(
|
||||
"terminates once when a server payload is incompatible with v2",
|
||||
"terminates once when a server payload is incompatible with the current wire",
|
||||
(payload) => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
class FakeWebSocket extends EventTarget {
|
||||
@@ -964,7 +964,7 @@ describe("client signaling recovery policy", () => {
|
||||
JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])),
|
||||
).toMatchObject({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v5",
|
||||
protocol: "screener-v6",
|
||||
});
|
||||
const message = new Event("message");
|
||||
Object.defineProperty(message, "data", { value: payload });
|
||||
|
||||
@@ -1443,7 +1443,7 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
relay.dispose();
|
||||
});
|
||||
|
||||
it("admits one provisional child only while the current Viewer cap has room", async () => {
|
||||
it("admits provisional children until endpoint cap three is full", async () => {
|
||||
const relay = new ViewerRelay(
|
||||
{ iceServers: [] },
|
||||
QUALITY_PROFILES["720p30"],
|
||||
@@ -1455,14 +1455,22 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
preparedConnection.connectionState = "connected";
|
||||
relay.activateChildren(1, ["child-0"]);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(relay.prepareChild(2, ["child-0", "child-1"])).toBe(true);
|
||||
FakePeerConnection.latest!.connectionState = "connected";
|
||||
relay.activateChildren(2, ["child-0", "child-1"]);
|
||||
expect(relay.prepareChild(3, ["child-0", "child-1", "child-2"])).toBe(
|
||||
true,
|
||||
);
|
||||
FakePeerConnection.latest!.connectionState = "connected";
|
||||
relay.activateChildren(3, ["child-0", "child-1", "child-2"]);
|
||||
expect(
|
||||
relay.prepareChild(2, ["child-0", "child-1"]),
|
||||
relay.prepareChild(4, ["child-0", "child-1", "child-2", "child-3"]),
|
||||
).toBe(false);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(FakePeerConnection.activeCount).toBe(3);
|
||||
relay.dispose();
|
||||
});
|
||||
|
||||
it("clamps a malicious multi-child assignment to one physical Viewer edge", async () => {
|
||||
it("clamps a malicious fourth child to three physical endpoint copies", async () => {
|
||||
const targets: string[] = [];
|
||||
const relay = new ViewerRelay(
|
||||
{ iceServers: [] },
|
||||
@@ -1479,10 +1487,17 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
relay.setChildren(["child-a", "child-b", "child-c", "ignored-fourth"]);
|
||||
relay.setStream(createStream(firstVideo, firstAudio));
|
||||
|
||||
await vi.waitFor(() => expect(targets).toEqual(["child-a"]));
|
||||
const [childAConnection] = FakePeerConnection.instances;
|
||||
await vi.waitFor(() =>
|
||||
expect(targets).toEqual(["child-a", "child-b", "child-c"]),
|
||||
);
|
||||
const [childAConnection, childBConnection, childCConnection] =
|
||||
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");
|
||||
@@ -1494,13 +1509,12 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
});
|
||||
|
||||
relay.setChildren(["child-b", "child-c", "child-d"]);
|
||||
await vi.waitFor(() => expect(targets).toContain("child-b"));
|
||||
await vi.waitFor(() => expect(targets).toContain("child-d"));
|
||||
expect(childAConnection!.connectionState).toBe("closed");
|
||||
const childBConnection = FakePeerConnection.latest!;
|
||||
childBConnection.connectionState = "connected";
|
||||
expect(targets).toEqual(["child-a", "child-b"]);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(FakePeerConnection.peakActiveCount).toBe(1);
|
||||
FakePeerConnection.latest!.connectionState = "connected";
|
||||
expect(targets).toEqual(["child-a", "child-b", "child-c", "child-d"]);
|
||||
expect(FakePeerConnection.activeCount).toBe(3);
|
||||
expect(FakePeerConnection.peakActiveCount).toBe(3);
|
||||
expect(targets).not.toContain("ignored-fourth");
|
||||
|
||||
await relay.acceptSignal("stale-child", {
|
||||
@@ -1510,14 +1524,14 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
}, 7);
|
||||
await relay.recover("stale-child", "stale-connection", true);
|
||||
await relay.recover("child-b", "wrong-connection", true);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(FakePeerConnection.activeCount).toBe(3);
|
||||
const childBConnectionId = relay.getSnapshot("child-b")!.connectionId;
|
||||
await relay.recover("child-b", childBConnectionId, true);
|
||||
await vi.waitFor(() =>
|
||||
expect(FakePeerConnection.latest).not.toBe(childBConnection),
|
||||
);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(FakePeerConnection.peakActiveCount).toBe(1);
|
||||
expect(FakePeerConnection.activeCount).toBe(3);
|
||||
expect(FakePeerConnection.peakActiveCount).toBe(3);
|
||||
|
||||
expect(
|
||||
relay.startSelectedEdgeTurn(
|
||||
@@ -1530,8 +1544,8 @@ describe("ViewerRelay downstream ownership", () => {
|
||||
7,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(FakePeerConnection.activeCount).toBe(1);
|
||||
expect(FakePeerConnection.peakActiveCount).toBe(1);
|
||||
expect(FakePeerConnection.activeCount).toBe(3);
|
||||
expect(FakePeerConnection.peakActiveCount).toBe(3);
|
||||
relay.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { SignalPayload } from "../src/shared/protocol.ts";
|
||||
import {
|
||||
limitMediaAssignment,
|
||||
MAX_HOST_MEDIA_CHILDREN,
|
||||
MAX_VIEWER_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
reconcileBoundedMediaChildren,
|
||||
retainSelectedMediaParent,
|
||||
viewerRestartMessage,
|
||||
@@ -18,9 +17,8 @@ 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);
|
||||
it("keeps endpoint child counts within the shared client limit", () => {
|
||||
expect(MAX_ENDPOINT_MEDIA_CHILDREN).toBe(3);
|
||||
|
||||
const assignment = {
|
||||
parentPeerId: "parent_12345678",
|
||||
@@ -34,20 +32,15 @@ describe("peer-assisted client assignment", () => {
|
||||
};
|
||||
|
||||
expect(
|
||||
limitMediaAssignment(assignment, MAX_HOST_MEDIA_CHILDREN),
|
||||
limitMediaAssignment(assignment, MAX_ENDPOINT_MEDIA_CHILDREN),
|
||||
).toEqual({
|
||||
parentPeerId: "parent_12345678",
|
||||
childPeerIds: [
|
||||
"child_12345678",
|
||||
"child_abcdefgh",
|
||||
"child_qwertyui",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
limitMediaAssignment(assignment, MAX_VIEWER_MEDIA_CHILDREN),
|
||||
).toEqual({
|
||||
parentPeerId: "parent_12345678",
|
||||
childPeerIds: ["child_12345678"],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stale host edges before starting replacement children", () => {
|
||||
@@ -56,7 +49,7 @@ describe("peer-assisted client assignment", () => {
|
||||
reconcileBoundedMediaChildren(
|
||||
new Set(["old-child", "kept-child"]),
|
||||
["kept-child", "new-child", "overflow-child"],
|
||||
MAX_HOST_MEDIA_CHILDREN,
|
||||
MAX_ENDPOINT_MEDIA_CHILDREN,
|
||||
(peerId) => operations.push(`remove:${peerId}`),
|
||||
(peerId) => operations.push(`start:${peerId}`),
|
||||
);
|
||||
@@ -65,6 +58,7 @@ describe("peer-assisted client assignment", () => {
|
||||
"remove:old-child",
|
||||
"start:kept-child",
|
||||
"start:new-child",
|
||||
"start:overflow-child",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertEndpointMediaCopyCapacity,
|
||||
countEndpointMediaCopies,
|
||||
endpointMediaCopyCountFits,
|
||||
endpointMediaCopyLimit,
|
||||
} from "../src/shared/media-copy-accounting.ts";
|
||||
|
||||
describe("endpoint media copy accounting", () => {
|
||||
it("counts children and one Host publication while upstream remains free", () => {
|
||||
expect(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: ["child-a", "child-b"],
|
||||
}),
|
||||
).toBe(2);
|
||||
expect(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: ["child-a", "child-b"],
|
||||
publicationGeneration: "publication-a",
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("does not double-count a committed selected transport for the same edge", () => {
|
||||
expect(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: ["child-a"],
|
||||
selectedChildPeerIds: ["child-a"],
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("charges each hidden carried selected edge as another copy", () => {
|
||||
expect(
|
||||
countEndpointMediaCopies({
|
||||
childPeerIds: ["child-a"],
|
||||
selectedChildPeerIds: ["child-b", "child-b", "child-c"],
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, 1, 2],
|
||||
[2, 2, 3],
|
||||
[3, 3, 3],
|
||||
] as const)(
|
||||
"bounds capacity %i at %i steady and %i during transition",
|
||||
(capacity, steady, transition) => {
|
||||
expect(endpointMediaCopyLimit(capacity, "steady")).toBe(steady);
|
||||
expect(endpointMediaCopyLimit(capacity, "transition")).toBe(transition);
|
||||
expect(
|
||||
endpointMediaCopyCountFits(transition, capacity, "transition"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
endpointMediaCopyCountFits(transition + 1, capacity, "transition"),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 4, 1.5, Number.NaN])(
|
||||
"rejects invalid capacity %s",
|
||||
(capacity) => {
|
||||
expect(() => assertEndpointMediaCopyCapacity(capacity)).toThrow(
|
||||
"must be 1, 2, or 3",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns false instead of throwing for an unavailable runtime capacity", () => {
|
||||
expect(endpointMediaCopyCountFits(0, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe("MediaRouteController", () => {
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A, SFU_ROOT_B],
|
||||
}),
|
||||
).toThrow("Host active media edge budget exceeded");
|
||||
).toThrow("Endpoint steady media copy capacity exceeded");
|
||||
});
|
||||
|
||||
it("atomically commits only after every expected participant is ready", () => {
|
||||
@@ -299,7 +299,7 @@ describe("MediaRouteController", () => {
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
}),
|
||||
).toThrow("Host active media edge budget exceeded");
|
||||
).toThrow("Endpoint steady media copy capacity exceeded");
|
||||
|
||||
const nonHostPublisher = directAssignments();
|
||||
nonHostPublisher.set(
|
||||
@@ -338,7 +338,7 @@ describe("MediaRouteController", () => {
|
||||
).toThrow("SFU roots and publication generation must be active together");
|
||||
});
|
||||
|
||||
it("does not let an explicit three-edge setting lift release role limits", () => {
|
||||
it("applies capacity three uniformly to Host and Viewer endpoints", () => {
|
||||
const threeHostChildren = new Map<string, ParticipantRouteAssignment>([
|
||||
[
|
||||
HOST,
|
||||
@@ -351,13 +351,12 @@ describe("MediaRouteController", () => {
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: HOST })],
|
||||
[THIRD_CHILD, assignment({ kind: "peer", peerId: HOST })],
|
||||
]);
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: threeHostChildren,
|
||||
maxEndpointMediaEdges: 3,
|
||||
}),
|
||||
).toThrow("Participant active media edge budget exceeded");
|
||||
const hostAtCapacity = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: threeHostChildren,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
});
|
||||
expect(hostAtCapacity.hostActiveMediaEdges()).toBe(3);
|
||||
|
||||
const viewerWithTwoChildren = new Map<string, ParticipantRouteAssignment>([
|
||||
[HOST, assignment({ kind: "none" }, [DIRECT_ROOT])],
|
||||
@@ -371,13 +370,13 @@ describe("MediaRouteController", () => {
|
||||
[SFU_ROOT_A, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
|
||||
[SFU_ROOT_B, assignment({ kind: "peer", peerId: DIRECT_ROOT })],
|
||||
]);
|
||||
expect(() =>
|
||||
expect(
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: viewerWithTwoChildren,
|
||||
maxEndpointMediaEdges: 3,
|
||||
}),
|
||||
).toThrow("Participant active media edge budget exceeded");
|
||||
endpointMediaCopyCapacity: 3,
|
||||
}).getActiveRoute().assignments.get(DIRECT_ROOT)?.childPeerIds,
|
||||
).toHaveLength(2);
|
||||
|
||||
const publicationAtHostLimit = new Map<string, ParticipantRouteAssignment>([
|
||||
[
|
||||
@@ -390,7 +389,7 @@ describe("MediaRouteController", () => {
|
||||
const allowed = new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: publicationAtHostLimit,
|
||||
maxEndpointMediaEdges: 3,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
});
|
||||
@@ -401,7 +400,7 @@ describe("MediaRouteController", () => {
|
||||
HOST,
|
||||
assignment(
|
||||
{ kind: "none" },
|
||||
[DIRECT_ROOT, THIRD_CHILD],
|
||||
[DIRECT_ROOT, THIRD_CHILD, SFU_ROOT_B],
|
||||
GENERATION_A,
|
||||
),
|
||||
);
|
||||
@@ -409,15 +408,19 @@ describe("MediaRouteController", () => {
|
||||
THIRD_CHILD,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
publicationOverHostLimit.set(
|
||||
SFU_ROOT_B,
|
||||
assignment({ kind: "peer", peerId: HOST }),
|
||||
);
|
||||
expect(() =>
|
||||
new MediaRouteController({
|
||||
hostPeerId: HOST,
|
||||
assignments: publicationOverHostLimit,
|
||||
maxEndpointMediaEdges: 3,
|
||||
endpointMediaCopyCapacity: 3,
|
||||
sfuPublicationGeneration: GENERATION_A,
|
||||
sfuRootPeerIds: [SFU_ROOT_A],
|
||||
}),
|
||||
).toThrow("Host active media edge budget exceeded");
|
||||
).toThrow("Endpoint steady media copy capacity exceeded");
|
||||
});
|
||||
|
||||
it("rejects reciprocal peer cycles", () => {
|
||||
|
||||
@@ -248,11 +248,12 @@ describe("peer topology loopback configuration", () => {
|
||||
).toThrow(/selected viewer count/);
|
||||
});
|
||||
|
||||
it("keeps Host cap2 by default and permits only deployment tightening", () => {
|
||||
it("defaults to cap two and accepts the deployment range", () => {
|
||||
expect(parseExpectedEndpointCap(undefined)).toBe(2);
|
||||
expect(parseExpectedEndpointCap("1")).toBe(1);
|
||||
for (const value of ["0", "2.5", "3", "4"]) {
|
||||
expect(() => parseExpectedEndpointCap(value)).toThrow(/integer from 1 to 2/);
|
||||
expect(parseExpectedEndpointCap("3")).toBe(3);
|
||||
for (const value of ["0", "2.5", "4"]) {
|
||||
expect(() => parseExpectedEndpointCap(value)).toThrow(/integer from 1 to 3/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -389,7 +390,7 @@ describe("peer topology loopback observations", () => {
|
||||
expect(summary.browserProcessResources.averageCpuUtilizationPercent).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("checks Host cap2 and Browser Viewer cap1 independently", () => {
|
||||
it("checks one endpoint cap for Host and Viewer senders", () => {
|
||||
const initial = [
|
||||
page("host", "host", 2, 0),
|
||||
...Array.from({ length: 3 }, (_, index) =>
|
||||
@@ -412,7 +413,7 @@ describe("peer topology loopback observations", () => {
|
||||
expect(checks.find((check) => check.name === "relay-active-media-edges")?.passed).toBe(true);
|
||||
const overused = {
|
||||
...summary,
|
||||
maxRelayActiveMediaEdges: 2,
|
||||
maxRelayActiveMediaEdges: 3,
|
||||
};
|
||||
expect(
|
||||
buildRunChecks(overused, 3, "720p30", 2).find(
|
||||
|
||||
@@ -30,18 +30,20 @@ function expectValidTree(
|
||||
roomId: string,
|
||||
hostPeerId: string,
|
||||
viewerPeerIds: readonly string[],
|
||||
maxViewerChildren = 1,
|
||||
endpointMediaCopyCapacity = 2,
|
||||
): number {
|
||||
const hostAssignment = topology.getAssignment(roomId, hostPeerId)!;
|
||||
expect(hostAssignment.parentPeerId).toBeNull();
|
||||
expect(hostAssignment.childPeerIds.length).toBeLessThanOrEqual(2);
|
||||
expect(hostAssignment.childPeerIds.length).toBeLessThanOrEqual(
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
|
||||
const allChildren = new Set(hostAssignment.childPeerIds);
|
||||
let maximumDepth = 0;
|
||||
for (const peerId of viewerPeerIds) {
|
||||
const assignment = topology.getAssignment(roomId, peerId)!;
|
||||
expect(assignment.childPeerIds.length).toBeLessThanOrEqual(
|
||||
maxViewerChildren,
|
||||
endpointMediaCopyCapacity,
|
||||
);
|
||||
for (const childPeerId of assignment.childPeerIds) {
|
||||
expect(allChildren.has(childPeerId)).toBe(false);
|
||||
@@ -197,42 +199,7 @@ describe("PeerRelayTopology", () => {
|
||||
expect(topology.getAssignment("room", "viewer-d")?.parentPeerId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps server relay ineligibility separate from advertised capacity", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "relay", "sibling", "child", "pending");
|
||||
topology.setHost("room", "host", peers);
|
||||
topology.addViewer("room", "relay", peers);
|
||||
topology.addViewer("room", "sibling", peers);
|
||||
topology.setViewerRelayCapacity("room", "relay", 1, peers);
|
||||
topology.addViewer("room", "child", peers);
|
||||
|
||||
topology.setViewerRelayEligible("room", "relay", false);
|
||||
expect(topology.getDownstreamCapacity("room", "relay")).toBe(0);
|
||||
expect(topology.getAdvertisedDownstreamCapacity("room", "relay")).toBe(1);
|
||||
expect(topology.getAssignment("room", "child")?.parentPeerId).toBe("relay");
|
||||
expect(
|
||||
topology.setViewerRelayCapacity("room", "relay", 2, peers),
|
||||
).toEqual([]);
|
||||
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",
|
||||
);
|
||||
topology.setViewerRelayEligible("room", "host", false);
|
||||
expect(topology.getDownstreamCapacity("room", "host")).toBe(2);
|
||||
});
|
||||
|
||||
it("applies role-aware release limits even when code supplies three", () => {
|
||||
it("applies one deployment capacity to Host and Viewer endpoints", () => {
|
||||
const peers = connected("host", "relay");
|
||||
const defaultTopology = new PeerRelayTopology();
|
||||
defaultTopology.setHost("default", "host", peers);
|
||||
@@ -243,7 +210,7 @@ describe("PeerRelayTopology", () => {
|
||||
).toBe(2);
|
||||
expect(
|
||||
defaultTopology.getAdvertisedDownstreamCapacity("default", "relay"),
|
||||
).toBe(1);
|
||||
).toBe(2);
|
||||
|
||||
const expandedTopology = new PeerRelayTopology(3);
|
||||
expandedTopology.setHost("expanded", "host", peers);
|
||||
@@ -251,10 +218,10 @@ describe("PeerRelayTopology", () => {
|
||||
expandedTopology.setViewerRelayCapacity("expanded", "relay", 3, peers);
|
||||
expect(
|
||||
expandedTopology.getAdvertisedDownstreamCapacity("expanded", "host"),
|
||||
).toBe(2);
|
||||
).toBe(3);
|
||||
expect(
|
||||
expandedTopology.getAdvertisedDownstreamCapacity("expanded", "relay"),
|
||||
).toBe(1);
|
||||
).toBe(3);
|
||||
|
||||
const tightenedTopology = new PeerRelayTopology(1);
|
||||
tightenedTopology.setHost("tightened", "host", peers);
|
||||
@@ -358,7 +325,7 @@ describe("PeerRelayTopology", () => {
|
||||
expect(expectValidTree(topology, roomId, hostPeerId, viewerPeerIds)).toBe(4);
|
||||
});
|
||||
|
||||
it("clamps old capacity-three viewers to one child in an eight-viewer tree", () => {
|
||||
it("uses capacity three uniformly in an eight-viewer tree", () => {
|
||||
const topology = new PeerRelayTopology(3);
|
||||
const roomId = "room";
|
||||
const hostPeerId = "host";
|
||||
@@ -382,23 +349,25 @@ describe("PeerRelayTopology", () => {
|
||||
roomId,
|
||||
hostPeerId,
|
||||
viewerPeerIds.slice(0, index + 1),
|
||||
1,
|
||||
3,
|
||||
);
|
||||
}
|
||||
|
||||
expect(topology.getAssignment(roomId, "viewer-1")?.childPeerIds).toEqual([
|
||||
"viewer-3",
|
||||
"viewer-4",
|
||||
"viewer-5",
|
||||
"viewer-6",
|
||||
]);
|
||||
expect(topology.getDownstreamCapacity(roomId, "viewer-1")).toBe(1);
|
||||
expect(topology.getDownstreamCapacity(roomId, "viewer-1")).toBe(3);
|
||||
expect(topology.getAssignment(roomId, "viewer-5")?.parentPeerId).toBe(
|
||||
"viewer-3",
|
||||
"viewer-1",
|
||||
);
|
||||
expect(
|
||||
expectValidTree(topology, roomId, hostPeerId, viewerPeerIds, 1),
|
||||
).toBe(4);
|
||||
expectValidTree(topology, roomId, hostPeerId, viewerPeerIds, 3),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("reattaches a relay child without creating a second viewer child", () => {
|
||||
it("reattaches relay children within the shared endpoint capacity", () => {
|
||||
const topology = new PeerRelayTopology();
|
||||
const peers = connected("host", "relay", "sibling", "child-a", "child-b");
|
||||
topology.setHost("room", "host", peers);
|
||||
@@ -408,10 +377,9 @@ 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",
|
||||
]);
|
||||
expect(topology.getAssignment("room", "sibling")?.childPeerIds).toEqual([]);
|
||||
|
||||
topology.removeViewer(
|
||||
"room",
|
||||
@@ -431,7 +399,7 @@ describe("PeerRelayTopology", () => {
|
||||
"room",
|
||||
"host",
|
||||
["sibling", "child-a", "child-b"],
|
||||
1,
|
||||
2,
|
||||
),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
@@ -343,6 +343,18 @@ describe("client signaling protocol", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the previous signaling wire", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
protocol: "screener-v5",
|
||||
roomId,
|
||||
role: "viewer",
|
||||
clientId: "client_12345678",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a host token and rejects viewer tokens or malformed room codes", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
} from "../src/client/media/relay-capability.ts";
|
||||
|
||||
describe("browser relay capability", () => {
|
||||
it("advertises the current one-child browser release capacity", () => {
|
||||
it("advertises the endpoint capability envelope without a Browser tier", () => {
|
||||
expect(relayCapacityMessageForBrowser(true)).toEqual({
|
||||
type: "relay-capacity",
|
||||
downstreamEdges: 1,
|
||||
downstreamEdges: 3,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+20
-12
@@ -12,7 +12,7 @@ describe("server configuration", () => {
|
||||
expect(config.stunUrls).toEqual([]);
|
||||
expect(config.maxViewersPerRoom).toBe(8);
|
||||
expect(config.peerAssistedMedia).toBe(false);
|
||||
expect(config.maxPeerRelayDownstreamEdges).toBe(2);
|
||||
expect(config.endpointMediaCopyCapacity).toBe(2);
|
||||
expect(config.livekitFallback).toBeUndefined();
|
||||
expect(config.selectedEdgeTurn).toBeUndefined();
|
||||
});
|
||||
@@ -227,28 +227,36 @@ describe("server configuration", () => {
|
||||
).toThrow("MAX_VIEWERS_PER_ROOM");
|
||||
});
|
||||
|
||||
it.each(["1", "2"])(
|
||||
"accepts a bounded peer relay downstream limit of %s",
|
||||
(maxPeerRelayDownstreamEdges) => {
|
||||
it.each(["1", "2", "3"])(
|
||||
"accepts an endpoint media copy capacity of %s",
|
||||
(endpointMediaCopyCapacity) => {
|
||||
expect(
|
||||
loadConfig({
|
||||
MAX_PEER_RELAY_DOWNSTREAM_EDGES: maxPeerRelayDownstreamEdges,
|
||||
}).maxPeerRelayDownstreamEdges,
|
||||
).toBe(Number(maxPeerRelayDownstreamEdges));
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY: endpointMediaCopyCapacity,
|
||||
}).endpointMediaCopyCapacity,
|
||||
).toBe(Number(endpointMediaCopyCapacity));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["0", "3", "4", "1.5"])(
|
||||
"rejects an invalid peer relay downstream limit of %s",
|
||||
(maxPeerRelayDownstreamEdges) => {
|
||||
it.each(["0", "4", "1.5"])(
|
||||
"rejects an invalid endpoint media copy capacity of %s",
|
||||
(endpointMediaCopyCapacity) => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
MAX_PEER_RELAY_DOWNSTREAM_EDGES: maxPeerRelayDownstreamEdges,
|
||||
ENDPOINT_MEDIA_COPY_CAPACITY: endpointMediaCopyCapacity,
|
||||
}),
|
||||
).toThrow("MAX_PEER_RELAY_DOWNSTREAM_EDGES");
|
||||
).toThrow("ENDPOINT_MEDIA_COPY_CAPACITY");
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed on the removed relay downstream setting", () => {
|
||||
expect(() =>
|
||||
loadConfig({ MAX_PEER_RELAY_DOWNSTREAM_EDGES: "2" }),
|
||||
).toThrow(
|
||||
"MAX_PEER_RELAY_DOWNSTREAM_EDGES is no longer supported; use ENDPOINT_MEDIA_COPY_CAPACITY",
|
||||
);
|
||||
});
|
||||
|
||||
it("enables the hybrid controller for every room when selected", () => {
|
||||
expect(loadConfig({ PEER_ASSISTED_MEDIA: "true" }).peerAssistedMedia).toBe(
|
||||
true,
|
||||
|
||||
@@ -43,7 +43,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
maxRooms: 10,
|
||||
maxViewersPerRoom: 8,
|
||||
peerAssistedMedia: false,
|
||||
maxPeerRelayDownstreamEdges: 2,
|
||||
endpointMediaCopyCapacity: 2,
|
||||
stunUrls: [],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
+359
-304
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user