From ed57d3ed5e98043da7a1de55fcb447af594fc384 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:01:04 +0800 Subject: [PATCH] fix(media): reselect persistently low-fps relay edges [skip ci] --- .../0005-automatic-hybrid-media-routing.md | 17 +- docs/research/advanced-peer-distribution.md | 12 +- docs/status.md | 2 +- src/server/hybrid-media-router.ts | 25 ++- src/server/signaling.ts | 3 + tests/server-signal.test.ts | 211 ++++++++++++++++++ 6 files changed, 254 insertions(+), 16 deletions(-) diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index d2e076a..38c47d4 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -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. diff --git a/docs/research/advanced-peer-distribution.md b/docs/research/advanced-peer-distribution.md index 36bfa09..cb0da5f 100644 --- a/docs/research/advanced-peer-distribution.md +++ b/docs/research/advanced-peer-distribution.md @@ -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 diff --git a/docs/status.md b/docs/status.md index c70ea3c..748a47f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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. diff --git a/src/server/hybrid-media-router.ts b/src/server/hybrid-media-router.ts index 2319566..b292d75 100644 --- a/src/server/hybrid-media-router.ts +++ b/src/server/hybrid-media-router.ts @@ -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; } | null; } @@ -130,6 +132,7 @@ interface ForwardedViewerQualityEvidence { roomId: string; viewerSessionId: string; parentSessionId: string; + maxFramerate: number; evidence: Extract; } @@ -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, + 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 diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 1c0ad4b..885a168 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -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, }); } diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 7881e09..1db58de 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -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({