cat / goq
clone

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

refactor(portal): extract client media metrics

Dami committed 5b78b5d
commit
5b78b5d2ccdc442a6a829402417bfe14a3da7cec
parent
c5535b99fc3427f72254c22cbd4514203e4fa503
author
Dami <dami.etoile@gmail.com>
3 files changed, +586 -568
Msrc-tauri/src/commands/network.rs575+-----------------------------
Asrc-tauri/src/media/metrics.rs578++++++++++++++++++++++++++++++
Msrc-tauri/src/media/mod.rs1+
M src-tauri/src/commands/network.rs +7 -568
@@ -7,17 +7,14 @@ use super::state::{
77 };88 pub use crate::media::frame_channel::FramePayload;99 use crate::media::frame_channel::{10- CLIENT_FRAME_CHANNEL_CAPACITY, FrameEnvelopeMetadata, byte_to_codec,11- close_generation_connection, emit_frame_error, encode_frame_envelope, next_media_generation,12- release_frame_channel_slot, release_frame_channel_slot_for_generation, take_generation_owned,13- take_generation_owned_triple, try_reserve_frame_channel_slot, validate_legacy_media_header,10+ FrameEnvelopeMetadata, byte_to_codec, close_generation_connection, emit_frame_error,11+ encode_frame_envelope, next_media_generation, release_frame_channel_slot,12+ release_frame_channel_slot_for_generation, take_generation_owned, take_generation_owned_triple,13+ try_reserve_frame_channel_slot, validate_legacy_media_header,1414 };15+use crate::media::metrics::{ClientMediaMetrics, lock_client_media_metrics};1516 use crate::media::moq_catalog::subscribe_goq_video_track;16-#[cfg(test)]17-use crate::media::network_diagnostics::PathMode;18-use crate::media::network_diagnostics::{19- NetworkDiagnosticsSnapshot, NetworkLeg, NetworkSessionDiagnostics,20-};17+use crate::media::network_diagnostics::{NetworkLeg, NetworkSessionDiagnostics};2118 use base64::Engine;2219 use iroh::{Endpoint, SecretKey, endpoint::presets};2320 use iroh_moq::{Moq, MoqSession};
@@ -41,7 +38,7 @@ use sigil_protocol::{
4138 read_media_object, read_media_object_v3, write_client_hello, write_input_event,4239 write_media_control_request_v3, write_media_feedback_report_v1,4340 };44-use std::collections::{BTreeMap, VecDeque};41+use std::collections::BTreeMap;4542 use std::io::Cursor;4643 use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};4744 use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard};
@@ -72,300 +69,10 @@ const CLIENT_ENDPOINT_CLOSE_TIMEOUT: Duration = Duration::from_secs(2);
7269 const CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);7370 const CLIENT_MEDIA_FEEDBACK_IO_TIMEOUT: Duration = Duration::from_secs(5);7471 const CLIENT_ADAPTIVE_DECISION_DELIVERY_INTERVAL: Duration = Duration::from_secs(1);75-const CLIENT_FRAME_RATE_WINDOW: Duration = Duration::from_secs(1);76-const CLIENT_FRAME_TIMING_WINDOW: Duration = Duration::from_secs(5);77-// Host configuration permits at most 240 fps. Leave a little headroom for78-// timer-boundary samples while keeping every rate window strictly bounded.79-const CLIENT_FRAME_RATE_SAMPLE_CAPACITY: usize = 256;80-const CLIENT_FRAME_TIMING_SAMPLE_CAPACITY: usize = 512;8172 const AUDIO_CHANNEL_MAGIC: [u8; 4] = *b"SGAC";8273 const AUDIO_CHANNEL_VERSION: u16 = 1;8374 const AUDIO_CHANNEL_HEADER_LEN: usize = 24;8475 85-#[derive(Debug, Default)]86-struct RollingFrameRate {87- samples: VecDeque<Duration>,88-}89-90-impl RollingFrameRate {91- fn record(&mut self, elapsed: Duration) {92- self.prune(elapsed);93- if self.samples.len() == CLIENT_FRAME_RATE_SAMPLE_CAPACITY {94- self.samples.pop_front();95- }96- self.samples.push_back(elapsed);97- }98-99- fn rate(&mut self, elapsed: Duration) -> f64 {100- self.prune(elapsed);101- let observed = elapsed.min(CLIENT_FRAME_RATE_WINDOW).as_secs_f64();102- if observed <= f64::EPSILON {103- return 0.0;104- }105- self.samples.len() as f64 / observed106- }107-108- fn prune(&mut self, elapsed: Duration) {109- let cutoff = elapsed.saturating_sub(CLIENT_FRAME_RATE_WINDOW);110- while self111- .samples112- .front()113- .is_some_and(|sample| *sample <= cutoff && elapsed >= CLIENT_FRAME_RATE_WINDOW)114- {115- self.samples.pop_front();116- }117- }118-}119-120-#[derive(Clone, Copy, Debug)]121-struct TimedDurationSample {122- observed_at: Duration,123- duration: Duration,124-}125-126-#[derive(Clone, Copy, Debug, Default, PartialEq)]127-struct DurationWindowSummary {128- sample_count: usize,129- p50_ms: Option<f64>,130- p95_ms: Option<f64>,131- max_ms: Option<f64>,132-}133-134-#[derive(Debug, Default)]135-struct RollingDurationWindow {136- samples: VecDeque<TimedDurationSample>,137-}138-139-impl RollingDurationWindow {140- fn record(&mut self, observed_at: Duration, duration: Duration) {141- self.prune(observed_at);142- if self.samples.len() == CLIENT_FRAME_TIMING_SAMPLE_CAPACITY {143- self.samples.pop_front();144- }145- self.samples.push_back(TimedDurationSample {146- observed_at,147- duration,148- });149- }150-151- fn summary(&mut self, elapsed: Duration) -> DurationWindowSummary {152- self.prune(elapsed);153- if self.samples.is_empty() {154- return DurationWindowSummary::default();155- }156-157- let mut durations = self158- .samples159- .iter()160- .map(|sample| sample.duration)161- .collect::<Vec<_>>();162- durations.sort_unstable();163- DurationWindowSummary {164- sample_count: durations.len(),165- p50_ms: Some(duration_ms(nearest_rank(&durations, 50))),166- p95_ms: Some(duration_ms(nearest_rank(&durations, 95))),167- max_ms: durations.last().copied().map(duration_ms),168- }169- }170-171- fn prune(&mut self, elapsed: Duration) {172- let cutoff = elapsed.saturating_sub(CLIENT_FRAME_TIMING_WINDOW);173- while self.samples.front().is_some_and(|sample| {174- sample.observed_at <= cutoff && elapsed >= CLIENT_FRAME_TIMING_WINDOW175- }) {176- self.samples.pop_front();177- }178- }179-}180-181-fn nearest_rank(values: &[Duration], percentile: usize) -> Duration {182- debug_assert!(!values.is_empty());183- debug_assert!((1..=100).contains(&percentile));184- let rank = values.len().saturating_mul(percentile).div_ceil(100);185- values[rank.saturating_sub(1).min(values.len() - 1)]186-}187-188-fn duration_ms(duration: Duration) -> f64 {189- duration.as_secs_f64() * 1_000.0190-}191-192-#[derive(Debug, Default)]193-struct ClientMediaMetrics {194- transport_rate: RollingFrameRate,195- frontend_send_rate: RollingFrameRate,196- transport_intervals: RollingDurationWindow,197- frontend_ipc_send_durations: RollingDurationWindow,198- transport_received_total: u64,199- frontend_sent_total: u64,200- sequence_dropped_total: u64,201- transport_object_dropped_total: u64,202- transport_late_object_dropped_total: u64,203- frontend_queue_dropped_total: u64,204- frontend_resync_dropped_total: u64,205- frontend_queue_peak: usize,206- frontend_resync_episode_total: u64,207- frontend_resync_started_at: Option<Duration>,208- frontend_resync_completed_duration: Duration,209- frontend_resync_max_duration: Duration,210- last_transport_received_at: Option<Duration>,211- last_sequence: Option<u64>,212- last_keyframe: bool,213-}214-215-impl ClientMediaMetrics {216- fn observe_transport_receive(217- &mut self,218- elapsed: Duration,219- sequence: Option<u64>,220- keyframe: bool,221- ) {222- if let Some(previous) = self.last_transport_received_at {223- let interval = elapsed.saturating_sub(previous);224- // Gamescope capture is damage-driven. A gap longer than the225- // rolling diagnostics window is idle, not one giant hitch; start226- // a fresh cadence anchor while retaining the exact boundary.227- if interval <= CLIENT_FRAME_TIMING_WINDOW {228- self.transport_intervals.record(elapsed, interval);229- }230- }231- self.last_transport_received_at = Some(elapsed);232- self.transport_received_total = self.transport_received_total.saturating_add(1);233- self.transport_rate.record(elapsed);234- self.last_sequence = sequence;235- self.last_keyframe = keyframe;236- }237-238- fn observe_frontend_send(&mut self, elapsed: Duration) {239- self.frontend_sent_total = self.frontend_sent_total.saturating_add(1);240- self.frontend_send_rate.record(elapsed);241- }242-243- fn observe_sequence_drop(&mut self, count: u64) {244- self.sequence_dropped_total = self.sequence_dropped_total.saturating_add(count);245- }246-247- fn observe_transport_object_drop(&mut self, late: bool) {248- self.transport_object_dropped_total = self.transport_object_dropped_total.saturating_add(1);249- if late {250- self.transport_late_object_dropped_total =251- self.transport_late_object_dropped_total.saturating_add(1);252- }253- }254-255- fn observe_frontend_queue_drop(&mut self) {256- self.frontend_queue_dropped_total = self.frontend_queue_dropped_total.saturating_add(1);257- }258-259- fn observe_frontend_resync_drop(&mut self) {260- self.frontend_resync_dropped_total = self.frontend_resync_dropped_total.saturating_add(1);261- }262-263- fn begin_frontend_resync(&mut self, elapsed: Duration) {264- if self.frontend_resync_started_at.is_none() {265- self.frontend_resync_episode_total =266- self.frontend_resync_episode_total.saturating_add(1);267- self.frontend_resync_started_at = Some(elapsed);268- }269- }270-271- fn finish_frontend_resync(&mut self, elapsed: Duration) {272- let Some(started_at) = self.frontend_resync_started_at.take() else {273- return;274- };275- let duration = elapsed.saturating_sub(started_at);276- self.frontend_resync_completed_duration = self277- .frontend_resync_completed_duration278- .saturating_add(duration);279- self.frontend_resync_max_duration = self.frontend_resync_max_duration.max(duration);280- }281-282- fn observe_frontend_ipc_send_duration(&mut self, elapsed: Duration, duration: Duration) {283- self.frontend_ipc_send_durations.record(elapsed, duration);284- }285-286- fn observe_frontend_queue_depth(&mut self, depth: usize) {287- self.frontend_queue_peak = self.frontend_queue_peak.max(depth);288- }289-290- fn snapshot(291- &mut self,292- elapsed: Duration,293- frontend_queue_depth: usize,294- network_diagnostics: NetworkDiagnosticsSnapshot,295- generation: u64,296- ) -> FrameStatsPayload {297- let transport_receive_fps = self.transport_rate.rate(elapsed);298- let frontend_send_fps = self.frontend_send_rate.rate(elapsed);299- let frontend_dropped_total = self300- .frontend_queue_dropped_total301- .saturating_add(self.frontend_resync_dropped_total);302- let transport_intervals = self.transport_intervals.summary(elapsed);303- let frontend_ipc_send_durations = self.frontend_ipc_send_durations.summary(elapsed);304- let frontend_resync_current_duration = self305- .frontend_resync_started_at306- .map(|started_at| elapsed.saturating_sub(started_at));307- let frontend_resync_duration = self308- .frontend_resync_completed_duration309- .saturating_add(frontend_resync_current_duration.unwrap_or_default());310- let frontend_resync_max_duration = self311- .frontend_resync_max_duration312- .max(frontend_resync_current_duration.unwrap_or_default());313- FrameStatsPayload {314- generation,315- stats_version: 4,316- transport_receive_fps,317- frontend_send_fps,318- transport_received_total: self.transport_received_total,319- frontend_sent_total: self.frontend_sent_total,320- sequence_dropped_total: self.sequence_dropped_total,321- transport_object_dropped_total: self.transport_object_dropped_total,322- transport_late_object_dropped_total: self.transport_late_object_dropped_total,323- frontend_queue_dropped_total: self.frontend_queue_dropped_total,324- frontend_resync_dropped_total: self.frontend_resync_dropped_total,325- frontend_dropped_total,326- frontend_queue_depth,327- frontend_queue_peak: self.frontend_queue_peak,328- frontend_queue_capacity: CLIENT_FRAME_CHANNEL_CAPACITY,329- frontend_resync_episode_total: self.frontend_resync_episode_total,330- frontend_resync_active: self.frontend_resync_started_at.is_some(),331- frontend_resync_duration_ms_total: duration_ms(frontend_resync_duration),332- frontend_resync_duration_ms_current: frontend_resync_current_duration.map(duration_ms),333- frontend_resync_duration_ms_max: duration_ms(frontend_resync_max_duration),334- timing_window_ms: duration_ms(CLIENT_FRAME_TIMING_WINDOW),335- timing_sample_capacity: CLIENT_FRAME_TIMING_SAMPLE_CAPACITY,336- transport_interval_sample_count: transport_intervals.sample_count,337- transport_interval_p50_ms: transport_intervals.p50_ms,338- transport_interval_p95_ms: transport_intervals.p95_ms,339- transport_interval_max_ms: transport_intervals.max_ms,340- frontend_ipc_send_duration_sample_count: frontend_ipc_send_durations.sample_count,341- frontend_ipc_send_duration_p50_ms: frontend_ipc_send_durations.p50_ms,342- frontend_ipc_send_duration_p95_ms: frontend_ipc_send_durations.p95_ms,343- frontend_ipc_send_duration_max_ms: frontend_ipc_send_durations.max_ms,344- path_mode: network_diagnostics.media.mode.as_str(),345- path_rtt_ms: network_diagnostics.media.rtt_current_ms,346- network_diagnostics,347- sequence: self.last_sequence,348- keyframe: self.last_keyframe,349- // Compatibility aliases for the currently bundled frontend. New350- // diagnostics should consume the explicitly named fields above.351- fps: frontend_send_fps,352- transport_fps: transport_receive_fps,353- frontend_fps: frontend_send_fps,354- count: self.frontend_sent_total,355- host_dropped_frames: self.sequence_dropped_total,356- frontend_dropped_frames: frontend_dropped_total,357- }358- }359-}360-361-fn lock_client_media_metrics(362- metrics: &StdMutex<ClientMediaMetrics>,363-) -> StdMutexGuard<'_, ClientMediaMetrics> {364- metrics365- .lock()366- .unwrap_or_else(std::sync::PoisonError::into_inner)367-}368-36976 fn lock_network_diagnostics(37077 diagnostics: &StdMutex<NetworkSessionDiagnostics>,37178 ) -> StdMutexGuard<'_, NetworkSessionDiagnostics> {
@@ -462,51 +169,6 @@ async fn retire_upstream_moq_generation(
462169 true463170 }464171 465-#[derive(Clone, Debug, Serialize)]466-struct FrameStatsPayload {467- generation: u64,468- stats_version: u8,469- transport_receive_fps: f64,470- frontend_send_fps: f64,471- transport_received_total: u64,472- frontend_sent_total: u64,473- sequence_dropped_total: u64,474- transport_object_dropped_total: u64,475- transport_late_object_dropped_total: u64,476- frontend_queue_dropped_total: u64,477- frontend_resync_dropped_total: u64,478- frontend_dropped_total: u64,479- frontend_queue_depth: usize,480- frontend_queue_peak: usize,481- frontend_queue_capacity: usize,482- frontend_resync_episode_total: u64,483- frontend_resync_active: bool,484- frontend_resync_duration_ms_total: f64,485- frontend_resync_duration_ms_current: Option<f64>,486- frontend_resync_duration_ms_max: f64,487- timing_window_ms: f64,488- timing_sample_capacity: usize,489- transport_interval_sample_count: usize,490- transport_interval_p50_ms: Option<f64>,491- transport_interval_p95_ms: Option<f64>,492- transport_interval_max_ms: Option<f64>,493- frontend_ipc_send_duration_sample_count: usize,494- frontend_ipc_send_duration_p50_ms: Option<f64>,495- frontend_ipc_send_duration_p95_ms: Option<f64>,496- frontend_ipc_send_duration_max_ms: Option<f64>,497- path_mode: &'static str,498- path_rtt_ms: Option<f64>,499- network_diagnostics: NetworkDiagnosticsSnapshot,500- sequence: Option<u64>,501- keyframe: bool,502- fps: f64,503- transport_fps: f64,504- frontend_fps: f64,505- count: u64,506- host_dropped_frames: u64,507- frontend_dropped_frames: u64,508-}509-510172 fn lock_audio_deliveries(511173 deliveries: &StdMutex<AudioDeliveryState>,512174 ) -> StdMutexGuard<'_, AudioDeliveryState> {
@@ -4656,229 +4318,6 @@ mod tests {
46564318 );46574319 }46584320 4659- #[test]4660- fn rolling_frame_rate_tracks_recent_cadence_ages_out_and_stays_bounded() {4661- let mut rate = RollingFrameRate::default();4662- for frame in 1..=60 {4663- rate.record(Duration::from_micros(frame * 1_000_000 / 60));4664- }4665- assert!((rate.rate(Duration::from_secs(1)) - 60.0).abs() < 0.001);4666-4667- for frame in 1..=30 {4668- rate.record(Duration::from_secs(1) + Duration::from_micros(frame * 1_000_000 / 30));4669- }4670- assert!((rate.rate(Duration::from_secs(2)) - 30.0).abs() < 0.001);4671- assert_eq!(rate.rate(Duration::from_secs(4)), 0.0);4672-4673- for frame in 0..10_000_u64 {4674- rate.record(Duration::from_secs(5) + Duration::from_micros(frame));4675- }4676- assert_eq!(rate.samples.len(), CLIENT_FRAME_RATE_SAMPLE_CAPACITY);4677- }4678-4679- #[test]4680- fn rolling_duration_window_reports_nearest_rank_percentiles_and_stays_bounded() {4681- let mut window = RollingDurationWindow::default();4682- for milliseconds in 1..=100_u64 {4683- window.record(4684- Duration::from_millis(milliseconds),4685- Duration::from_millis(milliseconds),4686- );4687- }4688-4689- let summary = window.summary(Duration::from_millis(100));4690- assert_eq!(summary.sample_count, 100);4691- assert_eq!(summary.p50_ms, Some(50.0));4692- assert_eq!(summary.p95_ms, Some(95.0));4693- assert_eq!(summary.max_ms, Some(100.0));4694-4695- for sample in 0..10_000_u64 {4696- window.record(4697- Duration::from_secs(1) + Duration::from_micros(sample),4698- Duration::from_micros(sample),4699- );4700- }4701- assert_eq!(window.samples.len(), CLIENT_FRAME_TIMING_SAMPLE_CAPACITY);4702- assert_eq!(4703- window.summary(Duration::from_secs(10)),4704- DurationWindowSummary::default()4705- );4706- }4707-4708- #[test]4709- fn media_metrics_separate_drop_causes_and_preserve_compatibility_aliases() {4710- let mut metrics = ClientMediaMetrics::default();4711- metrics.observe_transport_receive(Duration::from_millis(100), Some(40), false);4712- metrics.observe_transport_receive(Duration::from_millis(200), Some(43), true);4713- metrics.observe_sequence_drop(2);4714- metrics.observe_transport_object_drop(false);4715- metrics.observe_transport_object_drop(true);4716- metrics.observe_frontend_queue_drop();4717- metrics.begin_frontend_resync(Duration::from_millis(200));4718- metrics.begin_frontend_resync(Duration::from_millis(210));4719- metrics.observe_frontend_resync_drop();4720- metrics.observe_frontend_resync_drop();4721- metrics.observe_frontend_queue_depth(3);4722- metrics.observe_frontend_send(Duration::from_millis(200));4723- metrics.observe_frontend_ipc_send_duration(4724- Duration::from_millis(200),4725- Duration::from_millis(3),4726- );4727- metrics.finish_frontend_resync(Duration::from_millis(230));4728-4729- let payload = metrics.snapshot(4730- Duration::from_millis(250),4731- 2,4732- NetworkDiagnosticsSnapshot::test_fixture(4733- PathMode::Direct,4734- Some(Duration::from_millis(8)),4735- ),4736- 9,4737- );4738- let json = serde_json::to_value(payload).unwrap();4739-4740- assert_eq!(json["generation"], 9);4741- assert_eq!(json["stats_version"], 4);4742- assert_eq!(json["transport_received_total"], 2);4743- assert_eq!(json["frontend_sent_total"], 1);4744- assert_eq!(json["sequence_dropped_total"], 2);4745- assert_eq!(json["transport_object_dropped_total"], 2);4746- assert_eq!(json["transport_late_object_dropped_total"], 1);4747- assert_eq!(json["frontend_queue_dropped_total"], 1);4748- assert_eq!(json["frontend_resync_dropped_total"], 2);4749- assert_eq!(json["frontend_dropped_total"], 3);4750- assert_eq!(json["frontend_queue_depth"], 2);4751- assert_eq!(json["frontend_queue_peak"], 3);4752- assert_eq!(json["frontend_queue_capacity"], 4);4753- assert_eq!(json["frontend_resync_episode_total"], 1);4754- assert_eq!(json["frontend_resync_active"], false);4755- assert_eq!(json["frontend_resync_duration_ms_total"], 30.0);4756- assert_eq!(4757- json["frontend_resync_duration_ms_current"],4758- serde_json::Value::Null4759- );4760- assert_eq!(json["frontend_resync_duration_ms_max"], 30.0);4761- assert_eq!(json["timing_window_ms"], 5_000.0);4762- assert_eq!(json["timing_sample_capacity"], 512);4763- assert_eq!(json["transport_interval_sample_count"], 1);4764- assert_eq!(json["transport_interval_p50_ms"], 100.0);4765- assert_eq!(json["transport_interval_p95_ms"], 100.0);4766- assert_eq!(json["transport_interval_max_ms"], 100.0);4767- assert_eq!(json["frontend_ipc_send_duration_sample_count"], 1);4768- assert_eq!(json["frontend_ipc_send_duration_p50_ms"], 3.0);4769- assert_eq!(json["frontend_ipc_send_duration_p95_ms"], 3.0);4770- assert_eq!(json["frontend_ipc_send_duration_max_ms"], 3.0);4771- assert_eq!(json["sequence"], 43);4772- assert_eq!(json["keyframe"], true);4773- assert_eq!(json["path_mode"], "direct");4774- assert_eq!(json["path_rtt_ms"], 8.0);4775- assert_eq!(json["network_diagnostics"]["version"], 1);4776-4777- assert_eq!(json["count"], json["frontend_sent_total"]);4778- assert_eq!(json["host_dropped_frames"], json["sequence_dropped_total"]);4779- assert_eq!(4780- json["frontend_dropped_frames"],4781- json["frontend_dropped_total"]4782- );4783- assert_eq!(json["fps"], json["frontend_send_fps"]);4784- assert_eq!(json["transport_fps"], json["transport_receive_fps"]);4785- assert_eq!(json["frontend_fps"], json["frontend_send_fps"]);4786- }4787-4788- #[test]4789- fn initial_keyframe_wait_is_one_measured_resync_episode() {4790- let mut metrics = ClientMediaMetrics::default();4791- metrics.begin_frontend_resync(Duration::ZERO);4792- metrics.begin_frontend_resync(Duration::from_millis(50));4793- metrics.observe_frontend_resync_drop();4794-4795- let active = metrics.snapshot(4796- Duration::from_millis(75),4797- 4,4798- NetworkDiagnosticsSnapshot::test_fixture(PathMode::Direct, None),4799- 1,4800- );4801- assert_eq!(active.generation, 1);4802- assert_eq!(active.frontend_resync_episode_total, 1);4803- assert!(active.frontend_resync_active);4804- assert_eq!(active.frontend_resync_duration_ms_total, 75.0);4805- assert_eq!(active.frontend_resync_duration_ms_current, Some(75.0));4806- assert_eq!(active.frontend_resync_duration_ms_max, 75.0);4807- assert_eq!(active.frontend_resync_dropped_total, 1);4808-4809- metrics.finish_frontend_resync(Duration::from_millis(100));4810- let completed = metrics.snapshot(4811- Duration::from_millis(150),4812- 0,4813- NetworkDiagnosticsSnapshot::test_fixture(PathMode::Direct, None),4814- 1,4815- );4816- assert_eq!(completed.frontend_resync_episode_total, 1);4817- assert!(!completed.frontend_resync_active);4818- assert_eq!(completed.frontend_resync_duration_ms_total, 100.0);4819- assert_eq!(completed.frontend_resync_duration_ms_current, None);4820- assert_eq!(completed.frontend_resync_duration_ms_max, 100.0);4821- }4822-4823- #[test]4824- fn media_metrics_fps_decays_during_idle_without_new_frames() {4825- let mut metrics = ClientMediaMetrics::default();4826- for frame in 1..=60 {4827- let elapsed = Duration::from_micros(frame * 1_000_000 / 60);4828- metrics.observe_transport_receive(elapsed, Some(frame), frame == 60);4829- metrics.observe_frontend_send(elapsed);4830- }4831-4832- let active = metrics.snapshot(4833- Duration::from_secs(1),4834- 0,4835- NetworkDiagnosticsSnapshot::test_fixture(PathMode::Relay, None),4836- 1,4837- );4838- assert!((active.transport_receive_fps - 60.0).abs() < 0.001);4839- assert!((active.frontend_send_fps - 60.0).abs() < 0.001);4840-4841- let idle = metrics.snapshot(4842- Duration::from_secs(3),4843- 0,4844- NetworkDiagnosticsSnapshot::test_fixture(PathMode::Relay, None),4845- 1,4846- );4847- assert_eq!(idle.transport_receive_fps, 0.0);4848- assert_eq!(idle.frontend_send_fps, 0.0);4849- assert_eq!(idle.transport_received_total, 60);4850- assert_eq!(idle.frontend_sent_total, 60);4851- }4852-4853- #[test]4854- fn transport_interval_resets_after_damage_idle_but_retains_exact_boundary() {4855- let mut metrics = ClientMediaMetrics::default();4856- metrics.observe_transport_receive(Duration::ZERO, Some(1), true);4857- metrics.observe_transport_receive(CLIENT_FRAME_TIMING_WINDOW, Some(2), false);4858- let boundary = metrics4859- .transport_intervals4860- .summary(CLIENT_FRAME_TIMING_WINDOW);4861- assert_eq!(boundary.sample_count, 1);4862- assert_eq!(boundary.max_ms, Some(5_000.0));4863-4864- let mut resumed_metrics = ClientMediaMetrics::default();4865- resumed_metrics.observe_transport_receive(Duration::ZERO, Some(1), true);4866- let after_idle = CLIENT_FRAME_TIMING_WINDOW + Duration::from_millis(1);4867- resumed_metrics.observe_transport_receive(after_idle, Some(2), false);4868- resumed_metrics.observe_transport_receive(4869- after_idle + Duration::from_millis(16),4870- Some(3),4871- false,4872- );4873- let resumed = resumed_metrics4874- .transport_intervals4875- .summary(after_idle + Duration::from_millis(16));4876- assert_eq!(resumed.sample_count, 1);4877- assert_eq!(resumed.p50_ms, Some(16.0));4878- assert_eq!(resumed.p95_ms, Some(16.0));4879- assert_eq!(resumed.max_ms, Some(16.0));4880- }4881-48824321 #[test]48834322 fn sequence_checks_reject_duplicates_regressions_and_overflow() {48844323 assert_eq!(sequence_gap(41, 42).unwrap(), 0);
A src-tauri/src/media/metrics.rs +578 -0
@@ -0,0 +1,578 @@
1+use std::collections::VecDeque;2+use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard};3+use std::time::Duration;4+5+use serde::Serialize;6+7+use super::frame_channel::CLIENT_FRAME_CHANNEL_CAPACITY;8+use super::network_diagnostics::NetworkDiagnosticsSnapshot;9+#[cfg(test)]10+use super::network_diagnostics::PathMode;11+12+const CLIENT_FRAME_RATE_WINDOW: Duration = Duration::from_secs(1);13+const CLIENT_FRAME_TIMING_WINDOW: Duration = Duration::from_secs(5);14+// Host configuration permits at most 240 fps. Leave a little headroom for15+// timer-boundary samples while keeping every rate window strictly bounded.16+const CLIENT_FRAME_RATE_SAMPLE_CAPACITY: usize = 256;17+const CLIENT_FRAME_TIMING_SAMPLE_CAPACITY: usize = 512;18+19+#[derive(Debug, Default)]20+struct RollingFrameRate {21+ samples: VecDeque<Duration>,22+}23+24+impl RollingFrameRate {25+ fn record(&mut self, elapsed: Duration) {26+ self.prune(elapsed);27+ if self.samples.len() == CLIENT_FRAME_RATE_SAMPLE_CAPACITY {28+ self.samples.pop_front();29+ }30+ self.samples.push_back(elapsed);31+ }32+33+ fn rate(&mut self, elapsed: Duration) -> f64 {34+ self.prune(elapsed);35+ let observed = elapsed.min(CLIENT_FRAME_RATE_WINDOW).as_secs_f64();36+ if observed <= f64::EPSILON {37+ return 0.0;38+ }39+ self.samples.len() as f64 / observed40+ }41+42+ fn prune(&mut self, elapsed: Duration) {43+ let cutoff = elapsed.saturating_sub(CLIENT_FRAME_RATE_WINDOW);44+ while self45+ .samples46+ .front()47+ .is_some_and(|sample| *sample <= cutoff && elapsed >= CLIENT_FRAME_RATE_WINDOW)48+ {49+ self.samples.pop_front();50+ }51+ }52+}53+54+#[derive(Clone, Copy, Debug)]55+struct TimedDurationSample {56+ observed_at: Duration,57+ duration: Duration,58+}59+60+#[derive(Clone, Copy, Debug, Default, PartialEq)]61+struct DurationWindowSummary {62+ sample_count: usize,63+ p50_ms: Option<f64>,64+ p95_ms: Option<f64>,65+ max_ms: Option<f64>,66+}67+68+#[derive(Debug, Default)]69+struct RollingDurationWindow {70+ samples: VecDeque<TimedDurationSample>,71+}72+73+impl RollingDurationWindow {74+ fn record(&mut self, observed_at: Duration, duration: Duration) {75+ self.prune(observed_at);76+ if self.samples.len() == CLIENT_FRAME_TIMING_SAMPLE_CAPACITY {77+ self.samples.pop_front();78+ }79+ self.samples.push_back(TimedDurationSample {80+ observed_at,81+ duration,82+ });83+ }84+85+ fn summary(&mut self, elapsed: Duration) -> DurationWindowSummary {86+ self.prune(elapsed);87+ if self.samples.is_empty() {88+ return DurationWindowSummary::default();89+ }90+91+ let mut durations = self92+ .samples93+ .iter()94+ .map(|sample| sample.duration)95+ .collect::<Vec<_>>();96+ durations.sort_unstable();97+ DurationWindowSummary {98+ sample_count: durations.len(),99+ p50_ms: Some(duration_ms(nearest_rank(&durations, 50))),100+ p95_ms: Some(duration_ms(nearest_rank(&durations, 95))),101+ max_ms: durations.last().copied().map(duration_ms),102+ }103+ }104+105+ fn prune(&mut self, elapsed: Duration) {106+ let cutoff = elapsed.saturating_sub(CLIENT_FRAME_TIMING_WINDOW);107+ while self.samples.front().is_some_and(|sample| {108+ sample.observed_at <= cutoff && elapsed >= CLIENT_FRAME_TIMING_WINDOW109+ }) {110+ self.samples.pop_front();111+ }112+ }113+}114+115+fn nearest_rank(values: &[Duration], percentile: usize) -> Duration {116+ debug_assert!(!values.is_empty());117+ debug_assert!((1..=100).contains(&percentile));118+ let rank = values.len().saturating_mul(percentile).div_ceil(100);119+ values[rank.saturating_sub(1).min(values.len() - 1)]120+}121+122+fn duration_ms(duration: Duration) -> f64 {123+ duration.as_secs_f64() * 1_000.0124+}125+126+#[derive(Debug, Default)]127+pub(crate) struct ClientMediaMetrics {128+ transport_rate: RollingFrameRate,129+ frontend_send_rate: RollingFrameRate,130+ transport_intervals: RollingDurationWindow,131+ frontend_ipc_send_durations: RollingDurationWindow,132+ transport_received_total: u64,133+ frontend_sent_total: u64,134+ sequence_dropped_total: u64,135+ transport_object_dropped_total: u64,136+ transport_late_object_dropped_total: u64,137+ frontend_queue_dropped_total: u64,138+ frontend_resync_dropped_total: u64,139+ frontend_queue_peak: usize,140+ frontend_resync_episode_total: u64,141+ frontend_resync_started_at: Option<Duration>,142+ frontend_resync_completed_duration: Duration,143+ frontend_resync_max_duration: Duration,144+ last_transport_received_at: Option<Duration>,145+ last_sequence: Option<u64>,146+ last_keyframe: bool,147+}148+149+impl ClientMediaMetrics {150+ pub(crate) fn observe_transport_receive(151+ &mut self,152+ elapsed: Duration,153+ sequence: Option<u64>,154+ keyframe: bool,155+ ) {156+ if let Some(previous) = self.last_transport_received_at {157+ let interval = elapsed.saturating_sub(previous);158+ // Gamescope capture is damage-driven. A gap longer than the159+ // rolling diagnostics window is idle, not one giant hitch; start160+ // a fresh cadence anchor while retaining the exact boundary.161+ if interval <= CLIENT_FRAME_TIMING_WINDOW {162+ self.transport_intervals.record(elapsed, interval);163+ }164+ }165+ self.last_transport_received_at = Some(elapsed);166+ self.transport_received_total = self.transport_received_total.saturating_add(1);167+ self.transport_rate.record(elapsed);168+ self.last_sequence = sequence;169+ self.last_keyframe = keyframe;170+ }171+172+ pub(crate) fn observe_frontend_send(&mut self, elapsed: Duration) {173+ self.frontend_sent_total = self.frontend_sent_total.saturating_add(1);174+ self.frontend_send_rate.record(elapsed);175+ }176+177+ pub(crate) fn observe_sequence_drop(&mut self, count: u64) {178+ self.sequence_dropped_total = self.sequence_dropped_total.saturating_add(count);179+ }180+181+ pub(crate) fn observe_transport_object_drop(&mut self, late: bool) {182+ self.transport_object_dropped_total = self.transport_object_dropped_total.saturating_add(1);183+ if late {184+ self.transport_late_object_dropped_total =185+ self.transport_late_object_dropped_total.saturating_add(1);186+ }187+ }188+189+ pub(crate) fn observe_frontend_queue_drop(&mut self) {190+ self.frontend_queue_dropped_total = self.frontend_queue_dropped_total.saturating_add(1);191+ }192+193+ pub(crate) fn observe_frontend_resync_drop(&mut self) {194+ self.frontend_resync_dropped_total = self.frontend_resync_dropped_total.saturating_add(1);195+ }196+197+ pub(crate) fn begin_frontend_resync(&mut self, elapsed: Duration) {198+ if self.frontend_resync_started_at.is_none() {199+ self.frontend_resync_episode_total =200+ self.frontend_resync_episode_total.saturating_add(1);201+ self.frontend_resync_started_at = Some(elapsed);202+ }203+ }204+205+ pub(crate) fn finish_frontend_resync(&mut self, elapsed: Duration) {206+ let Some(started_at) = self.frontend_resync_started_at.take() else {207+ return;208+ };209+ let duration = elapsed.saturating_sub(started_at);210+ self.frontend_resync_completed_duration = self211+ .frontend_resync_completed_duration212+ .saturating_add(duration);213+ self.frontend_resync_max_duration = self.frontend_resync_max_duration.max(duration);214+ }215+216+ pub(crate) fn observe_frontend_ipc_send_duration(217+ &mut self,218+ elapsed: Duration,219+ duration: Duration,220+ ) {221+ self.frontend_ipc_send_durations.record(elapsed, duration);222+ }223+224+ pub(crate) fn observe_frontend_queue_depth(&mut self, depth: usize) {225+ self.frontend_queue_peak = self.frontend_queue_peak.max(depth);226+ }227+228+ pub(crate) fn snapshot(229+ &mut self,230+ elapsed: Duration,231+ frontend_queue_depth: usize,232+ network_diagnostics: NetworkDiagnosticsSnapshot,233+ generation: u64,234+ ) -> FrameStatsPayload {235+ let transport_receive_fps = self.transport_rate.rate(elapsed);236+ let frontend_send_fps = self.frontend_send_rate.rate(elapsed);237+ let frontend_dropped_total = self238+ .frontend_queue_dropped_total239+ .saturating_add(self.frontend_resync_dropped_total);240+ let transport_intervals = self.transport_intervals.summary(elapsed);241+ let frontend_ipc_send_durations = self.frontend_ipc_send_durations.summary(elapsed);242+ let frontend_resync_current_duration = self243+ .frontend_resync_started_at244+ .map(|started_at| elapsed.saturating_sub(started_at));245+ let frontend_resync_duration = self246+ .frontend_resync_completed_duration247+ .saturating_add(frontend_resync_current_duration.unwrap_or_default());248+ let frontend_resync_max_duration = self249+ .frontend_resync_max_duration250+ .max(frontend_resync_current_duration.unwrap_or_default());251+ FrameStatsPayload {252+ generation,253+ stats_version: 4,254+ transport_receive_fps,255+ frontend_send_fps,256+ transport_received_total: self.transport_received_total,257+ frontend_sent_total: self.frontend_sent_total,258+ sequence_dropped_total: self.sequence_dropped_total,259+ transport_object_dropped_total: self.transport_object_dropped_total,260+ transport_late_object_dropped_total: self.transport_late_object_dropped_total,261+ frontend_queue_dropped_total: self.frontend_queue_dropped_total,262+ frontend_resync_dropped_total: self.frontend_resync_dropped_total,263+ frontend_dropped_total,264+ frontend_queue_depth,265+ frontend_queue_peak: self.frontend_queue_peak,266+ frontend_queue_capacity: CLIENT_FRAME_CHANNEL_CAPACITY,267+ frontend_resync_episode_total: self.frontend_resync_episode_total,268+ frontend_resync_active: self.frontend_resync_started_at.is_some(),269+ frontend_resync_duration_ms_total: duration_ms(frontend_resync_duration),270+ frontend_resync_duration_ms_current: frontend_resync_current_duration.map(duration_ms),271+ frontend_resync_duration_ms_max: duration_ms(frontend_resync_max_duration),272+ timing_window_ms: duration_ms(CLIENT_FRAME_TIMING_WINDOW),273+ timing_sample_capacity: CLIENT_FRAME_TIMING_SAMPLE_CAPACITY,274+ transport_interval_sample_count: transport_intervals.sample_count,275+ transport_interval_p50_ms: transport_intervals.p50_ms,276+ transport_interval_p95_ms: transport_intervals.p95_ms,277+ transport_interval_max_ms: transport_intervals.max_ms,278+ frontend_ipc_send_duration_sample_count: frontend_ipc_send_durations.sample_count,279+ frontend_ipc_send_duration_p50_ms: frontend_ipc_send_durations.p50_ms,280+ frontend_ipc_send_duration_p95_ms: frontend_ipc_send_durations.p95_ms,281+ frontend_ipc_send_duration_max_ms: frontend_ipc_send_durations.max_ms,282+ path_mode: network_diagnostics.media.mode.as_str(),283+ path_rtt_ms: network_diagnostics.media.rtt_current_ms,284+ network_diagnostics,285+ sequence: self.last_sequence,286+ keyframe: self.last_keyframe,287+ // Compatibility aliases for the currently bundled frontend. New288+ // diagnostics should consume the explicitly named fields above.289+ fps: frontend_send_fps,290+ transport_fps: transport_receive_fps,291+ frontend_fps: frontend_send_fps,292+ count: self.frontend_sent_total,293+ host_dropped_frames: self.sequence_dropped_total,294+ frontend_dropped_frames: frontend_dropped_total,295+ }296+ }297+}298+299+pub(crate) fn lock_client_media_metrics(300+ metrics: &StdMutex<ClientMediaMetrics>,301+) -> StdMutexGuard<'_, ClientMediaMetrics> {302+ metrics303+ .lock()304+ .unwrap_or_else(std::sync::PoisonError::into_inner)305+}306+307+#[derive(Clone, Debug, Serialize)]308+pub(crate) struct FrameStatsPayload {309+ generation: u64,310+ stats_version: u8,311+ transport_receive_fps: f64,312+ frontend_send_fps: f64,313+ transport_received_total: u64,314+ frontend_sent_total: u64,315+ sequence_dropped_total: u64,316+ transport_object_dropped_total: u64,317+ transport_late_object_dropped_total: u64,318+ frontend_queue_dropped_total: u64,319+ frontend_resync_dropped_total: u64,320+ frontend_dropped_total: u64,321+ frontend_queue_depth: usize,322+ frontend_queue_peak: usize,323+ frontend_queue_capacity: usize,324+ frontend_resync_episode_total: u64,325+ frontend_resync_active: bool,326+ frontend_resync_duration_ms_total: f64,327+ frontend_resync_duration_ms_current: Option<f64>,328+ frontend_resync_duration_ms_max: f64,329+ timing_window_ms: f64,330+ timing_sample_capacity: usize,331+ transport_interval_sample_count: usize,332+ transport_interval_p50_ms: Option<f64>,333+ transport_interval_p95_ms: Option<f64>,334+ transport_interval_max_ms: Option<f64>,335+ frontend_ipc_send_duration_sample_count: usize,336+ frontend_ipc_send_duration_p50_ms: Option<f64>,337+ frontend_ipc_send_duration_p95_ms: Option<f64>,338+ frontend_ipc_send_duration_max_ms: Option<f64>,339+ path_mode: &'static str,340+ path_rtt_ms: Option<f64>,341+ network_diagnostics: NetworkDiagnosticsSnapshot,342+ sequence: Option<u64>,343+ keyframe: bool,344+ fps: f64,345+ transport_fps: f64,346+ frontend_fps: f64,347+ count: u64,348+ host_dropped_frames: u64,349+ frontend_dropped_frames: u64,350+}351+352+#[cfg(test)]353+mod tests {354+ use super::*;355+356+ #[test]357+ fn rolling_frame_rate_tracks_recent_cadence_ages_out_and_stays_bounded() {358+ let mut rate = RollingFrameRate::default();359+ for frame in 1..=60 {360+ rate.record(Duration::from_micros(frame * 1_000_000 / 60));361+ }362+ assert!((rate.rate(Duration::from_secs(1)) - 60.0).abs() < 0.001);363+364+ for frame in 1..=30 {365+ rate.record(Duration::from_secs(1) + Duration::from_micros(frame * 1_000_000 / 30));366+ }367+ assert!((rate.rate(Duration::from_secs(2)) - 30.0).abs() < 0.001);368+ assert_eq!(rate.rate(Duration::from_secs(4)), 0.0);369+370+ for frame in 0..10_000_u64 {371+ rate.record(Duration::from_secs(5) + Duration::from_micros(frame));372+ }373+ assert_eq!(rate.samples.len(), CLIENT_FRAME_RATE_SAMPLE_CAPACITY);374+ }375+376+ #[test]377+ fn rolling_duration_window_reports_nearest_rank_percentiles_and_stays_bounded() {378+ let mut window = RollingDurationWindow::default();379+ for milliseconds in 1..=100_u64 {380+ window.record(381+ Duration::from_millis(milliseconds),382+ Duration::from_millis(milliseconds),383+ );384+ }385+386+ let summary = window.summary(Duration::from_millis(100));387+ assert_eq!(summary.sample_count, 100);388+ assert_eq!(summary.p50_ms, Some(50.0));389+ assert_eq!(summary.p95_ms, Some(95.0));390+ assert_eq!(summary.max_ms, Some(100.0));391+392+ for sample in 0..10_000_u64 {393+ window.record(394+ Duration::from_secs(1) + Duration::from_micros(sample),395+ Duration::from_micros(sample),396+ );397+ }398+ assert_eq!(window.samples.len(), CLIENT_FRAME_TIMING_SAMPLE_CAPACITY);399+ assert_eq!(400+ window.summary(Duration::from_secs(10)),401+ DurationWindowSummary::default()402+ );403+ }404+405+ #[test]406+ fn media_metrics_separate_drop_causes_and_preserve_compatibility_aliases() {407+ let mut metrics = ClientMediaMetrics::default();408+ metrics.observe_transport_receive(Duration::from_millis(100), Some(40), false);409+ metrics.observe_transport_receive(Duration::from_millis(200), Some(43), true);410+ metrics.observe_sequence_drop(2);411+ metrics.observe_transport_object_drop(false);412+ metrics.observe_transport_object_drop(true);413+ metrics.observe_frontend_queue_drop();414+ metrics.begin_frontend_resync(Duration::from_millis(200));415+ metrics.begin_frontend_resync(Duration::from_millis(210));416+ metrics.observe_frontend_resync_drop();417+ metrics.observe_frontend_resync_drop();418+ metrics.observe_frontend_queue_depth(3);419+ metrics.observe_frontend_send(Duration::from_millis(200));420+ metrics.observe_frontend_ipc_send_duration(421+ Duration::from_millis(200),422+ Duration::from_millis(3),423+ );424+ metrics.finish_frontend_resync(Duration::from_millis(230));425+426+ let payload = metrics.snapshot(427+ Duration::from_millis(250),428+ 2,429+ NetworkDiagnosticsSnapshot::test_fixture(430+ PathMode::Direct,431+ Some(Duration::from_millis(8)),432+ ),433+ 9,434+ );435+ let json = serde_json::to_value(payload).unwrap();436+437+ assert_eq!(json["generation"], 9);438+ assert_eq!(json["stats_version"], 4);439+ assert_eq!(json["transport_received_total"], 2);440+ assert_eq!(json["frontend_sent_total"], 1);441+ assert_eq!(json["sequence_dropped_total"], 2);442+ assert_eq!(json["transport_object_dropped_total"], 2);443+ assert_eq!(json["transport_late_object_dropped_total"], 1);444+ assert_eq!(json["frontend_queue_dropped_total"], 1);445+ assert_eq!(json["frontend_resync_dropped_total"], 2);446+ assert_eq!(json["frontend_dropped_total"], 3);447+ assert_eq!(json["frontend_queue_depth"], 2);448+ assert_eq!(json["frontend_queue_peak"], 3);449+ assert_eq!(json["frontend_queue_capacity"], 4);450+ assert_eq!(json["frontend_resync_episode_total"], 1);451+ assert_eq!(json["frontend_resync_active"], false);452+ assert_eq!(json["frontend_resync_duration_ms_total"], 30.0);453+ assert_eq!(454+ json["frontend_resync_duration_ms_current"],455+ serde_json::Value::Null456+ );457+ assert_eq!(json["frontend_resync_duration_ms_max"], 30.0);458+ assert_eq!(json["timing_window_ms"], 5_000.0);459+ assert_eq!(json["timing_sample_capacity"], 512);460+ assert_eq!(json["transport_interval_sample_count"], 1);461+ assert_eq!(json["transport_interval_p50_ms"], 100.0);462+ assert_eq!(json["transport_interval_p95_ms"], 100.0);463+ assert_eq!(json["transport_interval_max_ms"], 100.0);464+ assert_eq!(json["frontend_ipc_send_duration_sample_count"], 1);465+ assert_eq!(json["frontend_ipc_send_duration_p50_ms"], 3.0);466+ assert_eq!(json["frontend_ipc_send_duration_p95_ms"], 3.0);467+ assert_eq!(json["frontend_ipc_send_duration_max_ms"], 3.0);468+ assert_eq!(json["sequence"], 43);469+ assert_eq!(json["keyframe"], true);470+ assert_eq!(json["path_mode"], "direct");471+ assert_eq!(json["path_rtt_ms"], 8.0);472+ assert_eq!(json["network_diagnostics"]["version"], 1);473+474+ assert_eq!(json["count"], json["frontend_sent_total"]);475+ assert_eq!(json["host_dropped_frames"], json["sequence_dropped_total"]);476+ assert_eq!(477+ json["frontend_dropped_frames"],478+ json["frontend_dropped_total"]479+ );480+ assert_eq!(json["fps"], json["frontend_send_fps"]);481+ assert_eq!(json["transport_fps"], json["transport_receive_fps"]);482+ assert_eq!(json["frontend_fps"], json["frontend_send_fps"]);483+ }484+485+ #[test]486+ fn initial_keyframe_wait_is_one_measured_resync_episode() {487+ let mut metrics = ClientMediaMetrics::default();488+ metrics.begin_frontend_resync(Duration::ZERO);489+ metrics.begin_frontend_resync(Duration::from_millis(50));490+ metrics.observe_frontend_resync_drop();491+492+ let active = metrics.snapshot(493+ Duration::from_millis(75),494+ 4,495+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Direct, None),496+ 1,497+ );498+ assert_eq!(active.generation, 1);499+ assert_eq!(active.frontend_resync_episode_total, 1);500+ assert!(active.frontend_resync_active);501+ assert_eq!(active.frontend_resync_duration_ms_total, 75.0);502+ assert_eq!(active.frontend_resync_duration_ms_current, Some(75.0));503+ assert_eq!(active.frontend_resync_duration_ms_max, 75.0);504+ assert_eq!(active.frontend_resync_dropped_total, 1);505+506+ metrics.finish_frontend_resync(Duration::from_millis(100));507+ let completed = metrics.snapshot(508+ Duration::from_millis(150),509+ 0,510+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Direct, None),511+ 1,512+ );513+ assert_eq!(completed.frontend_resync_episode_total, 1);514+ assert!(!completed.frontend_resync_active);515+ assert_eq!(completed.frontend_resync_duration_ms_total, 100.0);516+ assert_eq!(completed.frontend_resync_duration_ms_current, None);517+ assert_eq!(completed.frontend_resync_duration_ms_max, 100.0);518+ }519+520+ #[test]521+ fn media_metrics_fps_decays_during_idle_without_new_frames() {522+ let mut metrics = ClientMediaMetrics::default();523+ for frame in 1..=60 {524+ let elapsed = Duration::from_micros(frame * 1_000_000 / 60);525+ metrics.observe_transport_receive(elapsed, Some(frame), frame == 60);526+ metrics.observe_frontend_send(elapsed);527+ }528+529+ let active = metrics.snapshot(530+ Duration::from_secs(1),531+ 0,532+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Relay, None),533+ 1,534+ );535+ assert!((active.transport_receive_fps - 60.0).abs() < 0.001);536+ assert!((active.frontend_send_fps - 60.0).abs() < 0.001);537+538+ let idle = metrics.snapshot(539+ Duration::from_secs(3),540+ 0,541+ NetworkDiagnosticsSnapshot::test_fixture(PathMode::Relay, None),542+ 1,543+ );544+ assert_eq!(idle.transport_receive_fps, 0.0);545+ assert_eq!(idle.frontend_send_fps, 0.0);546+ assert_eq!(idle.transport_received_total, 60);547+ assert_eq!(idle.frontend_sent_total, 60);548+ }549+550+ #[test]551+ fn transport_interval_resets_after_damage_idle_but_retains_exact_boundary() {552+ let mut metrics = ClientMediaMetrics::default();553+ metrics.observe_transport_receive(Duration::ZERO, Some(1), true);554+ metrics.observe_transport_receive(CLIENT_FRAME_TIMING_WINDOW, Some(2), false);555+ let boundary = metrics556+ .transport_intervals557+ .summary(CLIENT_FRAME_TIMING_WINDOW);558+ assert_eq!(boundary.sample_count, 1);559+ assert_eq!(boundary.max_ms, Some(5_000.0));560+561+ let mut resumed_metrics = ClientMediaMetrics::default();562+ resumed_metrics.observe_transport_receive(Duration::ZERO, Some(1), true);563+ let after_idle = CLIENT_FRAME_TIMING_WINDOW + Duration::from_millis(1);564+ resumed_metrics.observe_transport_receive(after_idle, Some(2), false);565+ resumed_metrics.observe_transport_receive(566+ after_idle + Duration::from_millis(16),567+ Some(3),568+ false,569+ );570+ let resumed = resumed_metrics571+ .transport_intervals572+ .summary(after_idle + Duration::from_millis(16));573+ assert_eq!(resumed.sample_count, 1);574+ assert_eq!(resumed.p50_ms, Some(16.0));575+ assert_eq!(resumed.p95_ms, Some(16.0));576+ assert_eq!(resumed.max_ms, Some(16.0));577+ }578+}
M src-tauri/src/media/mod.rs +1 -0