cat / goq
clone

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

release/v0.1.0-pins
goq / src-tauri / src / media / metrics.rs
metrics.rs 22.8K
1use std::collections::VecDeque;2use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard};3use std::time::Duration;45use serde::Serialize;67use super::frame_channel::CLIENT_FRAME_CHANNEL_CAPACITY;8use super::network_diagnostics::NetworkDiagnosticsSnapshot;9#[cfg(test)]10use super::network_diagnostics::PathMode;1112const CLIENT_FRAME_RATE_WINDOW: Duration = Duration::from_secs(1);13const 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.16const CLIENT_FRAME_RATE_SAMPLE_CAPACITY: usize = 256;17const CLIENT_FRAME_TIMING_SAMPLE_CAPACITY: usize = 512;1819#[derive(Debug, Default)]20struct RollingFrameRate {21 samples: VecDeque<Duration>,22}2324impl 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 }3233 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 }4142 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}5354#[derive(Clone, Copy, Debug)]55struct TimedDurationSample {56 observed_at: Duration,57 duration: Duration,58}5960#[derive(Clone, Copy, Debug, Default, PartialEq)]61struct DurationWindowSummary {62 sample_count: usize,63 p50_ms: Option<f64>,64 p95_ms: Option<f64>,65 max_ms: Option<f64>,66}6768#[derive(Debug, Default)]69struct RollingDurationWindow {70 samples: VecDeque<TimedDurationSample>,71}7273impl 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 }8485 fn summary(&mut self, elapsed: Duration) -> DurationWindowSummary {86 self.prune(elapsed);87 if self.samples.is_empty() {88 return DurationWindowSummary::default();89 }9091 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 }104105 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}114115fn 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}121122fn duration_ms(duration: Duration) -> f64 {123 duration.as_secs_f64() * 1_000.0124}125126#[derive(Debug, Default)]127pub(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}148149impl 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 }171172 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 }176177 pub(crate) fn observe_sequence_drop(&mut self, count: u64) {178 self.sequence_dropped_total = self.sequence_dropped_total.saturating_add(count);179 }180181 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 }188189 pub(crate) fn observe_frontend_queue_drop(&mut self) {190 self.frontend_queue_dropped_total = self.frontend_queue_dropped_total.saturating_add(1);191 }192193 pub(crate) fn observe_frontend_resync_drop(&mut self) {194 self.frontend_resync_dropped_total = self.frontend_resync_dropped_total.saturating_add(1);195 }196197 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 }204205 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 }215216 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 }223224 pub(crate) fn observe_frontend_queue_depth(&mut self, depth: usize) {225 self.frontend_queue_peak = self.frontend_queue_peak.max(depth);226 }227228 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: 5,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}298299pub(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}306307#[derive(Clone, Debug, Serialize)]308pub(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}351352#[cfg(test)]353mod tests {354 use super::*;355356 #[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);363364 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);369370 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 }375376 #[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 }385386 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));391392 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 }404405 #[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));425426 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();436437 assert_eq!(json["generation"], 9);438 assert_eq!(json["stats_version"], 5);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"], 2);473474 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 }484485 #[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();491492 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);505506 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 }519520 #[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 }528529 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);537538 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 }549550 #[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));560561 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}