From b0ea60c95c03237956482e51aaa6d92a0f7134ea Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:37:08 +0800 Subject: [PATCH 1/4] perf(media): prewarm optional SFU fallback --- docs/README.md | 4 +- .../0005-automatic-hybrid-media-routing.md | 77 ++++++++---- docs/project-memory.md | 8 +- docs/research/low-server-media-routes.md | 32 ++++- docs/status.md | 13 +- src/client/media/sfu-standby-prewarmer.ts | 65 ++++++++++ src/client/pages/HostPage.tsx | 17 +++ src/client/pages/ViewerPage.tsx | 21 ++++ src/server/signaling.ts | 3 + src/shared/protocol.ts | 16 ++- tests/protocol.test.ts | 18 +++ tests/server-signal.test.ts | 6 + tests/sfu-standby-prewarmer.test.ts | 115 ++++++++++++++++++ 13 files changed, 359 insertions(+), 36 deletions(-) create mode 100644 src/client/media/sfu-standby-prewarmer.ts create mode 100644 tests/sfu-standby-prewarmer.test.ts diff --git a/docs/README.md b/docs/README.md index 8136fb7..a4051d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. @@ -16,6 +16,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, Draft-implemented 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 automatic P2P/peer/SFU routing, token-free standby prewarm, transitions, and 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. diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 745256b..8713833 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -72,14 +72,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 @@ -114,6 +113,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 }`; @@ -122,9 +123,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 @@ -138,10 +140,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: @@ -169,12 +174,38 @@ 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 before/after 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 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, not public-network performance evidence. ## Triggers And Budgets @@ -200,7 +231,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. @@ -269,5 +301,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/) diff --git a/docs/project-memory.md b/docs/project-memory.md index c08cfef..ddea6f4 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -22,7 +22,7 @@ Last updated: 2026-08-19 - 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. - 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 mini-SFU deployment and central single-node SFU Draft PR #12 as optional infrastructure. When configured, the route controller may select them automatically as the last fallback; their fanout egress belongs to their operator and does not justify Redis or multi-node infrastructure. -- Keep automatic routing 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 passes functionally but misses the sub-second gate. +- Keep automatic routing 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. Token-free authenticated standby prewarm now 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 3/5/8 Mbps profiles as ceilings. Production users report severe degradation across all three; run one controlled 1/2/3-viewer capture before changing constants, then A/B clarity-first against `balanced`. Do not hide actual limits or add an automatic score/controller without evidence. - 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. @@ -37,8 +37,8 @@ Last updated: 2026-08-19 - 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. - 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. - Draft PR #13 contains deterministic server/client peer-assisted assignment, standard remote-track relay, and synchronized 1080p60/1080p30/720p30 room profiles. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented. -- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU 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. +- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU for that share. The follow-up spike advertises a non-secret standby URL only when configured and makes one token-free SDK/DNS/TLS warmup per client; LiveKit remains dormant when unconfigured. +- Corrected Chrome 151/LiveKit 1.13.5 same-leaf/two-root smoke reduced failure-to-active from 1.481 seconds cold 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. - 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 peer-assisted authenticated snapshot and host-only update synchronize one bounded, non-persistent room profile. Online viewers receive changes; `ViewerRelay` uses the latest desired profile for current and future children, while `HostPeer` serializes sender setup, stream replacement, and last-wins profile updates. The ordinary P2P wire is unchanged. Real 1/3/5/8 quality, resource, and latency measurements remain pending. @@ -59,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. diff --git a/docs/research/low-server-media-routes.md b/docs/research/low-server-media-routes.md index 953f8f8..4ff0ea6 100644 --- a/docs/research/low-server-media-routes.md +++ b/docs/research/low-server-media-routes.md @@ -32,7 +32,34 @@ Any browser-spike failure other than isolated relay re-encoding closes that browser-relay route. It does not cancel the separate native sender plan. Draft SFU PR #12 remains unmerged and undeployed. ADR-0005 now owns a separate Draft implementation of the automatic cross-mode controller; it is also undeployed -and has not passed real LiveKit/browser network validation. +and has passed a corrected localhost Chrome/LiveKit functional smoke. 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 same-leaf comparison 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. This is localhost synthetic +720p30 video evidence only; internet DNS/TLS reuse, RTT/loss, audio, transport +fallback, and browser variance still require measurement. ## Traffic Conservation And The Impossible Triangle @@ -167,6 +194,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. diff --git a/docs/status.md b/docs/status.md index d875935..511772c 100644 --- a/docs/status.md +++ b/docs/status.md @@ -11,7 +11,8 @@ rooms, sequential IDs, and reusable links. Production still creates one host peer connection per viewer. Draft PR #13 adds default-off two-chain peer assistance with per-hop re-encoding; ADR-0005 adds optional automatic SFU fallback in Draft PR #17. Corrected recovery passes -functionally but misses its sub-second gate; neither Draft is merged or deployed. +the local sub-second gate with token-free standby prewarm; neither Draft nor the +follow-up spike is merged or deployed. ## Current Snapshot @@ -20,6 +21,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - 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. - Peer-assisted profile state is bounded, memory-only, absent from ordinary P2P wire, and applied to current and future relay children. @@ -29,7 +31,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - Draft PR #13 now provides `npm run benchmark:peer-assisted`; the full check passes type checking, 13 Vitest files with 152 tests, and both production builds. - 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. - A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding. -- 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. +- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 same-leaf/two-root smoke measured the cold path at 1.481 seconds to active and 2.257 seconds to render. With authenticated 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. - Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both production builds, dependency audit, and repository hygiene. - 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. @@ -44,13 +46,12 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - Production users report severe resolution/bitrate/FPS degradation across all profiles. A controlled 1/2/3-viewer and TURN sample must distinguish capture, per-edge CPU, bandwidth/path, and receiver limits before changing ceilings or codecs; clarity-first versus `balanced` is the first bounded A/B. - 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 corrected smoke is localhost/headless/video-only; public transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. +- 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 -Finish ADR-0005 before UI polish: address the 2.257-second fallback, then -validate public transports, rollback, reconnect, controls, edge counts, egress, -and load. +Finish ADR-0005 before UI polish: validate standby gains across public +transports, rollback, reconnect, controls, 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. diff --git a/src/client/media/sfu-standby-prewarmer.ts b/src/client/media/sfu-standby-prewarmer.ts new file mode 100644 index 0000000..cf565a5 --- /dev/null +++ b/src/client/media/sfu-standby-prewarmer.ts @@ -0,0 +1,65 @@ +interface StandbyRoom { + prepareConnection(url: string): Promise; +} + +interface LiveKitModule { + Room: new () => StandbyRoom; +} + +type LiveKitLoader = () => Promise; + +let sharedLiveKitModule: Promise | null = null; + +function loadLiveKit(): Promise { + sharedLiveKitModule ??= import("livekit-client"); + return sharedLiveKitModule; +} + +export class SfuStandbyPrewarmer { + private desiredUrl: string | null = null; + private disposed = false; + private module: Promise | null = null; + private readonly scheduledUrls = new Set(); + private readonly attemptedUrls = new Set(); + + 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 { + 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); + } + } +} diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 38d268e..2cd3829 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -42,6 +42,7 @@ import { type QualityProfileId, } from "../media/quality"; import { HostSfuRoute } from "../media/host-sfu-route"; +import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer"; import type { PeerSnapshot, SignalConnectionState, @@ -148,6 +149,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const picturePausedRef = useRef(false); const retiringStreamRef = useRef(null); const hostSfuRouteRef = useRef(null); + const sfuStandbyPrewarmerRef = useRef(null); const viewers = useMemo( () => Array.from(peerSnapshots.values()), @@ -171,6 +173,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; @@ -215,9 +218,19 @@ 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 disposeResources(notifyServer: boolean): void { sourceSwitchRef.current = null; qualityChangeRef.current = null; @@ -239,6 +252,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; @@ -558,6 +572,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 && diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index c25be61..e7555ce 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -28,6 +28,7 @@ import { type QualityProfileId, } 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 { @@ -84,6 +85,16 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) Extract > = []; 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( { @@ -100,6 +111,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) onProtocolError: (message) => { if (active) { messageAuthority.invalidate(); + setSfuStandbyUrl(null); setStatusText(message); } }, @@ -108,6 +120,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) return; } messageAuthority.invalidate(); + setSfuStandbyUrl(null); clearViewerSfuRoute(); clearPeerState(); setStatusText(message); @@ -115,6 +128,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) onAccessRequired: () => { if (active) { messageAuthority.invalidate(); + setSfuStandbyUrl(null); onAuthorizationRequired(); } }, @@ -375,6 +389,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) authorityToken: number, ): Promise { 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; diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 6811a1b..05eeb77 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -388,6 +388,9 @@ export class SignalingServer { qualityProfileId: this.qualityProfileIdsByRoom.get(participant.roomId) ?? DEFAULT_QUALITY_PROFILE_ID, + ...(this.options.sfuFallback + ? { sfuStandbyUrl: this.options.sfuFallback.url } + : {}), }); } else { this.send(socket, authenticatedMessage); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index c5ec504..7d4d53e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -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) @@ -258,6 +271,7 @@ const authenticatedMessageSchema = z.union([ routeRevision: mediaRouteRevisionSchema, routeAssignment: participantRouteAssignmentSchema, qualityProfileId: qualityProfileIdSchema, + sfuStandbyUrl: liveKitWebSocketUrlSchema.optional(), }) .strict(), ]); @@ -309,7 +323,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(), diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index b0de179..57cc9d4 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -311,6 +311,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", @@ -360,6 +366,18 @@ describe("server signaling protocol", () => { qualityProfileId: "1080p60", }).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-profile", diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index b2ae694..7eb7e77 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -763,6 +763,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, @@ -806,6 +807,7 @@ describe("WebSocket signaling", () => { ); expect("mediaMode" in hostAuth).toBe(false); expect("qualityProfileId" in hostAuth).toBe(false); + expect("sfuStandbyUrl" in hostAuth).toBe(false); const viewer = await openClient(harness.webSocketUrl); const viewerAuth = await authenticate( @@ -816,6 +818,7 @@ describe("WebSocket signaling", () => { ); expect("mediaMode" in viewerAuth).toBe(false); expect("qualityProfileId" in viewerAuth).toBe(false); + expect("sfuStandbyUrl" in viewerAuth).toBe(false); await host.inbox.next("peer-joined"); host.socket.send( @@ -932,6 +935,7 @@ describe("WebSocket signaling", () => { sfuPublicationGeneration: null, }, }); + expect("sfuStandbyUrl" in hostAuth).toBe(false); const firstViewer = await openClient(harness.webSocketUrl); const firstAuth = peerAssisted( @@ -1122,6 +1126,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( @@ -1131,6 +1136,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( diff --git a/tests/sfu-standby-prewarmer.test.ts b/tests/sfu-standby-prewarmer.test.ts new file mode 100644 index 0000000..9d7d40d --- /dev/null +++ b/tests/sfu-standby-prewarmer.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SfuStandbyPrewarmer } from "../src/client/media/sfu-standby-prewarmer.ts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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 }; + }>(); + 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 }; + }>(); + 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(); + }); +}); From 91a9015d3853388806abfe6c59b32846503aca14 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:41:57 +0800 Subject: [PATCH 2/4] docs(status): link SFU standby evidence --- .../0005-automatic-hybrid-media-routing.md | 32 ++++++++++++------- docs/project-memory.md | 6 ++-- docs/research/low-server-media-routes.md | 15 +++++---- docs/status.md | 10 +++--- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 8713833..3c50c5c 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -53,8 +53,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 @@ -190,11 +191,12 @@ 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 before/after 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. +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 | | --- | ---: | ---: | @@ -202,10 +204,14 @@ active revision. The resulting plan had two allowlisted SFU roots. | SFU active | 1,481 ms | 200 ms | | Same-leaf rendered frame | 2,257 ms | 319.7 ms | -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, not public-network performance evidence. +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 @@ -283,6 +289,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. diff --git a/docs/project-memory.md b/docs/project-memory.md index ddea6f4..a235bce 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -37,14 +37,14 @@ Last updated: 2026-08-19 - 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. - 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. - Draft PR #13 contains deterministic server/client peer-assisted assignment, standard remote-track relay, and synchronized 1080p60/1080p30/720p30 room profiles. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented. -- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU for that share. The follow-up spike advertises a non-secret standby URL only when configured and makes one token-free SDK/DNS/TLS warmup per client; LiveKit remains dormant when unconfigured. -- Corrected Chrome 151/LiveKit 1.13.5 same-leaf/two-root smoke reduced failure-to-active from 1.481 seconds cold 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. +- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU for that share. Draft PR #20 advertises a non-secret standby URL only when configured and performs one token-free SDK import plus DNS/TLS/HTTP warmup per client; it creates no participant/media edge, and LiveKit remains dormant when unconfigured. +- 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 peer-assisted authenticated snapshot and host-only update synchronize one bounded, non-persistent room profile. Online viewers receive changes; `ViewerRelay` uses the latest desired profile for current and future children, while `HostPeer` serializes sender setup, stream replacement, and last-wins profile updates. The ordinary P2P wire is unchanged. Real 1/3/5/8 quality, resource, and latency measurements remain pending. - Chromium 151 kept relay/sender/signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while the leaf decoded. Synthetic 640x360/30 proves control propagation, not full-resolution load. - 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. -- Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both builds, dependency audit, and repository hygiene. Production separately passed 108 tests and deployment checks. +- Draft PR #17 passes CI. Draft PR #20 passes CI, type checking, 20 Vitest files/259 tests, both builds, dependency audit, and repository hygiene. Production separately passed 108 tests and deployment checks. ## Provisional Quality Targets diff --git a/docs/research/low-server-media-routes.md b/docs/research/low-server-media-routes.md index 4ff0ea6..3104ee5 100644 --- a/docs/research/low-server-media-routes.md +++ b/docs/research/low-server-media-routes.md @@ -54,12 +54,15 @@ 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 same-leaf comparison 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. This is localhost synthetic -720p30 video evidence only; internet DNS/TLS reuse, RTT/loss, audio, transport -fallback, and browser variance still require measurement. +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 diff --git a/docs/status.md b/docs/status.md index 511772c..782bbc7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -10,9 +10,9 @@ rooms, sequential IDs, and reusable links. Production still creates one host peer connection per viewer. Draft PR #13 adds default-off two-chain peer assistance with per-hop re-encoding; ADR-0005 adds -optional automatic SFU fallback in Draft PR #17. Corrected recovery passes -the local sub-second gate with token-free standby prewarm; neither Draft nor the -follow-up spike is merged or deployed. +optional automatic SFU fallback in Draft PR #17. Corrected recovery passes the +local sub-second gate with token-free standby prewarm in Draft PR #20; neither +PR is merged or deployed. ## Current Snapshot @@ -31,8 +31,8 @@ follow-up spike is merged or deployed. - Draft PR #13 now provides `npm run benchmark:peer-assisted`; the full check passes type checking, 13 Vitest files with 152 tests, and both production builds. - 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. - A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding. -- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 same-leaf/two-root smoke measured the cold path at 1.481 seconds to active and 2.257 seconds to render. With authenticated 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. -- Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both production builds, dependency audit, and repository hygiene. +- 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. +- Draft PR #17 passes CI. Draft PR [#20](https://github.com/TNTcraftHIM/Screener/pull/20) passes CI, type checking, 20 Vitest files/259 tests, both production builds, dependency audit, and repository hygiene. - 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. From edf740e45ddb7e63618c57f8a25794b7b4057792 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:56:03 +0800 Subject: [PATCH 3/4] fix(media): surface SFU sender warnings --- docs/project-memory.md | 6 ++-- docs/research/peer-assisted-media.md | 3 ++ docs/status.md | 4 +-- src/client/media/host-sfu-route.ts | 10 ++++++ src/client/pages/HostPage.tsx | 47 ++++++++++++++++++++++----- src/client/pages/host-page-notices.ts | 28 ++++++++++++++++ src/client/sfu/publisher.ts | 7 +++- tests/host-page-notice.test.ts | 35 ++++++++++++++++++++ tests/route-transition.test.ts | 26 +++++++++++++++ tests/sfu-client.test.ts | 3 ++ 10 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 src/client/pages/host-page-notices.ts create mode 100644 tests/host-page-notice.test.ts diff --git a/docs/project-memory.md b/docs/project-memory.md index 5177f0b..c563066 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -33,7 +33,7 @@ Last updated: 2026-08-19 - 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. +- `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 SFU initial/update/replacement warnings visible. Neither change is deployed. - 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. - 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. @@ -42,10 +42,10 @@ Last updated: 2026-08-19 - 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. - 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 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. +- 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. +- The review revision passes repository hygiene, type checking, 21 Vitest files/268 tests, both builds, and the Chrome control smoke. Production separately passed 108 tests and deployment checks. ## Provisional Quality Targets diff --git a/docs/research/peer-assisted-media.md b/docs/research/peer-assisted-media.md index 3e61fad..1696296 100644 --- a/docs/research/peer-assisted-media.md +++ b/docs/research/peer-assisted-media.md @@ -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, diff --git a/docs/status.md b/docs/status.md index f38edfb..1f5d07b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,7 +22,7 @@ none is deployed. Corrected route recovery works but misses its sub-second gate. - 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. - 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 uses one strict, memory-only quality setting for current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings. -- It builds on `main`'s equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed. +- It builds on `main`'s equal idle-stage share/join actions and default-closed technical details; SFU initial/update/replacement warnings remain visible. It is not deployed. ## Verified Evidence @@ -32,7 +32,7 @@ none is deployed. Corrected route recovery works but misses its sub-second gate. - 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. +- The review revision passes repository hygiene, type checking, 21 Vitest files/268 tests, both production builds, and the Chrome control smoke above. - 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. diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts index c76a154..04f917b 100644 --- a/src/client/media/host-sfu-route.ts +++ b/src/client/media/host-sfu-route.ts @@ -91,11 +91,21 @@ export class HostSfuRoute { return result; } + async acceptAndWait( + update: RouteUpdateInput, + acknowledge = true, + ): Promise { + const result = this.accept(update, acknowledge); + await this.transitionTail; + return result; + } + async resyncAuthoritative( update: RouteUpdateInput, ): Promise { const result = this.accept(update, false); if (result !== "stale" || this.closed) { + await this.transitionTail; return result; } diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 6b9b470..0574cb4 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -60,6 +60,7 @@ import { limitMediaAssignment, MAX_HOST_MEDIA_CHILDREN, } from "../webrtc/media-assignment"; +import { sourceSwitchNotice } from "./host-page-notices"; type HostPhase = "idle" | "starting" | "live" | "ended" | "error"; @@ -239,6 +240,21 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { void route?.disconnect(); } + 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; @@ -605,13 +621,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 +643,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 +980,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { failedPeerIds.push(peerId); } } + const sfuWarning = + activeSfuRoute && hostSfuRouteRef.current === activeSfuRoute + ? activeSfuRoute.getQualityWarning() + : null; if ( !sfuReplaced && activeSfuRoute && @@ -997,9 +1024,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { sourceSwitchRef.current === token ) { setNotice( - failedPeerIds.length > 0 - ? "分享来源已切换,部分观看者正在重新连接" - : "分享来源已切换", + sourceSwitchNotice({ + failedPeerCount: failedPeerIds.length, + sfuReplaced, + sfuWarning, + }), ); } } finally { diff --git a/src/client/pages/host-page-notices.ts b/src/client/pages/host-page-notices.ts new file mode 100644 index 0000000..7cbf5df --- /dev/null +++ b/src/client/pages/host-page-notices.ts @@ -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}` + : "分享来源已切换"; +} diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts index 42204db..c780033 100644 --- a/src/client/sfu/publisher.ts +++ b/src/client/sfu/publisher.ts @@ -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) { diff --git a/tests/host-page-notice.test.ts b/tests/host-page-notice.test.ts new file mode 100644 index 0000000..a5538f2 --- /dev/null +++ b/tests/host-page-notice.test.ts @@ -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("分享来源已切换"); + }); +}); diff --git a/tests/route-transition.test.ts b/tests/route-transition.test.ts index c71bbde..1c56596 100644 --- a/tests/route-transition.test.ts +++ b/tests/route-transition.test.ts @@ -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"); diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index d9f45b7..8550356 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -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 () => { From c1ea13bbeb3ec7d6437f2c112ad21995bd00da54 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:17:34 +0800 Subject: [PATCH 4/4] docs(status): record 769de201 deployment --- docs/project-memory.md | 18 ++++++++---------- docs/status.md | 32 +++++++++++++------------------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/docs/project-memory.md b/docs/project-memory.md index b132677..bdf62c7 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -22,31 +22,29 @@ Last updated: 2026-08-19 - 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. Draft PR #20's token-free standby prewarm passes the local sub-second gate; public transport and load gates remain. +- 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 every quality setting as a ceiling. The current Draft defaults to clarity-first and adds bounded manual controls plus sender readback; production remains `balanced`, and its reported degradation is still unclassified. Run the 1/2/3-viewer plus TURN matrix before changing ceilings or adding any automatic controller. +- Treat every quality setting as a ceiling. Production now defaults clarity-first with bounded manual controls and sender readback, but reported degradation remains unclassified. 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. -- 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 plus PR #21's bounded advanced quality settings and visible SFU sender warnings. These changes are not deployed. +- 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. -- Draft PR #20 advertises a non-secret standby URL only when fallback is configured and performs one token-free SDK import plus DNS/TLS/HTTP warmup per client; it creates no participant or media edge and changes nothing when unconfigured. +- 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 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 integrated PR #20 revision passes repository hygiene, type checking, 22 Vitest files/274 tests, both builds, and the existing Chrome evidence. 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 diff --git a/docs/status.md b/docs/status.md index 54bde92..7f508b0 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,35 +4,29 @@ 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, simplified entry/diagnostics, and bounded quality settings from merged -PRs #13, #17, #19, and #21; none is deployed. Draft PR #20's token-free standby -prewarm passes the local sub-second recovery 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. -- `main` uses one strict, memory-only quality setting for current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first, exposes bounded manual ceilings, and keeps SFU initial/update/replacement warnings visible. -- `main` also carries equal idle-stage share/join actions and default-closed technical details. These changes are not deployed. +- Production uses one strict, memory-only quality setting with bounded manual ceilings and sender readback. It also carries equal idle-stage share/join actions and default-closed technical details. ## Verified Evidence -- Production passed 108 tests, both builds, public HTTPS/access/TURN checks, clean activation, and a database restart retaining room `1`. +- 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. - 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. -- The integrated PR #20 revision passes repository hygiene, type checking, 22 Vitest files/274 tests, both production builds, and the existing Chrome evidence above. +- 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. - 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. @@ -42,17 +36,17 @@ prewarm passes the local sub-second recovery gate. - 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. - 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 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. +- 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 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, validate standby gains across -public transports, rollback, reconnect, controls, edge counts, egress, and load. -The quality and routing stacks remain 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.