diff --git a/AGENTS.md b/AGENTS.md index 954ae06..e77ed8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ - Apply Occam's razor: choose the simplest design that satisfies the verified requirement, and stop there. Avoid over-engineering for hypothetical scale or future features. - Keep changes scoped and preserve unrelated user work. - Prefer the smallest proven extension point; do not add speculative frameworks, hooks, or abstractions without a current consumer. -- Add automated tests in proportion to behavioral risk. For realtime media changes, also document the manual network and browser matrix used. +- Add automated tests in proportion to behavioral risk. Block the current phase on P0 security, privacy, irreversible data, authorization, generation, bounded-resource, and rollback failures, plus P1 failures of the milestone's core user path. Record or cover P2/P3 rare environments, diagnostic detail, and minor UI edges cheaply without turning them into canary blockers unless they can cause P0 harm. Use high-repeat race runs only for a reproduced concurrency failure. For realtime media changes, also document the manual network and browser matrix used. - Batch validation by risk and phase: run narrow checks while iterating, and run expensive full suites, browser matrices, endurance tests, or deployment checks only at an acceptance boundary or when a relevant change invalidates prior evidence. Documentation-only or unrelated changes do not invalidate media-path evidence. - Use comments for non-obvious rationale, invariants, protocol constraints, and workarounds; do not narrate obvious code. - Use WebRTC statistics and reproducible measurements for latency, bitrate, candidate type, packet loss, encode time, and quality limitations. Do not claim performance from assumptions alone. diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 5767b32..6666665 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -72,21 +72,20 @@ configuration. Optional TURN is a separate selected-edge transport choice. ## Implementation Status -Merged PR #17 implements this controller on top of merged PR #13. Merged PR #20 -adds only the bounded standby prewarm described below. Their code ships in -production release `769de201f7cc`, but the deployment has neither -`PEER_ASSISTED_MEDIA` nor a LiveKit tuple, so its active path remains ordinary -one-host-peer-per-viewer P2P/TURN. +Merged PR #17 implements this controller on top of merged PR #13, and PR #20 +adds the bounded standby prewarm below. Production `d6c8aa0` now enables them +only for persistent room `1`; other rooms stay ordinary P2P, while ordinary ICE +is process-wide STUN-only. -The repository candidate now removes the old all-room coturn contract. Production +The current runtime removes the old all-room coturn contract. Production requires STUN, ordinary authenticated ICE snapshots contain only STUN servers, and the protocol has no TURN credential expiry or refresh messages. The tracked LiveKit sample explicitly sets `tcp_port: 0`, disables TCP fallback, supplies the deployment-owned STUN server, and configures no TURN service. The SFU controller still -activates only after a peer edge exhausts recovery, so public transport and route -admission remain unverified. Production `769de201f7cc` keeps its old coturn relay -until an isolated candidate canary passes; rollback is release/instance based, -not a permanent old-release compatibility branch in the new code. +activates only after a peer edge exhausts recovery. Public participant entry has +been observed, but retained media and route admission remain unverified. The old +`769de201f7cc` release and coturn relay are isolated rollback resources, not a +permanent compatibility branch or advertised current transport. The repository also requires a strict, non-empty `PEER_ASSISTED_ROOM_IDS` deployment allowlist whenever `PEER_ASSISTED_MEDIA=true`. Only exact listed room @@ -200,8 +199,14 @@ complete LiveKit tuple. ## Transition 1. Keep direct/peer UDP roots while those routes work. -2. A failed edge first exhausts its bounded ICE/UDP restart/rebuild and peer - reparent options. When admission has no eligible peer path, or recovery is +2. A failed edge performs one ICE restart, one same-parent connection rebuild, + and then one alternate eligible peer-parent attempt; it does not repeat an + identical action three times. A short `disconnected` state first uses + generation-bound bytes/stats evidence. The target gives initial `connecting` + a visible soft wait followed by a separately measured hard window; that + initial deadline is not implemented yet and must not reuse the existing + three-second recovery constant without mobile-network evidence. When + admission has no eligible peer path, or recovery is exhausted, select the SFU root plan without first advertising TURN to every peer edge. A future ordinary coturn transport, if a separate complete change accepts it, may issue a grant only to the selected exceptional peer edge after @@ -239,6 +244,16 @@ complete LiveKit tuple. baseline revision and disables SFU for the rest of the current share. Stop or a new sharing generation clears that one-shot circuit breaker. +Production logs on 2026-08-20 observed two root participants for about 4.6 +seconds and two short Host participants (about 0.46 and 0.27 seconds), all ending +with client-requested leave. Services did not restart and no track publication +was retained. That timing is consistent with the bounded fresh-grant retry and +Peer failback state machine, but the logs do not prove those transitions or the +publisher stage. The client now retains only a local enum +(`connect`, `source`, `video-publish`, `sender-config`, `audio-publish`, or +`transport`) for the current route revision and reports the failure as an event; +it never uploads the raw error, endpoint, token, candidate, or address. + An activated SFU-root route is sticky until a discrete route event requires a change or the current share stops. A new sharing generation starts from the cheapest available UDP route. The first target does not continuously rebalance @@ -317,7 +332,7 @@ publication feeds one or two roots, which keep their bounded peer descendants. The current executable ladder ends in bounded failure and has no TURN config or wire. Optional selected-edge TURN remains a future conditional PR, not an unused runtime tuple. LiveKit participant-wide embedded/external TURN is likewise not -configured by the current UDP-only candidate and would not be assignment-level +configured by current production and would not be assignment-level issuance. The bounded cost model, privacy-safe ICE fields, and exact-room A/B sequence @@ -331,16 +346,16 @@ bitrate `B_exc,j`, SFU egress adds `sum(B_exc,j)`; every TURN leg additionally adds its own ingress and egress. Every host NIC, TURN ingress/egress, SFU ingress/egress, root NIC, and exception NIC hop remains real traffic. -Migration is gated, not optional design debate: retain the deployed old release -as a separate baseline/rollback instance, then use exact test rooms on an -isolated candidate to verify direct/peer UDP, SFU/UDP, and bounded failure with -all UDP blocked. The candidate cannot share a process with rooms that require -the old all-room TURN wire. The matrix covers CGNAT, double NAT, mobile hotspot, +Migration is gated, not optional design debate. Production has entered a +shared-IP exact-room smoke while the old release remains a separate rollback +instance. Room `1` must still verify direct/peer UDP, SFU/UDP, and bounded failure +with all UDP blocked; the current process cannot host rooms that require the old +all-room TURN wire. The matrix covers CGNAT, double NAT, mobile hotspot, ordinary home networks, root departure, reconnect, SFU unavailable, and rollback. It records CPU seconds/GiB, NIC bytes/pps, RSS, host upload, p95/p99 forwarding latency, loss/recovery, final quality, host edges at most two, SFU roots at most two, and unchanged healthy subtrees. Config parsing and wire are already STUN-only; only -after that gate may production activate the candidate and retire relay listeners. +after that gate may the controller broaden beyond room `1` or retire rollback relay listeners. A peer root re-publishing its received stream to the SFU while also feeding peer descendants remains a separate bounded candidate. The current browser @@ -409,7 +424,7 @@ Positive: - Users do not select or understand a topology. - Host fanout remains bounded while server media egress is paid only for fallback roots and separately admitted exceptional viewers. -- The ordinary peer path is direct/UDP with STUN discovery; the current candidate +- The ordinary peer path is direct/UDP with STUN discovery; current production has no TURN media cost or fallback. - Revisioned prepare/commit isolates stale asynchronous results without a continuous optimizer. @@ -428,8 +443,8 @@ Negative: ## Relationship To Existing ADRs -- This decision corrects the automatic-migration interpretation in ADR-0001 - without changing the currently deployed MVP. +- This decision corrects the automatic-migration interpretation in ADR-0001; + production now runs its exact-room smoke while broad rollout remains gated. - ADR-0004 remains the bounded full-stream browser-relay experiment. - This decision and merged PR #17 supersede ADR-0003/closed PR #12's process-wide explicit media mode with default-off automatic hybrid fallback. diff --git a/docs/deployment.md b/docs/deployment.md index 747892c..d810900 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,10 +14,10 @@ current controller's automatic final media fallback. This capacity is dormant un the peer-assisted experiment, or required STUN discovery. It is configured with ICE/UDP only; the candidate has no ordinary TURN or media-TCP configuration. -Production `769de201f7cc` is still the old TURN-required release. Keep it on a -separate rollback instance while validating this candidate; do not reuse its -environment or coturn relay config. Every new candidate deployment must run the -direct, SFU/UDP, UDP-blocked failure, and mixed-network procedures below. +Production `d6c8aa06dbd` runs this candidate for exact room `1` on the existing +shared public IP. The unchanged `769de201f7cc` release, environment backup and +coturn relay remain rollback-only. This is a bounded production smoke, not +clean-port or broad-rollout acceptance; the media procedures below still apply. ## Topology and prerequisites @@ -83,11 +83,11 @@ the old release unchanged for rollback. If the candidate fails, roll back the release or instance; do not add a permanent dual-transport branch. A shared-public-IP instance can test candidate behavior while the old TURN -service stays live, but it cannot prove the clean-port boundary and must not -approve migration. Prefer a separate VM/public IP for the full gate. Before -starting, require candidate DNS/TLS, host and provider-firewall access, -independent LiveKit secrets, verified UDP 7882 and resource state, and -representative external networks and devices. +service stays live, but it cannot prove the clean-port boundary or approve broad +migration. The room-1 smoke uses this shape: nginx owns 443, LiveKit 1.13.5 owns +UDP 7882, ordinary ICE is STUN-only, and LiveKit is fail-closed under a 192 MiB +high/256 MiB hard cgroup limit with restart disabled. Prefer a separate VM/IP +for the full gate and verify representative external networks and devices. Optional selected-edge TURN is not supported by this candidate. If later network evidence justifies it, configuration, generation-bound grants, client wire, diff --git a/docs/project-memory.md b/docs/project-memory.md index 4aa388b..bbfb544 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -1,6 +1,6 @@ # Project Memory -Last updated: 2026-08-19 +Last updated: 2026-08-20 ## Confirmed Intent @@ -15,6 +15,7 @@ Last updated: 2026-08-19 - Separate abuse control from watching: production Host admission protects room creation/Host role, while a default private capability grants Viewer access to one room; public-watch is explicit. Keep raw grants out of localStorage/cookies/query/logs/SQLite; retain no account, ACL, user, or session table. - Keep decisions, snapshots, research, code, `AGENTS.md`, and `.codex/` in Git; rewrite memory/status in place. Research current primary sources before material work and reject speculative machinery. - Migrate client, server, and deployment atomically. After a canary, delete superseded config/wire/parsers/tests; do not retain compatibility layers, dual writes, or a second architecture without a current consumer. Git history owns the old implementation. +- Autonomously deploy each coherent low-risk milestone after narrow tests, independent review, one full gate, CI and rollback preflight. Keep protocol/database migrations atomic rather than folding them into routine UI/media updates. ## Current Recommendation @@ -22,34 +23,34 @@ Last updated: 2026-08-19 - Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an isolated exact-room candidate until resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile gates pass. - Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost. - Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate. -- ADR-0006's fixed-`HIGH` canary reached host setup and one encoder output but retained no downstream checkpoint before the first-viewer timeout. It is no-go-unclassified; no native product code is accepted, and any revisit starts at its staged evidence gate. -- Mobile Web Host is unsupported: current Android Chrome/Firefox and iOS Safari lack reliable `getDisplayMedia`; feature-detect and fail clearly, never by UA. Mobile Viewer stays leaf-only. After P1 Windows native/deploy, P2 may gate Android 14+ fixed-`HIGH`; iOS waits for stable iOS 27 ScreenCaptureKit without ReplayKit. +- ADR-0006 fixed-`HIGH` is no-go-unclassified after timing out before Viewer evidence; any revisit starts at its staged gate. +- Mobile Web Host is unsupported; feature-detect and fail clearly. Mobile Viewer stays leaf-only. After Windows native, gate Android 14+; iOS waits for stable iOS 27 ScreenCaptureKit. - ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP. The candidate has no TURN consumer or credential wire; any future exceptional-edge grant is a complete separate change. PR #12 is superseded. -- Keep the failure-only controller default-off until the isolated STUN/SFU exact-room gates pass. Preserve break-before-make, sticky healthy subtrees, mobile leaves, and PR #20 prewarm; public transport/load remains open. +- Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback. - Local reparenting is a later candidate: start with unassigned relay admission rescue; do not block current work. -- Flagship media is UDP; HTTPS/WSS stays TLS/TCP. Old production retains coturn TURN for rollback. The candidate uses self-hosted STUN-only ordinary ICE and separate LiveKit SFU/UDP, with no TURN/media-TCP wire. -- Treat settings as ceilings and deployed degradation as unclassified. Compare `769de201f7cc` with current `main` using Host A+B/Viewer C, game/load, preview, codec/encoder, and one-variable rebuilds. The SVC gate is closed; never use UA or a composite score. +- Flagship media is UDP; HTTPS/WSS stays TLS/TCP. Production `d6c8aa0` uses STUN-only ordinary ICE and separate LiveKit SFU/UDP; old coturn TURN and `769de201f7cc` remain 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. - Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`. - Candidate UI has local Viewer volume/mute, copyable room codes, and a favicon. Names/roster, endpoint details, and RTP loss remain pending; no wire, media, or routing effects. -- Do not add scene detection, dynamic-FPS control, or forced AV1 until stats and target hardware prove the need. Codec acceptance requires actual negotiation, power-efficient candidate evidence, interval encode cost, game FPS, CPU/GPU and sender count; Discord's native capture/hardware tuning is comparison evidence, not proof of server re-encoding or a reusable preset. +- Do not add scene detection, dynamic FPS or forced AV1 without stats/hardware evidence. Codec gates require actual negotiation, encode cost, game FPS, CPU/GPU and sender count. - After the current media acceptance boundary, migrate access atomically under amended ADR-0002: `HOST_ADMISSION_PASSWORD`, `screener-v2`, default private fragment grants, optional public-watch, rotate/revoke, and one nullable SQLite digest column. Do not mix username/roster runtime into that PR. - Deferred architecture audit: `docs/maintenance.md`. ## Current Implementation - The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. -- Production `769de201f7cc` at `https://share.bonfire.icu` has equal entry actions, default-closed details, bounded clarity-first quality, live source/quality changes, pause, and sender warnings. -- Production still uses one host `RTCPeerConnection` per viewer because peer assistance and LiveKit are unconfigured; above two viewers it still exceeds the target host-edge budget. Its real game-capture quality/pause cycle remains unverified. +- Production `d6c8aa0` at `https://share.bonfire.icu` keeps the current Web UI and enables peer/SFU routing only for persistent room `1`; unlisted rooms remain ordinary P2P. +- Ordinary ICE is process-wide STUN-only. Room `1` may use one LiveKit publication feeding at most two roots while retaining peer descendants; real 1/2-root media and game quality remain unverified. - Current production/repository runtime still uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie for both roles, internal Host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map. - Current SQLite still stores only room ID and Host-token digest; room `1` survived deployment. The accepted Viewer-grant digest/schema v2 design is documentation-only and not deployed. -- Production uses nginx, Node.js 24.19.0, and authenticated coturn 4.17.2 at `turn.bonfire.icu:3478`; public STUN, TURN/UDP, TURN/TCP, and relay-only traffic pass. TURN/TLS is off. -- Candidate migration is process-wide: ordinary ICE is STUN-only; coturn uses `stun-only`/`no-tcp`/`no-tls` without deprecated `no-dtls`; LiveKit 1.13.5 explicitly disables TCP fallback, uses deployment STUN and separate UDP participant ICE. Old TURN parser/signer/refresh/UI/SNI artifacts are gone; old production is isolated rollback. -- The default-off ADR-0005 controller has no production peer/SFU configuration. Enabling it requires non-empty exact `PEER_ASSISTED_ROOM_IDS`; missing/blank fails startup and unlisted rooms use current ordinary P2P. `screener-v1` is the single signaling literal; mismatch terminates once with a refresh prompt. -- When enabled, peer recovery precedes allowlisted SFU; session revisions prepare/commit/abort and active SFU gets one token refresh before failback. Browser relays re-encode; native sharing is outside product code. +- Production uses nginx, Node.js 24.19.0, coturn 4.17.2 for STUN/rollback, and pinned LiveKit 1.13.5 on UDP 7882 with TCP fallback disabled. Public TCP 7880/7881 is blocked; LiveKit has a 192/256 MiB cgroup boundary and no automatic restart. +- Old TURN parser/signer/refresh/UI/SNI artifacts are absent from `d6c8aa0`; the unchanged coturn relay and old release are operational rollback, not advertised candidate media. +- ADR-0005 is configured with exact `PEER_ASSISTED_ROOM_IDS=1`; unlisted rooms use ordinary P2P. `screener-v1` remains the deployed signaling literal until the separately reviewed access migration. +- Production logs show two root participants and two short Host participants with no service restart or retained track. Their timing is consistent with, but does not directly prove, the code's one fresh-grant retry and Peer failback. The current candidate exposes only the Host-local current-revision failure stage (`connect|source|video-publish|sender-config|audio-publish|transport`) and uploads no raw error or endpoint data. Browser relays still re-encode; native sharing remains outside product code. - Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence. -- Native drafts #16/#18/#22/#23/#25/#28 remain experiments: one WebCodecs object is not hardware proof; stock GCC+RTX is no-go, and no-RTX remains outside product code. +- Native drafts remain experiments: one WebCodecs object is not hardware proof; stock GCC+RTX is no-go. - Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only. -- The default-off exact-room SFU candidate now publishes exactly `HIGH+LOW` with Dynacast off and a per-subscriber `HIGH` ceiling. It is unverified and not enabled by default; next measure built-in BWE on zero-descendant leaves, then root suspect/evacuation. The topology classifier and explicit fallback remain absent, and root-with-children behavior is a default-on gate. +- Room 1 publishes `HIGH+LOW` with Dynacast off and subscriber `HIGH` ceilings. It is unverified; test zero-child leaves, then root evacuation before broad rollout. - Local Host A+B aligns one capture/outbound/transport generation; authenticated Viewer C adds a sanitized, read-only P2P window and fails closed for stale, ambiguous, or SFU-fed evidence. It retains no raw fmtp/SDP/stats and performs no media action. Topology classification remains absent. ## Provisional Quality Targets diff --git a/docs/research/advanced-peer-distribution.md b/docs/research/advanced-peer-distribution.md index 7279bdd..6108963 100644 --- a/docs/research/advanced-peer-distribution.md +++ b/docs/research/advanced-peer-distribution.md @@ -234,6 +234,38 @@ quality, or cannot beat the unchanged route. Start and stop the initial spike at admission rescue. Later measured work owns quality-driven moves and healthy TURN-to-direct optimization. +## Staged Connection Recovery Evidence + +Recovery should spend attempts on different layers, not repeat one opaque action +three times. The current WebRTC Recommendation advises an ICE restart when +`iceConnectionState` reaches `failed`; for `disconnected`, it recommends first +checking whether sent/received bytes progress over the next couple of seconds. +Pinned LiveKit client 2.22.0 uses reconnect delays of 0, 300, 1,200, 2,700, +4,800 and then 7,000 ms (with later jitter), a 15-second peer/WebSocket timeout, +and a four-second state reconciliation that requires three consecutive +mismatches before a full reconnect. Jitsi Videobridge similarly defaults to a +15-second first-transfer timeout and an eight-second inactivity limit. These are +reference boundaries, not thresholds copied into Screener. + +The accepted Screener order is therefore: soft visible wait while initial ICE +is still making progress; one ICE restart on hard failure; one same-parent PC +rebuild; one different eligible peer parent; then SFU only after peer exhaustion. +Each success cancels the remaining stages. Every completion is guarded by the +current session, route revision, assignment/connection generation, and cooldown. +Connected-but-bad media is a separate correlated-quality trigger and must not be +treated as a connection retry. + +The first room-1 production trace on 2026-08-20 observed two short Host +participants while two roots remained for roughly 4.6 seconds; all ended with +client-requested leave, no track publication survived, and neither service +restarted. This proves LiveKit participant entry. The timing is consistent with +the current one-shot grant refresh and Peer-failback state machine, but logs do +not prove those transitions and cannot distinguish +connect, source, video publish, sender configuration, optional audio publish, or +transport failure. The next bounded diagnostic exposes only that local enum to +the Host and deliberately keeps raw errors, URLs, tokens, candidates, and +addresses out of wire and logs. + ## Automatic Route Controller Boundary The accepted product order is: @@ -268,7 +300,7 @@ also requires a media/data heartbeat near 100-200 ms rather than the current ## Sources And License Boundary -Sources checked on 2026-08-19: +Sources checked on 2026-08-20: - [WebRTC SVC](https://www.w3.org/TR/webrtc-svc/), [Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/), @@ -279,6 +311,14 @@ Sources checked on 2026-08-19: - [Pion WebRTC](https://github.com/pion/webrtc) and [klauspost/reedsolomon](https://github.com/klauspost/reedsolomon) - MIT; no source copied. +- [WebRTC Recommendation](https://www.w3.org/TR/webrtc/) - `failed` ICE restart + and `disconnected` bytes/stats guidance. +- [LiveKit client 2.22 reconnect policy](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/DefaultReconnectPolicy.ts), + [room defaults](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/defaults.ts), + and [state reconciliation](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/Room.ts) + - pinned implementation behavior, not a universal timeout prescription. +- [Jitsi Videobridge endpoint status defaults](https://github.com/jitsi/jitsi-videobridge/blob/master/jvb/src/main/resources/reference.conf) + - first-transfer and inactivity reference values. - [SplitStream](https://www.microsoft.com/en-us/research/publication/splitstream-high-bandwidth-multicast-in-a-cooperative-environment/) and [Network Coding for Large Scale Content Distribution](https://www.microsoft.com/en-us/research/publication/network-coding-for-large-scale-content-distribution/) - research evidence, not reusable source licenses. diff --git a/docs/status.md b/docs/status.md index 2f6439b..c996f8f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,28 +1,32 @@ # Current Status -Last updated: 2026-08-19 +Last updated: 2026-08-20 ## Phase -`https://share.bonfire.icu` runs `769de201f7cc` with simplified entry, bounded -clarity-first controls, persistent rooms, and reusable links. Peer/LiveKit is -unconfigured, so production remains one host connection per viewer and P2P/TURN. +`https://share.bonfire.icu` runs `d6c8aa06dbd` as a shared-IP production smoke. +Ordinary ICE is STUN-only process-wide; persistent room `1` alone enables the +automatic peer/SFU-UDP controller with at most two SFU roots. The old +`769de201f7cc` release and coturn relay remain an isolated rollback path. +Coherent low-risk milestones may deploy after narrow tests, independent review, +one full gate, CI and rollback preflight; protocol/database changes stay atomic. ## Current Snapshot - Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. - Current runtime still uses optional `ACCESS_PASSWORD` and one stateless 12-hour HMAC HttpOnly Strict cookie for both roles; Host auth stays internal and signaling role-bound. - Current SQLite still stores only room ID and Host-token digest. Amended ADR-0002 accepts a later atomic `HOST_ADMISSION_PASSWORD`/`screener-v2` migration with default room-scoped private Viewer grants, explicit public-watch, rotate/revoke, and one nullable digest column; no runtime is implemented. -- PR #44 merged default-off exact-room STUN-only/SFU-UDP router: host/SFU roots <=2, browser relay <=1, bounded failure. Production/coturn is rollback; HTTPS/WSS stays TLS/TCP. -- Fallback prewarm is token-free and dormant without configuration. Mobile/iPad viewers are leaves and healthy edges stay sticky. -- PR #49/#50 accepted and implemented the default-off built-in-first `q/f` candidate: exactly `HIGH+LOW`, Dynacast/backup codec off, and a subscriber `HIGH` ceiling; it has no browser evidence. +- PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP. +- Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky. +- Room `1` can publish exactly `HIGH+LOW` with Dynacast/backup codec off and subscriber `HIGH` ceilings; it has no retained real media frame. - A+B/P2P C remains read-only. Autonomous BWE is only `suspect`; confirmed fallback evacuates children and loses parent capacity. Root impact remains a default-on gate. ## Verified Evidence -- Release `769de201f7cc` passed CI/hygiene/typecheck, 274 tests, both builds, zero production dependency vulnerabilities, atomic health/access/bundle/SQLite/service/log checks, and responsive 320/375/390 px checks without live media; rollback is ready. +- `d6c8aa0` passed typecheck, 330 tests and both builds, then an atomic production switch with SQLite integrity/room-1 preservation. Node, nginx, coturn and pinned LiveKit 1.13.5 are active with zero restarts; health is 200, retired `/rtc*` is 404, and public TCP 7880/7881 is blocked. Rollback is ready. +- On the 960 MiB host, idle LiveKit peaked near 16 MiB with no cgroup high/max/OOM event under `MemoryHigh=192M`, `MemoryMax=256M`, swap disabled and restart disabled. This is containment/idle evidence, not media capacity. - Chrome 151 synthetic `1/3/5/8` and 720p30 quality-change runs kept 2/1 fanout and decoding; slowest first frame was 1.05 seconds and one relay close recovered in 5.32 seconds. This is control evidence only. -- Chrome 151/LiveKit localhost A/B measured cold active/render at 1.481/2.257 seconds versus standby 0.200/0.320 with host edges at two. It includes SDK/prewarm and is not public-network evidence. +- A mobile-network room-1 run produced two root participants for about 4.6 seconds and two short Host participants (about 0.46/0.27 seconds), all client-requested leaves, with zero service restarts and no retained track. This is consistent with the code's one fresh-grant retry and Peer failback, but logs cannot prove those transitions. The next candidate shows only the local failure stage. - Host A+B and authenticated P2P Viewer C are sanitized, generation-bound, read-only, and fail closed for stale, ambiguous, or SFU-fed evidence; no raw media metadata is retained. - SVC is `no-go-web-svc-cross-path-hardware-contract`: no direct/peer selection, cross-PC shared encode, or portable hardware proof; pinned screen share is `L1T3`. No browser run was warranted. - HTTPS/WSS, access and room/WebSocket auth, renewal, public STUN, and authenticated TURN/UDP/TCP relay-only paths pass; TURN/TLS is off. @@ -30,57 +34,42 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN. ## Unverified Boundaries -- Full-resolution 30-minute runs, relay CPU/GPU, controlled loss/RTT, and depth latency are unverified. Production `769de201f7cc` game sharing is reported low-FPS/high-load; latest `main`, preview cost, codec, and hardware encoding are not yet compared. +- Room 1 proved LiveKit participant entry but not a retained SFU Viewer frame, exact retry/failback transition, selected UDP pair, edge cap, quality or resource deltas. Former game-share load/blur remains unclassified. - Android Chrome/iOS Safari Viewer leaves remain unverified and conservatively leaf-only. Mobile Web Host is unsupported; native senders are planned only. - Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified. - Real audio and heterogeneous clients remain unverified. Production reports poor film audio and voice-call self-echo under system capture; there is no app audio ceiling or Web process isolation. Diagnose A/B/C and sync, then gate Windows 11 game-process audio; Windows 10 stays unresolved without system fallback. -- Production `769de201f7cc` reportedly sustains bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not, and persistence across churn was also reported. This prioritizes, but does not prove, host sender/PC/GCC/capture generation. +- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it. - Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats. - Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges. -- PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go. -- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. -- Scoped Viewer access is designed but unimplemented. Tests owe role bounds, fragment clearing/no-leak, expiry/cross-room/rotate/revoke, v1 tab termination, and SQLite locked migration/restore. Human passwords/accounts stay out. +- Room-1 BWE is unverified: test zero-child leaves, then root impact. Use Linux `tc` or public canary, never CDP; Web P2P/SVC shortcuts remain no-go. +- Scoped Viewer access runtime is under final review and is not deployed. Human passwords/accounts stay out. ## Next Milestone -Use correlated Host A+B/Viewer C to compare exact production and current `main` on one wired/direct -game fixture. Record actual codec/encoder, game FPS, CPU/GPU, preview on/off and -A+B while separately rebuilding one `HostPeer`, replacing capture, and fully -refreshing Host. Only a proven stuck generation justifies guarded recovery; never -reconnect periodically, force AV1, or raise ceilings blindly. +Deploy the bounded SFU-stage diagnostic and repeat room 1 once. Fix only the +identified connect/source/video-publish/sender-config/audio/transport layer, +then retain selected UDP, a decoded/rendered frame, edge caps and resource deltas. +The recovery target uses one ICE restart, one same-parent rebuild, one alternate peer, then +SFU; it never runs three identical retries or abandons progressing P2P early. -The bounded SVC capability gate is closed statically. After that reproduction, -gate LiveKit exactly-two/Dynacast-off BWE on zero-descendant SFU leaves, then -bound root-with-children downshift/evacuation. Prove healthy `HIGH`, hardware, -game, upload, and received layers. Explicit quality, manual activation, and -custom/native are later fallbacks. -Diagnose audio A/B/C, -sync, and voice-source leakage in parallel when it does not displace that P0; -`maxBitrate` is not a quality fix. WebRTC/LiveKit owns congestion/layers; the app -owns evidence and topology guardrails. An isolated exact room later gates peer/SFU UDP and -bounded failure before replacing the old release. Selected-edge TURN, if later -justified, is a separate complete change rather than part of this canary. -Candidate UI has volume, room-code copy and favicon; names/roster/diagnostics remain. -After the current media acceptance boundary, implement amended ADR-0002 as one -atomic access PR; do not combine it with username/roster runtime or preserve the -old config, cookie, protocol, or SQLite parser. +Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root +evacuation. In parallel, finish the atomic ADR-0002 access PR and native staged +gate; username/roster stays separate. Audio A/B/C may run without displacing P0. +TURN remains a future selected-edge change, not this canary. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate 20-viewer gate must pass before the current default eight changes to target 20. -Observed disconnects retain 5-second grace plus at most 3 seconds to a decodable -picture; silent partitions add detection delay. - ADR-0004 fails closed. Native shared encode and packet/layer striping remain separate experiments, not ways to relabel a failed browser route. ## Blockers And Decisions -ADR-0004/0005 remain No-Go. Public canary lacks candidate DNS/TLS, -host/security-group access, independent LiveKit secrets, verified UDP -7882/resources, and external devices. Shared IP is smoke-only and cannot -approve clean-port migration; the full gate needs an isolated VM/IP. +ADR-0004/0005 remain No-Go for broad rollout. DNS/TLS, independent secrets, +host/provider UDP 7882 and bounded services are deployed, but real external +media/device evidence is absent. Shared IP remains smoke-only and cannot approve +clean-port migration; the full gate still needs an isolated VM/IP. Deferred architecture audit: `docs/maintenance.md`. - Whole-system versus selected-game audio for the first release. diff --git a/docs/方案设计.md b/docs/方案设计.md index 9b4e009..01401b4 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -2,18 +2,18 @@ ## 2026-08-18 14:45 -状态:首个 PoC 基线及其已接受扩展的当前设计。`769de201f7cc` 是仍在运行的旧 production P2P/TURN 基线;下文明确标注的 repository candidate 是隔离验证中的 STUN-only ordinary ICE、exact-room peer-assisted 与 LiveKit SFU/UDP,二者不是同进程兼容路径。 +状态:首个 PoC 基线及其已接受扩展的当前设计。Production `d6c8aa0` 已使用 STUN-only ordinary ICE,并只为 room `1` 启用 exact-room peer-assisted 与 LiveKit SFU/UDP;`769de201f7cc` 与 coturn relay 仅保留为隔离回滚路径,二者不是同进程兼容分支。 ## 目标与边界 -产品面向一名分享者和小范围可信朋友的低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;每条媒体边独立 ICE。旧 production 能直连时走 P2P、失败边使用 coturn;repository candidate 的普通边只接收自托管 STUN,peer 恢复耗尽后由 LiveKit ICE/UDP virtual parent 服务一至两个必要 roots,再失败则有界明确结束。当前房间默认最多八名观看者、可配置范围为 1 至 16;自动分布式拓扑通过 20 人门禁后,目标 release 才把默认值和上限原子改为 20。容量配置只是接入边界,不是性能声明。 +产品面向一名分享者和小范围可信朋友的低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;每条媒体边独立 ICE。当前 production 的普通边只接收自托管 STUN;room `1` 在 peer 恢复耗尽后可由 LiveKit ICE/UDP virtual parent 服务一至两个必要 roots,再失败则有界明确结束。当前房间默认最多八名观看者、可配置范围为 1 至 16;自动分布式拓扑通过 20 人门禁后,目标 release 才把默认值和上限原子改为 20。容量配置只是接入边界,不是性能声明。 本阶段必须形成完整的最小纵向链路: - 分享者主动选择屏幕、窗口或标签页,并请求可用音频; - 创建数字房间;production Host admission 密码严格控制建房/发布,默认 room-scoped capability link 让 Viewer 免站点密码观看,部署可选择最小 SQLite 持久化; - 每名观看者拥有独立的 `RTCPeerConnection`; -- 当前仓库候选要求 STUN,普通 peer ICE 只发 STUN servers;SFU candidate 只开放 LiveKit ICE/UDP; +- 当前 production 要求 STUN,普通 peer ICE 只发 STUN servers;SFU 只开放 LiveKit ICE/UDP; - 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据; - 超过部署配置上限的观看者被明确拒绝,不静默超载; - 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。 @@ -143,9 +143,9 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate | 1080p30 | 1920x1080、30 fps | 5 Mbps/peer | | 720p30 | 1280x720、30 fps | 3 Mbps/peer | -浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。生产 release `769de201f7cc` 已部署有界手动质量控制:视频 track 保留 `contentHint = "motion"`,默认使用清晰优先的 `degradationPreference = "maintain-resolution"`,并允许显式选择 balanced 或 fluid。折叠高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。 +浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。Current production 保留有界手动质量控制:视频 track 使用 `contentHint = "motion"`,默认采用清晰优先的 `degradationPreference = "maintain-resolution"`,并允许显式选择 balanced 或 fluid。折叠高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。 -每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。线上已是 clarity-first bounded controls,但用户报告的严重降级尚未完成受控分类,ADR-0007 双层发布与拓扑分类也尚未实现;不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者和 TURN 路径。不得只提高 ceiling、强制 codec 或修改 SDP。 +每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。线上已是 clarity-first bounded controls,但用户报告的严重降级尚未完成受控分类,ADR-0007 双层发布与拓扑分类也尚未实现;不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者的 direct/peer 与 SFU/UDP 路径;TURN 只有未来 selected-edge 实现后才单独验收。不得只提高 ceiling、强制 codec 或修改 SDP。 PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、`encoderImplementation` 和 `powerEfficientEncoder`,再依据目标机器测量决定是否调整 H.264、VP8、VP9 或 AV1 优先级。`getCapabilities()` 只证明可协商,Media Capabilities 的 supported/smooth/power-efficient 只提供候选证据;最终必须同时检查实际 outbound codec、区间 encode time、游戏 FPS、CPU/GPU encode 引擎和 sender 数量。AV1 压缩效率不能证明目标机器具有 WebRTC 硬件编码,强制后可能落入更重的软件路径。 @@ -246,8 +246,8 @@ reparent,再在 root budget 内准备 SFU,最终没有可行 route 时返回 `PEER_NOT_FOUND`,不长时间维持伪连接。HTTPS/WSS 始终使用 TLS/TCP,不 属于媒体 TCP;clarity/balanced/fluid 只改变编码策略。 -旧 production release `769de201f7cc` 仍需要 coturn TURN/UDP+TCP。它作为 -独立旧实例保留到新 candidate canary 通过;新代码不根据 room allowlist +旧 release `769de201f7cc` 需要 coturn TURN/UDP+TCP,目前仅作为 +独立回滚实例保留;current production 不根据 room allowlist 保留旧 release TURN 兼容分支。`PEER_ASSISTED_ROOM_IDS` 只灰度 topology/SFU route wire,进程内所有房间的 ordinary ICE 都是 STUN-only。因此验证与回滚必须 使用隔离 candidate/旧实例,不能假设同进程未命中房间仍有旧 transport。 @@ -366,7 +366,9 @@ fail-open。未命中房间不得进入 重连和删除保持普通 P2P 行为;ordinary STUN-only ICE snapshot 是进程级契约, 不为未命中房间保留旧 TURN wire。该边界不增加 UI、用户选择或百分比框架。 -产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。当前仓库已把普通 ICE 收敛为 STUN-only 并删除全房 TURN wire;default-off、failure-only 跨模式控制器仍需 exact-room 公网验收,生产旧 release 仍使用 coturn relay。未来可选 TURN 只能按需给异常 assignment/connection generation,由单独完整 PR 实现。ADR-0004/0005 的运行时验收尚未完成。 +产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。Production `d6c8aa0` 已把普通 ICE 收敛为 STUN-only、删除全房 TURN wire并只为 room `1` 开启 failure-only controller;exact-room 公网媒体仍未验收,旧 release/coturn 仅作回滚。未来可选 TURN 只能按需给异常 assignment/connection generation,由单独完整 PR 实现。ADR-0004/0005 的 broad-rollout 验收尚未完成。 + +恢复目标按证据和成本逐级升级:短暂 `disconnected` 先观察同代 bytes/stats,`failed` 才执行一次 ICE restart;仍失败时重建一次相同 parent,再尝试不同的合格 peer parent,peer 候选耗尽后才申请 SFU。每个动作最多一次且成功即停,不实现“三次相同重试”。初始 `connecting`/`checking` 的软提示和硬窗口尚未实现;硬窗口必须先用移动网络实测确定,不能直接复用现有 3 秒恢复常量。已实现的 SFU active 失败只刷新一次短期 grant,第二次失败回到新 peer baseline。Host 只保留当前 route revision 的 `connect | source | video-publish | sender-config | audio-publish | transport` 枚举阶段作为本地事件提示,不上传原始错误、URL、token、candidate 或地址。 实验只实现一个由服务端确定的广度优先拓扑:host 最多两个 child,每名 viewer 最多一个 child,因此形成两条按加入顺序平衡的 sticky 链。父节点只按深度和 `serverJoinSequence` 选择第一个空 slot。新 viewer 加入时不搬动已有节点;parent 离开时只把失去 parent 的 subtree root 重新分配到第一个空 slot,其 descendants 保持连接。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡。没有 parent 时明确等待或拒绝,不能创建分享端第三条边。 diff --git a/docs/需求理解.md b/docs/需求理解.md index d1e2a9c..7d27642 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -26,7 +26,7 @@ - [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,默认采用清晰优先;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,必须读回实际 sender 参数并显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。 - [ ] 路径隔离双表示:direct/peer 继续使用每条 PeerConnection 独立的 stock WebRTC GCC,不把任何 viewer 反馈合成全房目标。每条 SFU path 从 `HIGH` ceiling 开始;优先用 pinned LiveKit 2.22.0/1.13.5 的标准两层 screen-share simulcast,在 Dynacast off 下常驻 exactly `HIGH+LOW`,让内建 per-subscriber BWE 独立降层和恢复。若该 gate 通过,Screener 不实现媒体层 selector。首 gate 使用 zero-descendant SFU leaf;root-with-children 的自动 downshift 与有界迁移是 default-on 前独立硬门。只有内建选择不满足门槛时才用标准 subscriber quality 显式限制,常驻成本不合格时再测标准 sender 手动启停。`LOW` 停编只是资源优化;active representation/layer 硬上限为二,禁止按 viewer 增长。必须实测实际接收层、硬件编码证据、游戏 FPS/p1 low、CPU/GPU、区间 encode cost、上传和分层 bytes。当前 Web P2P simulcast 缺少 receiver 选 RID 和跨 PeerConnection shared-encode 契约;当前 Web/LiveKit SVC 也缺少 direct/peer 独立选层与可移植硬件保证,且 pinned screen share 强制三层 temporal-only `L1T3`,二者按现契约静态 no-go。全部标准路径失败后才考虑 custom/native dual encode。没有合格 hardware/power-efficient 路径或额外表示破坏游戏/上传预算时 fail closed:保护健康路径并明确显示弱路径降级或不可用。 - [ ] 路径级质量状态:应用的 `HIGH`/`FALLBACK` 非对称窗口只决定拓扑资格和诊断,不命令正常的媒体层切换,也不计算综合分数。SFU BWE downshift 先进入 `suspect`;多个连续窗口的 sender/viewer 关联证据确认 `FALLBACK` 后,应用不得稳定保留该 parent,并把 downstream capacity 降为 0。计划或显式质量 fallback 仅是内建路径失败后的候选:已有 children 时先在 connection/assignment generation guard 下全部迁走,失败或代次过期则保持原 assignment/quality。Autonomous downshift 无法 packet-level 预先迁移,default-on 前必须验收 descendants 的短暂影响。只有更长的稳定恢复窗口和 cooldown 后才能重新声明 relay capacity。Viewer 请求必须鉴权、限频、去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、协商 codec/profile/parameters 与 viewer 解码、丢帧、冻结证据交叉验证;不能仅凭自报生效,也不得上传 raw SDP、candidate 地址或原始设备/网络标识。 -- [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。 +- [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`failed` 边先做一次 ICE restart,再重建一次同 parent 的 `RTCPeerConnection`,随后尝试一个不同的合格 peer parent;只有 peer 路径耗尽才进入 SFU。`connecting`/`checking` 先显示不改变拓扑的慢连接状态,硬超时必须由真实移动网络证据确定,不能为了更快使用服务器而过早放弃最终可成功的 P2P。任一步恢复即停止后续动作;所有异步结果受 session、route revision、assignment/connection generation 约束。active SFU 只允许一次 fresh-token 重试,再失败即有界回到 peer baseline 或明确失败,并向 Host 显示不含 URL、地址、token 或原始错误的失败阶段。 - [ ] 可观测性:按目标 RTP transport 与不透明连接/ICE 代次关联 selected path、`iceGatheringState`/ICE/connection state、事件推导的建立时长、generation-baselined pair-change delta,以及 RTT、RTP 丢包率、抖动、码率、帧率、编码耗时和受限原因。候选与错误的 wire、日志和导出只使用[低服务器成本媒体路由](./research/low-server-media-routes.md)的脱敏字段和 code bucket,禁止 raw SDP、candidate、地址、URL、`errorText` 或网络标识。只有创建该 `RTCPeerConnection` 的本机可在折叠详情中显示浏览器对 selected local/remote candidate 实际暴露的地址与端口;该值不上传、不持久化、不用于地理/NAT 推断,缺失或被隐私保护时显示未知。 - [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。 - [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 严格控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。非空值必须为 16 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。建房 HTTP 只认该 cookie且不提供 Bearer 旁路;Host admission endpoint 由部署层/nginx 做 per-source `limit_req`,失败只返回统一结果,不在 Node 新增 IP 身份库/LRU/session 表或持久化来源地址。WebSocket upgrade 继续校验 Origin 与全局/未鉴权容量并记录 cookie 状态,首条 Host `authenticate` 必须同时通过该状态与房间 `hostToken`。Viewer 不经过此 gate,Host cookie 也不能绕过私密房间的 Viewer grant。不引入账号、JWT、服务端 session Map 或通用 logout。 @@ -44,7 +44,7 @@ - [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样最多维护两条下游媒体边,并应复用兼容编码输出而不再次编码。普通浏览器 audience 继续作为 leaf 或使用当前会逐跳重编码的实验路径,不伪称 shared encode。 - [ ] SVC 重开条件:当前标准 Web/LiveKit SVC 已静态拒绝为 `no-go-web-svc-cross-path-hardware-contract`。sender `scalabilityMode` 没有 direct/peer receiver 对应的按层选择或跨 PeerConnection shared-encode 契约;requested/applied mode、Media Capabilities `powerEfficient` 和 RTCStats encoder 字段也不能证明没有静默软件 fallback。Pinned client 2.22.0 还会把 screen-share SVC 覆盖为三层 temporal-only `L1T3`,不满足低分辨率 base 与最多两层。只有未来 native sender 能明确选择硬件编码器、最多发布两层、将同一编码输出复用于 direct/peer/SFU、保持每条路径独立选择并通过真实游戏和亚秒延迟门槛时,才可用新的 capability decision 重开 SVC;不能把浏览器 API 支持或 UA 推断当成采用理由。 - [ ] 分包/分层多树实验:把编码包、对象或可独立解码层拆到多个 peer 路径以分散上传,只能在独立 spike 中验证。必须保持亚秒级,处理多父重组、排序、丢包恢复、关键帧、同步和节点退出;未通过门槛前不进入产品路由控制器。 -- [ ] SFU-root 中央兜底:产品部署目标必须提供经 canary 验收的单节点 SFU 资源;系统在 direct/peer UDP 不可用或明确拓扑预算无法接纳时自动把它作为主要中央兜底,不要求用户选模式。它只向总计一至两个必要 roots 出口;root 优先承担受限 peer descendants,没有可靠 relay root 时必要 viewer 只作为同一总上限内的零 descendants root。已关闭 PR #12 的显式整房 SFU 模式已被取代。当前 PR #17 实现仍是 default-off、failure-only 且生产未配置;按 ADR-0005 的 exact-room canary 验收并修改配置契约后,才能启用新的默认梯级。 +- [ ] SFU-root 中央兜底:产品部署目标必须提供经 canary 验收的单节点 SFU 资源;系统在 direct/peer UDP 不可用或明确拓扑预算无法接纳时自动把它作为主要中央兜底,不要求用户选模式。它只向总计一至两个必要 roots 出口;root 优先承担受限 peer descendants,没有可靠 relay root 时必要 viewer 只作为同一总上限内的零 descendants root。已关闭 PR #12 的显式整房 SFU 模式已被取代。Production room `1` 已开启 failure-only exact-room smoke,但尚无 retained SFU media;通过 ADR-0005 的公网媒体、资源和恢复门禁后才能扩大到默认梯级。 - [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。 - [ ] Windows 应用音频:Windows 11 原生候选默认仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享;“全部系统声音”只能由分享者显式选择。普通 Windows 10 不具备当前 process-loopback API 门槛,必须明确标为未解决/不支持而不是伪兼容。浏览器无法证明或隔离任意进程音频时必须如实提示,原生捕获失败、无声或进程退出不得静默扩大为整机捕获。 - [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。当前没有 selected-edge TURN 或媒体 TCP 配置面;未来兼容层只有在完整实现后才加入自检。 @@ -79,7 +79,7 @@ - 当前八名观看者和 1 至 16 配置范围是接入边界;目标默认 20 也只是通过指定矩阵后的接入边界,不承诺任意机器、档位、网络或跨洲路径都稳定承载。 - 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。 - 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。 -- 已部署首版仍是标准 WebRTC P2P。Peer-assisted spike 只复用标准 remote-track WebRTC,不加入 Encoded Transform/DataChannel/WebCodecs 媒体协议,也不自研 UDP、编解码器、拥塞控制、FEC/RTX、多树、转码、codec ladder 或综合评分器;需要其中任一项即放弃本次浏览器 relay 实验。独立计划中的 native shared encode sender 不在本 spike 实现,也不能挽救其他拓扑门槛失败。跨 P2P、peer-assisted 与 SFU-root 的自动路由是产品要求,但由单独 ADR 约束;当前 spike 尚未实现跨模式迁移或短时双发。 +- 当前 production 的未命中房间仍是标准 WebRTC P2P,room `1` 已部署 peer-assisted/SFU 跨模式 controller。Peer relay 只复用标准 remote-track WebRTC,不加入 Encoded Transform/DataChannel/WebCodecs 媒体协议,也不自研 UDP、编解码器、拥塞控制、FEC/RTX、多树、转码、codec ladder 或综合评分器。独立 native shared encode sender 不在本实验内,也不能挽救拓扑门槛失败;跨模式迁移继续由 ADR-0005 的 generation 与 edge budget 约束,不使用短时双发。 - GPL/AGPL 项目在许可证策略确认前仅用于研究,不能直接复制到计划闭源的产品中。 ## 验收标准 diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts index 04f917b..f4f4109 100644 --- a/src/client/media/host-sfu-route.ts +++ b/src/client/media/host-sfu-route.ts @@ -4,7 +4,11 @@ import type { ParticipantRouteAssignment, ServerMessage, } from "../../shared/protocol"; -import { SfuPublisher, type SfuConnectionConfig } from "../sfu/publisher"; +import { + SfuPublisher, + type SfuConnectionConfig, + type SfuPublisherFailureStage, +} from "../sfu/publisher"; import type { QualityProfile, VideoSenderParameterReadback, @@ -23,6 +27,7 @@ interface HostPublisherTransport { replaceStream(stream: MediaStream): Promise; updateProfile(profile: QualityProfile): Promise; getQualityWarning?(): string | null; + getFailureStage?(): SfuPublisherFailureStage | null; getSenderParameters?(): VideoSenderParameterReadback | null; disconnect(): Promise; } @@ -50,6 +55,7 @@ export class HostSfuRoute { private pending: HostPublisherSlot | null = null; private active: HostPublisherSlot | null = null; private recovery: { revision: number; refreshed: boolean } | null = null; + private lastFailureStage: SfuPublisherFailureStage | null = null; private transitionTail: Promise = Promise.resolve(); private resyncGeneration = 0; private resyncing = false; @@ -68,6 +74,7 @@ export class HostSfuRoute { } if (previousRevision !== update.revision) { this.recovery = null; + this.lastFailureStage = null; } if (update.phase === "prepare") { if ( @@ -103,6 +110,7 @@ export class HostSfuRoute { async resyncAuthoritative( update: RouteUpdateInput, ): Promise { + this.lastFailureStage = null; const result = this.accept(update, false); if (result !== "stale" || this.closed) { await this.transitionTail; @@ -233,8 +241,11 @@ export class HostSfuRoute { } getQualityWarning(): string | null { - return this.active?.active - ? (this.active.publisher.getQualityWarning?.() ?? null) + if (this.active?.active) { + return this.active.publisher.getQualityWarning?.() ?? null; + } + return this.lastFailureStage + ? sfuFailureWarning(this.lastFailureStage) : null; } @@ -452,6 +463,7 @@ export class HostSfuRoute { if (!this.route.markMediaActive(token)) { return; } + this.lastFailureStage = null; this.recovery = null; if (acknowledge) { this.ready(token.revision, "active"); @@ -484,6 +496,7 @@ export class HostSfuRoute { return; } const wasActive = this.active === slot; + this.lastFailureStage = slot.publisher.getFailureStage?.() ?? "transport"; slot.failed = true; if (this.pending === slot) { this.pending = null; @@ -545,3 +558,15 @@ async function disconnectPublisher( ): Promise { await publisher.disconnect().catch(() => undefined); } + +function sfuFailureWarning(stage: SfuPublisherFailureStage): string { + const label: Record = { + connect: "连接", + source: "分享源", + "video-publish": "视频发布", + "sender-config": "视频参数配置", + "audio-publish": "音频发布", + transport: "传输", + }; + return `SFU ${label[stage]}失败,已启动自动恢复`; +} diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts index 7f79b1c..1520ad8 100644 --- a/src/client/sfu/publisher.ts +++ b/src/client/sfu/publisher.ts @@ -24,6 +24,14 @@ interface PublisherEvents { onDisconnected?: () => void; } +export type SfuPublisherFailureStage = + | "connect" + | "source" + | "video-publish" + | "sender-config" + | "audio-publish" + | "transport"; + interface PublishedTrack { publication: LocalTrackPublication; rawTrack: MediaStreamTrack; @@ -52,6 +60,7 @@ export class SfuPublisher { private profile: QualityProfile | null = null; private senderParameters: VideoSenderParameterReadback | null = null; private qualityWarning: string | null = null; + private failureStage: SfuPublisherFailureStage | null = null; private state: PublisherState = "idle"; private generation = 0; private operationTail: Promise = Promise.resolve(); @@ -65,6 +74,7 @@ export class SfuPublisher { } const generation = ++this.generation; + this.failureStage = null; this.state = "connecting"; try { @@ -79,6 +89,7 @@ export class SfuPublisher { room.on(sdk.RoomEvent.Disconnected, () => { if (this.owns(room, generation)) { const notify = this.state !== "connecting"; + this.failureStage = "transport"; this.invalidate(); if (notify) { this.notifyTerminalDisconnect(); @@ -98,6 +109,7 @@ export class SfuPublisher { if (!this.ownsGeneration(generation)) { return false; } + this.failureStage = "connect"; const room = this.invalidate(); if (room) { await safeDisconnect(room); @@ -113,15 +125,17 @@ export class SfuPublisher { if (!room) { return false; } - const sdk = this.sdk; - if (!sdk) { - throw new Error("SFU publisher SDK is unavailable"); - } - - const videoTrack = requiredVideoTrack(stream); - const audioTrack = stream.getAudioTracks()[0] ?? null; - + let failureStage: SfuPublisherFailureStage = "source"; try { + const sdk = this.sdk; + if (!sdk) { + throw new Error("SFU publisher SDK is unavailable"); + } + + const videoTrack = requiredVideoTrack(stream); + const audioTrack = stream.getAudioTracks()[0] ?? null; + + failureStage = "video-publish"; const video = await publishTrack( room, videoTrack, @@ -131,6 +145,7 @@ export class SfuPublisher { if (!this.owns(room, generation)) { return false; } + failureStage = "sender-config"; const videoConfiguration = await configurePublishedVideo( video, profile, @@ -142,6 +157,7 @@ export class SfuPublisher { let audio: PublishedTrack | null = null; if (audioTrack) { + failureStage = "audio-publish"; audio = await publishTrack( room, audioTrack, @@ -157,10 +173,12 @@ export class SfuPublisher { this.audio = audio; this.profile = profile; this.retainSenderParameters(videoConfiguration); + this.failureStage = null; this.state = "active"; return true; } catch (error) { if (this.owns(room, generation)) { + this.failureStage = failureStage; await this.failClosed(room, generation); } throw error; @@ -396,6 +414,10 @@ export class SfuPublisher { return this.qualityWarning; } + getFailureStage(): SfuPublisherFailureStage | null { + return this.failureStage; + } + getSenderParameters(): VideoSenderParameterReadback | null { return this.senderParameters; } diff --git a/tests/route-transition.test.ts b/tests/route-transition.test.ts index 1c56596..575780d 100644 --- a/tests/route-transition.test.ts +++ b/tests/route-transition.test.ts @@ -245,6 +245,71 @@ describe("HostSfuRoute", () => { ]); }); + it("surfaces a bounded publisher stage after active fallback fails", async () => { + const publisher = { + ...createFakePublisher([], "publisher"), + getFailureStage: vi.fn(() => "video-publish" as const), + }; + publisher.activate.mockResolvedValue(false); + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: () => undefined, + send: () => true, + createPublisher: () => publisher, + }); + const assignment = hostAssignment("generation-a"); + + route.accept({ revision: 1, phase: "prepare", assignment }); + await route.acceptConfig(sfuConfig(1)); + await route.acceptAndWait({ revision: 1, phase: "active", assignment }); + + expect(route.getQualityWarning()).toBe( + "SFU 视频发布失败,已启动自动恢复", + ); + + route.accept({ + revision: 2, + phase: "active", + assignment: hostAssignment(null), + }); + expect(route.getQualityWarning()).toBeNull(); + }); + + it("clears a prior failure when authoritative state replaces the revision", async () => { + const publisher = { + ...createFakePublisher([], "publisher"), + getFailureStage: vi.fn(() => "transport" as const), + }; + publisher.activate.mockResolvedValue(false); + const route = new HostSfuRoute({ + getStream: () => ({}) as MediaStream, + getProfile: () => QUALITY_PROFILES["720p30"], + reconcileChildren: () => undefined, + send: () => true, + createPublisher: () => publisher, + }); + const failedAssignment = hostAssignment("generation-a"); + + route.accept({ revision: 1, phase: "prepare", assignment: failedAssignment }); + await route.acceptConfig(sfuConfig(1)); + await route.acceptAndWait({ + revision: 1, + phase: "active", + assignment: failedAssignment, + }); + expect(route.getQualityWarning()).toBe( + "SFU 传输失败,已启动自动恢复", + ); + + await route.resyncAuthoritative({ + revision: 1, + phase: "active", + assignment: hostAssignment(null), + }); + expect(route.getQualityWarning()).toBeNull(); + }); + it("never overlaps more than two host media edges while changing route kinds", async () => { const log: string[] = []; const edgeCounts: number[] = []; diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index 02344d1..7239046 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -314,6 +314,16 @@ describe("SfuPublisher", () => { expect(room.disconnect).toHaveBeenCalledWith(false); }); + it("retains the bounded stage after connection setup fails", async () => { + livekit.state.connectGate = Promise.reject(new Error("connect failed")); + const publisher = new SfuPublisher(); + + await expect(publisher.connect(connection)).rejects.toThrow("connect failed"); + + expect(publisher.getFailureStage()).toBe("connect"); + expect(livekit.state.rooms[0]?.disconnect).toHaveBeenCalledWith(false); + }); + it("publishes one screen video and optional audio only while active", async () => { const publisher = new SfuPublisher(); const video = track("video", "video-1"); @@ -401,6 +411,19 @@ describe("SfuPublisher", () => { expect(livekit.state.rooms[0].disconnect).toHaveBeenCalledWith(false); expect(disconnected).toHaveBeenCalledOnce(); expect(publisher.getSenderParameters()).toBeNull(); + expect(publisher.getFailureStage()).toBe("sender-config"); + }); + + it("records an unexpected active transport disconnect", async () => { + const disconnected = vi.fn(); + const publisher = new SfuPublisher({ onDisconnected: disconnected }); + await publisher.connect(connection); + await publisher.activate(stream(track("video", "video-1")), qualityProfile); + + livekit.state.rooms[0].emit(RoomEvent.Disconnected); + + expect(publisher.getFailureStage()).toBe("transport"); + expect(disconnected).toHaveBeenCalledOnce(); }); it("updates the active sender profile without republishing", async () => {