perf(media): prewarm optional SFU fallback
This commit is contained in:
1 parent
62f6c0fd2e
commit
b0ea60c95c
13 files changed
+359
-36
No files matched your search
+2
-2
@@ -8,7 +8,7 @@
|
||||
- [Maintenance guide](./maintenance.md): Git workflow, context hygiene, document lifecycle, research policy, and automation.
|
||||
- [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment.
|
||||
- [Peer-assisted media research](./research/peer-assisted-media.md): browser shared-encode limits, standard relay re-encoding, deterministic sticky topology, runtime relay-capacity extension, and abandon gates.
|
||||
- [Low-server-cost media routes](./research/low-server-media-routes.md): traffic accounting, route screening, conditional native encoded-RTP relay, and automatic use of enabled SFU capacity as the final fallback.
|
||||
- [Low-server-cost media routes](./research/low-server-media-routes.md): traffic accounting, route screening, token-free SFU standby prewarm, conditional native encoded-RTP relay, and automatic final fallback.
|
||||
- [Advanced peer distribution](./research/advanced-peer-distribution.md): multi-tree/SVC, encoded-object relay, native RTP forwarding, FEC/network-coding, MoQ, and measurable go/no-go gates.
|
||||
- [Native shared-encode sender](./research/native-shared-encode-sender.md): bounded libwebrtc encoder-proxy risk spike, Draft PR #16's transport-only Pion fanout oracle, and hard failure gates.
|
||||
- [Realtime quality adaptation](./research/realtime-quality-adaptation.md): browser degradation policy, live profile changes, offline-encoding boundaries, and measurement gates.
|
||||
@@ -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.
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
+7
-6
@@ -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.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
interface StandbyRoom {
|
||||
prepareConnection(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface LiveKitModule {
|
||||
Room: new () => StandbyRoom;
|
||||
}
|
||||
|
||||
type LiveKitLoader = () => Promise<LiveKitModule>;
|
||||
|
||||
let sharedLiveKitModule: Promise<LiveKitModule> | null = null;
|
||||
|
||||
function loadLiveKit(): Promise<LiveKitModule> {
|
||||
sharedLiveKitModule ??= import("livekit-client");
|
||||
return sharedLiveKitModule;
|
||||
}
|
||||
|
||||
export class SfuStandbyPrewarmer {
|
||||
private desiredUrl: string | null = null;
|
||||
private disposed = false;
|
||||
private module: Promise<LiveKitModule> | null = null;
|
||||
private readonly scheduledUrls = new Set<string>();
|
||||
private readonly attemptedUrls = new Set<string>();
|
||||
|
||||
constructor(private readonly load: LiveKitLoader = loadLiveKit) {}
|
||||
|
||||
setUrl(url: string | null | undefined): void {
|
||||
this.desiredUrl = url ?? null;
|
||||
if (
|
||||
this.disposed ||
|
||||
!url ||
|
||||
this.scheduledUrls.has(url) ||
|
||||
this.attemptedUrls.has(url)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduledUrls.add(url);
|
||||
void this.prepare(url);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.desiredUrl = null;
|
||||
}
|
||||
|
||||
private async prepare(url: string): Promise<void> {
|
||||
try {
|
||||
this.module ??= this.load();
|
||||
const { Room } = await this.module;
|
||||
if (this.disposed || this.desiredUrl !== url) {
|
||||
return;
|
||||
}
|
||||
this.attemptedUrls.add(url);
|
||||
await new Room().prepareConnection(url);
|
||||
} catch {
|
||||
if (!this.disposed && this.desiredUrl === url) {
|
||||
this.attemptedUrls.add(url);
|
||||
}
|
||||
// Standby warming is optional; the existing tokenized cold path remains.
|
||||
} finally {
|
||||
this.scheduledUrls.delete(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MediaStream | null>(null);
|
||||
const hostSfuRouteRef = useRef<HostSfuRoute | null>(null);
|
||||
const sfuStandbyPrewarmerRef = useRef<SfuStandbyPrewarmer | null>(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 &&
|
||||
|
||||
@@ -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<ServerMessage, { type: "signal" }>
|
||||
> = [];
|
||||
const messageAuthority = new ViewerMessageAuthority();
|
||||
let sfuStandbyPrewarmer: SfuStandbyPrewarmer | null = null;
|
||||
|
||||
function setSfuStandbyUrl(url: string | null | undefined): void {
|
||||
if (!url) {
|
||||
sfuStandbyPrewarmer?.setUrl(null);
|
||||
return;
|
||||
}
|
||||
sfuStandbyPrewarmer ??= new SfuStandbyPrewarmer();
|
||||
sfuStandbyPrewarmer.setUrl(url);
|
||||
}
|
||||
|
||||
const signal = new SignalingClient(
|
||||
{
|
||||
@@ -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<void> {
|
||||
if (message.type === "authenticated") {
|
||||
setSfuStandbyUrl(
|
||||
"sfuStandbyUrl" in message ? message.sfuStandbyUrl : null,
|
||||
);
|
||||
const nextPeerAssisted =
|
||||
"mediaMode" in message && message.mediaMode === "peer-assisted";
|
||||
if (!nextPeerAssisted && peerAssisted) {
|
||||
@@ -533,6 +550,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return;
|
||||
}
|
||||
if (message.type === "sharing-stopped") {
|
||||
setSfuStandbyUrl(null);
|
||||
currentHostOnline = false;
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
@@ -541,6 +559,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
return;
|
||||
}
|
||||
if (message.type === "room-closed") {
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束");
|
||||
@@ -561,6 +580,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
"ROOM_FULL",
|
||||
].includes(message.code)
|
||||
) {
|
||||
setSfuStandbyUrl(null);
|
||||
clearViewerSfuRoute();
|
||||
clearPeerState();
|
||||
}
|
||||
@@ -571,6 +591,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
|
||||
signal.start();
|
||||
return () => {
|
||||
active = false;
|
||||
sfuStandbyPrewarmer?.dispose();
|
||||
signal.stop();
|
||||
void viewerSfuRoute?.disconnect();
|
||||
viewerSfuRoute = null;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-1
@@ -18,6 +18,19 @@ const tokenSchema = z
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
const liveKitWebSocketUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.max(2048)
|
||||
.refine((value) => {
|
||||
try {
|
||||
const protocol = new URL(value).protocol;
|
||||
return protocol === "ws:" || protocol === "wss:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export const roomCodeSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SfuStandbyPrewarmer } from "../src/client/media/sfu-standby-prewarmer.ts";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("SfuStandbyPrewarmer", () => {
|
||||
it("does nothing when fallback is not advertised", () => {
|
||||
const load = vi.fn();
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl(undefined);
|
||||
prewarmer.setUrl(null);
|
||||
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports and warms each advertised URL at most once", async () => {
|
||||
const prepareConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const load = vi.fn().mockResolvedValue({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
});
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await vi.waitFor(() => expect(prepareConnection).toHaveBeenCalledOnce());
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(prepareConnection).toHaveBeenCalledWith(
|
||||
"wss://sfu.example.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a stale authentication before the SDK import completes", async () => {
|
||||
const module = deferred<{
|
||||
Room: new () => { prepareConnection(url: string): Promise<void> };
|
||||
}>();
|
||||
const prepareConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const load = vi.fn(() => module.promise);
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://stale.example.test");
|
||||
prewarmer.setUrl(null);
|
||||
module.resolve({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
});
|
||||
await module.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prepareConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps only the latest advertised URL across an import race", async () => {
|
||||
const module = deferred<{
|
||||
Room: new () => { prepareConnection(url: string): Promise<void> };
|
||||
}>();
|
||||
const prepareConnection = vi.fn().mockResolvedValue(undefined);
|
||||
const load = vi.fn(() => module.promise);
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://stale.example.test");
|
||||
prewarmer.setUrl("wss://current.example.test");
|
||||
module.resolve({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(prepareConnection).toHaveBeenCalledOnce());
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(prepareConnection).toHaveBeenCalledWith(
|
||||
"wss://current.example.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("silently falls back without retrying a failed warmup", async () => {
|
||||
const prepareConnection = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
const prewarmer = new SfuStandbyPrewarmer(async () => ({
|
||||
Room: class {
|
||||
prepareConnection = prepareConnection;
|
||||
},
|
||||
}));
|
||||
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await vi.waitFor(() => expect(prepareConnection).toHaveBeenCalledOnce());
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prepareConnection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not reschedule a failed SDK import for the same URL", async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error("chunk unavailable"));
|
||||
const prewarmer = new SfuStandbyPrewarmer(load);
|
||||
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledOnce());
|
||||
await Promise.resolve();
|
||||
prewarmer.setUrl("wss://sfu.example.test");
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user