chore: merge main into feat/audio-quality-controls
# Conflicts: # docs/project-memory.md # docs/status.md
This commit is contained in:
commit
d2afabcf26
20 files changed
+549
-82
No files matched your search
+2
-2
@@ -8,7 +8,7 @@
|
||||
- [Maintenance guide](./maintenance.md): Git workflow, context hygiene, document lifecycle, research policy, and automation.
|
||||
- [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment.
|
||||
- [Peer-assisted media research](./research/peer-assisted-media.md): browser shared-encode limits, standard relay re-encoding, deterministic sticky topology, runtime relay-capacity extension, and abandon gates.
|
||||
- [Low-server-cost media routes](./research/low-server-media-routes.md): traffic accounting, route screening, conditional native encoded-RTP relay, and automatic use of enabled SFU capacity as the final fallback.
|
||||
- [Low-server-cost media routes](./research/low-server-media-routes.md): traffic accounting, route screening, token-free SFU standby prewarm, conditional native encoded-RTP relay, and automatic final fallback.
|
||||
- [Advanced peer distribution](./research/advanced-peer-distribution.md): multi-tree/SVC, encoded-object relay, native RTP forwarding, FEC/network-coding, MoQ, and measurable go/no-go gates.
|
||||
- [Native shared-encode sender](./research/native-shared-encode-sender.md): bounded libwebrtc encoder-proxy risk spike, Draft PR #16's transport-only Pion fanout oracle, and hard failure gates.
|
||||
- [Realtime quality adaptation](./research/realtime-quality-adaptation.md): browser degradation policy, live profile changes, offline-encoding boundaries, and measurement gates.
|
||||
@@ -17,6 +17,6 @@
|
||||
- [ADR-0001](./adr/0001-p2p-first-media-topology.md): accepted P2P-first media topology and its consequences.
|
||||
- [ADR-0002](./adr/0002-persistent-protected-rooms.md): optional SQLite persistence, protected sequential room IDs, and room lifecycle.
|
||||
- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): proposed, experiment-only standard-WebRTC peer-assisted spike with hard fanout and failure gates.
|
||||
- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed, default-off automatic P2P/peer/SFU preparation, commit, failback, and rollback with a hard host-edge budget.
|
||||
- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed, default-off automatic P2P/peer/SFU preparation, token-free standby prewarm, commit, failback, and rollback with a hard host-edge budget.
|
||||
|
||||
Documentation and project memory are part of the product source of truth. Update them in the same change that alters the corresponding requirement or architecture.
|
||||
@@ -54,8 +54,9 @@ at peer assistance and bounded waiting/failure.
|
||||
## Draft Implementation Status
|
||||
|
||||
Draft PR #17 (`feat/automatic-hybrid-routing`) implements this controller on top
|
||||
of Draft PR #13. It passes CI but is not merged or deployed. Production remains
|
||||
on the ordinary one-host-peer-per-viewer path.
|
||||
of Draft PR #13. Draft PR #20 (`spike/hybrid-warm-fallback`) adds only the
|
||||
bounded standby prewarm described below. Both pass CI but are not merged or
|
||||
deployed. Production remains on the ordinary one-host-peer-per-viewer path.
|
||||
|
||||
The Draft keeps the LiveKit dependency dormant unless the complete URL, API key,
|
||||
and API secret tuple is present together with `PEER_ASSISTED_MEDIA=true`. It
|
||||
@@ -73,14 +74,13 @@ does not proactively migrate an otherwise healthy existing edge.
|
||||
|
||||
Targeted tests cover the revision controller, protocol authorization, relay
|
||||
capacity, ordered client transitions, stale asynchronous work, server-restart
|
||||
resynchronization, one-shot credential recovery, and peer failback. A corrected
|
||||
Chrome 151/LiveKit 1.13.5 localhost run physically failed the same leaf through
|
||||
peer recovery/reparent and a two-root SFU route. That leaf resumed frames and
|
||||
hook-assisted 25 ms sampling saw a host edge peak of two. Failure report to
|
||||
active took 1.481 seconds and to new render 2.257 seconds, so the sub-second gate
|
||||
failed. The final repository check passes 253 tests, type checking, and both
|
||||
builds; public transport/audio/load/browser checks remain pending, so this ADR
|
||||
stays Proposed.
|
||||
resynchronization, one-shot credential recovery, peer failback, and optional
|
||||
standby warming. The corrected cold Chrome 151/LiveKit 1.13.5 localhost run took
|
||||
1.481 seconds from failure report to active and 2.257 seconds to a new rendered
|
||||
frame. With authenticated standby warming, the same physical-leaf/two-root
|
||||
scenario took 200 ms to active and 319.7 ms to render while the host edge peak
|
||||
remained two. Public transport/audio/load/browser checks remain pending, so this
|
||||
ADR stays Proposed.
|
||||
|
||||
## State And Wire
|
||||
|
||||
@@ -115,6 +115,8 @@ publisher from being counted as one logical edge during commit or rollback.
|
||||
|
||||
Minimal protocol additions:
|
||||
|
||||
- optional `sfuStandbyUrl` in a peer-assisted authenticated snapshot, only when
|
||||
the server has complete LiveKit fallback configuration;
|
||||
- `relay-capacity { downstreamEdges: 0 | 1 }` from an authenticated
|
||||
peer-assisted viewer;
|
||||
- `route-update { revision, phase: "prepare" | "active", assignment }`;
|
||||
@@ -123,9 +125,10 @@ Minimal protocol additions:
|
||||
- `route-failed { revision, phase, connectionId }`; and
|
||||
- `refresh-sfu { revision }`.
|
||||
|
||||
Authentication carries only the current participant assignment and room
|
||||
revision. An authenticated snapshot is the sole authority allowed to replace a
|
||||
higher client revision after the signaling server restarts; the client first
|
||||
Authentication carries the current participant assignment and room revision,
|
||||
plus the non-secret standby URL when fallback is configured. It never carries a
|
||||
standby JWT. An authenticated snapshot is the sole authority allowed to replace
|
||||
a higher client revision after the signaling server restarts; the client first
|
||||
retires media owned by the old revision. Existing peer signaling keeps its
|
||||
connection generation and assigned-edge authorization. The server
|
||||
derives the failed edge from the authenticated session, active revision, and
|
||||
@@ -139,10 +142,13 @@ Late, duplicate, or stale revision messages have no effect.
|
||||
direct/TURN options.
|
||||
3. The server computes revision `R+1` without mutating the active topology. It
|
||||
sends a prepare plan only to the host and required fallback roots.
|
||||
4. Those participants establish actual LiveKit connections with
|
||||
4. When fallback is configured, host and viewer clients have already made one
|
||||
best-effort, token-free `prepareConnection(url)` call after their authoritative
|
||||
authenticated snapshot. This only warms the SDK/DNS/TLS path and is not a
|
||||
participant, media edge, or ready signal. Required participants then establish
|
||||
actual LiveKit connections with
|
||||
`autoSubscribe:false`, but the host does not publish and roots do not
|
||||
subscribe yet. `prepareConnection()` may warm DNS/TLS earlier but is not a
|
||||
ready signal.
|
||||
subscribe yet.
|
||||
5. After every required participant acknowledges prepare, the server commits
|
||||
the active revision. The host synchronously closes the selected direct root
|
||||
before publishing to the SFU. Thus:
|
||||
@@ -170,12 +176,43 @@ starts from the cheapest available route. The first experiment does not
|
||||
continuously fail back during a live share, avoiding oscillation without adding
|
||||
a score or hysteresis framework.
|
||||
|
||||
The Draft warms only an unpublishing/unsubscribed transport during prepare. Its
|
||||
media transition is break-before-make: the host releases the replaced peer or
|
||||
SFU media edge before activating the new one, and a viewer retires its SFU
|
||||
subscriber before returning to peer media. This avoids depending on transient
|
||||
overlap and preserves the hard two-edge host invariant, at the cost of a bounded
|
||||
interruption that still needs measurement.
|
||||
The authenticated standby is not a transport: it has no grant and never joins a
|
||||
room. During route prepare the Draft warms only an unpublishing/unsubscribed
|
||||
transport. Its media transition remains break-before-make: the host releases the
|
||||
replaced peer or SFU media edge before activating the new one, and a viewer
|
||||
retires its SFU subscriber before returning to peer media.
|
||||
|
||||
## Standby Prewarm Result
|
||||
|
||||
Pinned `livekit-client` 2.22.0 implements token-free self-hosted
|
||||
`Room.prepareConnection(url)` as a best-effort HTTP `HEAD` to the corresponding
|
||||
HTTP(S) origin. The SDK catches failure internally. It performs no `connect`, so
|
||||
it neither creates a LiveKit participant nor publishes/subscribes media. The
|
||||
client dynamically imports the SDK once, attempts each advertised URL once, and
|
||||
drops work made stale before import by a newer authenticated snapshot. Failure
|
||||
silently preserves the existing cold path; there is no timer or retry loop.
|
||||
|
||||
One bounded cold/standby A/B comparison used Chrome 151, LiveKit 1.13.5,
|
||||
headless synthetic 1280x720/30 video, three viewers, and localhost. The harness
|
||||
physically closed the same leaf's inbound peer edge, observed it resume under a
|
||||
second peer, then physically closed that new edge and reported its real
|
||||
connection ID and active revision. The resulting plan had two allowlisted SFU
|
||||
roots.
|
||||
|
||||
| Failure report to | Cold Draft PR #17 | Authenticated standby |
|
||||
| --- | ---: | ---: |
|
||||
| SFU prepare | not retained | 5 ms |
|
||||
| SFU active | 1,481 ms | 200 ms |
|
||||
| Same-leaf rendered frame | 2,257 ms | 319.7 ms |
|
||||
|
||||
The standby path had already downloaded and parsed the dynamically imported SDK
|
||||
and had called its token-free `HEAD`, so this A/B does not isolate SDK startup
|
||||
from DNS/TLS/HTTP preparation. It created no participant or media edge. The same
|
||||
leaf decoded 31 SFU frames and produced 31 new frame callbacks before the
|
||||
after-run completed. Both roots and the host SFU transport were connected; 25 ms
|
||||
sampling observed a host media-edge peak of two. This is a local go for retaining
|
||||
the optimization; the roughly 86% improvement must not be extrapolated to a
|
||||
public network.
|
||||
|
||||
## Triggers And Budgets
|
||||
|
||||
@@ -201,7 +238,8 @@ transport event. This signal must be measured before its interval is fixed.
|
||||
|
||||
## Security And Privacy
|
||||
|
||||
LiveKit credentials remain short-lived, room-bound, role-bound, and memory-only.
|
||||
The standby URL is an origin, not a credential. LiveKit credentials remain
|
||||
short-lived, room-bound, role-bound, and memory-only.
|
||||
Only the host may publish screen tracks. Subscription permissions allow only
|
||||
the current fallback-root identities. Ordinary SFU media is not end-to-end
|
||||
encrypted from the SFU operator; the deployment and UI must not claim otherwise.
|
||||
@@ -252,6 +290,10 @@ Negative:
|
||||
- Strict two-edge migration can include a bounded interruption.
|
||||
- LiveKit adds an optional operational dependency and can inspect ordinary SFU
|
||||
media.
|
||||
- A configured peer-assisted client downloads/parses the current build's roughly
|
||||
137.5 kB gzip (531 kB minified) LiveKit chunk and sends one `HEAD` even if it
|
||||
never needs SFU, shifting that small one-time client/static-egress cost earlier.
|
||||
An unconfigured client pays neither cost.
|
||||
- A media liveness signal and versioned cross-client transition expand the
|
||||
state space and require real failure-injection tests.
|
||||
|
||||
@@ -270,5 +312,6 @@ Negative:
|
||||
|
||||
- [LiveKit selective subscription](https://docs.livekit.io/transport/media/subscribe/#selective-subscription)
|
||||
- [LiveKit track subscription permissions](https://docs.livekit.io/transport/media/publish/#track-permissions)
|
||||
- [LiveKit connection preparation](https://docs.livekit.io/reference/client-sdk-js/functions/Room.prepareConnection.html)
|
||||
- [LiveKit client 2.22.0 `prepareConnection` source](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/Room.ts)
|
||||
- [LiveKit JavaScript client usage](https://github.com/livekit/client-sdk-js#usage)
|
||||
- [WebRTC](https://w3c.github.io/webrtc-pc/)
|
||||
+16
-17
@@ -6,46 +6,45 @@ Last updated: 2026-08-19
|
||||
|
||||
- Build Discord/KOOK/Oopz/TeamSpeak-like low-latency game screen sharing for one broadcaster and a small trusted friend group. Public or large broadcasts belong on OBS/Twitch-class services.
|
||||
- Viewers should join from a normal desktop or mobile browser. A packaged/native sender is a planned later stage for shared encoding and may also improve capture and audio without changing the Web viewer requirement.
|
||||
- Use a Photon-style central rendezvous service for access, rooms, signaling, deterministic topology, STUN, TURN fallback, and observability while clients carry media whenever practical.
|
||||
- Use a small central service for access, rooms, signaling, deterministic topology, STUN/TURN, and observability while clients carry media whenever practical.
|
||||
- Server bandwidth cost is a primary constraint. The ordered media ladder is direct P2P, then measured peer assistance, then an enabled user-operated or central SFU fallback. Route selection and failure recovery must be automatic and viewer-transparent, while remaining deterministic, budget-driven, and small enough to reason about. This has always been the product intent; earlier text prohibiting silent migration was an incorrect interpretation and is superseded.
|
||||
- Host and relay-capable endpoints each have a target budget of at most two downstream media edges. Every P2P edge independently uses direct ICE or authenticated TURN fallback. With two edges, the long-term native/encoded-object path should reuse one compatible encoded output; the current browser experiment is stricter at one viewer child and re-encodes.
|
||||
- Packet or layer striping across two peer trees is a recorded candidate for reducing endpoint upload toward one full-stream bitrate. It requires a separate bounded experiment for multi-parent assembly, loss recovery, synchronization, churn, and sub-second latency; it is not part of the current full-stream two-chain implementation.
|
||||
- Browser convenience does not override reachability or honest resource accounting. Shared encoding reduces compute, not each viewer's last-hop copy.
|
||||
- Shared encoding reduces compute, not each viewer's last-hop copy.
|
||||
- Durable decisions, current snapshots, research, code, `AGENTS.md`, and `.codex/` belong in Git. Memory and status are rewritten in place rather than kept as transcripts.
|
||||
- Research current official sources and established implementations before material work. Apply Occam's razor and reject speculative protocols, services, scoring, and scale.
|
||||
|
||||
## Current Recommendation
|
||||
|
||||
- Keep the deployed Windows Chrome/Edge broadcaster and responsive Web viewer as the measurement baseline. Validate current Android Chrome and iOS Safari as leaves.
|
||||
- Keep desktop Chrome/Edge and the responsive Web viewer as the baseline. Validate Android Chrome and iOS Safari as leaves.
|
||||
- Use direct host P2P for one or two viewers. `main` carries ADR-0004's default-off experiment for later viewers: two sticky deterministic chains, host capacity two, viewer capacity one, no proactive rebalance or composite score, and no more than eight viewers when `PEER_ASSISTED_MEDIA=true`.
|
||||
- Standard browser relays resend remote `MediaStreamTrack` values and therefore decode and re-encode at every hop. Browser WebRTC does not guarantee one shared encoder across peer connections; measure this cost rather than hiding it.
|
||||
- Keep the full-stream browser experiment bounded. If it needs a different media plane, mark that experiment failed and evaluate native RTP relay, encoded-object striping, SVC, or FEC in separate measured spikes rather than hiding the change inside ADR-0004.
|
||||
- Keep the browser experiment bounded. Evaluate native RTP relay, encoded-object striping, SVC, or FEC only in separate measured spikes.
|
||||
- Plan a separate native-sender ADR for TeamSpeak-style shared encoding regardless of the browser relay result. One compatible encoded output may feed at most two standard WebRTC packetizers to reduce host encode work, but each edge still consumes upload bandwidth. This work is not part of the current Draft and cannot rescue another failed browser-relay gate.
|
||||
- Keep user-operated or central single-node SFU capacity optional. When configured, `main`'s #17 route controller may select it only as the final fallback; its fanout egress belongs to its operator and does not justify Redis or multi-node infrastructure. Closed PR #12's explicit whole-room SFU mode is superseded, not a current option.
|
||||
- Keep `main`'s automatic routing default-off behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make and never exceeds two active edges, and a binary per-session relay capability keeps detected mobile/iPad clients as leaves. Corrected local recovery works but misses the sub-second gate.
|
||||
- Keep automatic routing default-off behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make under two active edges, and a binary relay capability keeps mobile/iPad clients as leaves. Merged PR #20's token-free prewarm passes the local sub-second gate; public transport and load gates remain.
|
||||
- Prefer direct UDP, then TURN/UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses TCP 5349 by default; TCP 443 needs a dedicated address or validated L4/SNI routing.
|
||||
- Treat video settings as ceilings. Draft #21 adds bounded clarity-first controls/readback; production stays `balanced` and unclassified. Keep browser audio request/presence-only: capture channels/rate are not portable controls, `maxBitrate` cannot raise quality, and Opus stereo/DTX/FEC lack portable setters. Run 1/2/3-viewer plus TURN before further controls/automation.
|
||||
- Treat video settings as ceilings. Production now defaults clarity-first with bounded manual controls and sender readback, but reported degradation remains unclassified. Keep browser audio request/presence-only: capture channels/rate are not portable controls, `maxBitrate` cannot raise quality, and Opus stereo/DTX/FEC lack portable setters. Run the 1/2/3-viewer plus TURN matrix before changing ceilings or adding an automatic controller.
|
||||
- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements prove a material gap. Keep browser codec order until target hardware proves a more efficient common codec.
|
||||
- Keep room policy deployment-driven. Public and password-only deployments use random temporary rooms. A site password plus SQLite path enables sequential persistent rooms; stopping sharing leaves the room and viewer link available.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
|
||||
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
|
||||
- Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
|
||||
- `main` includes PR #19's equal idle-stage share/join actions and default-closed technical details. Draft PR #21 keeps advanced quality in the common control area and actionable warnings visible. Neither change is deployed.
|
||||
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn.
|
||||
- Production commit `769de201f7cc` runs at `https://share.bonfire.icu`. It adds equal share/join entry actions, default-closed details, clarity-first bounded quality settings, live source/quality changes, video pause, and visible 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.
|
||||
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
|
||||
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
|
||||
- With `ROOM_DATABASE_PATH` and the site password, SQLite stores only room ID and host-token digest. Links persist and stopping leaves viewers waiting. Room `1` survived the `769de201f7cc` deployment; without the path, rooms are random and temporary.
|
||||
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
|
||||
- `main` contains deterministic peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is not deployed. Native shared encoding and custom encoded browser transport are not implemented.
|
||||
- `main` also contains ADR-0005's default-off route controller: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back for that share. LiveKit is dormant when unconfigured.
|
||||
- Corrected Chrome 151/LiveKit 1.13.5 smoke physically failed one leaf through peer recovery/reparent and two-root SFU. Frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure-to-render took 2.257 seconds. It is localhost, headless, synthetic 720p30 video only.
|
||||
- The deployed binary contains the default-off peer relay and ADR-0005 controller, but production has neither `PEER_ASSISTED_MEDIA` nor a LiveKit tuple. It therefore exposes no standby URL, imports no SFU SDK at runtime, and uses only the existing P2P/TURN path.
|
||||
- When enabled later, peer recovery precedes an allowlisted SFU root; session-bound revisions prepare/commit/abort, and an active SFU gets one token refresh before share-local failback. Browser relays re-encode; native shared encoding remains unimplemented.
|
||||
- Corrected Chrome 151/LiveKit 1.13.5 same-leaf/two-root cold/standby A/B reduced failure-to-active from 1.481 seconds to 200 ms and failure-to-render from 2.257 seconds to 319.7 ms. The leaf decoded/rendered 31 new frames and 25 ms sampling kept host edge peak two. It is localhost, headless, synthetic 720p30 video only; the roughly 86% result combines SDK download/parse and network prewarm and must not be extrapolated to public networks.
|
||||
- Draft PR #16 (`5b09f0a`) proves one Pion RTP write fans equivalent data to two transports with independent SSRCs. It has no encoder and proves neither physical shared encode nor browser readiness.
|
||||
- Chrome 151 short synthetic `1/3/5/8` runs kept host edges at two, relay edges at one, and all viewers decoding; a three-viewer relay close recovered in 5.32 seconds. This proves topology/control only, not quality, load, endurance, or silent partitions.
|
||||
- The current video quality stack uses one strict, memory-only `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Initial, replaced, and updated senders share serialized configure/readback; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action.
|
||||
- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Initial, replaced, and updated senders share serialized configure/readback; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action. Successful replacement rollback retains the original failure warning.
|
||||
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one. This is control evidence, not a quality, load, public-network, or endurance claim.
|
||||
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
|
||||
- The review revision passes repository hygiene, type checking, 20 Vitest files/264 tests, both builds, and the Chrome control smoke. Production separately passed 108 tests and deployment checks.
|
||||
- Release `769de201f7cc` passed main CI, repository hygiene, type checking, 22 Vitest files/274 tests, both builds, zero production dependency vulnerabilities, atomic activation, public health/access checks, exact bundle verification, and service/log checks.
|
||||
|
||||
## Provisional Quality Targets
|
||||
|
||||
@@ -60,7 +59,7 @@ These are measurement gates, not performance claims.
|
||||
|
||||
- Sustainable viewer count by broadcaster and relay hardware, quality profile, network class, and media route; use instrumented 1/3/5/8 comparisons rather than a 1:8 claim.
|
||||
- Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates.
|
||||
- Whether ADR-0005 can reduce or accept its corrected 2.257-second local interruption and pass public LiveKit UDP/ICE-TCP/TURN, rollback, reconnect, restart, egress, and browser-matrix gates.
|
||||
- Whether ADR-0005 standby gains persist across public LiveKit UDP/ICE-TCP/TURN, rollback, reconnect, restart, egress, load, and browser-matrix gates.
|
||||
- Exact mobile lifecycle behavior and whether Windows per-application audio is required for the first release.
|
||||
- Project license and distribution model, which determines whether GPL/AGPL sources can move beyond study-only use.
|
||||
- Initial deployment regions and expected mainland China, Hong Kong, and overseas network mix.
|
||||
|
||||
@@ -31,8 +31,38 @@ The smallest current plan is:
|
||||
Any browser-spike failure other than isolated relay re-encoding closes that
|
||||
browser-relay route. It does not cancel the separate native sender plan. Closed
|
||||
PR #12's explicit whole-room SFU mode is superseded. ADR-0005 and merged PR #17
|
||||
own the default-off automatic cross-mode controller; it remains undeployed and
|
||||
has not passed real LiveKit/browser network validation.
|
||||
own the default-off automatic cross-mode controller; it remains undeployed.
|
||||
Corrected localhost Chrome/LiveKit functional recovery passed, while public
|
||||
transport, quality, load, and browser validation remain open.
|
||||
|
||||
## Token-Free SFU Standby Prewarm
|
||||
|
||||
The pinned Apache-2.0 `livekit-client` 2.22.0 source documents and implements
|
||||
`Room.prepareConnection(url)` as DNS/TLS preparation. For a self-hosted URL and
|
||||
no token, it converts `ws(s)` to `http(s)`, sends one `HEAD`, and catches errors.
|
||||
It does not call `connect`; therefore it does not join a room, acquire a
|
||||
participant identity, publish, subscribe, or create a media edge. A token is
|
||||
only relevant to LiveKit Cloud region selection, which this self-hosted spike
|
||||
does not use.
|
||||
|
||||
The smallest integration is to advertise the non-secret LiveKit origin only in
|
||||
the authoritative peer-assisted authentication snapshot when the complete
|
||||
fallback tuple exists. Each client dynamically imports the already-pinned SDK
|
||||
once and attempts the URL once. Newer authentication invalidates a stale task
|
||||
before the import completes; a failed HEAD is silent and is not retried. No JWT
|
||||
is issued until the existing route controller actually selects SFU fallback.
|
||||
With no fallback configuration, the field, import, HEAD, and behavior are all
|
||||
absent.
|
||||
|
||||
A bounded Chrome 151/LiveKit 1.13.5 localhost/headless/video-only same-leaf A/B
|
||||
reduced failure report to SFU active from 1.481 seconds to 200 ms and to a
|
||||
rendered frame from 2.257 seconds to 319.7 ms. It retained the fixed direct ->
|
||||
peer -> SFU order, two allowlisted roots, and host media-edge peak two. The
|
||||
standby had already downloaded/parsed the SDK and made its token-free network
|
||||
prewarm, without a participant or media edge, so the A/B does not isolate those
|
||||
effects. Its roughly 86% result must not be extrapolated to a public network;
|
||||
internet DNS/TLS reuse, RTT/loss, audio, transport fallback, and browser variance
|
||||
still require measurement.
|
||||
|
||||
## Traffic Conservation And The Impossible Triangle
|
||||
|
||||
@@ -167,6 +197,9 @@ Primary sources checked on 2026-08-19:
|
||||
- IETF standards under IETF Trust terms.
|
||||
- [LiveKit](https://github.com/livekit/livekit) - Apache-2.0; the optional SFU
|
||||
reference, with no code copied into this research change.
|
||||
- [LiveKit client 2.22.0 `Room.prepareConnection`](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/Room.ts)
|
||||
and [official usage](https://github.com/livekit/client-sdk-js#usage) -
|
||||
Apache-2.0; API behavior was inspected, with no source copied.
|
||||
|
||||
No GPL/AGPL code was copied. Published papers and specifications support design
|
||||
analysis only; their presence here is not an implementation license.
|
||||
@@ -217,6 +217,9 @@ successful track replacement, and live setting changes all configure the real
|
||||
`RTCRtpSender`, then retain requested/applied bitrate, frame rate, scale, and
|
||||
preference. Configuration rejection uses the existing rollback/fail-closed
|
||||
path rather than silently leaving the publication on unverified parameters.
|
||||
After initial activation or source replacement settles, the host reads and
|
||||
shows the route warning; a successful replacement rollback retains the original
|
||||
failure instead of clearing it with the restored sender readback.
|
||||
|
||||
This only removes the previous fixed-1080p60 relay envelope. Browser constraints
|
||||
and RTP sender parameters remain targets, so achieved bitrate, frame rate,
|
||||
|
||||
+20
-26
@@ -4,55 +4,49 @@ Last updated: 2026-08-19
|
||||
|
||||
## Phase
|
||||
|
||||
The WebRTC proof of concept at `https://share.bonfire.icu` runs commit
|
||||
`5b2fb005f6f7` with live quality/source changes, video pause, protected SQLite
|
||||
rooms, sequential IDs, and reusable links.
|
||||
|
||||
Production still creates one host peer connection per viewer. `main` now
|
||||
contains the default-off two-chain peer assistance, optional automatic SFU
|
||||
fallback, and simplified entry/diagnostics from merged PRs #13, #17, and #19;
|
||||
none is deployed. Corrected route recovery works but misses its sub-second gate.
|
||||
`https://share.bonfire.icu` runs commit `769de201f7cc` with simplified entry,
|
||||
default-closed diagnostics, bounded clarity-first quality settings, persistent
|
||||
rooms, and reusable links. Peer assistance and LiveKit are unconfigured, so the
|
||||
deployed binary still uses one host peer connection per viewer and P2P/TURN.
|
||||
|
||||
## Current Snapshot
|
||||
|
||||
- Capture precedes room creation. Live source and quality changes preserve healthy peers; picture pause keeps audio and connections active. `contentHint = "motion"` and explicit `balanced` degradation do not guarantee resolution-first behavior.
|
||||
- Capture precedes room creation. Live source/quality changes preserve healthy peers; picture pause keeps audio and connections active. Quality defaults to `maintain-resolution`, with balanced and fluid options.
|
||||
- Whole-site `ACCESS_PASSWORD` is optional. Protected sessions use a stateless 12-hour HMAC HttpOnly `SameSite=Strict` cookie; host authentication remains internal and signaling is role-bound.
|
||||
- Without `ROOM_DATABASE_PATH`, rooms are random and temporary. With both the database path and site password, room IDs start at `1`, links persist, and stopping a share leaves viewers waiting. SQLite stores only room ID and host-token digest.
|
||||
- Direct ICE is preferred independently per media edge. Authenticated TURN/UDP and TURN/TCP are required production fallbacks; TURN/TLS is optional.
|
||||
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
|
||||
- Complete fallback configuration adds a non-secret standby URL to peer-assisted authentication. Host and viewers import the SDK and make one token-free DNS/TLS warmup; no configuration means no field, import, request, participant, or media edge.
|
||||
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
|
||||
- Draft PR #21 keeps one strict, memory-only video setting for current/future relays/SFU; ordinary P2P wire is unchanged. It exposes bounded clarity-first controls. Browser audio remains request/presence-only.
|
||||
- It builds on `main`'s equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed.
|
||||
- Production uses one strict, memory-only video quality setting with bounded manual ceilings and sender readback. It also carries equal idle-stage share/join actions and default-closed technical details. Browser audio remains request/presence-only.
|
||||
|
||||
## Verified Evidence
|
||||
|
||||
- Production passed 108 tests, both builds, public HTTPS/access/TURN checks, clean activation, and a database restart retaining room `1`.
|
||||
- PR #13 introduced `npm run benchmark:peer-assisted`; its 152-test baseline and both builds passed.
|
||||
- Release `769de201f7cc` passed main CI, repository hygiene, type checking, 22 Vitest files/274 tests, both builds, and zero production dependency vulnerabilities.
|
||||
- A short Chrome 151 synthetic `1/3/5/8` benchmark passed every topology check: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first decoded frame was about 1.05 seconds. Closing a first-level relay in the three-viewer run recovered in about 5.32 seconds without exceeding host fanout two.
|
||||
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer-connection fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one.
|
||||
- That quality smoke was localhost, headless, and video-only. It proves control propagation and continuity, not visual quality, full-resolution performance, CPU/GPU load, TURN, public networks, or endurance.
|
||||
- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds.
|
||||
- The review revision passes repository hygiene, type checking, 20 Vitest files/264 tests, both production builds, and the Chrome control smoke above.
|
||||
- A localhost/headless Chrome 151 run with one host, three viewers, and synthetic 720p30 propagated balanced and clarity settings to every participant. Every active sender matched preference readback, peer fingerprints stayed stable, all viewers decoded/rendered new frames, and fanout stayed host two/relay one. This proves control continuity, not visual quality or performance.
|
||||
- Corrected Chrome 151/LiveKit 1.13.5 localhost/headless/video-only same-leaf A/B measured the cold path at 1.481 seconds to active and 2.257 seconds to render. Standby prepare arrived in 5 ms, active in 200 ms, and the same leaf rendered in 319.7 ms with 31 new decoded/frame-callback frames; 25 ms sampling kept host edge peak two. The roughly 86% result combines early SDK download/parse with token-free DNS/TLS/HTTP prewarm, creates no participant/media edge, and does not predict public-network performance.
|
||||
- Atomic production activation passed public/local health, exact served-bundle hash, unauthenticated 401, room `1` page, SQLite preservation, active Screener/nginx/coturn, and a clean warning log. The old release remains available for rollback.
|
||||
- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
|
||||
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
|
||||
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
|
||||
- Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled.
|
||||
|
||||
## Unverified Boundaries
|
||||
|
||||
- The short harness uses synthetic headless capture and proves topology, controls, stats collection, and recovery only. Full-resolution 30-minute runs, relay CPU/GPU, generational quality, controlled loss/RTT, depth latency, and actual game-capture behavior remain unverified.
|
||||
- Full-resolution 30-minute runs, relay CPU/GPU, generational quality, controlled loss/RTT, depth latency, and actual game capture remain unverified.
|
||||
- Android Chrome and iOS Safari remain unverified leaves. Runtime capability conservatively marks detected mobile/iPad clients as leaves; real UA/lifecycle behavior remains open.
|
||||
- The observed recovery starts from a page close immediately seen by the server. A silent partition can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; it remains unverified.
|
||||
- Real screen/game audio, heterogeneous machines and networks, mobile lifecycle behavior, the production live quality/pause cycle, room `1` stop-and-republish/link reuse, and sustained profile performance remain unverified.
|
||||
- Production users report severe resolution/bitrate/FPS degradation across all profiles. The Draft now permits bounded clarity/balanced comparison, but a controlled 1/2/3-viewer and TURN sample must still distinguish capture, per-edge CPU, path, and receiver limits before any quality claim or automatic controller.
|
||||
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
|
||||
- Real screen/game audio, heterogeneous machines/networks, mobile lifecycle, the new production quality/pause cycle, room `1` stop/re-publish, and sustained profiles remain unverified.
|
||||
- Earlier production showed severe degradation across all profiles. The new controls permit clarity/balanced comparison, but a controlled 1/2/3-viewer and TURN sample must still isolate capture, CPU, path, and receiver limits before any quality claim or automatic controller.
|
||||
- Browser relays do not share encoding. Encoded objects, custom congestion control, multiple trees, and network coding remain separate measured candidates.
|
||||
- The long-term endpoint budget is at most two downstream edges for both the host and relay-capable viewers, with one compatible encoded output reused across both edges where a native media engine can prove it. The current browser spike remains host capacity two/viewer capacity one and performs a new encode at each relay. Packet/layer striping and multi-parent assembly are recorded, not implemented.
|
||||
- The endpoint budget is host/relay at most two downstream edges; a native engine must prove shared encoding. The browser path remains host two/viewer one and re-encodes per relay. Striping and multi-parent assembly are only recorded.
|
||||
- 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.
|
||||
|
||||
## Next Milestone
|
||||
|
||||
Before enabling `main`'s experimental routes, address the 2.257-second fallback
|
||||
and validate public transports, rollback, reconnect, controls, edge counts,
|
||||
egress, and load. The quality stack remains undeployed until those gates pass.
|
||||
First validate the deployed quality controls on real game capture. Before
|
||||
enabling experimental routes, validate standby gains across public transports,
|
||||
rollback, reconnect, edge counts, egress, and load.
|
||||
|
||||
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
|
||||
resource, quality, latency, recovery, and browser/mobile-leaf matrix.
|
||||
|
||||
@@ -91,11 +91,21 @@ export class HostSfuRoute {
|
||||
return result;
|
||||
}
|
||||
|
||||
async acceptAndWait(
|
||||
update: RouteUpdateInput,
|
||||
acknowledge = true,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, acknowledge);
|
||||
await this.transitionTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
async resyncAuthoritative(
|
||||
update: RouteUpdateInput,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, false);
|
||||
if (result !== "stale" || this.closed) {
|
||||
await this.transitionTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
interface StandbyRoom {
|
||||
prepareConnection(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface LiveKitModule {
|
||||
Room: new () => StandbyRoom;
|
||||
}
|
||||
|
||||
type LiveKitLoader = () => Promise<LiveKitModule>;
|
||||
|
||||
let sharedLiveKitModule: Promise<LiveKitModule> | null = null;
|
||||
|
||||
function loadLiveKit(): Promise<LiveKitModule> {
|
||||
sharedLiveKitModule ??= import("livekit-client");
|
||||
return sharedLiveKitModule;
|
||||
}
|
||||
|
||||
export class SfuStandbyPrewarmer {
|
||||
private desiredUrl: string | null = null;
|
||||
private disposed = false;
|
||||
private module: Promise<LiveKitModule> | null = null;
|
||||
private readonly scheduledUrls = new Set<string>();
|
||||
private readonly attemptedUrls = new Set<string>();
|
||||
|
||||
constructor(private readonly load: LiveKitLoader = loadLiveKit) {}
|
||||
|
||||
setUrl(url: string | null | undefined): void {
|
||||
this.desiredUrl = url ?? null;
|
||||
if (
|
||||
this.disposed ||
|
||||
!url ||
|
||||
this.scheduledUrls.has(url) ||
|
||||
this.attemptedUrls.has(url)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduledUrls.add(url);
|
||||
void this.prepare(url);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.desiredUrl = null;
|
||||
}
|
||||
|
||||
private async prepare(url: string): Promise<void> {
|
||||
try {
|
||||
this.module ??= this.load();
|
||||
const { Room } = await this.module;
|
||||
if (this.disposed || this.desiredUrl !== url) {
|
||||
return;
|
||||
}
|
||||
this.attemptedUrls.add(url);
|
||||
await new Room().prepareConnection(url);
|
||||
} catch {
|
||||
if (!this.disposed && this.desiredUrl === url) {
|
||||
this.attemptedUrls.add(url);
|
||||
}
|
||||
// Standby warming is optional; the existing tokenized cold path remains.
|
||||
} finally {
|
||||
this.scheduledUrls.delete(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type QualitySettings,
|
||||
} from "../media/quality";
|
||||
import { HostSfuRoute } from "../media/host-sfu-route";
|
||||
import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer";
|
||||
import type {
|
||||
PeerSnapshot,
|
||||
SignalConnectionState,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
limitMediaAssignment,
|
||||
MAX_HOST_MEDIA_CHILDREN,
|
||||
} from "../webrtc/media-assignment";
|
||||
import { sourceSwitchNotice } from "./host-page-notices";
|
||||
|
||||
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
|
||||
|
||||
@@ -161,6 +163,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const picturePausedRef = useRef(false);
|
||||
const retiringStreamRef = useRef<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
const sfuStandbyPrewarmerRef = useRef<SfuStandbyPrewarmer | null>(null);
|
||||
|
||||
const viewers = useMemo(
|
||||
() => Array.from(peerSnapshots.values()),
|
||||
@@ -192,6 +195,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
peersRef.current.clear();
|
||||
void hostSfuRouteRef.current?.disconnect();
|
||||
hostSfuRouteRef.current = null;
|
||||
sfuStandbyPrewarmerRef.current?.dispose();
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
@@ -236,9 +240,34 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
function clearHostSfuRoute(): void {
|
||||
const route = hostSfuRouteRef.current;
|
||||
hostSfuRouteRef.current = null;
|
||||
sfuStandbyPrewarmerRef.current?.setUrl(null);
|
||||
void route?.disconnect();
|
||||
}
|
||||
|
||||
function setSfuStandbyUrl(url: string | null | undefined): void {
|
||||
if (!url) {
|
||||
sfuStandbyPrewarmerRef.current?.setUrl(null);
|
||||
return;
|
||||
}
|
||||
sfuStandbyPrewarmerRef.current ??= new SfuStandbyPrewarmer();
|
||||
sfuStandbyPrewarmerRef.current.setUrl(url);
|
||||
}
|
||||
|
||||
function showHostSfuQualityWarning(
|
||||
route: HostSfuRoute,
|
||||
generation: number,
|
||||
): void {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
hostSfuRouteRef.current === route
|
||||
) {
|
||||
const warning = route.getQualityWarning();
|
||||
if (warning) {
|
||||
setNotice(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disposeResources(notifyServer: boolean): void {
|
||||
sourceSwitchRef.current = null;
|
||||
qualityChangeRef.current = null;
|
||||
@@ -260,6 +289,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
peersRef.current.clear();
|
||||
void hostSfuRouteRef.current?.disconnect();
|
||||
hostSfuRouteRef.current = null;
|
||||
sfuStandbyPrewarmerRef.current?.setUrl(null);
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
retiringStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
@@ -595,6 +625,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "authenticated" && message.role === "host") {
|
||||
setSfuStandbyUrl(
|
||||
"sfuStandbyUrl" in message ? message.sfuStandbyUrl : null,
|
||||
);
|
||||
setMaxViewers(message.maxViewers);
|
||||
if (
|
||||
"mediaMode" in message &&
|
||||
@@ -605,13 +638,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
type: "set-quality-settings",
|
||||
qualitySettings: qualitySettingsRef.current,
|
||||
});
|
||||
void ensureHostSfuRoute(generation).resyncAuthoritative(
|
||||
{
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.resyncAuthoritative({
|
||||
revision: message.routeRevision,
|
||||
phase: "active",
|
||||
assignment: message.routeAssignment,
|
||||
},
|
||||
);
|
||||
})
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
return;
|
||||
}
|
||||
peerAssistedRef.current = false;
|
||||
@@ -626,13 +660,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
if (peerAssistedRef.current) {
|
||||
ensureHostSfuRoute(generation).accept(message);
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.acceptAndWait(message)
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "sfu-config") {
|
||||
if (peerAssistedRef.current) {
|
||||
void ensureHostSfuRoute(generation).acceptConfig(message);
|
||||
const route = ensureHostSfuRoute(generation);
|
||||
void route
|
||||
.acceptConfig(message)
|
||||
.then(() => showHostSfuQualityWarning(route, generation));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -957,6 +997,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
failedPeerIds.push(peerId);
|
||||
}
|
||||
}
|
||||
const sfuWarning =
|
||||
activeSfuRoute && hostSfuRouteRef.current === activeSfuRoute
|
||||
? activeSfuRoute.getQualityWarning()
|
||||
: null;
|
||||
if (
|
||||
!sfuReplaced &&
|
||||
activeSfuRoute &&
|
||||
@@ -997,9 +1041,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
sourceSwitchRef.current === token
|
||||
) {
|
||||
setNotice(
|
||||
failedPeerIds.length > 0
|
||||
? "分享来源已切换,部分观看者正在重新连接"
|
||||
: "分享来源已切换",
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: failedPeerIds.length,
|
||||
sfuReplaced,
|
||||
sfuWarning,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getStableClientId } from "../lib/session";
|
||||
import { SignalingClient } from "../lib/signaling";
|
||||
import type { QualitySettings } from "../media/quality";
|
||||
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
|
||||
import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer";
|
||||
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
|
||||
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
|
||||
import type {
|
||||
@@ -94,6 +95,16 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
Extract<ServerMessage, { type: "signal" }>
|
||||
> = [];
|
||||
const messageAuthority = new ViewerMessageAuthority();
|
||||
let sfuStandbyPrewarmer: SfuStandbyPrewarmer | null = null;
|
||||
|
||||
function setSfuStandbyUrl(url: string | null | undefined): void {
|
||||
if (!url) {
|
||||
sfuStandbyPrewarmer?.setUrl(null);
|
||||
return;
|
||||
}
|
||||
sfuStandbyPrewarmer ??= new SfuStandbyPrewarmer();
|
||||
sfuStandbyPrewarmer.setUrl(url);
|
||||
}
|
||||
|
||||
const signal = new SignalingClient(
|
||||
{
|
||||
@@ -110,6 +121,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
onProtocolError: (message) => {
|
||||
if (active) {
|
||||
messageAuthority.invalidate();
|
||||
setSfuStandbyUrl(null);
|
||||
setStatusText(message);
|
||||
}
|
||||
},
|
||||
@@ -118,6 +130,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return;
|
||||
}
|
||||
messageAuthority.invalidate();
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText(message);
|
||||
@@ -125,6 +138,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
onAccessRequired: () => {
|
||||
if (active) {
|
||||
messageAuthority.invalidate();
|
||||
setSfuStandbyUrl(null);
|
||||
onAuthorizationRequired();
|
||||
}
|
||||
},
|
||||
@@ -381,6 +395,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
authorityToken: number,
|
||||
): Promise<void> {
|
||||
if (message.type === "authenticated") {
|
||||
setSfuStandbyUrl(
|
||||
"sfuStandbyUrl" in message ? message.sfuStandbyUrl : null,
|
||||
);
|
||||
const nextPeerAssisted =
|
||||
"mediaMode" in message && message.mediaMode === "peer-assisted";
|
||||
if (!nextPeerAssisted && peerAssisted) {
|
||||
@@ -533,6 +550,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return;
|
||||
}
|
||||
if (message.type === "sharing-stopped") {
|
||||
setSfuStandbyUrl(null);
|
||||
currentHostOnline = false;
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
@@ -541,6 +559,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return;
|
||||
}
|
||||
if (message.type === "room-closed") {
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭");
|
||||
@@ -561,6 +580,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
"ROOM_FULL",
|
||||
].includes(message.code)
|
||||
) {
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
}
|
||||
@@ -571,6 +591,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
signal.start();
|
||||
return () => {
|
||||
active = false;
|
||||
sfuStandbyPrewarmer?.dispose();
|
||||
signal.stop();
|
||||
void viewerSfuRoute?.disconnect();
|
||||
viewerSfuRoute = null;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export function sourceSwitchNotice({
|
||||
failedPeerCount,
|
||||
sfuReplaced,
|
||||
sfuWarning,
|
||||
}: {
|
||||
failedPeerCount: number;
|
||||
sfuReplaced: boolean;
|
||||
sfuWarning: string | null;
|
||||
}): string {
|
||||
const peerWarning =
|
||||
failedPeerCount > 0 ? "部分观看者正在重新连接" : null;
|
||||
if (!sfuReplaced) {
|
||||
return [
|
||||
sfuWarning ?? "SFU 分享来源未切换成功,正在恢复观看连接",
|
||||
peerWarning,
|
||||
]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
}
|
||||
if (sfuWarning) {
|
||||
return [sfuWarning, peerWarning]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
}
|
||||
return peerWarning
|
||||
? `分享来源已切换,但${peerWarning}`
|
||||
: "分享来源已切换";
|
||||
}
|
||||
@@ -268,6 +268,10 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const failureWarning =
|
||||
error instanceof Error && error.message
|
||||
? `切换 SFU 分享来源失败:${error.message}`
|
||||
: "切换 SFU 分享来源失败";
|
||||
|
||||
// A rejected publish/unpublish may have changed server state without
|
||||
// returning enough ownership information to undo it safely.
|
||||
@@ -298,7 +302,8 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.retainSenderParameters(senderParameters);
|
||||
this.senderParameters = senderParameters;
|
||||
this.qualityWarning = failureWarning;
|
||||
}
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
|
||||
@@ -385,6 +385,9 @@ export class SignalingServer {
|
||||
qualitySettings:
|
||||
this.qualitySettingsByRoom.get(participant.roomId) ??
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
...(this.options.sfuFallback
|
||||
? { sfuStandbyUrl: this.options.sfuFallback.url }
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
this.send(socket, authenticatedMessage);
|
||||
|
||||
+15
-1
@@ -18,6 +18,19 @@ const tokenSchema = z
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
const liveKitWebSocketUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.max(2048)
|
||||
.refine((value) => {
|
||||
try {
|
||||
const protocol = new URL(value).protocol;
|
||||
return protocol === "ws:" || protocol === "wss:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export const roomCodeSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -286,6 +299,7 @@ const authenticatedMessageSchema = z.union([
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
routeAssignment: participantRouteAssignmentSchema,
|
||||
qualitySettings: qualitySettingsSchema,
|
||||
sfuStandbyUrl: liveKitWebSocketUrlSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
@@ -337,7 +351,7 @@ export const serverMessageSchema = z.union([
|
||||
.object({
|
||||
type: z.literal("sfu-config"),
|
||||
revision: mediaRouteRevisionSchema,
|
||||
url: z.string().url().max(2048),
|
||||
url: liveKitWebSocketUrlSchema,
|
||||
token: z.string().min(1).max(MAX_SFU_TOKEN_LENGTH),
|
||||
})
|
||||
.strict(),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sourceSwitchNotice } from "../src/client/pages/host-page-notices.ts";
|
||||
|
||||
describe("sourceSwitchNotice", () => {
|
||||
it("does not report success when the SFU source replacement failed", () => {
|
||||
expect(
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: 0,
|
||||
sfuReplaced: false,
|
||||
sfuWarning: null,
|
||||
}),
|
||||
).toBe("SFU 分享来源未切换成功,正在恢复观看连接");
|
||||
});
|
||||
|
||||
it("surfaces an SFU sender warning after a successful replacement", () => {
|
||||
expect(
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: 1,
|
||||
sfuReplaced: true,
|
||||
sfuWarning: "浏览器改写了 SFU 发送参数",
|
||||
}),
|
||||
).toBe("浏览器改写了 SFU 发送参数;部分观看者正在重新连接");
|
||||
});
|
||||
|
||||
it("reports success only when every active route replacement succeeded", () => {
|
||||
expect(
|
||||
sourceSwitchNotice({
|
||||
failedPeerCount: 0,
|
||||
sfuReplaced: true,
|
||||
sfuWarning: null,
|
||||
}),
|
||||
).toBe("分享来源已切换");
|
||||
});
|
||||
});
|
||||
@@ -329,6 +329,12 @@ describe("server signaling protocol", () => {
|
||||
};
|
||||
|
||||
expect(serverMessageSchema.safeParse(peerAssisted).success).toBe(true);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...peerAssisted,
|
||||
sfuStandbyUrl: "wss://sfu.example.com",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "media-assignment",
|
||||
@@ -378,6 +384,18 @@ describe("server signaling protocol", () => {
|
||||
qualitySettings,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...authenticatedMessage(8),
|
||||
sfuStandbyUrl: "wss://sfu.example.com",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...peerAssisted,
|
||||
sfuStandbyUrl: "https://sfu.example.com",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
type: "quality-settings",
|
||||
|
||||
@@ -187,6 +187,32 @@ describe("MediaRouteTransition", () => {
|
||||
});
|
||||
|
||||
describe("HostSfuRoute", () => {
|
||||
it("settles authoritative activation before exposing its quality warning", async () => {
|
||||
const publisher = {
|
||||
...createFakePublisher([], "publisher"),
|
||||
getQualityWarning: vi.fn(() => "SFU sender parameters were rewritten"),
|
||||
};
|
||||
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 expect(
|
||||
route.resyncAuthoritative({ revision: 1, phase: "active", assignment }),
|
||||
).resolves.toBe("accepted");
|
||||
|
||||
expect(publisher.activate).toHaveBeenCalledOnce();
|
||||
expect(route.getQualityWarning()).toBe(
|
||||
"SFU sender parameters were rewritten",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a publisher prepare failure without requesting refresh", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const publisher = createFakePublisher([], "publisher");
|
||||
|
||||
@@ -791,6 +791,7 @@ describe("WebSocket signaling", () => {
|
||||
kind: "peer",
|
||||
peerId: hostAuth.peerId,
|
||||
});
|
||||
expect("sfuStandbyUrl" in firstAuth).toBe(false);
|
||||
expect(secondAuth.routeAssignment.upstream).toEqual({
|
||||
kind: "peer",
|
||||
peerId: hostAuth.peerId,
|
||||
@@ -834,6 +835,7 @@ describe("WebSocket signaling", () => {
|
||||
);
|
||||
expect("mediaMode" in hostAuth).toBe(false);
|
||||
expect("qualitySettings" in hostAuth).toBe(false);
|
||||
expect("sfuStandbyUrl" in hostAuth).toBe(false);
|
||||
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const viewerAuth = await authenticate(
|
||||
@@ -844,6 +846,7 @@ describe("WebSocket signaling", () => {
|
||||
);
|
||||
expect("mediaMode" in viewerAuth).toBe(false);
|
||||
expect("qualitySettings" in viewerAuth).toBe(false);
|
||||
expect("sfuStandbyUrl" in viewerAuth).toBe(false);
|
||||
await host.inbox.next("peer-joined");
|
||||
|
||||
host.socket.send(
|
||||
@@ -960,6 +963,7 @@ describe("WebSocket signaling", () => {
|
||||
sfuPublicationGeneration: null,
|
||||
},
|
||||
});
|
||||
expect("sfuStandbyUrl" in hostAuth).toBe(false);
|
||||
|
||||
const firstViewer = await openClient(harness.webSocketUrl);
|
||||
const firstAuth = peerAssisted(
|
||||
@@ -1150,6 +1154,7 @@ describe("WebSocket signaling", () => {
|
||||
const hostAuth = peerAssisted(
|
||||
await authenticate(host, harness.room, "host", "hybrid-host"),
|
||||
);
|
||||
expect(hostAuth.sfuStandbyUrl).toBe("wss://sfu.example.test");
|
||||
const firstViewer = await openClient(harness.webSocketUrl);
|
||||
const firstAuth = peerAssisted(
|
||||
await authenticate(
|
||||
@@ -1159,6 +1164,7 @@ describe("WebSocket signaling", () => {
|
||||
"hybrid-viewer-a",
|
||||
),
|
||||
);
|
||||
expect(firstAuth.sfuStandbyUrl).toBe("wss://sfu.example.test");
|
||||
const secondViewer = await openClient(harness.webSocketUrl);
|
||||
const secondAuth = peerAssisted(
|
||||
await authenticate(
|
||||
|
||||
@@ -441,6 +441,9 @@ describe("SfuPublisher", () => {
|
||||
expect(localTrack.currentTrack).toBe(previousVideo);
|
||||
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(3);
|
||||
expect(publisher.getSenderParameters()?.applied.maxBitrate).toBe(8_000_000);
|
||||
expect(publisher.getQualityWarning()).toBe(
|
||||
"切换 SFU 分享来源失败:replacement parameters rejected",
|
||||
);
|
||||
});
|
||||
|
||||
it("disconnects fail-closed when replacement rollback also fails", async () => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SfuStandbyPrewarmer } from "../src/client/media/sfu-standby-prewarmer.ts";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("SfuStandbyPrewarmer", () => {
|
||||
it("does nothing when fallback is not advertised", () => {
|
||||
const load = vi.fn();
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl(undefined);
|
||||
prewarmer.setUrl(null);
|
||||
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports and warms each advertised URL at most once", async () => {
|
||||
const prepareConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const load = vi.fn().mockResolvedValue({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
});
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await vi.waitFor(() => expect(prepareConnection).toHaveBeenCalledOnce());
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(prepareConnection).toHaveBeenCalledWith(
|
||||
"wss://sfu.example.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a stale authentication before the SDK import completes", async () => {
|
||||
const module = deferred<{
|
||||
Room: new () => { prepareConnection(url: string): Promise<void> };
|
||||
}>();
|
||||
const prepareConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const load = vi.fn(() => module.promise);
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://stale.example.test");
|
||||
prewarmer.setUrl(null);
|
||||
module.resolve({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
});
|
||||
await module.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prepareConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps only the latest advertised URL across an import race", async () => {
|
||||
const module = deferred<{
|
||||
Room: new () => { prepareConnection(url: string): Promise<void> };
|
||||
}>();
|
||||
const prepareConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const load = vi.fn(() => module.promise);
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://stale.example.test");
|
||||
prewarmer.setUrl("wss://current.example.test");
|
||||
module.resolve({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(prepareConnection).toHaveBeenCalledOnce());
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(prepareConnection).toHaveBeenCalledWith(
|
||||
"wss://current.example.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("silently falls back without retrying a failed warmup", async () => {
|
||||
const prepareConnection = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
const prewarmer = new SfuStandbyPrewarmer(async () => ({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
}));
|
||||
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await vi.waitFor(() => expect(prepareConnection).toHaveBeenCalledOnce());
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prepareConnection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not reschedule a failed SDK import for the same URL", async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error("chunk unavailable"));
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledOnce());
|
||||
await Promise.resolve();
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user