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);