fix(media): prepare viewer relay parents before route commit (#175)

This commit is contained in:
AAA authored and GitHub committed 2026-08-22 06:50:45 +08:00
1 parent 55567839d4
commit 35ab8bb700
13 files changed
+670 -83

No files matched your search

@@ -413,15 +413,18 @@ unchanged severe or relative pair advances its own streak. Healthy or ambiguous
correlated windows clear it. A Viewer or parent session,
connection ID, route revision or parent identity change also resets it, as does
an evidence gap over five seconds. Severe and relative-FPS evidence each open at
most one peer-to-peer make-before-break attempt. Its v1 parent is an ordinary
Viewer whose active upstream is peer; Host and SFU-root provisional children
remain later work, and their exclusion does not stop enumeration. The old active edge and its
connection ID remain authoritative while the candidate uses a separate exact
identity. The Viewer sends prepare-ready only after positive current-candidate
most one peer-to-peer make-before-break attempt. The candidate is a Viewer whose
active upstream is peer or SFU, whose session/share/revision are current, and
whose effective relay capacity has one strict spare slot; Host remains excluded.
The candidate parent holds one provisional child `HostPeer` outside its active
child IDs/map. The old active edge and its connection ID remain authoritative
while the candidate uses a separate exact identity. The Viewer sends prepare-ready only after positive current-candidate
RTP and decoded-frame progress plus a live video track; only then does the server
commit the planned topology and promote the candidate connection ID. A failure
after prepare-ready retains that exact identity until matching active authority
is failed or rollback clears it. An unrelated authoritative route failure first
commit the planned topology and promote the same provisional PC plus candidate
connection ID. A failure after prepare-ready retains that exact identity across
repeated matching active updates, where the candidate parent stays fail-closed
without originating `route-failed`; the target Viewer's probe owns recovery.
Rollback clears the identity. An unrelated authoritative route failure first
aborts the soft probe and then immediately resumes its ordinary ladder. Failure,
timeout, stale identity, or no eligible peer keeps the old active edge and does
not start SFU, TURN, an error, or another migration. A started attempt creates
+1 -1
View File
@@ -28,7 +28,7 @@ Last updated: 2026-08-22
- Mobile endpoints are Web Viewer-only. Current Android/iOS browsers do not expose Web Host capture; AirPlay/system mirroring is Viewer-local output.
- ADR-0005: direct/peer UDP -> bounded SFU roots -> selected-edge TURN. The deployed controller caps pending/answered `peer-selected` at one per room; Host ingress is independent. A pre-`ecc794d` exact-source/production-media canary proves active Host ingress only. Ordinary peers stay STUN-only.
- `PEER_ASSISTED_MEDIA=true` covers all rooms. One-root healthy-SFU MBB is deployed with retained parent, media proof, Host2/browser1 overlap, cooldown and fresh two-window proof; it excludes `peer-selected`. Active-SFU loss uses normal recovery; the manual control keeps its route: peer restarts its edge, SFU reauthenticates signaling and rebuilds that subscriber. Capacity `0 -> 1`, multi-root/browser gates remain open.
- C+B stays edge-local: deployed MBB v1 gives only the triggering child one ordinary peer-upstream Viewer attempt while its old edge remains active; Host/SFU roots remain excluded. Current-generation RTP, decoded frames, and live video atomically promote the candidate; a ready-then-failed identity survives until active/rollback, and an unrelated authoritative failure preempts the soft probe. Failure, timeout, or no candidate keeps the old edge without SFU/TURN. Sibling MBB remains separate. No score.
- C+B stays edge-local: deployed MBB v1 gives only the triggering child one ordinary peer-upstream Viewer attempt while its old edge remains active. The source candidate extends the provisional-child owner to an ordinary Viewer whose active upstream is peer or SFU; Host remains excluded. Current-generation RTP, decoded frames, and live video atomically promote the same provisional PC. A ready-then-failed identity survives until active/rollback, while session/share change or an unrelated authoritative failure aborts the soft probe. Failure, timeout, or no candidate keeps the old edge without SFU/TURN. Sibling MBB remains separate. No score.
- Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only.
- Treat settings as ceilings and degradation as unclassified. Use correlated Host A+B/Viewer C and one-variable evidence; never force AV1, infer by UA, or create a composite score.
- Screen audio: production Web capture requests speech processing off and ideal stereo. Share advanced settings offer 64/128/256 kbps sender ceilings, default 128, and lock the choice for the active share across P2P, browser relay, and SFU. Peer answers permit stereo up to 256 kbps; SFU keeps `forceStereo`, DTX off, and RED retained. Local Viewer details expose non-uploaded A/V playout, jitter-buffer, and concealment evidence. Target-browser/route proof remains open; voice and Native stay separate.
+13 -8
View File
@@ -214,9 +214,10 @@ limitation or at least 100 sent packets with remote loss divided by sent packets
at least 30%. Three consecutive windows of the same severe or relative kind are
required. A healthy or incomplete correlated window, any Viewer/parent session,
connection, route revision or parent change, or a gap over five seconds clears
the streak. Severe and relative-FPS quality evidence each tries one ordinary
peer-upstream Viewer through make-before-break; v1 skips Host and SFU roots but
continues to later eligible Viewers. No candidate, probe failure, or timeout keeps
the streak. Severe and relative-FPS quality evidence each tries one Viewer whose
active upstream is peer or SFU through make-before-break; Host is excluded, and
session/share/revision plus one strict effective-capacity slot are required
before breadth-first selection continues. No candidate, probe failure, or timeout keeps
the old edge without SFU, TURN, or an error. A started attempt spends a
30-second room migration
budget, so a new public Viewer identity cannot bypass it. Per-edge state clears
@@ -224,13 +225,17 @@ on authentication/generation change, disconnect/removal and route replacement;
room stop/delete also clears the cooldown.
The candidate is bound to the exact Viewer/parent sessions, active and pending
route revisions, share lifecycle and a separate connection ID. Signaling keeps
route revisions, share lifecycle and a separate connection ID. Its parent owns
one provisional child PC outside the active child map and never auto-retries it
with another connection ID. Signaling keeps
the old connection ID authoritative until the Viewer proves positive RTP,
positive decoded-frame progress and a live video track on the candidate; commit
then changes topology, route and active connection identity together. Failure
after ready retains the exact provisional identity until matching active or
rollback, while another edge's authoritative failure aborts the soft probe
before using the ordinary ladder. A
then promotes that same PC and changes topology, route and active connection
identity together. Failure after ready retains the exact provisional identity
across repeated matching active updates; the candidate parent stays fail-closed
and the target Viewer probe owns recovery. Rollback or another edge's
authoritative failure clears or aborts the soft probe before using the ordinary
ladder. A
successful relative move holds the old parent for 30 seconds or until its
session changes. A real active-route failure remains a separate hard recovery
trigger and can reuse an otherwise playable old parent.
+1 -1
View File
@@ -18,7 +18,7 @@ This file is the current execution index. Load [verification status](./verificat
- The deployed pre-share self-check uses disposable, no-room resources for health, WSS, and STUN `srflx` checks and treats configured SFU as unverified until a real route. Expanded connection details provide click-only, versioned JSON export; its fixed allowlist excludes candidate addresses/ports, participant and room identity, raw signaling, URLs, and credentials, and no report is uploaded or persisted.
- Private fragment grants enter directly; code-only Viewers need site access and private rooms additionally need their password. Credentials remain unstored. Web names and presence are session-only; duplicate names alone show an ID suffix.
- Rooms default to eight Viewers and accept an explicit admission limit from one through sixteen. Roots and ordinary Web relays are bounded to two downstream edges, Viewer upstream receive is free, and source permits an explicit endpoint cap from one through three. SFU root capacity remains separate.
- Relay quality handling compares fresh child FPS with parent input. Two current corroborating children can pause one Viewer relay for 30 seconds and request peer-only reassignment. Deployed MBB v1 keeps the triggering child's old edge while one ordinary peer-upstream Viewer proves current-generation RTP, decoded frames, and live video; it excludes Host/SFU roots, preserves ready-failure identity through commit/rollback, and lets unrelated hard failures preempt the soft probe. Failure, timeout, or no candidate keeps the old edge without SFU/TURN. Sibling MBB remains open.
- Relay quality handling compares fresh child FPS with parent input. Two current corroborating children can pause one Viewer relay for 30 seconds and request peer-only reassignment. Deployed MBB v1 keeps the triggering child's old edge while one ordinary peer-upstream Viewer proves current-generation media. The source candidate also allows an ordinary SFU-root Viewer with a strict spare relay slot to own the provisional child PC; matching current-generation RTP, decoded frames, live video, revision, and identity promote that same PC. Host remains excluded. Failure, timeout, session/share change, or no candidate keeps the old edge without SFU/TURN. Sibling MBB remains open.
- A generation-bound 15-second initial-connect deadline enters ICE restart. One-root healthy-SFU make-before-break is deployed but media-unverified; capacity `0 -> 1`, multi-root, browser, and mobile gates remain open.
## Active Milestone
+1 -1
View File
@@ -184,7 +184,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,保留健康路径并明确显示弱路径降级或失败。
单个三窗确认的 C+B 事件仍是 edge-local,`severe` 与 `relative-fps` 都先且只尝试一个 ordinary peer-upstream Viewer;v1 排除 Host 与 SFU root,但继续枚举后续合格 Viewer,二者的 provisional-child 支持留待后续。服务端在现有 route prepare 上保留旧 active edge 与 connection generation;新 parent 只建立一个 provisional edge,Viewer 以当前 prepare revision 的正 RTP、正 decoded frames 和 live video track 证明后,服务端才一次性提交拓扑、route 与候选 connection ID。prepare-ready 后的候选失败必须保留精确 revision/parent/connection identity 到 matching active 或 rollback;matching active 先到时立即报告 active failure,不能 promotion 已失败 peer。其他 edge 的已鉴权 hard `route-failed` 先 abort soft probe 并让 target/candidate 回到 current authority,再立即进入原故障梯级。候选失败、超时或无候选均回滚/保留旧边,不创建 SFU/TURN intent。同房同时最多一个 peer migration。若同一 Viewer parent peer/session/share 在五秒内由两个不同 child 分别确认任一类型,服务端把该 parent 的独立 `relayEligible` 暂停 30 秒,使拓扑有效容量为零但保留客户端广告值;后续 capacity 广播不能绕过。Host 不参与,parent 自身 upstream/观看不变;本 slice 只保证触发 child 的 MBB,其他 current children 暂沿既有有界重选,完整 sibling MBB 后续实现。到期惰性恢复,parent 新 session 或新分享立即恢复,stop/delete/disconnect 清理状态。该离散资格不命令 LiveKit/WebRTC 媒体层,也不把 RTT、CPU、码率、设备信息压成综合分数;窗口仍需真实质量矩阵校准。
单个三窗确认的 C+B 事件仍是 edge-local,`severe` 与 `relative-fps` 都先且只尝试一个 active upstream 为 peer 或 SFU 的 Viewer;Host 排除,候选的 session/share/revision 必须当前,且 active children 加一不得超过服务端有效 relay capacity。服务端在现有 route prepare 上保留旧 active edge 与 connection generation;新 parent 在 active child IDs/map 外只建立一个 provisional `HostPeer`,answer/candidate 按 exact child/connection/revision 优先路由到该 PC。Viewer 以当前 prepare revision 的正 RTP、正 decoded frames 和 live video track 证明后,matching active revision 才把同一个 PC 原子移入 active map,再执行普通 child reconcile 并一次性提交拓扑、route 与候选 connection ID;provisional 禁止用新 connection ID 自动重试。prepare-ready 后的候选失败必须保留精确 revision/parent/connection identity 到 matching active 或 rollback;候选 parent 对同 revision 的重复 matching active 持续 fail-closed,不 promotion、不建新 PC,也不越权发送 `route-failed`,恢复只由 target Viewer probe 发起。其他 edge 的已鉴权 hard `route-failed` 先 abort soft probe 并让 target/candidate 回到 current authority,再立即进入原故障梯级。候选失败、超时、session/share 变化或无候选均回滚/保留旧边,不创建 SFU/TURN intent。同房同时最多一个 peer migration。若同一 Viewer parent peer/session/share 在五秒内由两个不同 child 分别确认任一类型,服务端把该 parent 的独立 `relayEligible` 暂停 30 秒,使拓扑有效容量为零但保留客户端广告值;后续 capacity 广播不能绕过。Host 不参与,parent 自身 upstream/观看不变;本 slice 只保证触发 child 的 MBB,其他 current children 暂沿既有有界重选,完整 sibling MBB 后续实现。到期惰性恢复,parent 新 session 或新分享立即恢复,stop/delete/disconnect 清理状态。该离散资格不命令 LiveKit/WebRTC 媒体层,也不把 RTT、CPU、码率、设备信息压成综合分数;窗口仍需真实质量矩阵校准。
状态决策必须关联同一时间区间和媒体代次的三段证据:
+1 -1
View File
@@ -41,7 +41,7 @@
- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示明确标为“站点访问”的整站验证,通过后才显示明确标为“加入房间”的当前房间密码输入;有效 site-access cookie 直接跳过第一步,但两类密码不得复用、代填或互相替代。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。合格父节点构成网状候选关系,但实际媒体保持无环且每个 Viewer 只有一个 active upstream;加入、容量变化、掉线和当前边失败触发有界重选,切换重叠必须受现有边预算与当前代媒体证据约束,不做常驻多源分片。一个 child 的已确认 C+B 质量事件仍只处理该 edge:v1 只选择 active upstream 仍为 peer 的普通 Viewer,跳过 Host/SFU root 后继续枚举;保留旧边并建立至多一个候选边,只有候选在当前 prepare revision 收到 RTP、解码出帧且持有 live video track 后才原子换父。prepare-ready 后候选失败时,精确 revision/parent/connection identity 必须保留到 matching active 或 rollback;matching active 先到则报告 active failure,不能 promotion 已失败 peer。其他 edge 的真实 `route-failed` 必须先抢占 soft probe、恢复 target/candidate current authority,再进入原故障梯级。候选失败、超时或无候选都保留旧边,质量事件本身不得进入 SFU/TURN。只有同一 Viewer parent peer/session/share 在五秒内收到两个不同 child 各自三窗确认的 `severe` 或 `relative-fps`,服务端才把该 parent 的独立 relay 资格暂停 30 秒,使有效容量为零但不覆盖客户端广告。Host 不退位,parent 的上游观看不变;其他当前 children 暂沿既有有界重选,完整 sibling MBB 是后续 slice。新 parent session、新分享或 30 秒到期立即/惰性恢复资格。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。合格父节点构成网状候选关系,但实际媒体保持无环且每个 Viewer 只有一个 active upstream;加入、容量变化、掉线和当前边失败触发有界重选,切换重叠必须受现有边预算与当前代媒体证据约束,不做常驻多源分片。一个 child 的已确认 C+B 质量事件仍只处理该 edge:候选只允许 active upstream 为 peer 或 SFU、session/share/revision 当前且有效 relay capacity 严格多一个空槽的 Viewer,Host 排除;保留旧边并在候选 parent 的 active child map 外建立至多一个 provisional `HostPeer`,只有候选在当前 prepare revision 收到 RTP、解码出帧且持有 live video track 后才把同一个 PC 原子 promotion 并换父。prepare-ready 后候选失败时,精确 revision/parent/connection identity 必须保留到 matching active 或 rollback;候选 parent 对同 revision 的重复 matching active 持续 fail-closed,不 promotion、不建新 PC,也不越权发送 `route-failed`,恢复只由 target Viewer probe 发起。其他 edge 的真实 `route-failed` 必须先抢占 soft probe、恢复 target/candidate current authority,再进入原故障梯级。候选失败、超时、session/share 变化或无候选都保留旧边,质量事件本身不得进入 SFU/TURN。只有同一 Viewer parent peer/session/share 在五秒内收到两个不同 child 各自三窗确认的 `severe` 或 `relative-fps`,服务端才把该 parent 的独立 relay 资格暂停 30 秒,使有效容量为零但不覆盖客户端广告。Host 不退位,parent 的上游观看不变;其他当前 children 暂沿既有有界重选,完整 sibling MBB 是后续 slice。新 parent session、新分享或 30 秒到期立即/惰性恢复资格。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
- [ ] 质量控制扩展:推荐档位仍是默认入口;“高级视频设置”只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling、清晰/平衡/流畅三种偏好,以及 `自动 | H.264 | VP8` 编码选择;初始值采用 `balanced` 与 `automatic`,其余偏好和编码保留为显式选择。视频档位在直播中显式应用;codec 直播中锁定并于下一次分享生效,避免滑杆产生连续控制流量。本期不重命名该区域,也不增加音频 UI:屏幕音频固定使用一个媒体默认,所有实际浏览器 sender 请求并读回 128 kbps ceiling,DTX 关闭,Web peer 与 SFU 都显式允许 Opus stereo 并协商 128 kbps maximum average bitrate;实际码率仍由浏览器拥塞控制和 stats 决定。Peer 只在唯一明确的 active Opus audio 上做结构化、幂等的 Viewer answer `stereo=1;maxaveragebitrate=128000` 变换;非目标形态或 parser/writer 异常必须原样保留浏览器 answer,不得阻断可继续的 mono/video edge。不得用宽泛字符串/正则改 SDP,也不得改写浏览器协商的 FEC。SFU 使用 pinned SDK 的 128 kbps high-quality stereo preset、`forceStereo = true`、`dtx = false`,并保留 pinned room default `red = true`。不得暴露声道、采样率、codec、任意码率、stereo、DTX、RED 或 FEC 控件。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。未来 microphone voice 若进入范围,必须是独立 track/处理链,不能把 AEC/降噪/DTX 套到电影或游戏音频。
- [ ] SFU 质量设置生命周期:直播中修改档位时,浏览器 sender 的 applied readback、LiveKit `LocalVideoTrack` 保存的 degradation preference、track restart options 与 `LocalTrackPublication` 的 republish options 必须作为同一次串行操作更新;任一步失败恢复旧 sender 与旧 SDK 保存态,旧 publication generation 不得回写当前状态。LiveKit full reconnect/republish 不得恢复初次发布的旧偏好。该修复不改变 codec、码率上限、层数或路由,也不能单独解释即时低帧。
- [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
+22 -5
View File
@@ -38,8 +38,16 @@ interface ViewerSfuRouteEvents {
revision?: number,
) => boolean | void | Promise<boolean | void>;
preparePeer?: (assignment: ParticipantRouteAssignment | null, revision?: number) => void;
prepareChild?: (
childPeerIds: readonly string[] | null,
revision?: number,
) => void;
activateChildren?: (
childPeerIds: readonly string[],
revision: number,
) => void;
resetMedia?: () => void;
reconcileSfuChildren: (childPeerIds: string[]) => void;
reconcileSfuChildren?: (childPeerIds: string[]) => void;
onSfuStream: (
stream: MediaStream,
assignment: ParticipantRouteAssignment,
@@ -96,6 +104,7 @@ export class ViewerSfuRoute {
this.healthySfuWindows = null;
}
if (update.phase === "prepare") {
this.events.prepareChild?.(update.assignment.childPeerIds, update.revision);
const mediaUpstream = this.route.getMediaAssignment()?.upstream;
if (
update.assignment.upstream.kind === "peer" &&
@@ -105,9 +114,6 @@ export class ViewerSfuRoute {
this.peerProbeRevision = update.revision;
this.events.preparePeer?.(update.assignment, update.revision);
}
if (update.assignment.upstream.kind !== "sfu") {
this.events.reconcileSfuChildren(update.assignment.childPeerIds);
}
if (this.pending?.revision !== update.revision) {
this.clearPending();
}
@@ -153,6 +159,7 @@ export class ViewerSfuRoute {
this.route.reset();
this.recovery = null;
this.healthySfuWindows = null;
this.events.prepareChild?.(null);
const pending = this.pending;
const active = this.active;
this.pending = null;
@@ -341,6 +348,7 @@ export class ViewerSfuRoute {
this.failedPeerProbe = null;
this.healthySfuWindows = null;
this.events.preparePeer?.(null);
this.events.prepareChild?.(null);
await this.queueTransition(async () => {
const pending = this.pending;
const active = this.active;
@@ -382,7 +390,7 @@ export class ViewerSfuRoute {
return;
}
if (assignment.upstream.kind === "sfu") {
this.events.reconcileSfuChildren(assignment.childPeerIds);
this.activateChildren(assignment.childPeerIds, token.revision);
const mediaAssignment = this.route.getMediaAssignment();
if (
mediaAssignment?.upstream.kind === "sfu" &&
@@ -429,6 +437,7 @@ export class ViewerSfuRoute {
if (!this.route.owns(token, "active")) {
return;
}
this.activateChildren(assignment.childPeerIds, token.revision);
this.commitMedia(token, acknowledge);
});
}
@@ -439,6 +448,14 @@ export class ViewerSfuRoute {
return this.transitionTail;
}
private activateChildren(childPeerIds: readonly string[], revision: number): void {
if (this.events.activateChildren) {
this.events.activateChildren(childPeerIds, revision);
} else {
this.events.reconcileSfuChildren?.([...childPeerIds]);
}
}
private async activateSfu(
token: RouteOperationToken,
acknowledge: boolean,
+29 -8
View File
@@ -439,6 +439,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
function reconcileRelayChildren(
previousChildPeerIds: readonly string[],
activeRevision?: number,
): void {
const nextChildPeerIds = currentAssignment.childPeerIds;
const changed =
@@ -454,7 +455,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
if (changed) {
clearRelayChildEvidence();
}
ensureViewerRelay()?.setChildren(nextChildPeerIds);
const relay = ensureViewerRelay();
if (activeRevision === undefined) {
relay?.setChildren(nextChildPeerIds);
} else {
relay?.activateChildren(activeRevision, nextChildPeerIds);
}
}
function discardPeerProbe(): void {
@@ -531,7 +537,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
applyMediaAssignment(
{
parentPeerId: probe.parentPeerId,
childPeerIds: assignment.childPeerIds,
childPeerIds: currentAssignment.childPeerIds,
},
true,
);
@@ -547,7 +553,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
assignment.upstream.kind === "peer"
? assignment.upstream.peerId
: null,
childPeerIds: assignment.childPeerIds,
childPeerIds: currentAssignment.childPeerIds,
});
if (assignment.upstream.kind === "peer") {
await drainPreparedParentSignals(assignment.upstream.peerId);
@@ -574,6 +580,16 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
: null,
);
},
prepareChild: (childPeerIds, revision) => {
if (!active || viewerSfuRoute !== route) {
return;
}
if (childPeerIds && revision !== undefined) {
ensureViewerRelay()?.prepareChild(revision, childPeerIds);
} else {
viewerRelay?.discardPreparedChild();
}
},
resetMedia: () => {
if (!active || viewerSfuRoute !== route) {
return;
@@ -584,7 +600,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
clearPeerState();
viewerRelay?.setChildren([]);
},
reconcileSfuChildren: (childPeerIds) => {
activateChildren: (childPeerIds, revision) => {
if (!active || viewerSfuRoute !== route) {
return;
}
@@ -592,11 +608,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
currentAssignment = limitMediaAssignment(
{
parentPeerId: currentAssignment.parentPeerId,
childPeerIds,
childPeerIds: [...childPeerIds],
},
MAX_VIEWER_MEDIA_CHILDREN,
);
reconcileRelayChildren(previousChildPeerIds);
reconcileRelayChildren(previousChildPeerIds, revision);
},
onSfuUpdate: (metrics) => {
if (active && viewerSfuRoute === route) {
@@ -1122,10 +1138,15 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
if (
peerAssisted &&
(await viewerRelay?.acceptSignal(
viewerRelay &&
(await viewerRelay.acceptSignal(
message.fromPeerId,
message.payload,
currentRouteRevision,
viewerRelay.getSignalRouteRevision(
message.fromPeerId,
message.payload.connectionId,
currentRouteRevision,
),
))
) {
return;
+223 -38
View File
@@ -30,6 +30,12 @@ interface SelectedChildConnection {
revision: number;
pendingCarryRevision: number | null;
}
interface PreparedChild {
revision: number;
childPeerId: string;
peer: HostPeer;
}
type FailedPreparedChild = Omit<PreparedChild, "peer"> & { connectionId: string };
export class ViewerRelay {
private childPeerIds: string[] = [];
@@ -38,6 +44,8 @@ export class ViewerRelay {
private readonly snapshots = new Map<string, PeerSnapshot>();
private readonly retiredConnections = new Map<string, string>();
private selectedChildConnection: SelectedChildConnection | null = null;
private preparedChild: PreparedChild | null = null;
private failedPreparedChild: FailedPreparedChild | null = null;
private syncQueue = Promise.resolve();
private disposed = false;
@@ -136,7 +144,104 @@ export class ViewerRelay {
this.clearSelectedEdgeTurn();
}
prepareChild(
revision: number,
plannedChildPeerIds: readonly string[],
): boolean {
const stream = this.stream;
const planned = [...new Set(plannedChildPeerIds)];
const childPeerId = planned.find(
(peerId) => !this.childPeerIds.includes(peerId),
);
if (
this.disposed ||
!stream ||
planned.length !== plannedChildPeerIds.length ||
planned.length !== this.childPeerIds.length + 1 ||
planned.length > MAX_VIEWER_MEDIA_CHILDREN ||
this.childPeerIds.some((peerId) => !planned.includes(peerId)) ||
!childPeerId ||
this.peers.has(childPeerId)
) {
this.discardPreparedChild();
return false;
}
if (
this.preparedChild?.revision === revision &&
this.preparedChild.childPeerId === childPeerId
) {
return true;
}
if (
this.failedPreparedChild?.revision === revision &&
this.failedPreparedChild.childPeerId === childPeerId
) {
return false;
}
this.discardPreparedChild();
const peer = this.createPeer(childPeerId, stream);
this.preparedChild = { revision, childPeerId, peer };
void peer
.start()
.catch(() => false)
.then((started) => {
if (!started && this.preparedChild?.peer === peer) {
this.failPreparedChild(peer);
}
});
return true;
}
activateChildren(revision: number, childPeerIds: readonly string[]): void {
const prepared = this.preparedChild;
const failed = this.failedPreparedChild;
const extendsActive =
childPeerIds.length === this.childPeerIds.length + 1 &&
this.childPeerIds.every((peerId) => childPeerIds.includes(peerId));
let promotedPeerId: string | null = null;
if (
prepared?.revision === revision &&
childPeerIds.includes(prepared.childPeerId) &&
extendsActive
) {
this.preparedChild = null;
this.peers.set(prepared.childPeerId, prepared.peer);
this.snapshots.set(prepared.childPeerId, prepared.peer.getSnapshot());
promotedPeerId = prepared.childPeerId;
} else if (
failed?.revision === revision &&
childPeerIds.includes(failed.childPeerId) &&
extendsActive
) {
return;
} else {
this.discardPreparedChild();
}
this.reconcileChildren(childPeerIds, promotedPeerId);
}
discardPreparedChild(): void {
const prepared = this.preparedChild;
this.preparedChild = null;
this.failedPreparedChild = null;
prepared?.peer.dispose();
}
setChildren(childPeerIds: readonly string[]): void {
const failedChildPeerId = this.failedPreparedChild?.childPeerId;
this.reconcileChildren(
failedChildPeerId
? childPeerIds.filter((peerId) => peerId !== failedChildPeerId)
: childPeerIds,
null,
);
}
private reconcileChildren(
childPeerIds: readonly string[],
promotedPeerId: string | null,
): void {
if (this.disposed) {
return;
}
@@ -162,7 +267,11 @@ export class ViewerRelay {
if (childPeerId === selectedPeerId) {
continue;
}
if (this.stream && (!peer || !peer.isConnected())) {
if (
this.stream &&
childPeerId !== promotedPeerId &&
(!peer || !peer.isConnected())
) {
this.disposePeer(childPeerId);
this.startPeer(childPeerId, this.stream);
}
@@ -173,9 +282,10 @@ export class ViewerRelay {
if (this.disposed) {
return;
}
const replacePrepared = this.stream !== null && this.stream !== stream;
this.stream = stream;
this.syncQueue = this.syncQueue
.then(() => this.syncStream(stream))
.then(() => this.syncStream(stream, replacePrepared))
.catch(() => undefined);
}
@@ -197,11 +307,32 @@ export class ViewerRelay {
return result.catch(() => false);
}
getSignalRouteRevision(
fromPeerId: string,
connectionId: string,
activeRevision: number,
): number {
const prepared = this.preparedChild;
return prepared?.childPeerId === fromPeerId &&
prepared.peer.connectionId === connectionId
? prepared.revision
: activeRevision;
}
async acceptSignal(
fromPeerId: string,
payload: SignalPayload,
routeRevision: number,
): Promise<boolean> {
const prepared = this.preparedChild;
if (
prepared?.revision === routeRevision &&
prepared.childPeerId === fromPeerId &&
prepared.peer.connectionId === payload.connectionId
) {
await prepared.peer.acceptSignal(payload);
return true;
}
const peer = this.peers.get(fromPeerId);
const selected = this.selectedChildConnection;
if (
@@ -253,10 +384,12 @@ export class ViewerRelay {
for (const peer of this.peers.values()) {
peer.updateIceConfig(iceConfig);
}
this.preparedChild?.peer.updateIceConfig(iceConfig);
}
stop(): void {
this.stream = null;
this.discardPreparedChild();
this.clearSelectedEdgeTurn();
this.disposePeers();
}
@@ -267,12 +400,23 @@ export class ViewerRelay {
}
this.disposed = true;
this.stream = null;
this.discardPreparedChild();
this.selectedChildConnection = null;
this.childPeerIds = [];
this.disposePeers();
}
private async syncStream(stream: MediaStream): Promise<void> {
private async syncStream(
stream: MediaStream,
replacePrepared: boolean,
): Promise<void> {
if (this.disposed || this.stream !== stream) {
return;
}
const prepared = replacePrepared ? this.preparedChild : null;
if (prepared && !(await this.replacePeerStream(prepared.peer, stream))) {
this.failPreparedChild(prepared.peer);
}
if (this.disposed || this.stream !== stream) {
return;
}
@@ -293,12 +437,7 @@ export class ViewerRelay {
return;
}
let replaced = false;
try {
replaced = await peer.replaceStream(stream);
} catch {
// Rebuilding below is safer than leaving a stopped upstream track.
}
const replaced = await this.replacePeerStream(peer, stream);
if (
replaced ||
this.disposed ||
@@ -316,19 +455,35 @@ export class ViewerRelay {
if (this.disposed || this.desiredProfile !== profile) {
return false;
}
const peers = [...this.peers.entries()];
const prepared = this.preparedChild;
const peers = [
...this.peers.values(),
...(prepared ? [prepared.peer] : []),
];
if (peers.length === 0) {
return true;
}
const results = await Promise.all(
peers.map(async ([childPeerId, peer]) =>
this.peers.get(childPeerId) !== peer ||
peers.map(async (peer) =>
(this.peers.get(peer.peerId) !== peer &&
this.preparedChild?.peer !== peer) ||
(await peer.updateProfile(this.desiredProfile)),
),
);
return this.desiredProfile === profile && results.every(Boolean);
}
private async replacePeerStream(
peer: HostPeer,
stream: MediaStream,
): Promise<boolean> {
try {
return await peer.replaceStream(stream);
} catch {
return false;
}
}
private startPeer(
childPeerId: string,
stream: MediaStream,
@@ -344,6 +499,38 @@ export class ViewerRelay {
return;
}
const peer = this.createPeer(childPeerId, stream, selectedTurn);
this.peers.set(childPeerId, peer);
void peer
.start()
.catch(() => false)
.then((started) => {
if (started || this.peers.get(childPeerId) !== peer) {
return;
}
if (selectedTurn) {
this.failSelectedChild(childPeerId, peer.connectionId);
return;
}
this.disposePeer(childPeerId);
if (
attempt < 1 &&
!this.disposed &&
this.stream === stream &&
this.childPeerIds.includes(childPeerId)
) {
window.setTimeout(() => {
this.startPeer(childPeerId, stream, attempt + 1);
}, 500);
}
});
}
private createPeer(
childPeerId: string,
stream: MediaStream,
selectedTurn?: SelectedEdgeTurn,
): HostPeer {
let peer: HostPeer;
peer = new HostPeer(
childPeerId,
@@ -355,11 +542,18 @@ export class ViewerRelay {
{
sendSignal: (targetPeerId, payload) =>
!this.disposed &&
this.peers.get(childPeerId) === peer &&
(this.peers.get(childPeerId) === peer ||
this.preparedChild?.peer === peer) &&
childPeerId === targetPeerId
? this.events.sendSignal(targetPeerId, payload)
: false,
onUpdate: (snapshot) => {
if (this.preparedChild?.peer === peer) {
if (snapshot.connectionState === "failed") {
this.failPreparedChild(peer);
}
return;
}
if (
!this.disposed &&
this.peers.get(childPeerId) === peer &&
@@ -382,31 +576,7 @@ export class ViewerRelay {
selectedTurn !== undefined,
selectedTurn?.newConnectionId,
);
this.peers.set(childPeerId, peer);
void peer
.start()
.catch(() => false)
.then((started) => {
if (started || this.peers.get(childPeerId) !== peer) {
return;
}
if (selectedTurn) {
this.failSelectedChild(childPeerId, peer.connectionId);
return;
}
this.disposePeer(childPeerId);
if (
!selectedTurn &&
attempt < 1 &&
!this.disposed &&
this.stream === stream &&
this.childPeerIds.includes(childPeerId)
) {
window.setTimeout(() => {
this.startPeer(childPeerId, stream, attempt + 1);
}, 500);
}
});
return peer;
}
private failSelectedChild(childPeerId: string, connectionId: string): void {
@@ -425,6 +595,21 @@ export class ViewerRelay {
this.disposePeer(childPeerId);
}
private failPreparedChild(peer: HostPeer): void {
const prepared = this.preparedChild;
if (prepared?.peer !== peer) {
return;
}
this.preparedChild = null;
const { revision, childPeerId } = prepared;
this.failedPreparedChild = {
revision,
childPeerId,
connectionId: peer.connectionId,
};
peer.dispose();
}
private disposePeers(): void {
for (const childPeerId of [...this.peers.keys()]) {
this.disposePeer(childPeerId);
+40 -5
View File
@@ -110,6 +110,8 @@ interface HealthySfuReselectionProbe {
}
interface PeerQualityReselectionProbe {
activeRevision: number;
shareGeneration: string;
excludedParentPeerIds: ReadonlySet<string>;
newParentPeerId: string;
newConnectionId: string | null;
@@ -2364,7 +2366,11 @@ export class HybridMediaRouter {
const guard = intent.qualityGuard;
const controller = this.mediaRouteControllers.get(roomId);
const active = controller?.getActiveRoute();
const viewer = this.options.roomStore.getConnectedViewer(roomId, viewerPeerId);
const viewer = this.options.roomStore.getConnectedViewer(
roomId,
viewerPeerId,
);
const shareGeneration = this.options.getShareGeneration(roomId);
if (
!guard ||
!controller ||
@@ -2372,15 +2378,27 @@ export class HybridMediaRouter {
this.roomPeerMigrationAttempt(roomId) !== null ||
!active ||
!viewer ||
!shareGeneration ||
active.revision !== guard.routeRevision ||
!this.qualityIntentIsCurrent(roomId, viewerPeerId, intent)
) {
return false;
}
const candidateExclusions = new Set(excludedParentPeerIds);
candidateExclusions.add(this.peerRelayTopology.getHostPeerId(roomId) ?? guard.parentPeerId);
candidateExclusions.add(
this.peerRelayTopology.getHostPeerId(roomId) ?? guard.parentPeerId,
);
for (const [peerId, assignment] of active.assignments) {
if (assignment.upstream.kind !== "peer" || active.sfu.rootPeerIds.includes(peerId)) candidateExclusions.add(peerId);
if (
(assignment.upstream.kind !== "peer" &&
assignment.upstream.kind !== "sfu") ||
!this.options.roomStore.getConnectedViewer(roomId, peerId) ||
assignment.childPeerIds.length + 1 >
this.peerRelayTopology.getDownstreamCapacity(roomId, peerId)
) {
candidateExclusions.add(peerId);
}
}
const newParentPeerId = this.peerRelayTopology.findViewerReassignmentParent(
roomId,
@@ -2390,7 +2408,7 @@ export class HybridMediaRouter {
MAX_PEER_RELAY_DEPTH,
);
const newParent = newParentPeerId
? this.connectedPeer(roomId, newParentPeerId)
? this.options.roomStore.getConnectedViewer(roomId, newParentPeerId)
: undefined;
if (!newParentPeerId || !newParent) {
return false;
@@ -2460,6 +2478,8 @@ export class HybridMediaRouter {
hostSfuIngressTurnAttempted: false,
healthyReselection: null,
peerQualityReselection: {
activeRevision: active.revision,
shareGeneration,
excludedParentPeerIds: candidateExclusions,
newParentPeerId,
newConnectionId: null,
@@ -2959,11 +2979,26 @@ export class HybridMediaRouter {
): boolean {
const probe = pending.peerQualityReselection;
const controller = this.mediaRouteControllers.get(roomId);
const active = controller?.getActiveRoute();
const planned = controller?.getPendingRoute()?.route;
const plannedUpstream = planned?.assignments.get(pending.intentPeerId)?.upstream;
const plannedUpstream = planned?.assignments.get(
pending.intentPeerId,
)?.upstream;
const parentAssignment = active?.assignments.get(
probe?.newParentPeerId ?? "",
);
return Boolean(
probe &&
this.pendingRoutePreparations.get(roomId) === pending &&
active?.revision === probe.activeRevision &&
this.options.getShareGeneration(roomId) === probe.shareGeneration &&
(parentAssignment?.upstream.kind === "peer" ||
parentAssignment?.upstream.kind === "sfu") &&
parentAssignment.childPeerIds.length + 1 <=
this.peerRelayTopology.getDownstreamCapacity(
roomId,
probe.newParentPeerId,
) &&
planned?.revision === pending.revision &&
plannedUpstream?.kind === "peer" &&
plannedUpstream.peerId === probe.newParentPeerId &&
+266
View File
@@ -982,6 +982,272 @@ describe("HostPeer source replacement", () => {
});
describe("ViewerRelay downstream ownership", () => {
it("promotes the exact prepared child connection without touching active children", async () => {
const signals: Array<{ peerId: string; connectionId: string }> = [];
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId, payload) => {
signals.push({ peerId, connectionId: payload.connectionId });
return true;
},
},
);
relay.setChildren(["active-child"]);
relay.setStream(createStream(createTrack("video", "prepared-video"), null));
await vi.waitFor(() => expect(signals).toHaveLength(1));
const activeConnection = FakePeerConnection.latest!;
activeConnection.connectionState = "connected";
expect(
relay.prepareChild(7, ["active-child", "prepared-child"]),
).toBe(true);
await vi.waitFor(() => expect(signals).toHaveLength(2));
const preparedConnection = FakePeerConnection.latest!;
const preparedConnectionId = signals[1]!.connectionId;
expect(preparedConnection).not.toBe(activeConnection);
expect(relay.getSnapshot("prepared-child")).toBeNull();
expect(FakePeerConnection.activeCount).toBe(2);
expect(
relay.getSignalRouteRevision(
"active-child",
signals[0]!.connectionId,
6,
),
).toBe(6);
await expect(
relay.acceptSignal("prepared-child", {
kind: "description",
connectionId: "wrong-connection",
description: { type: "answer", sdp: "wrong-answer" },
}, 7),
).resolves.toBe(false);
await expect(
relay.acceptSignal("prepared-child", {
kind: "description",
connectionId: preparedConnectionId,
description: { type: "answer", sdp: "right-answer" },
}, 6),
).resolves.toBe(false);
const preparedRevision = relay.getSignalRouteRevision(
"prepared-child",
preparedConnectionId,
6,
);
expect(preparedRevision).toBe(7);
const candidate = { candidate: "prepared-candidate" };
await expect(
relay.acceptSignal("prepared-child", {
kind: "candidate",
connectionId: preparedConnectionId,
candidate,
}, preparedRevision),
).resolves.toBe(true);
expect(preparedConnection.addedIceCandidates).toEqual([]);
await expect(
relay.acceptSignal("prepared-child", {
kind: "description",
connectionId: preparedConnectionId,
description: { type: "answer", sdp: "right-answer" },
}, preparedRevision),
).resolves.toBe(true);
expect(preparedConnection.remoteDescription?.sdp).toBe("right-answer");
expect(preparedConnection.addedIceCandidates).toEqual([candidate]);
preparedConnection.connectionState = "connected";
relay.activateChildren(7, ["active-child", "prepared-child"]);
expect(FakePeerConnection.latest).toBe(preparedConnection);
expect(FakePeerConnection.activeCount).toBe(2);
expect(relay.getSnapshot("prepared-child")?.connectionId).toBe(
preparedConnectionId,
);
expect(activeConnection.connectionState).toBe("connected");
relay.dispose();
});
it("cleans prepared children on failure, replacement, rollback, session reset, and share stop", async () => {
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{ sendSignal: () => true },
);
relay.setChildren(["active-child"]);
relay.setStream(createStream(createTrack("video", "rollback-video"), null));
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
const activeConnection = FakePeerConnection.latest!;
activeConnection.connectionState = "connected";
expect(relay.prepareChild(7, ["active-child", "first-probe"])).toBe(true);
const firstProbe = FakePeerConnection.latest!;
expect(relay.prepareChild(8, ["active-child", "second-probe"])).toBe(true);
const secondProbe = FakePeerConnection.latest!;
expect(firstProbe.connectionState).toBe("closed");
relay.activateChildren(9, ["active-child"]);
expect(secondProbe.connectionState).toBe("closed");
expect(activeConnection.connectionState).toBe("connected");
FakePeerConnection.offersFailing = 1;
const instancesBeforeFailure = FakePeerConnection.instances.length;
expect(relay.prepareChild(10, ["active-child", "failed-probe"])).toBe(true);
const failedProbe = FakePeerConnection.latest!;
await vi.waitFor(() => expect(failedProbe.connectionState).toBe("closed"));
expect(FakePeerConnection.instances).toHaveLength(instancesBeforeFailure + 1);
expect(activeConnection.connectionState).toBe("connected");
expect(relay.prepareChild(11, ["active-child", "session-probe"])).toBe(true);
const sessionProbe = FakePeerConnection.latest!;
relay.discardPreparedChild();
expect(sessionProbe.connectionState).toBe("closed");
expect(activeConnection.connectionState).toBe("connected");
expect(relay.prepareChild(12, ["active-child", "stopped-probe"])).toBe(true);
const stoppedProbe = FakePeerConnection.latest!;
relay.stop();
expect(stoppedProbe.connectionState).toBe("closed");
expect(activeConnection.connectionState).toBe("closed");
relay.dispose();
});
it("fails closed when the committed prepared connection already failed", async () => {
const signals: Array<{ peerId: string; connectionId: string }> = [];
let rejectSignalsFor: string | null = null;
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId, payload) => {
signals.push({ peerId, connectionId: payload.connectionId });
return peerId !== rejectSignalsFor;
},
},
);
relay.setChildren(["active-child"]);
relay.setStream(createStream(createTrack("video", "failed-active-video"), null));
await vi.waitFor(() => expect(signals).toHaveLength(1));
const activeConnection = FakePeerConnection.latest!;
activeConnection.connectionState = "connected";
rejectSignalsFor = "failed-child";
expect(relay.prepareChild(7, ["active-child", "failed-child"])).toBe(true);
await vi.waitFor(() => expect(signals).toHaveLength(2));
const failedConnectionId = signals[1]!.connectionId;
const failedConnection = FakePeerConnection.latest!;
await vi.waitFor(() => expect(failedConnection.connectionState).toBe("closed"));
const instanceCount = FakePeerConnection.instances.length;
expect(relay.prepareChild(7, ["active-child", "failed-child"])).toBe(false);
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
relay.setChildren(["active-child", "failed-child"]);
relay.activateChildren(7, ["active-child", "failed-child"]);
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
expect(FakePeerConnection.activeCount).toBe(1);
expect(activeConnection.connectionState).toBe("connected");
relay.setChildren(["active-child", "failed-child"]);
relay.activateChildren(7, ["active-child", "failed-child"]);
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
expect(signals).toHaveLength(2);
expect(signals[1]!.connectionId).toBe(failedConnectionId);
relay.dispose();
});
it("keeps the prepared connection when its stream replacement succeeds", async () => {
const signals: Array<{ peerId: string; connectionId: string }> = [];
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId, payload) => {
signals.push({ peerId, connectionId: payload.connectionId });
return true;
},
},
);
relay.setChildren(["active-child"]);
relay.setStream(createStream(createTrack("video", "initial-video"), null));
await vi.waitFor(() => expect(signals).toHaveLength(1));
const activeConnection = FakePeerConnection.latest!;
activeConnection.connectionState = "connected";
expect(relay.prepareChild(7, ["active-child", "prepared-child"])).toBe(true);
await vi.waitFor(() => expect(signals).toHaveLength(2));
const preparedConnection = FakePeerConnection.latest!;
const preparedConnectionId = signals[1]!.connectionId;
const instanceCount = FakePeerConnection.instances.length;
const replacementVideo = createTrack("video", "replacement-video");
relay.setStream(createStream(replacementVideo, null));
await vi.waitFor(() =>
expect(preparedConnection.senders[0]!.track).toBe(replacementVideo),
);
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
preparedConnection.connectionState = "connected";
relay.activateChildren(7, ["active-child", "prepared-child"]);
expect(relay.getSnapshot("prepared-child")?.connectionId).toBe(
preparedConnectionId,
);
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
relay.dispose();
});
it("retains failed replacement identity across repeated active updates", async () => {
const signals: Array<{ peerId: string; connectionId: string }> = [];
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId, payload) => {
signals.push({ peerId, connectionId: payload.connectionId });
return true;
},
},
);
relay.setChildren(["active-child"]);
relay.setStream(createStream(createTrack("video", "initial-video"), null));
await vi.waitFor(() => expect(signals).toHaveLength(1));
const activeConnection = FakePeerConnection.latest!;
activeConnection.connectionState = "connected";
expect(relay.prepareChild(7, ["active-child", "prepared-child"])).toBe(true);
await vi.waitFor(() => expect(signals).toHaveLength(2));
const preparedConnection = FakePeerConnection.latest!;
const preparedConnectionId = signals[1]!.connectionId;
preparedConnection.senders[0]!.failNextReplace = true;
const instanceCount = FakePeerConnection.instances.length;
relay.setStream(createStream(createTrack("video", "failed-video"), null));
await vi.waitFor(() => expect(preparedConnection.connectionState).toBe("closed"));
relay.activateChildren(7, ["active-child", "prepared-child"]);
relay.activateChildren(7, ["active-child", "prepared-child"]);
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
expect(FakePeerConnection.activeCount).toBe(1);
expect(activeConnection.connectionState).toBe("connected");
expect(signals).toHaveLength(2);
expect(signals[1]!.connectionId).toBe(preparedConnectionId);
relay.dispose();
});
it("admits one provisional child only while the one-through-three cap has room", async () => {
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{ sendSignal: () => true },
);
relay.setStream(createStream(createTrack("video", "cap-video"), null));
for (let count = 1; count <= 3; count += 1) {
const children = Array.from({ length: count }, (_, index) => `child-${index}`);
expect(relay.prepareChild(count, children)).toBe(true);
const preparedConnection = FakePeerConnection.latest!;
preparedConnection.connectionState = "connected";
relay.activateChildren(count, children);
expect(FakePeerConnection.activeCount).toBe(count);
}
expect(
relay.prepareChild(4, ["child-0", "child-1", "child-2", "child-3"]),
).toBe(false);
expect(FakePeerConnection.activeCount).toBe(3);
relay.dispose();
});
it("reconciles three children without ever opening a fourth edge", async () => {
const targets: string[] = [];
const relay = new ViewerRelay(
+49 -1
View File
@@ -1189,7 +1189,7 @@ describe("ViewerSfuRoute", () => {
const peer = peerAssignment("host-peer", ["relay-child"]);
route.accept({ revision: 2, phase: "prepare", assignment: peer });
expect(prepared).toContainEqual({ parent: "host-peer", revision: 2 });
expect(log).toContain("children:relay-child");
expect(log).not.toContain("children:relay-child");
expect(subscriber.deactivate).not.toHaveBeenCalled();
route.accept({ revision: 2, phase: "active", assignment: peer });
@@ -1203,9 +1203,57 @@ describe("ViewerSfuRoute", () => {
expect(log.indexOf("peer:host-peer:2")).toBeLessThan(
log.indexOf("subscriber:deactivate"),
);
expect(log).toContain("children:relay-child");
expect(activatePeer).not.toHaveBeenCalledWith(peer);
});
it.each([
["peer Viewer", peerAssignment("current-parent", ["provisional-child"])],
["SFU root Viewer", sfuAssignment(["provisional-child"])],
])("uses the prepare/active child lifecycle for a %s", async (_label, assignment) => {
const prepared: Array<{
revision?: number;
children: readonly string[] | null;
}> = [];
const activated: Array<{
revision: number;
children: readonly string[];
}> = [];
const route = new ViewerSfuRoute({
activatePeer: () => undefined,
prepareChild: (children, revision) => prepared.push({
children,
...(revision === undefined ? {} : { revision }),
}),
activateChildren: (children, revision) =>
activated.push({ children, revision }),
onSfuStream: () => undefined,
send: () => true,
});
route.accept({
revision: 1,
phase: "active",
assignment: { ...assignment, childPeerIds: [] },
});
await vi.waitFor(() => expect(activated).toEqual([{
revision: 1,
children: [],
}]));
prepared.length = 0;
activated.length = 0;
route.accept({ revision: 2, phase: "prepare", assignment });
expect(prepared).toEqual([{
revision: 2,
children: ["provisional-child"],
}]);
route.accept({ revision: 2, phase: "active", assignment });
await vi.waitFor(() => expect(activated).toEqual([{
revision: 2,
children: ["provisional-child"],
}]));
});
it("prepares a second peer without replacing the active peer before commit", async () => {
const prepared: Array<{ parent: string | null; revision?: number }> = [];
const activatePeer = vi.fn();
+13 -6
View File
@@ -3466,7 +3466,7 @@ describe("WebSocket signaling", () => {
]);
});
it("keeps the old edge when only the Host and an SFU root are capable", async () => {
it("uses a capable SFU root as the provisional peer parent", async () => {
let now = 128_000;
const harness = await startSfuHarness({
now: () => now,
@@ -3545,11 +3545,18 @@ describe("WebSocket signaling", () => {
await correlateParentEdgeQualityEvidence(parent);
now += 2_000;
}
await Promise.all([
expect(child.inbox.next("route-update", 40)).rejects.toThrow("Timed out"),
expect(active.host.inbox.next("route-update", 40)).rejects.toThrow("Timed out"),
expect(active.viewer.inbox.next("route-update", 40)).rejects.toThrow("Timed out"),
]);
const moved = await completePreparedPeerMigration(
child,
active.viewer,
childAuth.peerId,
active.viewerAuth.peerId,
rootFreed.revision,
"quality_sfu_root_probe",
);
expect(moved.assignment.upstream).toEqual({
kind: "peer",
peerId: active.viewerAuth.peerId,
});
});
it("keeps the old relay edge when relative FPS has no alternate peer", async () => {