From 3536671016abacba3e7a6c32fde207ed0a41d815 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:09:41 +0800 Subject: [PATCH] feat(browser): complete v9 runtime --- .../0005-automatic-hybrid-media-routing.md | 11 +- docs/research/advanced-peer-distribution.md | 12 +- docs/方案设计.md | 4 +- docs/需求理解.md | 2 +- scripts/peer-assisted-benchmark.ts | 564 +++++-- src/client/App.tsx | 4 +- src/client/components/RoomCodeEntry.tsx | 8 +- src/client/components/StageEntryActions.tsx | 2 +- src/client/components/StatsGrid.tsx | 2 +- src/client/lib/api.ts | 25 +- src/client/lib/signaling.ts | 103 +- src/client/media/codec-proof.ts | 187 +++ src/client/media/decoded-frame-proof.ts | 73 + src/client/media/host-sfu-route.ts | 194 ++- src/client/media/host-source-authority.ts | 53 + src/client/media/route-transition.ts | 34 +- src/client/media/viewer-presentation.ts | 43 +- src/client/media/viewer-sfu-route.ts | 196 ++- src/client/pages/HostPage.tsx | 553 ++++++- src/client/pages/ViewerPage.tsx | 224 ++- src/client/pages/host-page-notices.ts | 80 +- src/client/sfu/publisher.ts | 18 +- src/client/sfu/subscriber.ts | 67 +- src/client/styles.css | 17 +- src/client/webrtc/host-peer.ts | 429 ++++- src/client/webrtc/stats.ts | 7 + src/client/webrtc/video-codec-preference.ts | 15 +- src/client/webrtc/viewer-peer.ts | 247 +-- src/client/webrtc/viewer-relay.ts | 19 + src/server/codec-transition.ts | 1247 +++++++++++++++ src/server/hybrid-media-router.ts | 589 ++++++- src/server/room-route-controller.ts | 684 +++++++- src/server/sfu-resource-admission.ts | 14 +- src/server/signaling.ts | 484 +++++- src/shared/protocol.ts | 217 ++- tests/client-state.test.ts | 199 ++- tests/codec-proof.test.ts | 134 ++ tests/codec-transition.test.ts | 1423 +++++++++++++++++ tests/decoded-frame-proof.test.ts | 89 ++ tests/host-page-notice.test.ts | 69 +- tests/host-peer-tracks.test.ts | 387 ++++- tests/host-source-authority.test.ts | 71 + tests/hybrid-media-router.test.ts | 479 +++++- tests/media-assignment.test.ts | 1 + tests/peer-assisted-benchmark.test.ts | 459 +++++- tests/protocol.test.ts | 107 +- tests/room-route-controller.test.ts | 642 ++++++++ tests/route-transition.test.ts | 582 ++++++- tests/server-signal.test.ts | 223 ++- tests/sfu-client.test.ts | 75 +- tests/sfu-resource-admission.test.ts | 12 +- tests/stage-entry-actions.test.ts | 6 +- tests/stats-grid.test.ts | 15 + tests/viewer-peer-generation.test.ts | 132 +- tests/viewer-presentation.test.ts | 119 ++ 55 files changed, 11004 insertions(+), 648 deletions(-) create mode 100644 src/client/media/codec-proof.ts create mode 100644 src/client/media/decoded-frame-proof.ts create mode 100644 src/client/media/host-source-authority.ts create mode 100644 src/server/codec-transition.ts create mode 100644 tests/codec-proof.test.ts create mode 100644 tests/codec-transition.test.ts create mode 100644 tests/decoded-frame-proof.test.ts create mode 100644 tests/host-source-authority.test.ts diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index c5c82aa..f463d09 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -233,7 +233,13 @@ second mutable graph. - Candidate-list creation, reserve, prepare, the child's first decoded frame, atomic commit, abort, and idempotent release are one bounded child operation. Its one total deadline cleans abandoned reservations and never revokes a - healthy committed edge. + healthy committed edge. While that one exact candidate is pending, its + receive-transport owner runs one short-lived, generation-fenced + `framesDecoded` observer and stops it on proof, promotion, pause, replacement, + or teardown. A fresh peer connection or fresh SFU activation proves a + positive cumulative count; a paused prepared SFU route first records a + Resume-time baseline and requires later progress. This observer adds no + success/failure deadline and cannot select, reject, or reorder candidates. - A child invalidates only its own exact edge after PeerConnection hard failure or a named non-paused interval without a newly decoded frame. Bitrate, FPS, resolution, blur, and sender statistics remain diagnostics or stock @@ -248,7 +254,8 @@ second mutable graph. - Web clients derive active decoded progress from their existing periodic WebRTC/LiveKit stats sampling. One route-keyed last-progress deadline reports the exact edge once; it resets on route/connection change or decoded progress - and is suppressed while authoritatively paused. It adds no polling loop and + and is suppressed while authoritatively paused. This steady-state sampler is + separate from the pending candidate's short-lived first-frame observer and does not treat bitrate, FPS, track availability, or SFU layer choice as route authority. - Authoritative pause aborts the pending child operation, including its current diff --git a/docs/research/advanced-peer-distribution.md b/docs/research/advanced-peer-distribution.md index ffbc199..c20d1ad 100644 --- a/docs/research/advanced-peer-distribution.md +++ b/docs/research/advanced-peer-distribution.md @@ -1,6 +1,6 @@ # Advanced Peer Distribution -- Research date: 2026-08-23 +- Research date: 2026-08-24 - Scope: current route admission up to twenty trusted viewers, sub-second interactive media, endpoint downstream cap `1..3`, and minimal central-server media egress; retained advanced-media measurements may cover smaller cohorts @@ -290,8 +290,14 @@ it exposes no topology choices. One reconcile loop handles join, waiting, disconnect, effective-capacity overflow, and failed edges. W3C `framesDecoded` counts successfully decoded video frames, so the first new candidate frame is the only application readiness event. LiveKit owns SFU -reconnection and stream state; the application adds no network-type poll or -SFU-specific timer. +reconnection and stream state. The one pending candidate's receive-transport +owner therefore uses a short-lived exact-generation stats observer until a +fresh peer/SFU activation has a positive cumulative count; a prepared SFU route +that remained alive while paused instead takes a Resume-time baseline and waits +for later progress. It stops on proof, promotion, pause, replacement, or +teardown, creates no additional deadline, and is distinct from the existing +two-second active-path diagnostic/stall sampler. The application adds no +network-type poll or SFU-specific route timer. The overlap edge is physical: an active Host SFU publication consumes one steady sender slot, and a media-producing provisional peer candidate consumes another. diff --git a/docs/方案设计.md b/docs/方案设计.md index 002fabe..42a2557 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -309,7 +309,7 @@ Host 与 relay Viewer 复用同一个纯 presentation reducer 保存远端 `View | 2 | Host authoritative pause | 保留仍属于 current binding 的最后画面并显示“分享者已暂停”;等待 Host,不显示 retry | | 3 | 同一 current media identity 的 `play()` 仅因 `NotAllowedError` 被拒绝 | 不要求先有 frame proof,立即显示唯一主动作 `Play`;点击后播放成功只清除 autoplay block,仍须等 current-generation frame proof 才撤遮罩,其他播放异常使用 typed failure | | 4 | current binding 已有 composited frame | 去掉阻塞层;若 signaling 恢复中但媒体仍健康,只显示非阻塞提示且不清空画面 | -| 5 | route terminal failure、Host stopped/offline | 只有仍 current 且已证明的旧画面可保留;否则阻塞。仅 route/controller 明确允许时显示 bounded retry | +| 5 | route terminal failure、Host stopped/offline | Host offline 本身保留仍健康的 current media;exact upstream 硬失败或 route terminal 先使 current frame proof 失效,已证明画面至多降为 retained background 并显示离线或失败。仅 route/controller 明确允许时显示 bounded retry | | 6 | SFU capacity wait、peer/SFU prepare、已绑定但未证明首帧、尚无 assignment | 依次显示备用线路等待、建立线路、接收画面或分配线路;无 `Play`、无虚假百分比 | Token-free SFU standby prewarm 保持可选且不创建 participant、publication、subscription 或 route authority。它只提前加载 pinned SDK 并调用官方 `prepareConnection()`;是否缩短公网首帧由阶段时间戳验证,失败时继续既有冷路径。 @@ -382,6 +382,8 @@ Token-free SFU standby prewarm 保持可选且不创建 participant、publicatio `reconcileRoom` 在没有 pending operation 时先删除 disconnected childless participant,再找一个 waiting Viewer,或 parent offline/effective-capacity 不足、exact edge 已失效的 child。Candidate planning 拒绝 stale session、不可 source-reachable、成环或没有稳态/overlap/bounded-gap producer slot 的 candidate;server resource admission 在执行当前 cursor 时单独检查。由 failed edge 启动时只为当前 fact version 把 exact candidate 记为已尝试;新的外部事实允许重新评估。对剩余 upstream 依次比较结果深度、剩余 sender slots、稳定加入顺序与 peer identity,先建立其他 direct/STUN,再复用或建立 SFU。Host root退出后新释放的Host槽位因此优先于另一root的更深槽位;健康旧边保持sticky,不做周期性整树平衡。每次只 reserve/prepare cursor 指向的 candidate。WebRTC 负责 ICE/DTLS/consent;exact candidate child 解码出第一帧新画面后发送一次 ready,controller 先同步 commit typed admission,再原子提交 graph。失败释放 candidate、广播严格更新的 rollback revision 并推进 cursor,operation 总 deadline 不重置。耗尽时 hard-invalid、confirmed-departed 或 deterministic overflow edge 被退休并进入明确 waiting;healthy bootstrap edge 保留。无环和房间 admission 已约束最大深度,不增加独立 depth hard cap。 +Pending candidate 的 receive-transport owner 只在当前 exact generation 下运行一个短生命周期 `framesDecoded` 观察器。全新 peer/SFU activation 以正累计值证明首帧;暂停期间仍保留的 prepared SFU 在 Resume 时先取 baseline,再要求后续增长。观察器在 proof、promotion、pause、换代或 teardown 时停止,不增加另一 success/failure deadline,也不参与 candidate 选择。Active route 仍由既有两秒 stats sampler维护诊断和 decoded stall,两者不得互相替代。 + 一个 operation 仍只有一个 timer 和一个总 deadline,但 timer 的下一次 wake 从当前 candidate list 派生。Controller 提取实际存在的 direct 与 SFU 阶段并等分总窗口;显式失败可以在阶段内继续尝试其他 candidate,silent candidate 到达 direct wake boundary 后释放 reservation、广播 rollback 并跳到 SFU。只有 direct 时仍使用完整 deadline;direct+SFU 时各用一半。这样不引入每-candidate magic milliseconds,也不允许一条 silent direct 把 SFU suffix 饿死。 首次需要 SFU 但 Host 已满时不建立另一条 rescue 流程。若 affected child 已是 Host direct child,就用它;否则用稳定加入顺序中最新的 connected Host direct child。`reconcileRoom` 为该 child 建立普通 SFU candidate,创建唯一 publication,保持其 subtree,并在该 child 首帧后提交。Host publication 取代释放的 peer slot,随后同一 fixed-point scan 再处理原 waiting/failed child。若旧 Host peer sender 在首帧前仍保留,candidate 必须持有同一 `TransitionOverlapSlot`。没有 overlap 时先 preflight server resource,再退休 exact producer;operation 保持原总 deadline、按新 graph 重算未尝试 suffix,并为被切断的 healthy direct edge追加一个最后 restore tuple。Forward candidate 与 restore 都失败才进入 waiting,不得产生第四份 endpoint copy或另一套恢复状态。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 8bdf775..e3e59cb 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -45,7 +45,7 @@ SFU 的 publication、subscription、server-resource accounting、临时 overlap - 独立房间入口:合法 `/r/{code}#v={grant}` 邀请直接进入对应房间;没有 grant 的 `/join` 或 `/r/{code}` 先显示明确标为“站点访问”的整站验证,再提交 code-only 尝试。`open` 且可准入时直接进入;其余预期拒绝统一为 `ROOM_ACCESS_DENIED`,不得先披露 `password | disabled` policy。有效 site-access cookie 只跳过第一步,两类密码不得复用或互相替代。Host 可设置、轮换或移除房间观看密码;密码接受 1 至 64 个可见 ASCII 字符,异步 scrypt 全局最多执行 2 个、等待 16 个派生。Host 可把房间密码保存在同源本地创建偏好中;它不得进入 URL、cookie、日志或服务端持久存储。未知或过期房间、disabled policy、缺失/错误密码、队满和其他已知有界 admission refusal 都返回相同 code、公开文案和 close 行为。 - 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,每个房间使用一个服务端 route controller,独占 committed graph、一个 pending child operation 和 event-driven reconcile。运行时只增加一个串行pump、一个operation deadline timer与typed resource lifecycle task,不维护另一份graph。operation持有candidate list/cursor、一个current candidate/资源预留、fact version和一个总deadline。committed graph必须无环、source-reachable,且每名Viewer同时至多一个active upstream。加入、容量变化、节点离开、exact edge `failed/closed`,以及非暂停decoded-frame stall都只唤醒同一个reconcile;其余健康分支保持sticky。候选按当前身份、source reachability、无环、effective capacity与sender transition硬过滤,再按结果深度、剩余sender slots、稳定加入顺序和peer identity排序;因此 Host root退出后新释放的Host槽位比已有root的更深槽位优先,但健康旧边不因周期性平衡而迁移。server admission在执行当前cursor时独立reserve。candidate child首帧ready触发`settleReady(commitAdmissionSync)`,成功prepare `P`提交active `P`;失败释放资源并用同一monotonic allocator广播`R>P` rollback,cursor推进但总deadline不重置。bounded-gap先preflight server resource,再retire producer、按新graph重算未尝试suffix,并在切断healthy edge时追加一个普通restore candidate。relay自身ingress失效时作为child换父并保留subtree;relay disconnect/cap降低让overflow children逐个走同一事务,childless后删除。房间admission和无环约束最大深度,不设独立hard cap。普通peer始终STUN-only,SFU使用独立admission。 -- 观看端连接反馈:Viewer 舞台从鉴权到首帧使用一个由现有权威状态推导的稳定阶段反馈,至少区分等待分享、分配线路、建立 P2P、连接备用线路、等待首帧、浏览器阻止自动播放、恢复连接和有界失败。阶段不是进度百分比,也不产生 route authority;只有当前 video element 实际提交首帧后才显示正在播放。播放按钮只在 `play()` 因自动播放策略被拒绝时稳定出现,连接中不得忽隐忽现;明确失败后才提供一次受现有 controller 约束的重试入口。make-before-break 或仍有可见旧帧时保留画面并叠加恢复状态,不为显示反馈主动清空健康 media element。Accepted v9 的服务端补充消息是 strict、current-revision-fenced `route-status` union,且只允许 `waiting/sfu-admission` 与 `failed/route-exhausted` 两种配对;它不携带 raw text、身份、candidate 或 retry hint,prepare/active、access、Host、signal、media 与 autoplay 继续由各自权威输入派生。 +- 观看端连接反馈:Viewer 舞台从鉴权到首帧使用一个由现有权威状态推导的稳定阶段反馈,至少区分等待分享、分配线路、建立 P2P、连接备用线路、等待首帧、浏览器阻止自动播放、恢复连接和有界失败。阶段不是进度百分比,也不产生 route authority;只有当前 video element 实际提交首帧后才显示正在播放。播放按钮只在 `play()` 因自动播放策略被拒绝时稳定出现,连接中不得忽隐忽现;明确失败后才提供一次受现有 controller 约束的重试入口。make-before-break 或仍有可见旧帧时保留画面并叠加恢复状态,不为显示反馈主动清空健康 media element。Host 离线本身不使仍健康的媒体失效;exact upstream 硬失败或 route terminal failure 必须使 current frame proof 失效,已证明画面至多降为 retained background,不能遮蔽离线或有界失败。Accepted v9 的服务端补充消息是 strict、current-revision-fenced `route-status` union,且只允许 `waiting/sfu-admission` 与 `failed/route-exhausted` 两种配对;它不携带 raw text、身份、candidate 或 retry hint,prepare/active、access、Host、signal、media 与 autoplay 继续由各自权威输入派生。 - 质量控制扩展:推荐档位仍是默认入口且只保留 1080p60/1080p30/720p30 三档,当前页面初始 desired state 选择 1080p30 且不写 `localStorage`;“分享高级设置”允许 480p(854x480)/720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling、清晰/平衡/流畅三种偏好、`自动 | H.264 | VP8` 编码选择,以及离散的 `64 kbps 省流 | 128 kbps 音乐默认 | 256 kbps 极高音质` 屏幕音频 sender ceiling;480p 不形成推荐档或固定 FPS/码率组合,初始值采用 `balanced`、`automatic` 与 128 kbps。视频档位和音频档位在直播中显式应用;音频变更只更新固定 Opus 路径的 sender ceiling,不是 codec 切换。所有当前及以后建立的 Web Host、Browser relay 与 SFU sender 都读取最新 desired 档位,并各自在 fresh sender parameters 上应用和读回 endpoint-local applied 值;实际码率仍由浏览器拥塞控制和 stats 决定。Peer 只在唯一明确的 active Opus audio 上做结构化、幂等的 Viewer answer `stereo=1;maxaveragebitrate=256000` 变换,使同一接收上限覆盖三档;非目标形态或 parser/writer 异常必须原样保留浏览器 answer,不得阻断可继续的 mono/video edge。不得用宽泛字符串/正则改 SDP,也不得改写浏览器协商的 FEC。SFU 将三档映射到 pinned SDK 的 music stereo preset 或显式 256 kbps ceiling,统一保留 `forceStereo = true`、`dtx = false` 与 pinned room default `red = true`。不得暴露任意码率、声道、采样率、audio codec、stereo、DTX、RED 或 FEC 控件;256 kbps 只是显式较高上限,不是无损、高保真或最低码率保证。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。未来 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。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。 diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 69a63ff..d1ca461 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -9,6 +9,7 @@ import WebSocket from "ws"; import { MAX_VIEWERS_PER_ROOM_LIMIT, type ParticipantRouteAssignment, + type RouteDiagnosticSnapshot, } from "../src/shared/protocol"; import { DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY, @@ -29,12 +30,33 @@ import { type QualityProfileId, type QualitySettings, } from "../src/client/media/quality"; +import { + summarizeRouteTiming, + type RouteTimingDistribution, +} from "../src/client/lib/diagnostic-export"; const PROFILE_SETTINGS = QUALITY_PROFILES; type ProfileId = QualityProfileId; type PageRole = "host" | "viewer"; export type BenchmarkCanaryMode = "none" | "viewer-mbb"; +const ROUTE_TIMING_KEYS = [ + "queueWaitMs", + "candidateStartMs", + "firstDecodedFrameMs", + "finalMs", +] as const satisfies readonly (keyof RouteDiagnosticSnapshot["children"][number])[]; +type RouteTimingKey = (typeof ROUTE_TIMING_KEYS)[number]; +export type BenchmarkRouteTimingSamples = { + [Key in RouteTimingKey]: Array< + RouteDiagnosticSnapshot["children"][number][Key] + >; +}; +export type BenchmarkRouteTimingSummary = Record< + RouteTimingKey, + RouteTimingDistribution +>; + interface ViewerMbbCanaryResult { kind: "viewer-mbb"; status: "passed" | "failed"; @@ -83,12 +105,6 @@ interface ConnectionObservation { error?: string; } -interface ActiveRouteReadyObservation { - revision: number; - upstreamKind: ParticipantRouteAssignment["upstream"]["kind"]; - sfuPublicationGeneration: string | null; -} - interface PageObservation { label: string; role: PageRole; @@ -101,7 +117,6 @@ interface PageObservation { qualitySettings: QualitySettings | null; routeRevision: number | null; routeAssignment: ParticipantRouteAssignment | null; - activeRouteReady: ActiveRouteReadyObservation[]; maxActiveOutboundMediaEdges: number; maxAssignedChildren: number; firstDecodedAtEpochMs: number | null; @@ -110,6 +125,33 @@ interface PageObservation { connections: ConnectionObservation[]; } +interface FailurePageEvidence { + label: string; + role: PageRole; + authenticated: boolean; + signalingConnected: boolean; + routeRevision: number | null; + upstreamKind: ParticipantRouteAssignment["upstream"]["kind"] | null; + assignedChildCount: number; + firstDecoded: boolean; + firstRendered: boolean; + connectionCount: number; + connections: Array<{ + connectionState: string; + iceConnectionState: string; + identity: "peer" | "unidentified"; + hasOutboundVideo: boolean; + hasInboundVideo: boolean; + decoded: boolean; + }>; +} + +interface BenchmarkFailureEvidence { + expectedPageCount: number; + observedPageCount: number; + pages: FailurePageEvidence[]; +} + interface TimedSample { atEpochMs: number; elapsedMs: number; @@ -162,13 +204,16 @@ interface BenchmarkRun { status: "passed" | "failed"; checks: RunCheck[]; summary: ReturnType | null; + routeTimingSummary: BenchmarkRouteTimingSummary | null; + routeTimingStatus: "not-requested" | "captured" | "unavailable"; samples: TimedSample[]; recovery: RecoveryResult; + failureEvidence?: BenchmarkFailureEvidence; error?: string; } interface BenchmarkReport { - schemaVersion: 2; + schemaVersion: 3; startedAt: string; completedAt: string | null; gitCommit: string | null; @@ -217,8 +262,10 @@ interface ManagedProcess { log: BoundedLog; } -const DEFAULT_VIEWER_COUNTS = [1, 3, 5, 8]; const MAX_VIEWERS = MAX_VIEWERS_PER_ROOM_LIMIT; +const MAX_FAILURE_CONNECTIONS_PER_PAGE = + MAX_ENDPOINT_MEDIA_COPY_CAPACITY * 2; +const DEFAULT_VIEWER_COUNTS = [MAX_VIEWERS]; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); class BoundedLog { @@ -327,6 +374,73 @@ class CdpConnection { } } +export function summarizeBenchmarkRouteTiming( + samples: BenchmarkRouteTimingSamples, +): BenchmarkRouteTimingSummary { + return Object.fromEntries( + ROUTE_TIMING_KEYS.map((key) => [ + key, + summarizeRouteTiming(samples[key]), + ]), + ) as BenchmarkRouteTimingSummary; +} + +export function buildRouteTimingCheck( + status: BenchmarkRun["routeTimingStatus"], + summary: BenchmarkRouteTimingSummary | null, + expectedChildCount = MAX_VIEWERS, +): RunCheck { + const completeDistributionCount = + status === "captured" && summary + ? ROUTE_TIMING_KEYS.filter((key) => { + const distribution = summary[key]; + return ( + distribution.sampleCount + distribution.pendingCount === + expectedChildCount + ); + }).length + : 0; + return { + name: "route-timing-current-children", + passed: completeDistributionCount === ROUTE_TIMING_KEYS.length, + actual: completeDistributionCount, + expected: + `${ROUTE_TIMING_KEYS.length} captured timing distributions with ` + + `${expectedChildCount} current-child sample or pending values each`, + }; +} + +export async function joinViewerBurst( + viewerCount: number, + createViewer: (viewerIndex: number) => Promise, + waitForMedia: (viewer: T, viewerIndex: number) => Promise, +): Promise { + const creations = await Promise.allSettled( + Array.from({ length: viewerCount }, (_, index) => + createViewer(index + 1), + ), + ); + const creationFailure = creations.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (creationFailure) { + throw creationFailure.reason; + } + const viewers = creations.map( + (result) => (result as PromiseFulfilledResult).value, + ); + const media = await Promise.allSettled( + viewers.map((viewer, index) => waitForMedia(viewer, index + 1)), + ); + const mediaFailure = media.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (mediaFailure) { + throw mediaFailure.reason; + } + return viewers; +} + export function parseViewerCounts(value: string | undefined): number[] { if (!value?.trim()) { return [...DEFAULT_VIEWER_COUNTS]; @@ -424,6 +538,11 @@ export function parseBenchmarkConfig( ); } const canaryMode = parseBenchmarkCanaryMode(environment.BENCHMARK_CANARY); + if (canaryMode === "viewer-mbb" && !viewerCounts.includes(3)) { + throw new Error( + "BENCHMARK_CANARY=viewer-mbb requires a selected viewer count of 3", + ); + } const recoveryText = environment.BENCHMARK_RECOVERY_VIEWERS?.trim(); const recoveryViewerCount = recoveryText ? Number(recoveryText) : null; if ( @@ -516,6 +635,59 @@ export function activeVideoEdgeCount( }).length; } +export function sanitizeFailurePageEvidence( + page: PageObservation, +): FailurePageEvidence { + const connectionStates = new Set([ + "new", + "connecting", + "connected", + "disconnected", + "failed", + "closed", + ]); + const iceConnectionStates = new Set([ + "new", + "checking", + "connected", + "completed", + "disconnected", + "failed", + "closed", + ]); + return { + label: page.label, + role: page.role, + authenticated: page.peerId !== null, + signalingConnected: page.signalingConnected, + routeRevision: page.routeRevision, + upstreamKind: page.routeAssignment?.upstream.kind ?? null, + assignedChildCount: page.routeAssignment?.childPeerIds.length ?? 0, + firstDecoded: page.firstDecodedAtEpochMs !== null, + firstRendered: page.firstRenderedAtEpochMs !== null, + connectionCount: page.connections.length, + connections: page.connections + .slice(-MAX_FAILURE_CONNECTIONS_PER_PAGE) + .map((connection) => ({ + connectionState: connectionStates.has(connection.connectionState) + ? connection.connectionState + : "unknown", + iceConnectionState: iceConnectionStates.has( + connection.iceConnectionState, + ) + ? connection.iceConnectionState + : "unknown", + identity: + connection.connectionId !== null || connection.remotePeerId !== null + ? "peer" + : "unidentified", + hasOutboundVideo: connection.hasOutboundVideo, + hasInboundVideo: connection.hasInboundVideo, + decoded: (connection.receiveTotals?.framesTotal ?? 0) > 0, + })), + }; +} + function hasAuthoritativeMediaUpstream(page: PageObservation): boolean { return ( page.routeRevision !== null && @@ -541,6 +713,7 @@ function inspectSfuPublication(pages: readonly PageObservation[]) { ); const publicationOwners = pages.filter( (page) => + page.role === "host" && typeof page.routeAssignment?.sfuPublicationGeneration === "string", ); if (sfuViewers.length === 0) { @@ -557,24 +730,36 @@ function inspectSfuPublication(pages: readonly PageObservation[]) { const host = publicationOwners[0]!; const hostRevision = host.routeRevision; const hostGeneration = host.routeAssignment?.sfuPublicationGeneration ?? null; - const hasMatchingActiveReady = (page: PageObservation): boolean => - page.activeRouteReady.some( - (ready) => - ready.revision === page.routeRevision && - ready.upstreamKind === page.routeAssignment?.upstream.kind && - ready.sfuPublicationGeneration === - page.routeAssignment?.sfuPublicationGeneration, - ); + const hasExactSfuMedia = ( + page: PageObservation, + direction: "send" | "receive", + ): boolean => + page.connections.some((connection) => { + const totals = + direction === "send" + ? connection.sendTotals + : connection.receiveTotals; + return ( + connection.connectionId === null && + connection.remotePeerId === null && + connection.connectionState === "connected" && + (direction === "send" + ? connection.hasOutboundVideo + : connection.hasInboundVideo) && + totals !== null && + typeof totals.framesTotal === "number" && + totals.framesTotal > 0 + ); + }); const coherent = - host.role === "host" && hostRevision !== null && hostGeneration !== null && pages.every((page) => page.routeRevision === hostRevision) && - hasMatchingActiveReady(host) && + hasExactSfuMedia(host, "send") && sfuViewers.every( (viewer) => - viewer.routeAssignment?.sfuPublicationGeneration === null && - hasMatchingActiveReady(viewer), + viewer.routeAssignment?.sfuPublicationGeneration === hostGeneration && + hasExactSfuMedia(viewer, "receive"), ); return { observed: true, rootCount: sfuViewers.length, coherent }; } @@ -973,7 +1158,6 @@ export function buildBenchmarkInitScript(options: { qualitySettings: null, routeRevision: null, routeAssignment: null, - activeRouteReady: [], maxActiveOutboundMediaEdges: 0, maxAssignedChildren: 0, firstDecodedAtEpochMs: null, @@ -991,6 +1175,9 @@ export function buildBenchmarkInitScript(options: { let plannedRouteAssignment = null; const canary = { prepareUpdates: 0, activeUpdates: 0, routeFailed: 0 }; const nativeSends = new WeakMap(); + const routeTimingKeys = ${JSON.stringify(ROUTE_TIMING_KEYS)}; + let routeDiagnosticRequested = false; + let routeTimingSamples = null; function isOpaqueId(value) { return typeof value === "string" && @@ -1017,7 +1204,11 @@ export function buildBenchmarkInitScript(options: { !value.childPeerIds.every(isOpaqueId) || new Set(value.childPeerIds).size !== value.childPeerIds.length || (value.sfuPublicationGeneration !== null && - !isOpaqueId(value.sfuPublicationGeneration)) + !isOpaqueId(value.sfuPublicationGeneration)) || + (clonedUpstream.kind === "sfu" && + value.sfuPublicationGeneration === null) || + (clonedUpstream.kind === "peer" && + value.sfuPublicationGeneration !== null) ) { return null; } @@ -1103,6 +1294,33 @@ export function buildBenchmarkInitScript(options: { if (descriptions.length > 128) descriptions.shift(); } + function sanitizeRouteTimingSamples(value) { + const children = value && typeof value === "object" && + !Array.isArray(value) && Array.isArray(value.children) + ? value.children + : null; + if (!children || children.length > ${MAX_VIEWERS}) return null; + const samples = Object.fromEntries( + routeTimingKeys.map((key) => [key, []]), + ); + for (const child of children) { + if (!child || typeof child !== "object" || Array.isArray(child)) { + return null; + } + for (const key of routeTimingKeys) { + const duration = child[key]; + if ( + duration !== null && + (!Number.isSafeInteger(duration) || duration < 0) + ) { + return null; + } + samples[key].push(duration); + } + } + return samples; + } + function handleSignalMessage(value, direction, socket) { if (typeof value !== "string") return; let message; @@ -1111,7 +1329,6 @@ export function buildBenchmarkInitScript(options: { if (direction === "out" && message.type === "authenticate") { signalingSocket = socket; peerAssisted = false; - state.activeRouteReady = []; state.role = message.role; state.roomId = message.roomId; state.authenticateSentAtEpochMs = Date.now(); @@ -1120,22 +1337,6 @@ export function buildBenchmarkInitScript(options: { return; } else if (direction === "out" && message.type === "set-quality-settings") { state.qualitySettings = message.qualitySettings; - } else if ( - direction === "out" && - message.type === "route-ready" && - message.phase === "active" && - peerAssisted && - Number.isSafeInteger(message.revision) && - message.revision === state.routeRevision && - state.routeAssignment - ) { - state.activeRouteReady.push({ - revision: message.revision, - upstreamKind: state.routeAssignment.upstream.kind, - sfuPublicationGeneration: - state.routeAssignment.sfuPublicationGeneration, - }); - if (state.activeRouteReady.length > 32) state.activeRouteReady.shift(); } else if (direction === "out" && message.type === "route-failed") { canary.routeFailed += 1; } @@ -1161,6 +1362,14 @@ export function buildBenchmarkInitScript(options: { acceptRouteUpdate(message.revision, message.phase, message.assignment); } else if (direction === "in" && message.type === "quality-settings") { state.qualitySettings = message.qualitySettings; + } else if ( + direction === "in" && + message.type === "route-diagnostic-snapshot" && + state.role === "host" && + routeDiagnosticRequested && + routeTimingSamples === null + ) { + routeTimingSamples = sanitizeRouteTimingSamples(message.snapshot); } if (message.type === "signal") recordDescription(message, direction); } @@ -1198,6 +1407,30 @@ export function buildBenchmarkInitScript(options: { } catch { return false; } } + function requestRouteDiagnosticSnapshot() { + if ( + state.role !== "host" || + !state.signalingConnected || + routeDiagnosticRequested + ) { + return false; + } + routeDiagnosticRequested = true; + if (sendCanaryMessage({ type: "request-route-diagnostic" })) { + return true; + } + routeDiagnosticRequested = false; + return false; + } + + function routeDiagnosticTimingSamples() { + return routeTimingSamples + ? Object.fromEntries( + routeTimingKeys.map((key) => [key, [...routeTimingSamples[key]]]), + ) + : null; + } + const NativePeerConnection = globalThis.RTCPeerConnection; function recordActiveOutboundEdges() { const active = connections.filter(({ connection }) => @@ -1384,7 +1617,6 @@ export function buildBenchmarkInitScript(options: { return { ...state, routeAssignment: cloneRouteAssignment(state.routeAssignment), - activeRouteReady: state.activeRouteReady.map((ready) => ({ ...ready })), }; } @@ -1441,7 +1673,7 @@ export function buildBenchmarkInitScript(options: { } Object.defineProperty(globalThis, "__SCREENER_BENCHMARK__", { configurable: false, - value: { sample, progress, snapshot, canarySnapshot, sendViewerQualityEvidence, stop: () => clearInterval(videoObserver) }, + value: { sample, progress, snapshot, canarySnapshot, sendViewerQualityEvidence, requestRouteDiagnosticSnapshot, routeDiagnosticTimingSamples, stop: () => clearInterval(videoObserver) }, }); })();`; } @@ -1564,7 +1796,7 @@ async function evaluate( return evaluation.result.value as T; } -async function createPage( +export async function createPage( cdp: CdpConnection, url: string, options: Parameters[0], @@ -1574,51 +1806,60 @@ async function createPage( url: "about:blank", background: false, }); - const attached = await cdp.call<{ sessionId: string }>("Target.attachToTarget", { - targetId: created.targetId, - flatten: true, - }); - const page: PageHandle = { - targetId: created.targetId, - sessionId: attached.sessionId, - label: options.label, - }; - await Promise.all([ - cdp.call("Page.enable", {}, page.sessionId), - cdp.call("Runtime.enable", {}, page.sessionId), - ]); - await cdp.call( - "Page.addScriptToEvaluateOnNewDocument", - { source: buildBenchmarkInitScript(options) }, - page.sessionId, - ); - const navigation = await cdp.call<{ errorText?: string }>( - "Page.navigate", - { url }, - page.sessionId, - ); - if (navigation.errorText) { - throw new Error(`Navigation failed: ${navigation.errorText}`); + try { + const attached = await cdp.call<{ sessionId: string }>( + "Target.attachToTarget", + { targetId: created.targetId, flatten: true }, + ); + const page: PageHandle = { + targetId: created.targetId, + sessionId: attached.sessionId, + label: options.label, + }; + await Promise.all([ + cdp.call("Page.enable", {}, page.sessionId), + cdp.call("Runtime.enable", {}, page.sessionId), + ]); + await cdp.call( + "Page.addScriptToEvaluateOnNewDocument", + { source: buildBenchmarkInitScript(options) }, + page.sessionId, + ); + const navigation = await cdp.call<{ errorText?: string }>( + "Page.navigate", + { url }, + page.sessionId, + ); + if (navigation.errorText) { + throw new Error(`Navigation failed: ${navigation.errorText}`); + } + await waitForPage( + cdp, + page, + "document.readyState === 'complete' && Boolean(globalThis.__SCREENER_BENCHMARK__)", + 15_000, + "page initialization", + signal, + ); + return page; + } catch (error) { + await closeTarget(cdp, created.targetId); + throw error; } - await waitForPage( - cdp, - page, - "document.readyState === 'complete' && Boolean(globalThis.__SCREENER_BENCHMARK__)", - 15_000, - "page initialization", - signal, - ); - return page; } -async function closePage(cdp: CdpConnection, page: PageHandle): Promise { +async function closeTarget(cdp: CdpConnection, targetId: string): Promise { try { - await cdp.call("Target.closeTarget", { targetId: page.targetId }); + await cdp.call("Target.closeTarget", { targetId }); } catch { // Browser shutdown and a prior recovery close make target cleanup idempotent. } } +async function closePage(cdp: CdpConnection, page: PageHandle): Promise { + await closeTarget(cdp, page.targetId); +} + async function waitForPage( cdp: CdpConnection, page: PageHandle, @@ -1674,6 +1915,35 @@ async function progressSample( ); } +export function buildBenchmarkFailureEvidence( + pages: readonly PageObservation[], + expectedPageCount: number, +): BenchmarkFailureEvidence { + return { + expectedPageCount, + observedPageCount: pages.length, + pages: pages.map(sanitizeFailurePageEvidence), + }; +} + +async function captureFailureEvidence( + cdp: CdpConnection, + pages: readonly PageHandle[], + expectedPageCount: number, +): Promise { + const observations = await Promise.allSettled( + pages + .slice(0, MAX_VIEWERS + 1) + .map((page) => progressSample(cdp, page)), + ); + return buildBenchmarkFailureEvidence( + observations.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ), + expectedPageCount, + ); +} + async function startHost( cdp: CdpConnection, page: PageHandle, @@ -1745,6 +2015,36 @@ async function waitForViewerMedia( throw new Error(`Timed out waiting for decoded media in ${page.label}`); } +async function requestRouteTimingSummary( + cdp: CdpConnection, + hostPage: PageHandle, + timeoutMs: number, + signal: AbortSignal, +): Promise { + const requested = await evaluate( + cdp, + hostPage, + "globalThis.__SCREENER_BENCHMARK__.requestRouteDiagnosticSnapshot()", + ); + if (!requested) { + throw new Error("Host route diagnostic request was unavailable"); + } + await waitForPage( + cdp, + hostPage, + "globalThis.__SCREENER_BENCHMARK__.routeDiagnosticTimingSamples() !== null", + timeoutMs, + "Host route diagnostic snapshot", + signal, + ); + const samples = await evaluate( + cdp, + hostPage, + "globalThis.__SCREENER_BENCHMARK__.routeDiagnosticTimingSamples()", + ); + return summarizeBenchmarkRouteTiming(samples); +} + async function samplePages( cdp: CdpConnection, pages: PageHandle[], @@ -2366,6 +2666,8 @@ async function runCase( const pages: PageHandle[] = []; const samples: TimedSample[] = []; let recovery: RecoveryResult = { triggered: false }; + let routeTimingSummary: BenchmarkRouteTimingSummary | null = null; + let routeTimingStatus: BenchmarkRun["routeTimingStatus"] = "not-requested"; try { const capture = PROFILE_SETTINGS[config.profileId]; const captureResolution = QUALITY_RESOLUTIONS[capture.resolution]; @@ -2399,21 +2701,41 @@ async function runCase( if (!viewerUrl.startsWith(`${baseUrl}/r/${roomId}#v=`)) { throw new Error("Host private Viewer invite was unavailable"); } - for (let viewerIndex = 1; viewerIndex <= viewerCount; viewerIndex += 1) { - const viewerPage = await createPage(cdp, viewerUrl, { - ...commonInit, - label: `viewer-${viewerIndex}`, - role: "viewer", - viewerIndex, - clearHostRoom: false, - }, signal); - pages.push(viewerPage); - await waitForViewerMedia( - cdp, - viewerPage, - config.connectionTimeoutMs, - signal, - ); + const viewerPages = await joinViewerBurst( + viewerCount, + async (viewerIndex) => { + const viewerPage = await createPage(cdp, viewerUrl, { + ...commonInit, + label: `viewer-${viewerIndex}`, + role: "viewer", + viewerIndex, + clearHostRoom: false, + }, signal); + pages.push(viewerPage); + return viewerPage; + }, + (viewerPage) => + waitForViewerMedia( + cdp, + viewerPage, + config.connectionTimeoutMs, + signal, + ), + ); + pages.splice(1, pages.length - 1, ...viewerPages); + if (viewerCount === MAX_VIEWERS) { + routeTimingStatus = "unavailable"; + try { + routeTimingSummary = await requestRouteTimingSummary( + cdp, + hostPage, + config.connectionTimeoutMs, + signal, + ); + routeTimingStatus = "captured"; + } catch { + routeTimingSummary = null; + } } if (config.settleMs > 0) { await delay(config.settleMs, signal); @@ -2427,6 +2749,8 @@ async function runCase( status: canaryResult.status, checks: canaryChecks(canaryResult), summary: null, + routeTimingSummary, + routeTimingStatus, samples: [], recovery: { triggered: false }, }; @@ -2451,6 +2775,11 @@ async function runCase( config.profileId, config.expectedEndpointCap, ); + if (viewerCount === MAX_VIEWERS) { + checks.push( + buildRouteTimingCheck(routeTimingStatus, routeTimingSummary, viewerCount), + ); + } if (config.qualityControlSmoke && viewerCount >= 3) { const qualityControl = await runQualityControlSmoke( cdp, @@ -2500,19 +2829,60 @@ async function runCase( status: checks.every((check) => check.passed) ? "passed" : "failed", checks, summary, + routeTimingSummary, + routeTimingStatus, samples, recovery, }; } catch (error) { + if ( + viewerCount === MAX_VIEWERS && + routeTimingStatus !== "captured" && + pages[0] + ) { + routeTimingStatus = "unavailable"; + try { + routeTimingSummary = await requestRouteTimingSummary( + cdp, + pages[0], + config.sampleIntervalMs, + signal, + ); + routeTimingStatus = "captured"; + } catch { + routeTimingSummary = null; + } + } + const failureEvidence = await captureFailureEvidence( + cdp, + pages, + viewerCount + 1, + ).catch(() => ({ + expectedPageCount: viewerCount + 1, + observedPageCount: 0, + pages: [], + })); return { viewerCount, startedAt: new Date(startedAtMs).toISOString(), completedAt: new Date().toISOString(), status: "failed", - checks: [], + checks: + viewerCount === MAX_VIEWERS + ? [ + buildRouteTimingCheck( + routeTimingStatus, + routeTimingSummary, + viewerCount, + ), + ] + : [], summary: samples.length > 0 ? summarizeSamples(samples, viewerCount) : null, + routeTimingSummary, + routeTimingStatus, samples, recovery, + failureEvidence, error: errorMessage(error), }; } finally { @@ -2560,7 +2930,7 @@ export async function main(): Promise { const profile = PROFILE_SETTINGS[config.profileId]; const profileResolution = QUALITY_RESOLUTIONS[profile.resolution]; const report: BenchmarkReport = { - schemaVersion: 2, + schemaVersion: 3, startedAt: new Date().toISOString(), completedAt: null, gitCommit: await gitCommit(), @@ -2598,7 +2968,7 @@ export async function main(): Promise { limitations: [ "Synthetic canvas motion exercises real Chromium WebRTC but is not a game-capture quality claim.", "Headless runs are topology and transport evidence, not representative GPU or power evidence.", - "First-frame and recovery timing fields are diagnostics and never determine this loopback gate's status.", + "Timing values never determine this loopback gate's status; the exact 20-Viewer route snapshot and four complete current-child distributions are required without a duration threshold.", "CDP process CPU covers the isolated Chromium instance, not a specific Host or relay page; identity changes or counter resets make that interval unknown, and multicore utilization may exceed 100%.", "CDP SystemInfo exposes no resident-set field, so peakResidentSetBytes is null; GPU, NIC, glass-to-glass latency, generational visual quality, mobile browsers, and SFU require other measurement.", "The local runner does not start LiveKit; SFU consistency is reported only when an SFU route is actually observed.", diff --git a/src/client/App.tsx b/src/client/App.tsx index 755855a..43fd2f5 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -27,7 +27,7 @@ function stateFromStatus(status: SiteAccessStatus): AccessState { } function readableError(error: unknown): string { - return error instanceof Error && error.message + return error instanceof ApiError ? error.message : "无法连接站点访问服务,请重试"; } @@ -206,7 +206,7 @@ function MalformedRoomRoute() {

房间号格式不正确

-

房间号必须是 1000..9999 的四位数字

+

请输入 1000 至 9999 的四位房间号

diff --git a/src/client/components/RoomCodeEntry.tsx b/src/client/components/RoomCodeEntry.tsx index 42c6493..58b5582 100644 --- a/src/client/components/RoomCodeEntry.tsx +++ b/src/client/components/RoomCodeEntry.tsx @@ -17,7 +17,7 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod event.preventDefault(); const route = roomRouteFromInput(roomId); if (!route) { - setError("房间号必须是 1000..9999 的四位数字"); + setError("请输入 1000 至 9999 的四位房间号"); return; } window.location.assign(route); @@ -32,12 +32,12 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod {!inline && (

加入房间

-

输入分享者提供的房间码

+

输入分享者提供的房间号

)}