cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

feat: add bounded Portal network diagnostics (#26)

FelineStateMachine committed 9816a4e
Sample media, input, and audio Iroh paths into fixed-size session telemetry and negotiate cumulative input acknowledgements without regressing older pointer-capability fallbacks. Render the versioned snapshot in Portal with strict validation, residency, loss, RTT, and input-apply latency diagnostics.
commit
9816a4ef4989fbb84838cf64a67a7e37f2ee77ad
parent
8b49df3f0c264600937aa55b3f5d8d25c6cad17b
author
FelineStateMachine <dami.etoile@gmail.com>
9 files changed, +1510 -119
Mportal/frame-stats.mjs15+
Mportal/frame-stats.test.mjs71+++
Mportal/index.html5+
Mportal/main.js10+
Aportal/network-diagnostics.mjs275+++++++++++
Aportal/network-diagnostics.test.mjs169+++++++
Msrc-tauri/src/commands/mod.rs1+
Msrc-tauri/src/commands/network.rs356++++++++++-----
Asrc-tauri/src/commands/network_diagnostics.rs727++++++++++++++++++++++++++++++
M portal/frame-stats.mjs +15 -0
@@ -1,3 +1,5 @@
1+import { normalizeNetworkDiagnostics } from './network-diagnostics.mjs';2+13 function exactUnsigned(value) {24 return Number.isSafeInteger(value) && value >= 0 ? value : null;35 }
@@ -82,6 +84,7 @@ export function normalizeFrameStatsPayload(payload) {
8284 const statsVersion = exactUnsigned(payload.stats_version) ?? 1;8385 const v2 = statsVersion >= 2;8486 const v3 = statsVersion >= 3;87+ const v4 = statsVersion >= 4;8588 const queueDroppedFrames = v2 ? exactUnsigned(payload.frontend_queue_dropped_total) : null;8689 const resyncDroppedFrames = v2 ? exactUnsigned(payload.frontend_resync_dropped_total) : null;8790 const splitFrontendTotal = queueDroppedFrames !== null && resyncDroppedFrames !== null
@@ -101,6 +104,7 @@ export function normalizeFrameStatsPayload(payload) {
101104 let transportIntervals = null;102105 let ipcSendDurations = null;103106 let timingWindow = null;107+ let networkDiagnostics = null;104108 if (v2) {105109 const depth = exactUnsigned(payload.frontend_queue_depth);106110 const peak = exactUnsigned(payload.frontend_queue_peak);
@@ -130,6 +134,16 @@ export function normalizeFrameStatsPayload(payload) {
130134 }131135 }132136 137+ if (v4 && payload.network_diagnostics !== null && payload.network_diagnostics !== undefined) {138+ try {139+ networkDiagnostics = normalizeNetworkDiagnostics(payload.network_diagnostics);140+ } catch (_) {141+ // Network diagnostics are supplemental. A malformed snapshot must not142+ // discard otherwise valid frame statistics or be partially displayed.143+ networkDiagnostics = null;144+ }145+ }146+133147 return {134148 statsVersion,135149 transportDroppedFrames: firstExactUnsigned(
@@ -150,5 +164,6 @@ export function normalizeFrameStatsPayload(payload) {
150164 transportIntervals,151165 ipcSendDurations,152166 timingWindow,167+ networkDiagnostics,153168 };154169 }
M portal/frame-stats.test.mjs +71 -0
@@ -90,6 +90,7 @@ test('normalizes exact v2 drop, queue, timing, and resync units', () => {
9090 transportIntervals: { count: 3, p50Ms: 16.6, p95Ms: 25.1, maxMs: 40.2 },9191 ipcSendDurations: { count: 2, p50Ms: 0.1, p95Ms: 0.4, maxMs: 0.4 },9292 timingWindow: { windowMs: 5000, sampleCapacity: 512 },93+ networkDiagnostics: null,9394 });9495 });9596
@@ -110,6 +111,76 @@ test('uses legacy aggregate drop aliases without inventing v2 splits', () => {
110111 assert.equal(stats.transportIntervals, null);111112 assert.equal(stats.ipcSendDurations, null);112113 assert.equal(stats.timingWindow, null);114+ assert.equal(stats.networkDiagnostics, null);115+});116+117+test('normalizes v4 network diagnostics without changing v1-v3 behavior', () => {118+ const leg = {119+ mode: 'direct',120+ sample_count: 1,121+ direct_samples: 1,122+ relay_samples: 0,123+ custom_samples: 0,124+ unknown_samples: 0,125+ path_epoch: 1,126+ path_switch_total: 0,127+ mode_transition_total: 0,128+ rtt_sample_count: 1,129+ rtt_current_ms: 0.8,130+ rtt_p50_ms: 5,131+ rtt_p95_ms: 5,132+ rtt_max_ms: 5,133+ rtt_overflow_total: 0,134+ tx_datagrams: 1,135+ tx_bytes: 100,136+ rx_datagrams: 1,137+ rx_bytes: 100,138+ lost_packets: 0,139+ lost_bytes: 0,140+ cwnd_bytes: 12000,141+ mtu: 1200,142+ congestion_events: 0,143+ black_holes_detected: 0,144+ counter_regression_total: 0,145+ complete: true,146+ };147+ const inputAck = {148+ negotiated: true,149+ sent_total: 1,150+ acknowledged_total: 1,151+ pending_count: 0,152+ pending_capacity: 64,153+ duplicate_total: 0,154+ untracked_total: 0,155+ malformed: false,156+ closed: false,157+ latency_sample_count: 1,158+ latency_p50_ms: 2,159+ latency_p95_ms: 2,160+ latency_max_ms: 2,161+ latency_overflow_total: 0,162+ complete: true,163+ };164+ const raw = {165+ version: 1,166+ session_elapsed_ms: 1000,167+ media: leg,168+ input: leg,169+ audio: null,170+ input_ack: inputAck,171+ };172+173+ const v4 = normalizeFrameStatsPayload({ stats_version: 4, network_diagnostics: raw });174+ assert.equal(v4.networkDiagnostics.media.mode, 'direct');175+ assert.equal(v4.networkDiagnostics.inputAck.acknowledgedTotal, 1);176+177+ const v3 = normalizeFrameStatsPayload({ stats_version: 3, network_diagnostics: raw });178+ assert.equal(v3.networkDiagnostics, null);179+ const malformed = normalizeFrameStatsPayload({180+ stats_version: 4,181+ network_diagnostics: { ...raw, media: { ...leg, complete: 'yes' } },182+ });183+ assert.equal(malformed.networkDiagnostics, null);113184 });114185 115186 test('normalizes bounded v3 independent-object discard counters', () => {
M portal/index.html +5 -0
@@ -200,6 +200,11 @@
200200 <div class="info-row"><span class="info-label">transport</span><span class="info-value" id="stream-transport">—</span></div>201201 <div class="info-row"><span class="info-label">path</span><span class="info-value" id="stream-path">—</span></div>202202 <div class="info-row"><span class="info-label">RTT</span><span class="info-value" id="stream-rtt">— ms</span></div>203+ <div class="info-row"><span class="info-label">network snapshot</span><span class="info-value" id="stream-network-session">unavailable</span></div>204+ <div class="info-row metric-stack"><span class="info-label">media network</span><span class="info-value" id="stream-network-media">unavailable</span></div>205+ <div class="info-row metric-stack"><span class="info-label">input network</span><span class="info-value" id="stream-network-input">unavailable</span></div>206+ <div class="info-row metric-stack"><span class="info-label">audio network</span><span class="info-value" id="stream-network-audio">unavailable</span></div>207+ <div class="info-row metric-stack"><span class="info-label">input ACK</span><span class="info-value" id="stream-network-input-ack">unavailable</span></div>203208 <div class="info-row metric-stack"><span class="info-label">adaptive feedback</span><span class="info-value" id="stream-adaptive-feedback">unavailable</span></div>204209 <div class="info-row metric-stack"><span class="info-label">adaptive decision</span><span class="info-value" id="stream-adaptive-decision">unavailable</span></div>205210 <div class="info-row"><span class="info-label">control</span><span class="info-value" id="stream-control">—</span></div>
M portal/main.js +10 -0
@@ -31,6 +31,7 @@ import {
3131 isCurrentFrameGeneration,3232 normalizeFrameStatsPayload,3333 } from './frame-stats.mjs';34+import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';3435 import {3536 FRAME_CHANNEL_CAPACITY,3637 activateFrameSession,
@@ -1088,6 +1089,7 @@ let frontendResyncStats = null;
10881089 let transportIntervalStats = null;10891090 let frontendIpcSendDurationStats = null;10901091 let rustTimingWindow = null;1092+let networkDiagnostics = null;10911093 let receivedFrames = 0;10921094 let decoderInputFrames = 0;10931095 let decoderOutputFrames = 0;
@@ -1252,6 +1254,7 @@ function resetStreamTelemetry() {
12521254 transportIntervalStats = null;12531255 frontendIpcSendDurationStats = null;12541256 rustTimingWindow = null;1257+ networkDiagnostics = null;12551258 receivedFrames = 0;12561259 decoderInputFrames = 0;12571260 decoderOutputFrames = 0;
@@ -1364,6 +1367,7 @@ function updateStreamStats() {
13641367 const decoderOutputIntervals = decoderOutputCadence.summary(now);13651368 const presentationIntervals = presentationCadence.summary(now);13661369 const avSkewPercentiles = avSkew.summary(now);1370+ const networkPresentation = networkDiagnosticsPresentation(networkDiagnostics);13671371 document.getElementById('stream-received').textContent = receivedFrames;13681372 document.getElementById('stream-decoder-input').textContent = decoderInputFrames;13691373 document.getElementById('stream-decoder-output').textContent = decoderOutputFrames;
@@ -1429,6 +1433,11 @@ function updateStreamStats() {
14291433 document.getElementById('stream-rtt').textContent = Number.isFinite(streamRttMs)14301434 ? `${streamRttMs.toFixed(1)} ms`14311435 : '— ms';1436+ document.getElementById('stream-network-session').textContent = networkPresentation.session;1437+ document.getElementById('stream-network-media').textContent = networkPresentation.media;1438+ document.getElementById('stream-network-input').textContent = networkPresentation.input;1439+ document.getElementById('stream-network-audio').textContent = networkPresentation.audio;1440+ document.getElementById('stream-network-input-ack').textContent = networkPresentation.inputAck;14321441 document.getElementById('stream-adaptive-feedback').textContent = adaptiveFeedbackAvailable14331442 ? 'authenticated · 1 Hz bounded reports'14341443 : adaptiveFeedbackError || 'unavailable';
@@ -1956,6 +1965,7 @@ listen('frame-stats', (event) => {
19561965 transportIntervalStats = diagnostics.transportIntervals;19571966 frontendIpcSendDurationStats = diagnostics.ipcSendDurations;19581967 rustTimingWindow = diagnostics.timingWindow;1968+ networkDiagnostics = diagnostics.networkDiagnostics;19591969 streamPathMode = typeof event.payload.path_mode === 'string'19601970 ? event.payload.path_mode19611971 : streamPathMode;
A portal/network-diagnostics.mjs +275 -0
@@ -0,0 +1,275 @@
1+const NETWORK_DIAGNOSTICS_VERSION = 1;2+const PATH_MODES = new Set(['direct', 'relay', 'custom', 'unknown']);3+4+function record(value, label) {5+ if (!value || typeof value !== 'object' || Array.isArray(value)) {6+ throw new TypeError(`${label} must be an object`);7+ }8+ return value;9+}10+11+function exactUnsigned(value, label) {12+ if (!Number.isSafeInteger(value) || value < 0) {13+ throw new TypeError(`${label} must be an exact unsigned integer`);14+ }15+ return value;16+}17+18+function optionalExactUnsigned(value, label) {19+ return value === null ? null : exactUnsigned(value, label);20+}21+22+function boolean(value, label) {23+ if (typeof value !== 'boolean') throw new TypeError(`${label} must be a boolean`);24+ return value;25+}26+27+function durationMs(value, label) {28+ if (!Number.isFinite(value) || value < 0) {29+ throw new TypeError(`${label} must be a finite non-negative duration in milliseconds`);30+ }31+ return value;32+}33+34+function percentileSummary(value, prefix, sampleCount, overflowTotal) {35+ const p50 = value[`${prefix}_p50_ms`];36+ const p95 = value[`${prefix}_p95_ms`];37+ const max = value[`${prefix}_max_ms`];38+ if (sampleCount === 0) {39+ if (overflowTotal !== 0 || p50 !== null || p95 !== null || max !== null) {40+ throw new TypeError(`${prefix} percentiles require at least one retained sample`);41+ }42+ return { sampleCount, p50Ms: null, p95Ms: null, maxMs: null, overflowTotal };43+ }44+45+ if (overflowTotal > sampleCount) {46+ throw new RangeError(`${prefix}_overflow_total cannot exceed its sample count`);47+ }48+ const retainedSamples = sampleCount - overflowTotal;49+ const p50Available = retainedSamples >= Math.ceil(sampleCount * 0.5);50+ const p95Available = retainedSamples >= Math.ceil(sampleCount * 0.95);51+ if ((p50 !== null) !== p50Available || (p95 !== null) !== p95Available) {52+ throw new TypeError(`${prefix} percentile availability does not match bounded samples`);53+ }54+ const p50Ms = p50 === null ? null : durationMs(p50, `${prefix}_p50_ms`);55+ const p95Ms = p95 === null ? null : durationMs(p95, `${prefix}_p95_ms`);56+ const maxMs = durationMs(max, `${prefix}_max_ms`);57+ if ((p50Ms !== null && p50Ms > maxMs)58+ || (p95Ms !== null && p95Ms > maxMs)59+ || (p50Ms !== null && p95Ms !== null && p50Ms > p95Ms)) {60+ throw new RangeError(`${prefix} percentiles must be monotonic`);61+ }62+ return { sampleCount, p50Ms, p95Ms, maxMs, overflowTotal };63+}64+65+function normalizeLeg(value, label) {66+ const leg = record(value, label);67+ if (!PATH_MODES.has(leg.mode)) throw new TypeError(`${label}.mode is unsupported`);68+69+ const sampleCount = exactUnsigned(leg.sample_count, `${label}.sample_count`);70+ const directSamples = exactUnsigned(leg.direct_samples, `${label}.direct_samples`);71+ const relaySamples = exactUnsigned(leg.relay_samples, `${label}.relay_samples`);72+ const customSamples = exactUnsigned(leg.custom_samples, `${label}.custom_samples`);73+ const unknownSamples = exactUnsigned(leg.unknown_samples, `${label}.unknown_samples`);74+ const classifiedSamples = directSamples + relaySamples + customSamples + unknownSamples;75+ if (!Number.isSafeInteger(classifiedSamples) || classifiedSamples !== sampleCount) {76+ throw new RangeError(`${label} path sample buckets must exactly match sample_count`);77+ }78+79+ const rttSampleCount = exactUnsigned(leg.rtt_sample_count, `${label}.rtt_sample_count`);80+ const rttOverflowTotal = exactUnsigned(leg.rtt_overflow_total, `${label}.rtt_overflow_total`);81+ const rtt = percentileSummary(leg, 'rtt', rttSampleCount, rttOverflowTotal);82+ const rttCurrentMs = leg.rtt_current_ms === null83+ ? null84+ : durationMs(leg.rtt_current_ms, `${label}.rtt_current_ms`);85+ if ((rttSampleCount === 0 || leg.mode === 'unknown') && rttCurrentMs !== null) {86+ throw new RangeError(`${label}.rtt_current_ms requires a selected measured path`);87+ }88+89+ const counterRegressionTotal = exactUnsigned(90+ leg.counter_regression_total,91+ `${label}.counter_regression_total`,92+ );93+ const complete = boolean(leg.complete, `${label}.complete`);94+ if (complete !== (counterRegressionTotal === 0 && rttOverflowTotal === 0)) {95+ throw new RangeError(`${label}.complete is inconsistent with bounded telemetry state`);96+ }97+ if (rttSampleCount > sampleCount) {98+ throw new RangeError(`${label}.rtt_sample_count cannot exceed sample_count`);99+ }100+101+ return {102+ mode: leg.mode,103+ sampleCount,104+ pathSamples: {105+ direct: directSamples,106+ relay: relaySamples,107+ custom: customSamples,108+ unknown: unknownSamples,109+ },110+ pathEpoch: exactUnsigned(leg.path_epoch, `${label}.path_epoch`),111+ pathSwitchTotal: exactUnsigned(leg.path_switch_total, `${label}.path_switch_total`),112+ modeTransitionTotal: exactUnsigned(113+ leg.mode_transition_total,114+ `${label}.mode_transition_total`,115+ ),116+ rttCurrentMs,117+ rtt,118+ txDatagrams: exactUnsigned(leg.tx_datagrams, `${label}.tx_datagrams`),119+ txBytes: exactUnsigned(leg.tx_bytes, `${label}.tx_bytes`),120+ rxDatagrams: exactUnsigned(leg.rx_datagrams, `${label}.rx_datagrams`),121+ rxBytes: exactUnsigned(leg.rx_bytes, `${label}.rx_bytes`),122+ lostPackets: exactUnsigned(leg.lost_packets, `${label}.lost_packets`),123+ lostBytes: exactUnsigned(leg.lost_bytes, `${label}.lost_bytes`),124+ cwndBytes: optionalExactUnsigned(leg.cwnd_bytes, `${label}.cwnd_bytes`),125+ mtu: optionalExactUnsigned(leg.mtu, `${label}.mtu`),126+ congestionEvents: optionalExactUnsigned(leg.congestion_events, `${label}.congestion_events`),127+ blackHolesDetected: optionalExactUnsigned(128+ leg.black_holes_detected,129+ `${label}.black_holes_detected`,130+ ),131+ counterRegressionTotal,132+ complete,133+ };134+}135+136+function normalizeInputAck(value) {137+ const ack = record(value, 'network_diagnostics.input_ack');138+ const sentTotal = exactUnsigned(ack.sent_total, 'network_diagnostics.input_ack.sent_total');139+ const acknowledgedTotal = exactUnsigned(140+ ack.acknowledged_total,141+ 'network_diagnostics.input_ack.acknowledged_total',142+ );143+ const pendingCount = exactUnsigned(144+ ack.pending_count,145+ 'network_diagnostics.input_ack.pending_count',146+ );147+ const pendingCapacity = exactUnsigned(148+ ack.pending_capacity,149+ 'network_diagnostics.input_ack.pending_capacity',150+ );151+ if (acknowledgedTotal > sentTotal) {152+ throw new RangeError('acknowledged_total cannot exceed sent_total');153+ }154+ if (pendingCount > pendingCapacity) {155+ throw new RangeError('pending_count cannot exceed pending_capacity');156+ }157+158+ const latencySampleCount = exactUnsigned(159+ ack.latency_sample_count,160+ 'network_diagnostics.input_ack.latency_sample_count',161+ );162+ const latencyOverflowTotal = exactUnsigned(163+ ack.latency_overflow_total,164+ 'network_diagnostics.input_ack.latency_overflow_total',165+ );166+167+ const malformed = boolean(ack.malformed, 'network_diagnostics.input_ack.malformed');168+ const closed = boolean(ack.closed, 'network_diagnostics.input_ack.closed');169+ const untrackedTotal = exactUnsigned(170+ ack.untracked_total,171+ 'network_diagnostics.input_ack.untracked_total',172+ );173+ const complete = boolean(ack.complete, 'network_diagnostics.input_ack.complete');174+ if (complete !== (!malformed && !closed && untrackedTotal === 0 && latencyOverflowTotal === 0)) {175+ throw new RangeError(176+ 'network_diagnostics.input_ack.complete is inconsistent with bounded telemetry state',177+ );178+ }179+180+ return {181+ negotiated: boolean(ack.negotiated, 'network_diagnostics.input_ack.negotiated'),182+ sentTotal,183+ acknowledgedTotal,184+ pendingCount,185+ pendingCapacity,186+ duplicateTotal: exactUnsigned(187+ ack.duplicate_total,188+ 'network_diagnostics.input_ack.duplicate_total',189+ ),190+ untrackedTotal,191+ malformed,192+ closed,193+ latency: percentileSummary(ack, 'latency', latencySampleCount, latencyOverflowTotal),194+ complete,195+ };196+}197+198+/**199+ * Strictly validates the versioned network snapshot and returns a presentation-safe200+ * allowlist. Peer identities, addresses, and unknown future fields never cross this boundary.201+ */202+export function normalizeNetworkDiagnostics(value) {203+ const diagnostics = record(value, 'network_diagnostics');204+ if (diagnostics.version !== NETWORK_DIAGNOSTICS_VERSION) {205+ throw new TypeError(`unsupported network_diagnostics version: ${diagnostics.version}`);206+ }207+208+ return {209+ version: NETWORK_DIAGNOSTICS_VERSION,210+ sessionElapsedMs: exactUnsigned(211+ diagnostics.session_elapsed_ms,212+ 'network_diagnostics.session_elapsed_ms',213+ ),214+ media: normalizeLeg(diagnostics.media, 'network_diagnostics.media'),215+ input: normalizeLeg(diagnostics.input, 'network_diagnostics.input'),216+ audio: diagnostics.audio === null217+ ? null218+ : normalizeLeg(diagnostics.audio, 'network_diagnostics.audio'),219+ inputAck: normalizeInputAck(diagnostics.input_ack),220+ };221+}222+223+function formatBytes(value) {224+ if (value < 1024) return `${value} B`;225+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;226+ if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB`;227+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GiB`;228+}229+230+function formatPercentiles(summary) {231+ if (summary.sampleCount === 0) return `— · 0 samples · ${summary.overflowTotal} overflow`;232+ const p50 = summary.p50Ms === null ? '—' : summary.p50Ms.toFixed(1);233+ const p95 = summary.p95Ms === null ? '—' : summary.p95Ms.toFixed(1);234+ return `${p50} / ${p95} / ${summary.maxMs.toFixed(1)} ms · ${summary.sampleCount} samples · ${summary.overflowTotal} overflow`;235+}236+237+function formatOptionalBytes(value) {238+ return value === null ? '—' : formatBytes(value);239+}240+241+function formatOptionalInteger(value, suffix = '') {242+ return value === null ? '—' : `${value}${suffix}`;243+}244+245+export function formatNetworkLeg(leg) {246+ if (leg === null) return 'unavailable';247+ const paths = leg.pathSamples;248+ const currentRtt = leg.rttCurrentMs === null ? '—' : `${leg.rttCurrentMs.toFixed(1)} ms`;249+ return `${leg.mode} · RTT current ${currentRtt} · history ${formatPercentiles(leg.rtt)} · paths d/r/c/u ${paths.direct}/${paths.relay}/${paths.custom}/${paths.unknown} · epoch ${leg.pathEpoch} · switches ${leg.pathSwitchTotal} · transitions ${leg.modeTransitionTotal} · tx ${leg.txDatagrams} datagrams / ${formatBytes(leg.txBytes)} · rx ${leg.rxDatagrams} datagrams / ${formatBytes(leg.rxBytes)} · lost ${leg.lostPackets} packets / ${formatBytes(leg.lostBytes)} · cwnd ${formatOptionalBytes(leg.cwndBytes)} · MTU ${formatOptionalInteger(leg.mtu, ' B')} · congestion ${formatOptionalInteger(leg.congestionEvents)} · black holes ${formatOptionalInteger(leg.blackHolesDetected)} · regressions ${leg.counterRegressionTotal} · ${leg.complete ? 'complete' : 'partial'}`;250+}251+252+export function formatInputAck(ack) {253+ const negotiation = ack.negotiated ? 'negotiated' : 'not negotiated';254+ const health = ack.malformed ? 'malformed' : ack.closed ? 'closed' : 'open';255+ return `${negotiation} · ${ack.acknowledgedTotal}/${ack.sentTotal} acknowledged · ${ack.pendingCount}/${ack.pendingCapacity} pending · duplicates ${ack.duplicateTotal} · untracked ${ack.untrackedTotal} · latency ${formatPercentiles(ack.latency)} · ${health} · ${ack.complete ? 'complete' : 'partial'}`;256+}257+258+export function networkDiagnosticsPresentation(diagnostics) {259+ if (diagnostics === null) {260+ return {261+ session: 'unavailable',262+ media: 'unavailable',263+ input: 'unavailable',264+ audio: 'unavailable',265+ inputAck: 'unavailable',266+ };267+ }268+ return {269+ session: `v${diagnostics.version} · ${(diagnostics.sessionElapsedMs / 1000).toFixed(1)} s`,270+ media: formatNetworkLeg(diagnostics.media),271+ input: formatNetworkLeg(diagnostics.input),272+ audio: formatNetworkLeg(diagnostics.audio),273+ inputAck: formatInputAck(diagnostics.inputAck),274+ };275+}
A portal/network-diagnostics.test.mjs +169 -0
@@ -0,0 +1,169 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import {4+ networkDiagnosticsPresentation,5+ normalizeNetworkDiagnostics,6+} from './network-diagnostics.mjs';7+8+function leg(overrides = {}) {9+ return {10+ mode: 'direct',11+ sample_count: 3,12+ direct_samples: 3,13+ relay_samples: 0,14+ custom_samples: 0,15+ unknown_samples: 0,16+ path_epoch: 1,17+ path_switch_total: 0,18+ mode_transition_total: 0,19+ rtt_sample_count: 3,20+ rtt_current_ms: 9.25,21+ rtt_p50_ms: 8,22+ rtt_p95_ms: 12.5,23+ rtt_max_ms: 13,24+ rtt_overflow_total: 0,25+ tx_datagrams: 20,26+ tx_bytes: 2048,27+ rx_datagrams: 18,28+ rx_bytes: 1024,29+ lost_packets: 1,30+ lost_bytes: 64,31+ cwnd_bytes: 65536,32+ mtu: 1200,33+ congestion_events: 2,34+ black_holes_detected: 0,35+ counter_regression_total: 0,36+ complete: true,37+ ...overrides,38+ };39+}40+41+function snapshot(overrides = {}) {42+ return {43+ version: 1,44+ session_elapsed_ms: 12345,45+ media: leg(),46+ input: leg({ mode: 'relay', direct_samples: 0, relay_samples: 3 }),47+ audio: null,48+ input_ack: {49+ negotiated: true,50+ sent_total: 10,51+ acknowledged_total: 9,52+ pending_count: 1,53+ pending_capacity: 64,54+ duplicate_total: 1,55+ untracked_total: 0,56+ malformed: false,57+ closed: false,58+ latency_sample_count: 3,59+ latency_p50_ms: 4,60+ latency_p95_ms: 9,61+ latency_max_ms: 10,62+ latency_overflow_total: 0,63+ complete: true,64+ },65+ ...overrides,66+ };67+}68+69+test('normalizes a bounded v1 snapshot and keeps transport legs separate', () => {70+ const diagnostics = normalizeNetworkDiagnostics(snapshot());71+ assert.equal(diagnostics.version, 1);72+ assert.equal(diagnostics.sessionElapsedMs, 12345);73+ assert.equal(diagnostics.media.mode, 'direct');74+ assert.equal(diagnostics.input.mode, 'relay');75+ assert.equal(diagnostics.audio, null);76+ assert.equal(diagnostics.media.rttCurrentMs, 9.25);77+ assert.deepEqual(diagnostics.media.rtt, {78+ sampleCount: 3,79+ p50Ms: 8,80+ p95Ms: 12.5,81+ maxMs: 13,82+ overflowTotal: 0,83+ });84+ assert.equal(diagnostics.inputAck.pendingCount, 1);85+});86+87+test('validates exact counters, classified samples, percentiles, and completeness', () => {88+ assert.throws(() => normalizeNetworkDiagnostics(snapshot({89+ media: leg({ tx_bytes: Number.MAX_SAFE_INTEGER + 1 }),90+ })), /exact unsigned integer/);91+ assert.throws(() => normalizeNetworkDiagnostics(snapshot({92+ media: leg({ direct_samples: 2 }),93+ })), /exactly match sample_count/);94+ assert.throws(() => normalizeNetworkDiagnostics(snapshot({95+ media: leg({ rtt_p50_ms: 14 }),96+ })), /percentiles must be monotonic/);97+ assert.throws(() => normalizeNetworkDiagnostics(snapshot({98+ media: leg({ complete: 'yes' }),99+ })), /must be a boolean/);100+ assert.throws(() => normalizeNetworkDiagnostics(snapshot({101+ input_ack: { ...snapshot().input_ack, pending_count: 65 },102+ })), /pending_count cannot exceed pending_capacity/);103+});104+105+test('requires null percentiles for an empty bounded window', () => {106+ const empty = leg({107+ rtt_sample_count: 0,108+ rtt_current_ms: null,109+ rtt_p50_ms: null,110+ rtt_p95_ms: null,111+ rtt_max_ms: null,112+ });113+ assert.equal(normalizeNetworkDiagnostics(snapshot({ media: empty })).media.rtt.sampleCount, 0);114+ assert.throws(() => normalizeNetworkDiagnostics(snapshot({115+ media: { ...empty, rtt_p50_ms: 0 },116+ })), /require at least one retained sample/);117+});118+119+test('accepts nullable path gauges and represents bounded histogram overflow', () => {120+ const overflowed = leg({121+ sample_count: 2,122+ direct_samples: 0,123+ unknown_samples: 2,124+ mode: 'unknown',125+ rtt_sample_count: 2,126+ rtt_current_ms: null,127+ rtt_p50_ms: 12,128+ rtt_p95_ms: null,129+ rtt_max_ms: 6000,130+ rtt_overflow_total: 1,131+ cwnd_bytes: null,132+ mtu: null,133+ congestion_events: null,134+ black_holes_detected: null,135+ complete: false,136+ });137+ const diagnostics = normalizeNetworkDiagnostics(snapshot({ media: overflowed }));138+ assert.equal(diagnostics.media.rtt.p50Ms, 12);139+ assert.equal(diagnostics.media.rtt.p95Ms, null);140+ assert.equal(diagnostics.media.cwndBytes, null);141+ assert.match(142+ networkDiagnosticsPresentation(diagnostics).media,143+ /RTT current — · history 12\.0 \/ — \/ 6000\.0 ms/,144+ );145+});146+147+test('renders legs and ACK health without exposing identifiers or addresses', () => {148+ const raw = snapshot({149+ peer_id: 'secret-peer',150+ remote_address: '192.0.2.1:443',151+ media: leg({ endpoint_id: 'secret-endpoint' }),152+ });153+ const presentation = networkDiagnosticsPresentation(normalizeNetworkDiagnostics(raw));154+ assert.match(presentation.media, /^direct · RTT/);155+ assert.match(presentation.input, /^relay · RTT/);156+ assert.equal(presentation.audio, 'unavailable');157+ assert.match(presentation.inputAck, /^negotiated · 9\/10 acknowledged/);158+ assert.doesNotMatch(JSON.stringify(presentation), /secret|192\.0\.2\.1|endpoint/i);159+});160+161+test('formats a missing v4 snapshot as explicitly unavailable', () => {162+ assert.deepEqual(networkDiagnosticsPresentation(null), {163+ session: 'unavailable',164+ media: 'unavailable',165+ input: 'unavailable',166+ audio: 'unavailable',167+ inputAck: 'unavailable',168+ });169+});
M src-tauri/src/commands/mod.rs +1 -0
M src-tauri/src/commands/network.rs +237 -119
@@ -1,6 +1,11 @@
11 use super::auth::derive_iroh_secret_from_key;22 use super::enrollment::{connection_enrollment, mark_invitation_redeemed};33 use super::moq_catalog::subscribe_goq_video_track;4+#[cfg(test)]5+use super::network_diagnostics::PathMode;6+use super::network_diagnostics::{7+ NetworkDiagnosticsSnapshot, NetworkLeg, NetworkSessionDiagnostics,8+};49 use super::state::{510 AUDIO_DELIVERY_CAPACITY, AccumulatedMediaFeedback, AppState, AudioDeliveryState,611 CLIENT_INPUT_QUEUE_CAPACITY, FRAME_ALPN, INPUT_ALPN, MediaFeedbackSender,
@@ -330,8 +335,7 @@ impl ClientMediaMetrics {
330335 &mut self,331336 elapsed: Duration,332337 frontend_queue_depth: usize,333- path_mode: &'static str,334- path_rtt_ms: Option<f64>,338+ network_diagnostics: NetworkDiagnosticsSnapshot,335339 generation: u64,336340 ) -> FrameStatsPayload {337341 let transport_receive_fps = self.transport_rate.rate(elapsed);
@@ -352,7 +356,7 @@ impl ClientMediaMetrics {
352356 .max(frontend_resync_current_duration.unwrap_or_default());353357 FrameStatsPayload {354358 generation,355- stats_version: 3,359+ stats_version: 4,356360 transport_receive_fps,357361 frontend_send_fps,358362 transport_received_total: self.transport_received_total,
@@ -381,8 +385,9 @@ impl ClientMediaMetrics {
381385 frontend_ipc_send_duration_p50_ms: frontend_ipc_send_durations.p50_ms,382386 frontend_ipc_send_duration_p95_ms: frontend_ipc_send_durations.p95_ms,383387 frontend_ipc_send_duration_max_ms: frontend_ipc_send_durations.max_ms,384- path_mode,385- path_rtt_ms,388+ path_mode: network_diagnostics.media.mode.as_str(),389+ path_rtt_ms: network_diagnostics.media.rtt_current_ms,390+ network_diagnostics,386391 sequence: self.last_sequence,387392 keyframe: self.last_keyframe,388393 // Compatibility aliases for the currently bundled frontend. New
@@ -405,6 +410,14 @@ fn lock_client_media_metrics(
405410 .unwrap_or_else(std::sync::PoisonError::into_inner)406411 }407412 413+fn lock_network_diagnostics(414+ diagnostics: &StdMutex<NetworkSessionDiagnostics>,415+) -> StdMutexGuard<'_, NetworkSessionDiagnostics> {416+ diagnostics417+ .lock()418+ .unwrap_or_else(std::sync::PoisonError::into_inner)419+}420+408421 fn close_generation_connection<T>(409422 connection: Option<(u64, T)>,410423 close: impl FnOnce(T),
@@ -562,6 +575,7 @@ struct FrameStatsPayload {
562575 frontend_ipc_send_duration_max_ms: Option<f64>,563576 path_mode: &'static str,564577 path_rtt_ms: Option<f64>,578+ network_diagnostics: NetworkDiagnosticsSnapshot,565579 sequence: Option<u64>,566580 keyframe: bool,567581 fps: f64,
@@ -1726,6 +1740,7 @@ struct InputAvailability {
17261740 keyboard: bool,17271741 text: bool,17281742 gamepad: bool,1743+ input_ack: bool,17291744 control: bool,17301745 }17311746
@@ -1737,6 +1752,7 @@ impl InputAvailability {
17371752 let keyboard = capabilities.contains(&Capability::Keyboard);17381753 let text = capabilities.contains(&Capability::Text);17391754 let gamepad = capabilities.contains(&Capability::Gamepad);1755+ let input_ack = capabilities.contains(&Capability::InputAck);17401756 Self {17411757 relative_pointer,17421758 pointer_position_feedback,
@@ -1744,6 +1760,7 @@ impl InputAvailability {
17441760 keyboard,17451761 text,17461762 gamepad,1763+ input_ack,17471764 control: relative_pointer || absolute_pointer || keyboard || text || gamepad,17481765 }17491766 }
@@ -1878,6 +1895,7 @@ async fn open_negotiated_input_stream(
18781895 capabilities: Vec<Capability>,18791896 ) -> Result<18801897 (1898+ iroh::endpoint::Connection,18811899 iroh::endpoint::SendStream,18821900 iroh::endpoint::RecvStream,18831901 NegotiatedV1Stream,
@@ -1902,7 +1920,7 @@ async fn open_negotiated_input_stream(
19021920 None,19031921 )19041922 .await?;1905- Ok((send, recv, negotiation))1923+ Ok((connection, send, recv, negotiation))19061924 }19071925 19081926 fn connection_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectionError) -> bool {
@@ -2424,8 +2442,8 @@ fn try_queue_media_keyframe_request(
24242442 }24252443 }24262444 2427-fn input_capability_offers(grants: InvitationGrants) -> [Vec<Capability>; 4] {2428- let mut offers = [2445+fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {2446+ let base = [24292447 vec![24302448 Capability::RelativePointer,24312449 Capability::PointerPositionFeedback,
@@ -2457,6 +2475,16 @@ fn input_capability_offers(grants: InvitationGrants) -> [Vec<Capability>; 4] {
24572475 Capability::Gamepad,24582476 ],24592477 ];2478+ let has_input_grant = grants.contains(InvitationGrants::POINTER_KEYBOARD)2479+ || grants.contains(InvitationGrants::GAMEPAD);2480+ let mut offers = Vec::with_capacity(base.len() * 2);2481+ if has_input_grant {2482+ for mut offer in base.clone() {2483+ offer.push(Capability::InputAck);2484+ offers.push(offer);2485+ }2486+ }2487+ offers.extend(base);24602488 for offer in &mut offers {24612489 offer.retain(|capability| match capability {24622490 Capability::Gamepad => grants.contains(InvitationGrants::GAMEPAD),
@@ -2468,9 +2496,11 @@ fn input_capability_offers(grants: InvitationGrants) -> [Vec<Capability>; 4] {
24682496 | Capability::PointerVisibilityFeedback => {24692497 grants.contains(InvitationGrants::POINTER_KEYBOARD)24702498 }2499+ Capability::InputAck => has_input_grant,24712500 _ => true,24722501 });24732502 }2503+ offers.dedup();24742504 offers24752505 }24762506
@@ -2499,7 +2529,11 @@ async fn write_client_input_event(
24992529 stream: &mut iroh::endpoint::SendStream,25002530 event: &InputEvent,25012531 use_v1: bool,2532+ diagnostics: Option<&Arc<StdMutex<NetworkSessionDiagnostics>>>,25022533 ) -> Result<(), String> {2534+ if let Some(diagnostics) = diagnostics {2535+ lock_network_diagnostics(diagnostics).begin_input_send(Instant::now());2536+ }25032537 if use_v1 {25042538 write_input_event(stream, event)25052539 .await
@@ -2513,6 +2547,18 @@ async fn write_client_input_event(
25132547 }25142548 }25152549 2550+fn observe_input_ack_if_negotiated(2551+ diagnostics: &StdMutex<NetworkSessionDiagnostics>,2552+ negotiated: bool,2553+ sequence: u64,2554+ now: Instant,2555+) -> Result<(), String> {2556+ if !negotiated {2557+ return Ok(());2558+ }2559+ lock_network_diagnostics(diagnostics).observe_input_ack(sequence, now)2560+}2561+25162562 #[derive(Debug, Default)]25172563 struct AudioReorderBuffer {25182564 expected_sequence: Option<u64>,
@@ -3024,70 +3070,30 @@ pub async fn iroh_client_connect(
30243070 (None, None)30253071 };30263072 3027- let (input_send, input_recv, input_capabilities) = if use_v1 {3028- let [3029- visibility_capabilities,3030- position_capabilities,3031- relative_capabilities,3032- legacy_capabilities,3033- ] = input_capability_offers(grants);3034- let feedback_offer = open_negotiated_input_stream(3035- &endpoint,3036- &addr,3037- handshake_nonce,3038- visibility_capabilities,3039- )3040- .await;3041- let (send, recv, input_negotiation) = match feedback_offer {3042- Ok(result) => result,3043- Err(feedback_error) => {3044- // Capability variants are a strict enum on older hosts. First3045- // retry without visibility so the immediately preceding host3046- // retains compositor-position feedback.3047- let position_offer = open_negotiated_input_stream(3048- &endpoint,3049- &addr,3050- handshake_nonce,3051- position_capabilities,3052- )3053- .await;3054- match position_offer {3055- Ok(result) => result,3056- Err(position_error) => {3057- let relative_offer = open_negotiated_input_stream(3058- &endpoint,3059- &addr,3060- handshake_nonce,3061- relative_capabilities,3062- )3063- .await;3064- match relative_offer {3065- Ok(result) => result,3066- Err(relative_error) => {3067- // A pre-relative-pointer host needs the exact3068- // inherited absolute-pointer compatibility leg.3069- open_negotiated_input_stream(3070- &endpoint,3071- &addr,3072- handshake_nonce,3073- legacy_capabilities,3074- )3075- .await3076- .map_err(|legacy_error| {3077- format!(3078- "Pointer-visibility negotiation failed ({feedback_error}); pointer-position fallback failed ({position_error}); relative input fallback failed ({relative_error}); inherited input fallback failed ({legacy_error})"3079- )3080- })?3081- }3082- }3083- }3073+ let (input_connection, input_send, input_recv, input_capabilities) = if use_v1 {3074+ let mut errors = Vec::new();3075+ let mut accepted = None;3076+ // Older hosts reject unknown capability enum values. Try all four3077+ // pointer feature levels with ACK first, then repeat the exact legacy3078+ // offers without ACK so lack of diagnostics never forces absolute3079+ // pointer input prematurely.3080+ for capabilities in input_capability_offers(grants) {3081+ match open_negotiated_input_stream(&endpoint, &addr, handshake_nonce, capabilities)3082+ .await3083+ {3084+ Ok(result) => {3085+ accepted = Some(result);3086+ break;30843087 }3088+ Err(error) => errors.push(error),30853089 }3086- };3090+ }3091+ let (connection, send, recv, input_negotiation) = accepted3092+ .ok_or_else(|| format!("All input capability offers failed: {}", errors.join("; ")))?;30873093 if Some(input_negotiation.session_id) != media_session_id {30883094 return Err("Host returned mismatched media and input sessions".to_string());30893095 }3090- (send, recv, input_negotiation.capabilities)3096+ (connection, send, recv, input_negotiation.capabilities)30913097 } else {30923098 let input_conn = endpoint30933099 .connect(addr.clone(), input_alpn)
@@ -3103,6 +3109,7 @@ pub async fn iroh_client_connect(
31033109 // The inherited protocol predates negotiation and supports the current31043110 // absolute-pointer, keyboard, and text event set.31053111 (3112+ input_conn,31063113 send,31073114 recv,31083115 vec![
@@ -3114,28 +3121,56 @@ pub async fn iroh_client_connect(
31143121 };31153122 31163123 let input_availability = InputAvailability::from_capabilities(&input_capabilities);3124+ let network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(3125+ Instant::now(),3126+ input_availability.input_ack,3127+ )));31173128 3118- if input_availability.pointer_position_feedback {3129+ if input_availability.pointer_position_feedback || input_availability.input_ack {31193130 let mut input_feedback = input_recv;3131+ let feedback_diagnostics = Arc::clone(&network_diagnostics);3132+ let mut pointer_feedback_enabled = input_availability.pointer_position_feedback;3133+ let input_ack_enabled = input_availability.input_ack;31203134 tokio::spawn(async move {31213135 let terminal_reason = loop {31223136 let response = match read_input_ack(&mut input_feedback).await {31233137 Ok(Some(response)) => response,3124- Ok(None) => break PointerFeedbackTerminalReason::Eof,3138+ Ok(None) => {3139+ lock_network_diagnostics(&feedback_diagnostics)3140+ .mark_input_feedback_closed();3141+ break PointerFeedbackTerminalReason::Eof;3142+ }31253143 Err(error) => {3126- eprintln!("[client] invalid pointer-position feedback: {error}");3144+ lock_network_diagnostics(&feedback_diagnostics)3145+ .mark_input_feedback_malformed();3146+ eprintln!("[client] invalid input feedback: {error}");31273147 break PointerFeedbackTerminalReason::Malformed;31283148 }31293149 };3130- if pointer_channel3131- .send(PointerFeedbackPayload::Position {3132- sequence: response.sequence,3133- position: response.pointer_position,3134- pointer_visible: response.pointer_visible,3135- })3136- .is_err()3150+ if let Err(error) = observe_input_ack_if_negotiated(3151+ &feedback_diagnostics,3152+ input_ack_enabled,3153+ response.sequence,3154+ Instant::now(),3155+ ) {3156+ eprintln!("[client] invalid input acknowledgement: {error}");3157+ break PointerFeedbackTerminalReason::Malformed;3158+ }3159+ if pointer_feedback_enabled3160+ && pointer_channel3161+ .send(PointerFeedbackPayload::Position {3162+ sequence: response.sequence,3163+ position: response.pointer_position,3164+ pointer_visible: response.pointer_visible,3165+ })3166+ .is_err()31373167 {3138- return;3168+ // Losing the webview's pointer channel must not stop ACK3169+ // draining and apply backpressure to host input.3170+ pointer_feedback_enabled = false;3171+ if !input_ack_enabled {3172+ return;3173+ }31393174 }31403175 };31413176 // The session-owned channel emits at most one terminal message.
@@ -3165,8 +3200,10 @@ pub async fn iroh_client_connect(
31653200 },31663201 )31673202 .await;3203+ let mut audio_connection_for_stats = None;31683204 let (audio_available, connected_audio_generation, audio_error) = match audio_result {31693205 Ok(connection) => {3206+ audio_connection_for_stats = Some(connection.clone());31703207 *state.audio_connection.lock().await = Some((audio_generation, connection));31713208 (true, Some(audio_generation), None)31723209 }
@@ -3224,6 +3261,7 @@ pub async fn iroh_client_connect(
32243261 // dropped at the 60 Hz boundary. Relative motion is displacement, so it32253262 // owns a separate accumulator and timer that coalesces rather than drops.32263263 let mut input_stream = input_send;3264+ let input_send_diagnostics = Arc::clone(&network_diagnostics);32273265 tokio::spawn(async move {32283266 let mut rx = rx;32293267 const MOUSE_INTERVAL: Duration = Duration::from_millis(16);
@@ -3240,8 +3278,13 @@ pub async fn iroh_client_connect(
32403278 let Some(event) = pending_relative.take() else {32413279 continue;32423280 };3243- if let Err(error) =3244- write_client_input_event(&mut input_stream, &event, use_v1).await3281+ if let Err(error) = write_client_input_event(3282+ &mut input_stream,3283+ &event,3284+ use_v1,3285+ Some(&input_send_diagnostics),3286+ )3287+ .await32453288 {32463289 eprintln!("[client] input stream write failed: {error}; disconnecting");32473290 break;
@@ -3255,7 +3298,12 @@ pub async fn iroh_client_connect(
32553298 let Some(event) = pending_relative.take() else {32563299 continue;32573300 };3258- if let Err(error) = write_client_input_event(&mut input_stream, &event, use_v1).await {3301+ if let Err(error) = write_client_input_event(3302+ &mut input_stream,3303+ &event,3304+ use_v1,3305+ Some(&input_send_diagnostics),3306+ ).await {32593307 eprintln!("[client] input stream write failed: {error}; disconnecting");32603308 break;32613309 }
@@ -3282,8 +3330,13 @@ pub async fn iroh_client_connect(
32823330 };32833331 let mut flushed_relative_barrier = false;32843332 while let Some(relative_barrier) = pending_relative.take() {3285- if let Err(error) =3286- write_client_input_event(&mut input_stream, &relative_barrier, use_v1).await3333+ if let Err(error) = write_client_input_event(3334+ &mut input_stream,3335+ &relative_barrier,3336+ use_v1,3337+ Some(&input_send_diagnostics),3338+ )3339+ .await32873340 {32883341 eprintln!("[client] input stream write failed: {error}; disconnecting");32893342 input_open = false;
@@ -3304,13 +3357,27 @@ pub async fn iroh_client_connect(
33043357 }33053358 last_absolute_mouse_time = now;33063359 }3307- if let Err(error) = write_client_input_event(&mut input_stream, &event, use_v1).await {3360+ if let Err(error) = write_client_input_event(3361+ &mut input_stream,3362+ &event,3363+ use_v1,3364+ Some(&input_send_diagnostics),3365+ )3366+ .await3367+ {33083368 eprintln!("[client] input stream write failed: {error}; disconnecting");33093369 break;33103370 }33113371 }33123372 while let Some(event) = pending_relative.take() {3313- if let Err(error) = write_client_input_event(&mut input_stream, &event, use_v1).await {3373+ if let Err(error) = write_client_input_event(3374+ &mut input_stream,3375+ &event,3376+ use_v1,3377+ Some(&input_send_diagnostics),3378+ )3379+ .await3380+ {33143381 eprintln!("[client] final relative input write failed: {error}");33153382 break;33163383 }
@@ -3363,29 +3430,36 @@ pub async fn iroh_client_connect(
33633430 let stats_metrics = Arc::clone(&metrics);33643431 let stats_in_flight = Arc::clone(&frame_events_in_flight);33653432 let stats_connection = frame_connection_for_stats.clone();3433+ let stats_input_connection = input_connection.clone();3434+ let stats_audio_connection = audio_connection_for_stats.clone();3435+ let stats_network_diagnostics = Arc::clone(&network_diagnostics);33663436 let stats_task = tokio::spawn(async move {33673437 let mut interval = tokio::time::interval(CLIENT_FRAME_STATS_INTERVAL);33683438 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);33693439 // Tokio intervals tick immediately once. Consume that tick so the33703440 // first payload represents a full diagnostics interval.33713441 interval.tick().await;3372- let mut path_mode = "unknown";3373- let mut path_rtt_ms = None;33743442 let mut last_path_sample = Instant::now() - Duration::from_secs(1);33753443 33763444 loop {33773445 tokio::select! {33783446 _ = interval.tick() => {33793447 if last_path_sample.elapsed() >= Duration::from_secs(1) {3380- (path_mode, path_rtt_ms) = selected_path_diagnostics(&stats_connection);3448+ let mut diagnostics = lock_network_diagnostics(&stats_network_diagnostics);3449+ diagnostics.observe_connection(NetworkLeg::Media, &stats_connection);3450+ diagnostics.observe_connection(NetworkLeg::Input, &stats_input_connection);3451+ if let Some(audio_connection) = stats_audio_connection.as_ref() {3452+ diagnostics.observe_connection(NetworkLeg::Audio, audio_connection);3453+ }33813454 last_path_sample = Instant::now();33823455 }33833456 let queue_depth = stats_in_flight.load(Ordering::SeqCst);3457+ let network_snapshot = lock_network_diagnostics(&stats_network_diagnostics)3458+ .snapshot(Instant::now());33843459 let payload = lock_client_media_metrics(&stats_metrics).snapshot(33853460 metrics_started.elapsed(),33863461 queue_depth,3387- path_mode,3388- path_rtt_ms,3462+ network_snapshot,33893463 media_generation,33903464 );33913465 let _ = stats_app.emit("frame-stats", payload);
@@ -3931,23 +4005,6 @@ fn sequence_gap(previous: u64, current: u64) -> Result<u64, String> {
39314005 Ok(current - expected)39324006 }39334007 3934-fn selected_path_diagnostics(3935- connection: &iroh::endpoint::Connection,3936-) -> (&'static str, Option<f64>) {3937- let paths = connection.paths();3938- let Some(path) = paths.iter().find(|path| path.is_selected()) else {3939- return ("unknown", None);3940- };3941- let mode = if path.is_ip() {3942- "direct"3943- } else if path.is_relay() {3944- "relay"3945- } else {3946- "custom"3947- };3948- (mode, Some(path.rtt().as_secs_f64() * 1000.0))3949-}3950-39514008 #[cfg(test)]39524009 mod tests {39534010 use super::*;
@@ -5118,11 +5175,19 @@ mod tests {
51185175 );51195176 metrics.finish_frontend_resync(Duration::from_millis(230));51205177 5121- let payload = metrics.snapshot(Duration::from_millis(250), 2, "direct", Some(7.5), 9);5178+ let payload = metrics.snapshot(5179+ Duration::from_millis(250),5180+ 2,5181+ NetworkDiagnosticsSnapshot::test_fixture(5182+ PathMode::Direct,5183+ Some(Duration::from_millis(8)),5184+ ),5185+ 9,5186+ );51225187 let json = serde_json::to_value(payload).unwrap();51235188 51245189 assert_eq!(json["generation"], 9);5125- assert_eq!(json["stats_version"], 3);5190+ assert_eq!(json["stats_version"], 4);51265191 assert_eq!(json["transport_received_total"], 2);51275192 assert_eq!(json["frontend_sent_total"], 1);51285193 assert_eq!(json["sequence_dropped_total"], 2);
@@ -5155,7 +5220,8 @@ mod tests {
51555220 assert_eq!(json["sequence"], 43);51565221 assert_eq!(json["keyframe"], true);51575222 assert_eq!(json["path_mode"], "direct");5158- assert_eq!(json["path_rtt_ms"], 7.5);5223+ assert_eq!(json["path_rtt_ms"], 8.0);5224+ assert_eq!(json["network_diagnostics"]["version"], 1);51595225 51605226 assert_eq!(json["count"], json["frontend_sent_total"]);51615227 assert_eq!(json["host_dropped_frames"], json["sequence_dropped_total"]);
@@ -5175,7 +5241,12 @@ mod tests {
51755241 metrics.begin_frontend_resync(Duration::from_millis(50));51765242 metrics.observe_frontend_resync_drop();51775243 5178- let active = metrics.snapshot(Duration::from_millis(75), 4, "direct", None, 1);5244+ let active = metrics.snapshot(5245+ Duration::from_millis(75),5246+ 4,5247+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Direct, None),5248+ 1,5249+ );51795250 assert_eq!(active.generation, 1);51805251 assert_eq!(active.frontend_resync_episode_total, 1);51815252 assert!(active.frontend_resync_active);
@@ -5185,7 +5256,12 @@ mod tests {
51855256 assert_eq!(active.frontend_resync_dropped_total, 1);51865257 51875258 metrics.finish_frontend_resync(Duration::from_millis(100));5188- let completed = metrics.snapshot(Duration::from_millis(150), 0, "direct", None, 1);5259+ let completed = metrics.snapshot(5260+ Duration::from_millis(150),5261+ 0,5262+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Direct, None),5263+ 1,5264+ );51895265 assert_eq!(completed.frontend_resync_episode_total, 1);51905266 assert!(!completed.frontend_resync_active);51915267 assert_eq!(completed.frontend_resync_duration_ms_total, 100.0);
@@ -5202,11 +5278,21 @@ mod tests {
52025278 metrics.observe_frontend_send(elapsed);52035279 }52045280 5205- let active = metrics.snapshot(Duration::from_secs(1), 0, "relay", None, 1);5281+ let active = metrics.snapshot(5282+ Duration::from_secs(1),5283+ 0,5284+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Relay, None),5285+ 1,5286+ );52065287 assert!((active.transport_receive_fps - 60.0).abs() < 0.001);52075288 assert!((active.frontend_send_fps - 60.0).abs() < 0.001);52085289 5209- let idle = metrics.snapshot(Duration::from_secs(3), 0, "relay", None, 1);5290+ let idle = metrics.snapshot(5291+ Duration::from_secs(3),5292+ 0,5293+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Relay, None),5294+ 1,5295+ );52105296 assert_eq!(idle.transport_receive_fps, 0.0);52115297 assert_eq!(idle.frontend_send_fps, 0.0);52125298 assert_eq!(idle.transport_received_total, 60);
@@ -5390,6 +5476,7 @@ mod tests {
53905476 keyboard: false,53915477 text: false,53925478 gamepad: false,5479+ input_ack: false,53935480 control: false,53945481 }53955482 );
@@ -5406,6 +5493,7 @@ mod tests {
54065493 keyboard: false,54075494 text: true,54085495 gamepad: false,5496+ input_ack: false,54095497 control: true,54105498 }54115499 );
@@ -5418,6 +5506,7 @@ mod tests {
54185506 keyboard: true,54195507 text: false,54205508 gamepad: false,5509+ input_ack: false,54215510 control: true,54225511 }54235512 );
@@ -5430,9 +5519,14 @@ mod tests {
54305519 keyboard: false,54315520 text: false,54325521 gamepad: true,5522+ input_ack: false,54335523 control: true,54345524 }54355525 );5526+ assert!(5527+ InputAvailability::from_capabilities(&[Capability::Gamepad, Capability::InputAck])5528+ .input_ack5529+ );54365530 assert_eq!(54375531 InputAvailability::from_capabilities(&[54385532 Capability::RelativePointer,
@@ -5445,16 +5539,37 @@ mod tests {
54455539 keyboard: false,54465540 text: false,54475541 gamepad: false,5542+ input_ack: false,54485543 control: true,54495544 }54505545 );54515546 }54525547 5548+ #[test]5549+ fn pointer_feedback_without_input_ack_does_not_fail_ack_validation() {5550+ let diagnostics = StdMutex::new(NetworkSessionDiagnostics::new(Instant::now(), false));5551+ assert!(observe_input_ack_if_negotiated(&diagnostics, false, 0, Instant::now()).is_ok());5552+ }5553+54535554 #[test]54545555 fn input_capability_fallbacks_remove_only_one_protocol_extension_at_a_time() {5455- let [visibility, position, relative, inherited] =5456- input_capability_offers(InvitationGrants::ALL);5556+ let offers = input_capability_offers(InvitationGrants::ALL);5557+ assert_eq!(offers.len(), 8);5558+ let visibility = &offers[0];5559+ let position = &offers[1];5560+ let relative = &offers[2];5561+ let inherited = &offers[3];54575562 5563+ assert!(5564+ offers[..4]5565+ .iter()5566+ .all(|offer| offer.contains(&Capability::InputAck))5567+ );5568+ assert!(5569+ offers[4..]5570+ .iter()5571+ .all(|offer| !offer.contains(&Capability::InputAck))5572+ );54585573 assert!(visibility.contains(&Capability::PointerVisibilityFeedback));54595574 assert!(visibility.contains(&Capability::PointerPositionFeedback));54605575 assert!(!position.contains(&Capability::PointerVisibilityFeedback));
@@ -5464,12 +5579,13 @@ mod tests {
54645579 assert!(relative.contains(&Capability::RelativePointer));54655580 assert!(!inherited.contains(&Capability::RelativePointer));54665581 assert_eq!(5467- inherited,5468- vec![5582+ inherited.as_slice(),5583+ &[54695584 Capability::AbsolutePointer,54705585 Capability::Keyboard,54715586 Capability::Text,54725587 Capability::Gamepad,5588+ Capability::InputAck,54735589 ]54745590 );54755591 }
@@ -5501,11 +5617,13 @@ mod tests {
55015617 InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),55025618 );55035619 assert!(pointer[0].contains(&Capability::Keyboard));5620+ assert!(pointer[0].contains(&Capability::InputAck));55045621 assert!(!pointer[0].contains(&Capability::Gamepad));55055622 55065623 let gamepad =55075624 input_capability_offers(InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD));5508- assert_eq!(gamepad[0], vec![Capability::Gamepad]);5625+ assert_eq!(gamepad[0], vec![Capability::Gamepad, Capability::InputAck]);5626+ assert_eq!(gamepad[1], vec![Capability::Gamepad]);55095627 }55105628 55115629 #[test]
A src-tauri/src/commands/network_diagnostics.rs +727 -0
@@ -0,0 +1,727 @@
1+use std::{2+ collections::VecDeque,3+ time::{Duration, Instant},4+};5+6+use iroh::endpoint::{Connection, PathId};7+use serde::Serialize;8+9+const LATENCY_BUCKETS_MS: usize = 5_001;10+pub(crate) const INPUT_ACK_PENDING_CAPACITY: usize = 1_024;11+12+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]13+#[serde(rename_all = "snake_case")]14+pub(crate) enum PathMode {15+ #[default]16+ Unknown,17+ Direct,18+ Relay,19+ Custom,20+}21+22+impl PathMode {23+ pub(crate) const fn as_str(self) -> &'static str {24+ match self {25+ Self::Unknown => "unknown",26+ Self::Direct => "direct",27+ Self::Relay => "relay",28+ Self::Custom => "custom",29+ }30+ }31+}32+33+#[derive(Clone, Copy, Debug)]34+pub(crate) enum NetworkLeg {35+ Media,36+ Input,37+ Audio,38+}39+40+#[derive(Clone, Debug, Default)]41+struct BoundedLatencyHistogram {42+ buckets: Box<[u64]>,43+ sample_count: u64,44+ overflow_total: u64,45+ max_ms: Option<u64>,46+}47+48+impl BoundedLatencyHistogram {49+ fn new() -> Self {50+ Self {51+ buckets: vec![0; LATENCY_BUCKETS_MS].into_boxed_slice(),52+ ..Self::default()53+ }54+ }55+56+ fn record(&mut self, duration: Duration) {57+ let millis = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);58+ self.sample_count = self.sample_count.saturating_add(1);59+ self.max_ms = Some(self.max_ms.unwrap_or_default().max(millis));60+ if let Ok(index) = usize::try_from(millis)61+ && let Some(bucket) = self.buckets.get_mut(index)62+ {63+ *bucket = bucket.saturating_add(1);64+ } else {65+ self.overflow_total = self.overflow_total.saturating_add(1);66+ }67+ }68+69+ fn percentile(&self, percentile: u64) -> Option<u64> {70+ if self.sample_count == 0 {71+ return None;72+ }73+ let rank = self.sample_count.saturating_mul(percentile).div_ceil(100);74+ let mut seen = 0_u64;75+ for (millis, count) in self.buckets.iter().copied().enumerate() {76+ seen = seen.saturating_add(count);77+ if seen >= rank {78+ return u64::try_from(millis).ok();79+ }80+ }81+ None82+ }83+84+ fn summary(&self) -> LatencySummary {85+ LatencySummary {86+ sample_count: self.sample_count,87+ p50_ms: self.percentile(50),88+ p95_ms: self.percentile(95),89+ max_ms: self.max_ms,90+ overflow_total: self.overflow_total,91+ }92+ }93+}94+95+#[derive(Clone, Copy, Debug, Default)]96+struct LatencySummary {97+ sample_count: u64,98+ p50_ms: Option<u64>,99+ p95_ms: Option<u64>,100+ max_ms: Option<u64>,101+ overflow_total: u64,102+}103+104+#[derive(Clone, Debug)]105+struct LegObservation {106+ selected_path_id: Option<PathId>,107+ mode: PathMode,108+ rtt: Option<Duration>,109+ cwnd_bytes: Option<u64>,110+ mtu: Option<u16>,111+ congestion_events: Option<u64>,112+ black_holes_detected: Option<u64>,113+ tx_datagrams: u64,114+ tx_bytes: u64,115+ rx_datagrams: u64,116+ rx_bytes: u64,117+ lost_packets: u64,118+ lost_bytes: u64,119+}120+121+impl LegObservation {122+ fn from_connection(connection: &Connection) -> Self {123+ let paths = connection.paths();124+ let selected = paths.iter().find(|path| path.is_selected());125+ let (selected_path_id, mode, rtt, cwnd_bytes, mtu, congestion_events, black_holes_detected) =126+ if let Some(path) = selected {127+ let stats = path.stats();128+ let mode = classify_path_mode(path.is_ip(), path.is_relay());129+ (130+ Some(path.id()),131+ mode,132+ Some(stats.rtt),133+ Some(stats.cwnd),134+ Some(stats.current_mtu),135+ Some(stats.congestion_events),136+ Some(stats.black_holes_detected),137+ )138+ } else {139+ (None, PathMode::Unknown, None, None, None, None, None)140+ };141+ let stats = connection.stats();142+ Self {143+ selected_path_id,144+ mode,145+ rtt,146+ cwnd_bytes,147+ mtu,148+ congestion_events,149+ black_holes_detected,150+ tx_datagrams: stats.udp_tx.datagrams,151+ tx_bytes: stats.udp_tx.bytes,152+ rx_datagrams: stats.udp_rx.datagrams,153+ rx_bytes: stats.udp_rx.bytes,154+ lost_packets: stats.lost_packets,155+ lost_bytes: stats.lost_bytes,156+ }157+ }158+}159+160+const fn classify_path_mode(is_ip: bool, is_relay: bool) -> PathMode {161+ match (is_ip, is_relay) {162+ (true, false) => PathMode::Direct,163+ (false, true) => PathMode::Relay,164+ _ => PathMode::Custom,165+ }166+}167+168+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]169+struct ConnectionCounters {170+ tx_datagrams: u64,171+ tx_bytes: u64,172+ rx_datagrams: u64,173+ rx_bytes: u64,174+ lost_packets: u64,175+ lost_bytes: u64,176+}177+178+impl ConnectionCounters {179+ fn from_observation(observation: &LegObservation) -> Self {180+ Self {181+ tx_datagrams: observation.tx_datagrams,182+ tx_bytes: observation.tx_bytes,183+ rx_datagrams: observation.rx_datagrams,184+ rx_bytes: observation.rx_bytes,185+ lost_packets: observation.lost_packets,186+ lost_bytes: observation.lost_bytes,187+ }188+ }189+190+ fn regressed_from(self, previous: Self) -> bool {191+ self.tx_datagrams < previous.tx_datagrams192+ || self.tx_bytes < previous.tx_bytes193+ || self.rx_datagrams < previous.rx_datagrams194+ || self.rx_bytes < previous.rx_bytes195+ || self.lost_packets < previous.lost_packets196+ || self.lost_bytes < previous.lost_bytes197+ }198+}199+200+#[derive(Clone, Debug)]201+struct LegDiagnostics {202+ mode: PathMode,203+ selected_path_id: Option<PathId>,204+ sample_count: u64,205+ direct_samples: u64,206+ relay_samples: u64,207+ custom_samples: u64,208+ unknown_samples: u64,209+ path_epoch: u64,210+ path_switch_total: u64,211+ mode_transition_total: u64,212+ rtt: BoundedLatencyHistogram,213+ rtt_current_ms: Option<f64>,214+ counters: ConnectionCounters,215+ has_counters: bool,216+ cwnd_bytes: Option<u64>,217+ mtu: Option<u16>,218+ congestion_events: Option<u64>,219+ black_holes_detected: Option<u64>,220+ counter_regression_total: u64,221+}222+223+impl Default for LegDiagnostics {224+ fn default() -> Self {225+ Self {226+ mode: PathMode::Unknown,227+ selected_path_id: None,228+ sample_count: 0,229+ direct_samples: 0,230+ relay_samples: 0,231+ custom_samples: 0,232+ unknown_samples: 0,233+ path_epoch: 0,234+ path_switch_total: 0,235+ mode_transition_total: 0,236+ rtt: BoundedLatencyHistogram::new(),237+ rtt_current_ms: None,238+ counters: ConnectionCounters::default(),239+ has_counters: false,240+ cwnd_bytes: None,241+ mtu: None,242+ congestion_events: None,243+ black_holes_detected: None,244+ counter_regression_total: 0,245+ }246+ }247+}248+249+impl LegDiagnostics {250+ fn observe(&mut self, observation: LegObservation) {251+ let previous_mode = self.mode;252+ let previous_path_id = self.selected_path_id;253+ self.sample_count = self.sample_count.saturating_add(1);254+ match observation.mode {255+ PathMode::Direct => self.direct_samples = self.direct_samples.saturating_add(1),256+ PathMode::Relay => self.relay_samples = self.relay_samples.saturating_add(1),257+ PathMode::Custom => self.custom_samples = self.custom_samples.saturating_add(1),258+ PathMode::Unknown => self.unknown_samples = self.unknown_samples.saturating_add(1),259+ }260+ if observation.selected_path_id != previous_path_id {261+ if previous_path_id.is_some() {262+ self.path_switch_total = self.path_switch_total.saturating_add(1);263+ }264+ if observation.selected_path_id.is_some() {265+ self.path_epoch = self.path_epoch.saturating_add(1);266+ }267+ }268+ if self.sample_count > 1 && previous_mode != observation.mode {269+ self.mode_transition_total = self.mode_transition_total.saturating_add(1);270+ }271+ if let Some(rtt) = observation.rtt {272+ self.rtt.record(rtt);273+ }274+ self.rtt_current_ms = observation.rtt.map(|rtt| rtt.as_secs_f64() * 1_000.0);275+ let counters = ConnectionCounters::from_observation(&observation);276+ if self.has_counters && counters.regressed_from(self.counters) {277+ self.counter_regression_total = self.counter_regression_total.saturating_add(1);278+ }279+ self.mode = observation.mode;280+ self.selected_path_id = observation.selected_path_id;281+ self.counters = counters;282+ self.has_counters = true;283+ self.cwnd_bytes = observation.cwnd_bytes;284+ self.mtu = observation.mtu;285+ self.congestion_events = observation.congestion_events;286+ self.black_holes_detected = observation.black_holes_detected;287+ }288+289+ fn snapshot(&self) -> NetworkLegSnapshot {290+ let rtt = self.rtt.summary();291+ NetworkLegSnapshot {292+ mode: self.mode,293+ sample_count: self.sample_count,294+ direct_samples: self.direct_samples,295+ relay_samples: self.relay_samples,296+ custom_samples: self.custom_samples,297+ unknown_samples: self.unknown_samples,298+ path_epoch: self.path_epoch,299+ path_switch_total: self.path_switch_total,300+ mode_transition_total: self.mode_transition_total,301+ rtt_sample_count: rtt.sample_count,302+ rtt_current_ms: self.rtt_current_ms,303+ rtt_p50_ms: rtt.p50_ms,304+ rtt_p95_ms: rtt.p95_ms,305+ rtt_max_ms: rtt.max_ms,306+ rtt_overflow_total: rtt.overflow_total,307+ tx_datagrams: self.counters.tx_datagrams,308+ tx_bytes: self.counters.tx_bytes,309+ rx_datagrams: self.counters.rx_datagrams,310+ rx_bytes: self.counters.rx_bytes,311+ lost_packets: self.counters.lost_packets,312+ lost_bytes: self.counters.lost_bytes,313+ cwnd_bytes: self.cwnd_bytes,314+ mtu: self.mtu,315+ congestion_events: self.congestion_events,316+ black_holes_detected: self.black_holes_detected,317+ counter_regression_total: self.counter_regression_total,318+ complete: self.counter_regression_total == 0 && rtt.overflow_total == 0,319+ }320+ }321+}322+323+#[derive(Clone, Debug, Serialize)]324+pub(crate) struct NetworkLegSnapshot {325+ pub(crate) mode: PathMode,326+ sample_count: u64,327+ direct_samples: u64,328+ relay_samples: u64,329+ custom_samples: u64,330+ unknown_samples: u64,331+ path_epoch: u64,332+ path_switch_total: u64,333+ mode_transition_total: u64,334+ rtt_sample_count: u64,335+ pub(crate) rtt_current_ms: Option<f64>,336+ pub(crate) rtt_p50_ms: Option<u64>,337+ rtt_p95_ms: Option<u64>,338+ rtt_max_ms: Option<u64>,339+ rtt_overflow_total: u64,340+ tx_datagrams: u64,341+ tx_bytes: u64,342+ rx_datagrams: u64,343+ rx_bytes: u64,344+ lost_packets: u64,345+ lost_bytes: u64,346+ cwnd_bytes: Option<u64>,347+ mtu: Option<u16>,348+ congestion_events: Option<u64>,349+ black_holes_detected: Option<u64>,350+ counter_regression_total: u64,351+ complete: bool,352+}353+354+#[derive(Clone, Debug)]355+struct InputAckDiagnostics {356+ negotiated: bool,357+ sent_total: u64,358+ acknowledged_total: u64,359+ last_ack: Option<u64>,360+ pending: VecDeque<(u64, Instant)>,361+ duplicate_total: u64,362+ untracked_total: u64,363+ malformed: bool,364+ closed: bool,365+ latency: BoundedLatencyHistogram,366+}367+368+impl InputAckDiagnostics {369+ fn new(negotiated: bool) -> Self {370+ Self {371+ negotiated,372+ sent_total: 0,373+ acknowledged_total: 0,374+ last_ack: None,375+ pending: VecDeque::with_capacity(INPUT_ACK_PENDING_CAPACITY),376+ duplicate_total: 0,377+ untracked_total: 0,378+ malformed: false,379+ closed: false,380+ latency: BoundedLatencyHistogram::new(),381+ }382+ }383+384+ fn begin_send(&mut self, now: Instant) -> Option<u64> {385+ if !self.negotiated {386+ return None;387+ }388+ self.sent_total = self.sent_total.saturating_add(1);389+ if self.pending.len() == INPUT_ACK_PENDING_CAPACITY {390+ self.pending.pop_front();391+ self.untracked_total = self.untracked_total.saturating_add(1);392+ }393+ self.pending.push_back((self.sent_total, now));394+ Some(self.sent_total)395+ }396+397+ fn observe_ack(&mut self, sequence: u64, now: Instant) -> Result<(), String> {398+ if !self.negotiated {399+ self.malformed = true;400+ return Err("Host sent an input acknowledgement that was not negotiated".to_string());401+ }402+ if self.last_ack == Some(sequence) {403+ self.duplicate_total = self.duplicate_total.saturating_add(1);404+ return Ok(());405+ }406+ if self.last_ack.is_some_and(|last| sequence < last) || sequence > self.sent_total {407+ self.malformed = true;408+ return Err(format!(409+ "Host sent invalid input acknowledgement {sequence} after {} local events",410+ self.sent_total411+ ));412+ }413+ if sequence == 0 && self.last_ack.is_none() {414+ self.last_ack = Some(0);415+ return Ok(());416+ }417+ while self418+ .pending419+ .front()420+ .is_some_and(|(pending, _)| *pending <= sequence)421+ {422+ let (_, sent_at) = self.pending.pop_front().expect("front checked");423+ self.latency.record(now.saturating_duration_since(sent_at));424+ }425+ self.acknowledged_total = sequence;426+ self.last_ack = Some(sequence);427+ Ok(())428+ }429+430+ fn snapshot(&self) -> InputAckSnapshot {431+ let latency = self.latency.summary();432+ InputAckSnapshot {433+ negotiated: self.negotiated,434+ sent_total: self.sent_total,435+ acknowledged_total: self.acknowledged_total,436+ pending_count: self.pending.len(),437+ pending_capacity: INPUT_ACK_PENDING_CAPACITY,438+ duplicate_total: self.duplicate_total,439+ untracked_total: self.untracked_total,440+ malformed: self.malformed,441+ closed: self.closed,442+ latency_sample_count: latency.sample_count,443+ latency_p50_ms: latency.p50_ms,444+ latency_p95_ms: latency.p95_ms,445+ latency_max_ms: latency.max_ms,446+ latency_overflow_total: latency.overflow_total,447+ complete: !self.malformed448+ && !self.closed449+ && self.untracked_total == 0450+ && latency.overflow_total == 0,451+ }452+ }453+}454+455+#[derive(Clone, Debug, Serialize)]456+pub(crate) struct InputAckSnapshot {457+ negotiated: bool,458+ sent_total: u64,459+ acknowledged_total: u64,460+ pending_count: usize,461+ pending_capacity: usize,462+ duplicate_total: u64,463+ untracked_total: u64,464+ malformed: bool,465+ closed: bool,466+ latency_sample_count: u64,467+ latency_p50_ms: Option<u64>,468+ latency_p95_ms: Option<u64>,469+ latency_max_ms: Option<u64>,470+ latency_overflow_total: u64,471+ complete: bool,472+}473+474+#[derive(Clone, Debug)]475+pub(crate) struct NetworkSessionDiagnostics {476+ started_at: Instant,477+ media: LegDiagnostics,478+ input: LegDiagnostics,479+ audio: Option<LegDiagnostics>,480+ input_ack: InputAckDiagnostics,481+}482+483+impl NetworkSessionDiagnostics {484+ pub(crate) fn new(started_at: Instant, input_ack_negotiated: bool) -> Self {485+ Self {486+ started_at,487+ media: LegDiagnostics::default(),488+ input: LegDiagnostics::default(),489+ audio: None,490+ input_ack: InputAckDiagnostics::new(input_ack_negotiated),491+ }492+ }493+494+ pub(crate) fn observe_connection(&mut self, leg: NetworkLeg, connection: &Connection) {495+ let diagnostics = match leg {496+ NetworkLeg::Media => &mut self.media,497+ NetworkLeg::Input => &mut self.input,498+ NetworkLeg::Audio => self.audio.get_or_insert_with(LegDiagnostics::default),499+ };500+ diagnostics.observe(LegObservation::from_connection(connection));501+ }502+503+ pub(crate) fn begin_input_send(&mut self, now: Instant) {504+ let _ = self.input_ack.begin_send(now);505+ }506+507+ pub(crate) fn observe_input_ack(&mut self, sequence: u64, now: Instant) -> Result<(), String> {508+ self.input_ack.observe_ack(sequence, now)509+ }510+511+ pub(crate) fn mark_input_feedback_closed(&mut self) {512+ if self.input_ack.negotiated {513+ self.input_ack.closed = true;514+ }515+ }516+517+ pub(crate) fn mark_input_feedback_malformed(&mut self) {518+ if self.input_ack.negotiated {519+ self.input_ack.malformed = true;520+ }521+ }522+523+ pub(crate) fn snapshot(&self, now: Instant) -> NetworkDiagnosticsSnapshot {524+ NetworkDiagnosticsSnapshot {525+ version: 1,526+ session_elapsed_ms: u64::try_from(527+ now.saturating_duration_since(self.started_at).as_millis(),528+ )529+ .unwrap_or(u64::MAX),530+ media: self.media.snapshot(),531+ input: self.input.snapshot(),532+ audio: self.audio.as_ref().map(LegDiagnostics::snapshot),533+ input_ack: self.input_ack.snapshot(),534+ }535+ }536+}537+538+#[derive(Clone, Debug, Serialize)]539+pub(crate) struct NetworkDiagnosticsSnapshot {540+ version: u8,541+ session_elapsed_ms: u64,542+ pub(crate) media: NetworkLegSnapshot,543+ input: NetworkLegSnapshot,544+ audio: Option<NetworkLegSnapshot>,545+ input_ack: InputAckSnapshot,546+}547+548+#[cfg(test)]549+impl NetworkDiagnosticsSnapshot {550+ pub(crate) fn test_fixture(mode: PathMode, rtt: Option<Duration>) -> Self {551+ let mut diagnostics = NetworkSessionDiagnostics::new(Instant::now(), false);552+ diagnostics.media.mode = mode;553+ diagnostics.media.sample_count = 1;554+ match mode {555+ PathMode::Direct => diagnostics.media.direct_samples = 1,556+ PathMode::Relay => diagnostics.media.relay_samples = 1,557+ PathMode::Custom => diagnostics.media.custom_samples = 1,558+ PathMode::Unknown => diagnostics.media.unknown_samples = 1,559+ }560+ if let Some(rtt) = rtt {561+ diagnostics.media.rtt.record(rtt);562+ diagnostics.media.rtt_current_ms = Some(rtt.as_secs_f64() * 1_000.0);563+ }564+ diagnostics.snapshot(Instant::now())565+ }566+}567+568+#[cfg(test)]569+mod tests {570+ use super::*;571+572+ fn observation(mode: PathMode, counters: u64) -> LegObservation {573+ LegObservation {574+ selected_path_id: Some(PathId::ZERO),575+ mode,576+ rtt: Some(Duration::from_millis(10 + counters)),577+ cwnd_bytes: Some(64_000),578+ mtu: Some(1_200),579+ congestion_events: Some(0),580+ black_holes_detected: Some(0),581+ tx_datagrams: counters,582+ tx_bytes: counters * 100,583+ rx_datagrams: counters,584+ rx_bytes: counters * 100,585+ lost_packets: 0,586+ lost_bytes: 0,587+ }588+ }589+590+ #[test]591+ fn path_mode_adapter_is_fail_closed_for_ambiguous_paths() {592+ assert_eq!(classify_path_mode(true, false), PathMode::Direct);593+ assert_eq!(classify_path_mode(false, true), PathMode::Relay);594+ assert_eq!(classify_path_mode(false, false), PathMode::Custom);595+ assert_eq!(classify_path_mode(true, true), PathMode::Custom);596+ }597+598+ #[test]599+ fn path_residency_and_transitions_remain_bounded() {600+ let mut diagnostics = LegDiagnostics::default();601+ diagnostics.observe(observation(PathMode::Direct, 1));602+ diagnostics.observe(observation(PathMode::Direct, 2));603+ diagnostics.observe(observation(PathMode::Relay, 3));604+ let snapshot = diagnostics.snapshot();605+ assert_eq!(snapshot.sample_count, 3);606+ assert_eq!(snapshot.direct_samples, 2);607+ assert_eq!(snapshot.relay_samples, 1);608+ assert_eq!(snapshot.mode_transition_total, 1);609+ assert!(snapshot.complete);610+ }611+612+ #[test]613+ fn same_mode_path_switch_advances_epoch_without_mode_transition() {614+ let mut diagnostics = LegDiagnostics::default();615+ diagnostics.observe(observation(PathMode::Direct, 1));616+ let mut replacement = observation(PathMode::Direct, 2);617+ replacement.selected_path_id = Some(PathId::MAX);618+ diagnostics.observe(replacement);619+ let snapshot = diagnostics.snapshot();620+ assert_eq!(snapshot.path_epoch, 2);621+ assert_eq!(snapshot.path_switch_total, 1);622+ assert_eq!(snapshot.mode_transition_total, 0);623+ }624+625+ #[test]626+ fn current_rtt_preserves_sub_millisecond_precision() {627+ let mut diagnostics = LegDiagnostics::default();628+ let mut sample = observation(PathMode::Direct, 1);629+ sample.rtt = Some(Duration::from_micros(800));630+ diagnostics.observe(sample);631+ let current = diagnostics.snapshot().rtt_current_ms.unwrap();632+ assert!((current - 0.8).abs() < f64::EPSILON);633+ }634+635+ #[test]636+ fn counter_regression_marks_the_leg_incomplete() {637+ let mut diagnostics = LegDiagnostics::default();638+ diagnostics.observe(observation(PathMode::Direct, 5));639+ diagnostics.observe(observation(PathMode::Direct, 4));640+ let snapshot = diagnostics.snapshot();641+ assert_eq!(snapshot.counter_regression_total, 1);642+ assert!(!snapshot.complete);643+ }644+645+ #[test]646+ fn latency_overflow_is_visible_instead_of_clamped() {647+ let mut histogram = BoundedLatencyHistogram::new();648+ histogram.record(Duration::from_millis(5_001));649+ let summary = histogram.summary();650+ assert_eq!(summary.sample_count, 1);651+ assert_eq!(summary.p95_ms, None);652+ assert_eq!(summary.max_ms, Some(5_001));653+ assert_eq!(summary.overflow_total, 1);654+ }655+656+ #[test]657+ fn cumulative_ack_accounts_for_each_pending_event() {658+ let started = Instant::now();659+ let mut diagnostics = InputAckDiagnostics::new(true);660+ diagnostics.begin_send(started);661+ diagnostics.begin_send(started + Duration::from_millis(1));662+ diagnostics.begin_send(started + Duration::from_millis(2));663+ diagnostics664+ .observe_ack(3, started + Duration::from_millis(12))665+ .unwrap();666+ let snapshot = diagnostics.snapshot();667+ assert_eq!(snapshot.acknowledged_total, 3);668+ assert_eq!(snapshot.pending_count, 0);669+ assert_eq!(snapshot.latency_sample_count, 3);670+ assert_eq!(snapshot.latency_p50_ms, Some(11));671+ }672+673+ #[test]674+ fn initial_and_duplicate_ack_do_not_invent_latency() {675+ let now = Instant::now();676+ let mut diagnostics = InputAckDiagnostics::new(true);677+ diagnostics.observe_ack(0, now).unwrap();678+ diagnostics.observe_ack(0, now).unwrap();679+ let snapshot = diagnostics.snapshot();680+ assert_eq!(snapshot.duplicate_total, 1);681+ assert_eq!(snapshot.latency_sample_count, 0);682+ }683+684+ #[test]685+ fn lower_and_future_ack_fail_closed() {686+ let now = Instant::now();687+ let mut diagnostics = InputAckDiagnostics::new(true);688+ diagnostics.begin_send(now);689+ diagnostics.observe_ack(1, now).unwrap();690+ assert!(diagnostics.observe_ack(0, now).is_err());691+ assert!(diagnostics.observe_ack(2, now).is_err());692+ assert!(diagnostics.snapshot().malformed);693+ }694+695+ #[test]696+ fn pending_overflow_drops_telemetry_not_input() {697+ let now = Instant::now();698+ let mut diagnostics = InputAckDiagnostics::new(true);699+ for _ in 0..=INPUT_ACK_PENDING_CAPACITY {700+ diagnostics.begin_send(now);701+ }702+ let snapshot = diagnostics.snapshot();703+ assert_eq!(snapshot.sent_total, (INPUT_ACK_PENDING_CAPACITY + 1) as u64);704+ assert_eq!(snapshot.pending_count, INPUT_ACK_PENDING_CAPACITY);705+ assert_eq!(snapshot.untracked_total, 1);706+ assert!(!snapshot.complete);707+ }708+709+ #[test]710+ fn serialized_snapshot_omits_path_and_peer_identity_material() {711+ let now = Instant::now();712+ let snapshot = NetworkSessionDiagnostics::new(now, true).snapshot(now);713+ let json = serde_json::to_string(&snapshot).unwrap();714+ for forbidden in [715+ "path_id",716+ "remote_addr",717+ "local_addr",718+ "node_id",719+ "relay_url",720+ ] {721+ assert!(722+ !json.contains(forbidden),723+ "serialized forbidden field {forbidden}"724+ );725+ }726+ }727+}