From f81577c4c5e187fa93e51d080de0cceab2d54bce Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:26:41 +0800 Subject: [PATCH] feat(web): observe selected pair responses (#181) Add local, generation-guarded selected-pair response evidence without granting it routing or recovery authority. --- docs/research/peer-assisted-media.md | 49 ++++++++++ docs/status.md | 1 + docs/verification-status.md | 2 +- src/client/components/StatsGrid.tsx | 25 +++++ src/client/lib/diagnostic-export.ts | 2 + src/client/types.ts | 6 ++ src/client/webrtc/stats.ts | 49 ++++++++++ tests/client-state.test.ts | 139 ++++++++++++++++++++++++++- tests/diagnostic-export.test.ts | 6 ++ tests/stats-grid.test.ts | 8 ++ 10 files changed, 285 insertions(+), 2 deletions(-) diff --git a/docs/research/peer-assisted-media.md b/docs/research/peer-assisted-media.md index 7d50d4d..384218c 100644 --- a/docs/research/peer-assisted-media.md +++ b/docs/research/peer-assisted-media.md @@ -367,6 +367,55 @@ zero, then the current Web client advertises an explicit per-session capacity of one. The first production smoke scoped it to room `1`; current production no longer has that room boundary. There is no mobile/iPad, UA, or visibility branch. +## Selected-Pair Response Observation + +The W3C Stats specification makes `RTCTransportStats.selectedCandidatePairId` +the exact reference to the current `RTCIceCandidatePairStats`. A stats object's +`id` identifies the monitored object, while `responsesReceived` is the +cumulative number of connectivity-check responses received by that candidate +pair. Stats snapshots carry timestamps and applications derive interval values +from two snapshots. Unsupported fields are omitted, and a current pair is +deleted when the transport switches to a newly generated pair. These semantics +support a local observation; they do not define an application failure signal. +The existing compatibility fallback is retained only when that exact reference +is absent and exactly one same-transport succeeded nominated/selected pair is +available; an ambiguous or broken relationship remains unknown. + +RFC 7675 binds consent to one transport 5-tuple. An authenticated matching STUN +response refreshes consent, but the default consent-check interval is randomized +between four and six seconds and a response can match an earlier request. +Therefore zero `responsesReceived` growth over one two-second application sample +does not prove a silent partition. Conversely, growth proves only that a STUN +response was observed on that pair, not that media quality or future reachability +is healthy. Video-frame counters are independent, so a static shared frame must +not be classified as a transport failure. + +The bounded implementation uses one accumulator per `RTCPeerConnection` and the +selected pair's own stats timestamp. It reports the cumulative counter plus a +delta and elapsed milliseconds only across adjacent samples of the same pair. +The first sample, PC replacement, pair change or disappearance, missing/invalid +counter or timestamp, non-increasing timestamp, counter retreat, or a sampling +gap above five seconds reports the interval fields as unknown and establishes a +new baseline. The five-second ceiling is a named observation-window bound for +the current two-second sampler; it is not a consent timeout or a route policy. + +Acceptance for this slice is observation-only: + +- expanded local connection details show the opaque selected-pair identity, + cumulative `responsesReceived`, and the adjacent bounded delta/window; +- the click-only diagnostic allowlist may contain the cumulative counter, + interval delta, and interval duration, but not the pair ID, candidate + addresses/ports, raw stats, signaling, or credentials; +- a missing response counter or required timestamp remains unknown rather than + zero; and +- no value starts ICE restart, emits `route-failed`, changes topology/quality, + adds a wire or server ping, or uploads/persists telemetry. + +Sources, accessed 2026-08-22: + +- [W3C Identifiers for WebRTC's Statistics API](https://www.w3.org/TR/webrtc-stats/) +- [RFC 7675, Consent Freshness for WebRTC](https://www.rfc-editor.org/rfc/rfc7675.html) + ## Bounded Spike And Gates This historical gate assumed default-off configuration through diff --git a/docs/status.md b/docs/status.md index f5eba5c..cc2f932 100644 --- a/docs/status.md +++ b/docs/status.md @@ -15,6 +15,7 @@ This file is the current execution index. Load [verification status](./verificat - Capture precedes room creation. Balanced, automatic codec, and 128 kbps screen audio are defaults; clarity/fluid, H.264/VP8, and 64/128/256 kbps audio are deployed next-share choices. Codec and audio quality lock during a share. - Local-only connection details expose current Host SFU sender evidence, actual codec/fmtp and encoder fields, media-source/encode FPS evidence, A/V playout/jitter/concealment evidence, and selected candidate endpoints. These fields do not enter quality signaling, server state, logs, persistence, or route selection. +- A source candidate additionally shows the selected pair's local cumulative STUN responses and a bounded same-pair sample delta. It is observation-only, is not deployed, and does not trigger recovery or upload the pair identity. - The deployed pre-share self-check uses disposable, no-room resources for health, WSS, and STUN `srflx` checks and treats configured SFU as unverified until a real route. Expanded connection details provide click-only, versioned JSON export; its fixed allowlist excludes candidate addresses/ports, participant and room identity, raw signaling, URLs, and credentials, and no report is uploaded or persisted. - Private fragment grants enter directly; code-only Viewers need site access and private rooms additionally need their password. Credentials remain unstored. Web names and presence are session-only; duplicate names alone show an ID suffix. - Rooms default to eight Viewers and accept an explicit admission limit from one through sixteen. Current source is server-authoritative and role-aware: Host physical media edges are bounded to two, ordinary Browser Viewer downstream edges to one, and upstream receive is free. Active/provisional/selected overlays count, as does Host SFU publication. Deployment values 1/2 may only tighten these limits; old/malicious Viewer advertisements 2/3 cannot lift them. Web assignment execution also truncates physical children to Host2/Viewer1 as defense-in-depth, and deployment limit one promotes an occupied Host branch root before reserving the SFU publication slot. The wire retains `0/1/2/3` only as a future envelope, and SFU roots remain separately bounded to two. diff --git a/docs/verification-status.md b/docs/verification-status.md index 391cff9..3b0d7f1 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -33,7 +33,7 @@ This is the demand-loaded ledger for cross-cutting validation evidence and open - Relay resources: production is Host2/ordinary Browser Viewer1 and rejects any Viewer child2 regardless of advertised capacity. Two-child relay corroboration is therefore a future-capacity experiment, while the triggering child's edge-local MBB remains active. Relay CPU/GPU/upload cost and heterogeneous-network behavior remain unverified. Finish the role-aware matrix before the 20-viewer gate or any future capacity-tier change. - SFU: production deploys ordered `q,h`, roots <=2, and Dynacast off. Real zero-child shaped LiveKit packet flow, BWE downshift/recovery, resource cost, and root-with-children behavior remain open. Use Linux `tc` or a public canary for per-leaf shaping, never CDP throttling. - Audio: verify the 64/128/256 kbps Share settings, negotiated/observed bitrate, audible music quality, route changes, and game A/V synchronization. Bitrate settings are ceilings, not quality proof. -- Observability: the UI and local stats plumbing are deployed, but target-browser availability and actual values remain unverified for Host SFU sender evidence, codec/fmtp and encoder implementation/power efficiency, media-source and encoded FPS, A/V playout/jitter/concealment, and selected candidate endpoints. These fields diagnose; they do not by themselves prove hardware encode, synchronization quality, or route quality. +- Observability: the UI and local stats plumbing are deployed, but target-browser availability and actual values remain unverified for Host SFU sender evidence, codec/fmtp and encoder implementation/power efficiency, media-source and encoded FPS, A/V playout/jitter/concealment, and selected candidate endpoints. A source-only candidate adds bounded same-pair STUN-response observation without recovery authority. These fields diagnose; they do not by themselves prove hardware encode, synchronization quality, or route quality. - Mobile: Android/iOS remain Viewer-only. Autoplay, rotation, iOS lock-screen/page reclamation, background reconnection, and network changes need device evidence. AirPlay/system mirroring is Viewer-local output, not a portable in-app WebRTC output contract. - Native: ADR-0006 has one-Viewer evidence only. Viewer2/FIFO, hardware diversity, endurance, downloaded-package/public-host, game A/V, and browser diversity remain open. - Access and operations: run headful/production checks for fragment consumption, rotate/revoke, and request/nginx/journal/SQLite leakage. The isolated VM/IP gate remains required before clean-port migration; shared-IP smoke cannot approve it. diff --git a/src/client/components/StatsGrid.tsx b/src/client/components/StatsGrid.tsx index cd1f174..d15cd58 100644 --- a/src/client/components/StatsGrid.tsx +++ b/src/client/components/StatsGrid.tsx @@ -53,6 +53,21 @@ function candidateEndpoint( return `${host}:${port}`; } +function candidatePairResponses(metrics: ConnectionMetrics): string { + if (metrics.candidatePairResponsesReceived === null) { + return "未知"; + } + if ( + metrics.intervalCandidatePairResponsesReceived === null || + metrics.candidatePairSampleWindowMs === null + ) { + return `${metrics.candidatePairResponsesReceived} 累计 · 区间未知`; + } + return `${metrics.candidatePairResponsesReceived} 累计 · +${ + metrics.intervalCandidatePairResponsesReceived + } / ${(metrics.candidatePairSampleWindowMs / 1_000).toFixed(1)} s`; +} + function Metric({ label, value, title }: { label: string; value: string; title?: string }) { return (
@@ -164,6 +179,16 @@ export function StatsGrid({ title={remoteCandidateEndpoint} /> )} + + {metrics.codecProfile && ( diff --git a/src/client/lib/diagnostic-export.ts b/src/client/lib/diagnostic-export.ts index b502d9d..a2e0e9e 100644 --- a/src/client/lib/diagnostic-export.ts +++ b/src/client/lib/diagnostic-export.ts @@ -4,6 +4,8 @@ export const DIAGNOSTIC_SCHEMA_VERSION = 1; const DIAGNOSTIC_METRIC_KEYS = [ "sampleWindowMs", "captureWidth", "captureHeight", + "candidatePairResponsesReceived", "intervalCandidatePairResponsesReceived", + "candidatePairSampleWindowMs", "captureFramesPerSecond", "mediaSourceFramesPerSecond", "path", "iceProtocol", "localRelayProtocol", "localCandidateType", "remoteCandidateType", "rttMs", "bitrateKbps", "availableOutgoingKbps", diff --git a/src/client/types.ts b/src/client/types.ts index 6db01ac..0cad9a2 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -15,6 +15,9 @@ export interface ConnectionMetrics { rtpRid: string | null; trackIdentifier: string | null; selectedCandidatePairId: string | null; + candidatePairResponsesReceived: number | null; + intervalCandidatePairResponsesReceived: number | null; + candidatePairSampleWindowMs: number | null; captureWidth: number | null; captureHeight: number | null; captureFramesPerSecond: number | null; @@ -93,6 +96,9 @@ export const EMPTY_METRICS: ConnectionMetrics = { rtpRid: null, trackIdentifier: null, selectedCandidatePairId: null, + candidatePairResponsesReceived: null, + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, captureWidth: null, captureHeight: null, captureFramesPerSecond: null, diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts index 2a6fec9..f90d9ef 100644 --- a/src/client/webrtc/stats.ts +++ b/src/client/webrtc/stats.ts @@ -10,7 +10,12 @@ type StatsRecord = Record & { timestamp: number; }; +const MAX_CANDIDATE_PAIR_SAMPLE_WINDOW_MS = 5_000; + export interface StatsAccumulator { + candidatePairId: string | null; + candidatePairResponsesReceived: number | null; + candidatePairTimestamp: number | null; mediaId: string | null; ssrc: number | null; trackIdentifier: string | null; @@ -76,6 +81,9 @@ export function captureMetrics( export function createStatsAccumulator(): StatsAccumulator { return { + candidatePairId: null, + candidatePairResponsesReceived: null, + candidatePairTimestamp: null, mediaId: null, ssrc: null, trackIdentifier: null, @@ -354,6 +362,16 @@ function positiveIntegerValue( return value !== null && Number.isInteger(value) && value > 0 ? value : null; } +function nonNegativeIntegerValue( + record: StatsRecord | null, + key: string, +): number | null { + const value = numberValue(record, key); + return value !== null && Number.isSafeInteger(value) && value >= 0 + ? value + : null; +} + function candidateAddressValue(record: StatsRecord | null): string | null { const value = stringValue(record, "address"); return value !== null && @@ -433,6 +451,34 @@ export function collectConnectionMetricsFromReport( ? "direct" : "unknown"; + const candidatePairId = pair?.id ?? null; + const candidatePairResponsesReceived = nonNegativeIntegerValue( + pair, + "responsesReceived", + ); + const candidatePairTimestamp = numberValue(pair, "timestamp"); + const candidatePairSampleWindowMs = + candidatePairId !== null && + candidatePairId === previous.candidatePairId && + candidatePairResponsesReceived !== null && + previous.candidatePairResponsesReceived !== null && + candidatePairResponsesReceived >= previous.candidatePairResponsesReceived && + candidatePairTimestamp !== null && + previous.candidatePairTimestamp !== null && + candidatePairTimestamp > previous.candidatePairTimestamp && + candidatePairTimestamp - previous.candidatePairTimestamp <= + MAX_CANDIDATE_PAIR_SAMPLE_WINDOW_MS + ? candidatePairTimestamp - previous.candidatePairTimestamp + : null; + const intervalCandidatePairResponsesReceived = intervalDelta( + candidatePairResponsesReceived, + previous.candidatePairResponsesReceived, + candidatePairSampleWindowMs !== null, + ); + previous.candidatePairId = candidatePairId; + previous.candidatePairResponsesReceived = candidatePairResponsesReceived; + previous.candidatePairTimestamp = candidatePairTimestamp; + const mediaId = media?.id ?? null; const ssrc = numberValue(media, "ssrc"); const trackIdentifier = media @@ -763,6 +809,9 @@ export function collectConnectionMetricsFromReport( rtpRid: stringValue(media, "rid"), trackIdentifier, selectedCandidatePairId: pair?.id ?? null, + candidatePairResponsesReceived, + intervalCandidatePairResponsesReceived, + candidatePairSampleWindowMs, path, iceProtocol, localRelayProtocol, diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index f75b5d6..9babde2 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -33,7 +33,11 @@ import { labelViewerPresence, } from "../src/client/lib/viewer-presence.ts"; import { qualityEvidenceWindowFromMetrics } from "../src/client/media/viewer-quality-evidence.ts"; -import { createStatsAccumulator, collectConnectionMetrics } from "../src/client/webrtc/stats.ts"; +import { + collectConnectionMetrics, + collectConnectionMetricsFromReport, + createStatsAccumulator, +} from "../src/client/webrtc/stats.ts"; afterEach(() => { vi.unstubAllGlobals(); @@ -798,6 +802,139 @@ describe("client signaling recovery policy", () => { }); describe("WebRTC stats parsing", () => { + it("rebases selected-pair response observations across identity and sampling gaps", () => { + const report = ( + pairId: string, + timestamp: number, + responsesReceived?: unknown, + ) => + new Map([ + [ + "transport", + { + id: "transport", + type: "transport", + timestamp, + selectedCandidatePairId: pairId, + }, + ], + [ + pairId, + { + id: pairId, + type: "candidate-pair", + timestamp, + transportId: "transport", + localCandidateId: "local", + remoteCandidateId: "remote", + ...(responsesReceived === undefined ? {} : { responsesReceived }), + }, + ], + [ + "local", + { + id: "local", + type: "local-candidate", + timestamp, + candidateType: "host", + protocol: "udp", + }, + ], + [ + "remote", + { + id: "remote", + type: "remote-candidate", + timestamp, + candidateType: "srflx", + protocol: "udp", + }, + ], + [ + "inbound", + { + id: "inbound", + type: "inbound-rtp", + timestamp, + kind: "video", + transportId: "transport", + bytesReceived: 1_000, + framesDecoded: 10, + }, + ], + ]) as unknown as RTCStatsReport; + const accumulator = createStatsAccumulator(); + const sample = ( + pairId: string, + timestamp: number, + responsesReceived?: unknown, + ) => + collectConnectionMetricsFromReport( + report(pairId, timestamp, responsesReceived), + "receive", + accumulator, + ); + + expect(sample("pair-a", 1_000, 5)).toMatchObject({ + selectedCandidatePairId: "pair-a", + candidatePairResponsesReceived: 5, + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + expect(sample("pair-a", 3_000, 6)).toMatchObject({ + candidatePairResponsesReceived: 6, + intervalCandidatePairResponsesReceived: 1, + candidatePairSampleWindowMs: 2_000, + }); + expect(sample("pair-a", 5_000, 6)).toMatchObject({ + intervalCandidatePairResponsesReceived: 0, + candidatePairSampleWindowMs: 2_000, + }); + expect(sample("pair-a", 7_000, 1)).toMatchObject({ + candidatePairResponsesReceived: 1, + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + expect(sample("pair-a", 9_000, 2)).toMatchObject({ + intervalCandidatePairResponsesReceived: 1, + candidatePairSampleWindowMs: 2_000, + }); + expect(sample("pair-b", 11_000, 10)).toMatchObject({ + selectedCandidatePairId: "pair-b", + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + expect(sample("pair-b", 16_001, 11)).toMatchObject({ + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + expect(sample("pair-b", 18_001, 12)).toMatchObject({ + intervalCandidatePairResponsesReceived: 1, + candidatePairSampleWindowMs: 2_000, + }); + expect(sample("pair-b", 20_001)).toMatchObject({ + candidatePairResponsesReceived: null, + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + expect(sample("pair-b", 22_001, 13)).toMatchObject({ + candidatePairResponsesReceived: 13, + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + expect( + collectConnectionMetricsFromReport( + report("pair-b", 24_001, 14), + "receive", + createStatsAccumulator(), + ), + ).toMatchObject({ + candidatePairResponsesReceived: 14, + intervalCandidatePairResponsesReceived: null, + candidatePairSampleWindowMs: null, + }); + }); + it("binds media, remote evidence, transport, and pair without guessing", async () => { const entry = ( id: string, diff --git a/tests/diagnostic-export.test.ts b/tests/diagnostic-export.test.ts index 98de8f3..76c0735 100644 --- a/tests/diagnostic-export.test.ts +++ b/tests/diagnostic-export.test.ts @@ -29,6 +29,9 @@ describe("diagnostic export privacy boundary", () => { framesPerSecond: 59.8, codec: "video/VP8", audioVideoPlayoutDeltaMs: -12.5, + candidatePairResponsesReceived: 18, + intervalCandidatePairResponsesReceived: 1, + candidatePairSampleWindowMs: 2_000, }, }, ], @@ -50,6 +53,9 @@ describe("diagnostic export privacy boundary", () => { framesPerSecond: 59.8, codec: "video/VP8", audioVideoPlayoutDeltaMs: -12.5, + candidatePairResponsesReceived: 18, + intervalCandidatePairResponsesReceived: 1, + candidatePairSampleWindowMs: 2_000, audioJitterMs: null, }, }); diff --git a/tests/stats-grid.test.ts b/tests/stats-grid.test.ts index 75ffa15..fe7395c 100644 --- a/tests/stats-grid.test.ts +++ b/tests/stats-grid.test.ts @@ -23,6 +23,10 @@ const metrics = { localCandidatePort: 50_000, remoteCandidateAddress: "2001:db8::10", remoteCandidatePort: 50_001, + selectedCandidatePairId: "candidate-pair-7", + candidatePairResponsesReceived: 18, + intervalCandidatePairResponsesReceived: 1, + candidatePairSampleWindowMs: 2_000, codec: "video/VP8", codecParameters: "max-fs=8160", captureWidth: 1920, @@ -75,6 +79,10 @@ describe("StatsGrid progressive disclosure", () => { expect(html.indexOf("远端候选地址")).toBeGreaterThan(panelStart); expect(html).toContain("192.0.2.10:50000"); expect(html).toContain("[2001:db8::10]:50001"); + expect(html).toContain("候选对 ID"); + expect(html).toContain("candidate-pair-7"); + expect(html).toContain("STUN 响应"); + expect(html).toContain("18 累计 · +1 / 2.0 s"); expect(html.indexOf("视频 Codec")).toBeGreaterThan(panelStart); expect(html.indexOf("音频发送码率")).toBeGreaterThan(panelStart); expect(html.indexOf("编码输入帧率")).toBeGreaterThan(panelStart);