fix(media): reselect persistently low-fps relay edges [skip ci]

This commit is contained in:
AAA authored and GitHub committed 2026-08-22 00:01:04 +08:00
1 parent d94bac11ca
commit ed57d3ed5e
6 files changed
+254 -16

No files matched your search

@@ -169,8 +169,10 @@ current connection, using the existing finite monotonic stats timestamp; its
DOMHighResTimeStamp-derived window is rounded before the one-to-five-second
bound is checked. Viewer C may wait pending, but the router advances the streak
only when that C has a hard receive predicate and B independently reports one
of the two hard sender predicates. Both inputs are WebRTC stats gathered by
stock browsers at separate endpoints; parent remote loss originates in RTCP.
of the two hard sender predicates. Signaling binds the room's current
`maxFramerate` to C internally without changing the public wire. Both inputs
are WebRTC stats gathered by stock browsers at separate endpoints; parent
remote loss originates in RTCP.
This is cross-endpoint corroboration, not cryptographic independence or defense
against colluding authenticated participants. Raw stats, addresses, candidates,
SDP, URLs and a composite score remain out of wire.
@@ -379,7 +381,8 @@ The first controller reacts only to discrete events:
- three consecutive, current-identity correlated Viewer C/parent B windows in
which C has a hard receive predicate (freeze duration at least 50% of the
sample window, positive received RTP with zero decoded frames, or at least
100 received-plus-lost packets with loss at least 30%) and B independently
100 received-plus-lost packets with loss at least 30%, or decoded FPS below
five-sixths of the room's current `maxFramerate`) and B independently
reports `cpu`/`bandwidth` sender limitation or at least 100 sent packets with
RTCP-reported remote loss divided by sent packets at least 30%.
@@ -402,10 +405,10 @@ release only the quality-owned temporary parent exclusion; a real
`route-failed` exclusion remains owned by the failure path. The W3C stats
definitions establish the counter meanings and their WebRTC 1.0 example uses
30% loss as a likely culprit; they do not
prescribe Screener's route policy. Therefore 50%, 100 packets, three windows,
five seconds and 30 seconds are conservative candidate constants pending
production calibration, not claimed optimums. Measurements may change them
only through a reviewed change.
prescribe Screener's route policy. Therefore 50% freeze share, five-sixths FPS,
100 packets, three windows, five seconds and 30 seconds are conservative
candidate constants pending production calibration, not claimed optimums.
Measurements may change them only through a reviewed change.
ADR-0007's `HIGH`/`FALLBACK` quality state is separate and does not become a
topology trigger or a room-wide health score.
+7 -5
View File
@@ -196,14 +196,16 @@ server accepts one B from the bound parent session and generation. The router
may retain C while awaiting B, but advances only when the correlated pair has a
hard C receive predicate and a hard B sender predicate. `sending`, either
report alone, and healthy or ambiguous pairs reset or do not advance the
streak.
streak. Signaling supplies the room's current `maxFramerate` only to the
server-side router alongside C; no public evidence field is added.
It holds at most one pending/streak state per connected Viewer and one room
cooldown, with no timer, weighted score or global parent-capacity decision.
The conservative Viewer C hard predicates are: freeze duration at least half
of the one-to-five-second window; positive received-packet delta with zero
decoded frames; or at least 100 received-plus-lost packets with loss at least
30%. Parent B must independently report the exact `cpu`/`bandwidth` sender
30%; or decoded FPS below five-sixths of the room's current `maxFramerate`.
Parent B must independently report the exact `cpu`/`bandwidth` sender
limitation or at least 100 sent packets with remote loss divided by sent packets
at least 30%. Three consecutive dual-hard-bad windows are required. A healthy
or incomplete correlated window, any Viewer/parent session, connection, route
@@ -233,9 +235,9 @@ Viewer-side
`packetsReceived`, `packetsLost`, `framesDecoded`, `freezeCount` and
`totalFreezesDuration`; its WebRTC 1.0 diagnostic example treats loss over 30%
as a likely culprit. It does not define route-migration thresholds. The 50%
freeze share, 100-packet floor, three windows, five-second gap and 30-second
cooldown are falsifiable candidate constants for production calibration, not
standards-derived or claimed optimum values.
freeze share, five-sixths FPS ratio, 100-packet floor, three windows,
five-second gap and 30-second cooldown are falsifiable candidate constants for
production calibration, not standards-derived or claimed optimum values.
The correlated pair can therefore attribute a problem only to the current
parent-to-child edge generation. It cannot prove that the parent is globally
+1 -1
View File
@@ -43,7 +43,7 @@ Parallel flagship work; use minimum relevant checks and rollback proof. Target-d
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
- Production startup blur remains open. One local Chrome synthetic same-`balanced` A/B kept route/PC/SSRC/track and coincided with 720p -> 1080p without loss/freeze, but natural ramp prevents causal, production, or SFU proof.
- ADR-0006 has one Viewer proof; two-edge/FIFO, hardware, endurance, public/downloaded-package, and browser-diversity proof remain open.
- Production fanout is Host2/Viewer2 and rejects child3. Relay resource behavior remains open.
- Production fanout is Host2/Viewer2 and rejects child3. C+B moves only the affected subtree; relay-capacity penalties stay a separate P2.
- Active SFU already publishes `q,h`; only BWE/resource/zero-child/root gates are unverified. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go.
- Access lacks headful/production proof for fragment consumption, rotate/revoke and request/nginx/journal/SQLite leakage; its source gate is loopback-only. No accounts.
- Split large modules only at proven consumer boundaries, never by file length.
+22 -3
View File
@@ -33,6 +33,7 @@ const VIEWER_QUALITY_BAD_WINDOWS_TO_REASSIGN = 3;
const VIEWER_QUALITY_EVIDENCE_GAP_MS = 5_000;
const VIEWER_QUALITY_REASSIGN_COOLDOWN_MS = 30_000;
const VIEWER_QUALITY_FREEZE_RATIO = 0.5;
const VIEWER_QUALITY_MIN_FPS_RATIO = 5 / 6;
const VIEWER_QUALITY_MIN_LOSS_PACKETS = 100;
const VIEWER_QUALITY_HIGH_LOSS_RATIO = 0.3;
interface PendingRoutePreparation {
@@ -122,6 +123,7 @@ interface ViewerQualityEvidenceState {
badWindowCount: number;
pendingViewerEvidence: {
acceptedAtMs: number;
maxFramerate: number;
evidence: Extract<ServerMessage, { type: "viewer-quality-evidence" }>;
} | null;
}
@@ -130,6 +132,7 @@ interface ForwardedViewerQualityEvidence {
roomId: string;
viewerSessionId: string;
parentSessionId: string;
maxFramerate: number;
evidence: Extract<ServerMessage, { type: "viewer-quality-evidence" }>;
}
@@ -496,7 +499,13 @@ export class HybridMediaRouter {
}
handleViewerQualityEvidence(input: ForwardedViewerQualityEvidence): void {
const { evidence, roomId, viewerSessionId, parentSessionId } = input;
const {
evidence,
roomId,
viewerSessionId,
parentSessionId,
maxFramerate,
} = input;
const { viewerPeerId, parentPeerId } = evidence;
const viewer = this.options.roomStore.getConnectedViewer(
roomId,
@@ -552,7 +561,7 @@ export class HybridMediaRouter {
? previous.lastCorrelatedAtMs
: null,
badWindowCount: canContinueStreak ? previous.badWindowCount : 0,
pendingViewerEvidence: { acceptedAtMs: now, evidence },
pendingViewerEvidence: { acceptedAtMs: now, maxFramerate, evidence },
});
}
@@ -626,7 +635,10 @@ export class HybridMediaRouter {
}
const hardBad =
isHardBadViewerQualityWindow(pending.evidence) &&
isHardBadViewerQualityWindow(
pending.evidence,
pending.maxFramerate,
) &&
isHardBadParentEdgeQualityProof(evidence.proof);
state.badWindowCount = hardBad
? state.lastCorrelatedAtMs !== null &&
@@ -3205,6 +3217,7 @@ function sameUpstream(
function isHardBadViewerQualityWindow(
evidence: Extract<ServerMessage, { type: "viewer-quality-evidence" }>,
maxFramerate: number,
): boolean {
const metrics = evidence.metrics;
const freezeRatio =
@@ -3221,6 +3234,12 @@ function isHardBadViewerQualityWindow(
) {
return true;
}
if (
metrics.framesPerSecond !== null &&
metrics.framesPerSecond < maxFramerate * VIEWER_QUALITY_MIN_FPS_RATIO
) {
return true;
}
if (
metrics.packetsReceivedDelta === null ||
metrics.packetsLostDelta === null
+3
View File
@@ -1122,6 +1122,9 @@ export class SignalingServer {
roomId: source.roomId,
viewerSessionId: viewerState.sessionId,
parentSessionId: parent.sessionId,
maxFramerate:
(this.qualitySettingsByRoom.get(source.roomId) ??
DEFAULT_QUALITY_SETTINGS).maxFramerate,
evidence: forwarded,
});
}
+211
View File
@@ -282,6 +282,62 @@ async function correlateParentEdgeQualityEvidence(
return viewerEvidence;
}
async function startPeerQualityReselectionFixture(
prefix: string,
now: () => number,
) {
const harness = await startHarness({
peerAssistedMedia: true,
now,
});
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", `${prefix}-host`),
);
const alternateParent = await openClient(harness.webSocketUrl);
const alternateParentAuth = peerAssisted(
await authenticate(
alternateParent,
harness.room,
"viewer",
`${prefix}-alternate`,
),
);
const viewer = await openClient(harness.webSocketUrl);
const viewerAuth = peerAssisted(
await authenticate(viewer, harness.room, "viewer", `${prefix}-viewer`),
);
expect(alternateParentAuth.routeAssignment.upstream).toEqual({
kind: "peer",
peerId: hostAuth.peerId,
});
expect(viewerAuth.routeAssignment.upstream).toEqual({
kind: "peer",
peerId: hostAuth.peerId,
});
const connectionId = `${prefix}_connection`;
host.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: viewerAuth.peerId,
payload: {
kind: "description",
connectionId,
description: { type: "offer", sdp: "v=0\r\n" },
},
}),
);
await viewer.inbox.next("signal");
return {
alternateParentAuth,
connectionId,
host,
viewer,
viewerAuth,
};
}
async function startSfuHarness(options: {
tokenIssuer: SfuTokenIssuer;
selectedEdgeTurn?: boolean;
@@ -3005,6 +3061,161 @@ describe("WebSocket signaling", () => {
acceptedParentEvidence.mockRestore();
});
it("requires hard parent proof before low-FPS windows reparent", async () => {
let now = 90_000;
const fixture = await startPeerQualityReselectionFixture(
"quality_low_fps_proof",
() => now,
);
let sequence = 0;
const sendWindow = async (
framesPerSecond: number,
proof: ParentEdgeQualityProof,
) => {
fixture.viewer.socket.send(
JSON.stringify(
viewerQualityEvidenceWithMetrics(
fixture.connectionId,
fixture.viewerAuth.routeRevision,
sequence,
{ framesPerSecond },
),
),
);
sequence += 1;
await correlateParentEdgeQualityEvidence(fixture.host, proof);
now += 2_000;
};
for (let index = 0; index < 3; index += 1) {
await sendWindow(40, { kind: "sending", packetsSentDelta: 1_500 });
}
await expect(
fixture.viewer.inbox.next("route-update", 40),
).rejects.toThrow("Timed out");
for (let index = 0; index < 3; index += 1) {
await sendWindow(49, {
kind: "sender-limited",
packetsSentDelta: 1_500,
reason: "bandwidth",
});
}
const moved = await nextActiveRouteAfter(
fixture.viewer,
fixture.viewerAuth.routeRevision,
);
expect(moved.assignment.upstream).toEqual({
kind: "peer",
peerId: fixture.alternateParentAuth.peerId,
});
});
it("resets a low-FPS streak at five-sixths of maxFramerate", async () => {
let now = 120_000;
const fixture = await startPeerQualityReselectionFixture(
"quality_low_fps_reset",
() => now,
);
let sequence = 0;
const sendWindow = async (framesPerSecond: number) => {
fixture.viewer.socket.send(
JSON.stringify(
viewerQualityEvidenceWithMetrics(
fixture.connectionId,
fixture.viewerAuth.routeRevision,
sequence,
{ framesPerSecond },
),
),
);
sequence += 1;
await correlateParentEdgeQualityEvidence(fixture.host, {
kind: "sender-limited",
packetsSentDelta: 1_500,
reason: "cpu",
});
now += 2_000;
};
await sendWindow(49);
await sendWindow(49);
await sendWindow(50);
await sendWindow(49);
await sendWindow(49);
await expect(
fixture.viewer.inbox.next("route-update", 40),
).rejects.toThrow("Timed out");
await sendWindow(49);
const moved = await nextActiveRouteAfter(
fixture.viewer,
fixture.viewerAuth.routeRevision,
);
expect(moved.assignment.upstream).toEqual({
kind: "peer",
peerId: fixture.alternateParentAuth.peerId,
});
});
it("uses the current room maxFramerate for each low-FPS window", async () => {
let now = 150_000;
const fixture = await startPeerQualityReselectionFixture(
"quality_low_fps_profile",
() => now,
);
let sequence = 0;
const sendWindow = async (framesPerSecond: number) => {
fixture.viewer.socket.send(
JSON.stringify(
viewerQualityEvidenceWithMetrics(
fixture.connectionId,
fixture.viewerAuth.routeRevision,
sequence,
{ framesPerSecond },
),
),
);
sequence += 1;
await correlateParentEdgeQualityEvidence(fixture.host, {
kind: "sender-limited",
packetsSentDelta: 1_500,
reason: "bandwidth",
});
now += 2_000;
};
await sendWindow(49);
await sendWindow(49);
fixture.host.socket.send(
JSON.stringify({
type: "set-quality-settings",
qualitySettings: balancedQualitySettings,
}),
);
expect(await fixture.viewer.inbox.next("quality-settings")).toEqual({
type: "quality-settings",
qualitySettings: balancedQualitySettings,
});
await sendWindow(29);
await sendWindow(24);
await sendWindow(24);
await expect(
fixture.viewer.inbox.next("route-update", 40),
).rejects.toThrow("Timed out");
await sendWindow(24);
const moved = await nextActiveRouteAfter(
fixture.viewer,
fixture.viewerAuth.routeRevision,
);
expect(moved.assignment.upstream).toEqual({
kind: "peer",
peerId: fixture.alternateParentAuth.peerId,
});
});
it("reparents only a viewer subtree after three hard quality windows", async () => {
let now = 100_000;
const harness = await startHarness({