fix(media): surface SFU sender warnings
This commit is contained in:
1 parent
d43d7b5eb3
commit
edf740e45d
10 files changed
+154
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -91,11 +91,21 @@ export class HostSfuRoute {
|
||||
return result;
|
||||
}
|
||||
|
||||
async acceptAndWait(
|
||||
update: RouteUpdateInput,
|
||||
acknowledge = true,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, acknowledge);
|
||||
await this.transitionTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
async resyncAuthoritative(
|
||||
update: RouteUpdateInput,
|
||||
): Promise<RouteUpdateResult> {
|
||||
const result = this.accept(update, false);
|
||||
if (result !== "stale" || this.closed) {
|
||||
await this.transitionTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export function sourceSwitchNotice({
|
||||
failedPeerCount,
|
||||
sfuReplaced,
|
||||
sfuWarning,
|
||||
}: {
|
||||
failedPeerCount: number;
|
||||
sfuReplaced: boolean;
|
||||
sfuWarning: string | null;
|
||||
}): string {
|
||||
const peerWarning =
|
||||
failedPeerCount > 0 ? "部分观看者正在重新连接" : null;
|
||||
if (!sfuReplaced) {
|
||||
return [
|
||||
sfuWarning ?? "SFU 分享来源未切换成功,正在恢复观看连接",
|
||||
peerWarning,
|
||||
]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
}
|
||||
if (sfuWarning) {
|
||||
return [sfuWarning, peerWarning]
|
||||
.filter((message): message is string => message !== null)
|
||||
.join(";");
|
||||
}
|
||||
return peerWarning
|
||||
? `分享来源已切换,但${peerWarning}`
|
||||
: "分享来源已切换";
|
||||
}
|
||||
@@ -268,6 +268,10 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const failureWarning =
|
||||
error instanceof Error && error.message
|
||||
? `切换 SFU 分享来源失败:${error.message}`
|
||||
: "切换 SFU 分享来源失败";
|
||||
|
||||
// A rejected publish/unpublish may have changed server state without
|
||||
// returning enough ownership information to undo it safely.
|
||||
@@ -298,7 +302,8 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.retainSenderParameters(senderParameters);
|
||||
this.senderParameters = senderParameters;
|
||||
this.qualityWarning = failureWarning;
|
||||
}
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
|
||||
@@ -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("分享来源已切换");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user