//! Opt-in resize diagnostics. No measurement code exists without resize-metrics. //! Times are unscaled wall time except handoff_animation, which is the existing //! handoff clock (affected by animation speed and pauses). use std::collections::HashMap; use std::time::Duration; use ferese_core::WorkspaceId; use ferese_layout::WindowId; const BUCKETS_MS: [u64; 16] = [ 16, 32, 64, 75, 80, 85, 100, 150, 250, 290, 299, 300, 301, 310, 350, 500, ]; #[derive(Debug, Default)] struct Histogram { counts: [u64; 17], } impl Histogram { fn add(&mut self, duration: Duration) { let index = BUCKETS_MS.partition_point(|ms| Duration::from_millis(*ms) < duration); self.counts[index] += 1; } } #[derive(Clone, Copy, Debug)] pub(crate) enum End { Commit, Deadline, Replaced, Cancelled, } #[derive(Default, Debug)] struct Client { workspace: Option, app_id: String, started: Option, target_changed: Option, last_observation: Duration, handoff_started: Option, barriers: u64, deadlines: u64, commits: u64, replaced: u64, cancelled: u64, viewport_pauses: u64, other_window_pauses: u64, viewport_seen: bool, others_seen: bool, viewport_overlap: Duration, other_overlap: Duration, barrier_duration: Histogram, viewport_blocked: Histogram, other_windows_blocked: Histogram, target_to_handoff: Histogram, handoff_wall: Histogram, handoff_animation: Histogram, } #[derive(Default)] pub(crate) struct ResizeMetrics { clients: HashMap, workspace_started: HashMap, workspace_duration: Histogram, } impl ResizeMetrics { pub(crate) fn begin( &mut self, id: WindowId, workspace: Option, app_id: &str, now: Duration, ) { // Keep an overlapping workspace barrier continuous through retargets. let previous_workspace = self.end_client(id, now, End::Replaced); if previous_workspace != workspace { self.finish_workspace(previous_workspace, now); } let client = self.clients.entry(id).or_default(); client.workspace = workspace; client.app_id.clear(); client.app_id.push_str(app_id); client.started = Some(now); client.target_changed = Some(now); client.last_observation = now; client.handoff_started = None; client.viewport_seen = false; client.others_seen = false; client.viewport_overlap = Duration::ZERO; client.other_overlap = Duration::ZERO; client.barriers += 1; if let Some(workspace) = workspace { self.workspace_started.entry(workspace).or_insert(now); } } fn end_client(&mut self, id: WindowId, now: Duration, reason: End) -> Option { let client = self.clients.get_mut(&id)?; let started = client.started.take()?; client.barrier_duration.add(now.saturating_sub(started)); client.viewport_blocked.add(client.viewport_overlap); client.other_windows_blocked.add(client.other_overlap); match reason { End::Commit => client.commits += 1, End::Deadline => client.deadlines += 1, End::Replaced => client.replaced += 1, End::Cancelled => client.cancelled += 1, } client.workspace } pub(crate) fn end(&mut self, id: WindowId, now: Duration, reason: End) { let Some(workspace) = self.end_client(id, now, reason) else { return; }; self.finish_workspace(Some(workspace), now); } fn finish_workspace(&mut self, workspace: Option, now: Duration) { let Some(workspace) = workspace else { return }; if !self .clients .values() .any(|client| client.workspace == Some(workspace) && client.started.is_some()) && let Some(started) = self.workspace_started.remove(&workspace) { self.workspace_duration.add(now.saturating_sub(started)); } } pub(crate) fn observe( &mut self, id: WindowId, now: Duration, viewport_active: bool, other_active: bool, ) { let Some(client) = self.clients.get_mut(&id) else { return; }; if client.started.is_none() { return; } let interval = now.saturating_sub(client.last_observation); client.last_observation = now; if viewport_active { client.viewport_overlap += interval; if !client.viewport_seen { client.viewport_seen = true; client.viewport_pauses += 1; } } if other_active { client.other_overlap += interval; if !client.others_seen { client.others_seen = true; client.other_window_pauses += 1; } } } pub(crate) fn pause_clock(&mut self, now: Duration) { // Activity overlap excludes globally paused presentation time. Barrier // and target-to-handoff durations remain actual wall-clock durations. for client in self .clients .values_mut() .filter(|client| client.started.is_some()) { client.last_observation = now; } } pub(crate) fn handoff_tick(&mut self, id: WindowId, now: Duration, blocked: bool) { let Some(client) = self.clients.get_mut(&id) else { return; }; if !blocked && client.handoff_started.is_none() { client.handoff_started = Some(now); if let Some(changed) = client.target_changed.take() { client.target_to_handoff.add(now.saturating_sub(changed)); } } } pub(crate) fn handoff_end(&mut self, id: WindowId, now: Duration, elapsed: Duration) { let Some(client) = self.clients.get_mut(&id) else { return; }; if let Some(started) = client.handoff_started.take() { client.handoff_wall.add(now.saturating_sub(started)); client.handoff_animation.add(elapsed); } } pub(crate) fn dump(&self) { // Enabling the feature explicitly opts into the exit report. This does // not depend on a tracing filter and adds no per-frame log messages. eprintln!( "resize-metrics: histogram upper bounds ms {BUCKETS_MS:?}, final bucket overflow" ); eprintln!( "resize-metrics: workspace barrier durations {:?}", self.workspace_duration ); for (id, client) in &self.clients { eprintln!("resize-metrics: client {id:?} {client:?}"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn overlapping_clients_deadlines_and_handoff_are_measured_separately() { let mut metrics = ResizeMetrics::default(); let ws = Some(WorkspaceId(1)); let ms = Duration::from_millis; metrics.begin(WindowId(1), ws, "terminal", ms(0)); metrics.begin(WindowId(2), ws, "editor", ms(50)); metrics.observe(WindowId(1), ms(100), true, true); metrics.observe(WindowId(1), ms(300), true, true); metrics.end(WindowId(1), ms(300), End::Deadline); assert!(metrics.workspace_started.contains_key(&WorkspaceId(1))); metrics.end(WindowId(2), ms(310), End::Commit); assert!(metrics.workspace_started.is_empty()); metrics.handoff_tick(WindowId(1), ms(310), false); metrics.handoff_end(WindowId(1), ms(390), ms(80)); let client = &metrics.clients[&WindowId(1)]; assert_eq!(client.deadlines, 1); assert_eq!(client.viewport_pauses, 1); assert_eq!(client.other_window_pauses, 1); assert_eq!(client.viewport_overlap, ms(300)); assert_eq!(client.other_overlap, ms(300)); assert_eq!(client.barrier_duration.counts[11], 1); assert_eq!(client.handoff_animation.counts[4], 1); assert_eq!(client.handoff_wall.counts[4], 1); assert_eq!(metrics.workspace_duration.counts[13], 1); } #[test] fn globally_paused_time_is_not_counted_as_animation_blocking_overlap() { let mut metrics = ResizeMetrics::default(); let ms = Duration::from_millis; metrics.begin(WindowId(1), Some(WorkspaceId(1)), "terminal", ms(0)); metrics.observe(WindowId(1), ms(40), true, true); metrics.pause_clock(ms(340)); metrics.observe(WindowId(1), ms(356), true, true); metrics.end(WindowId(1), ms(356), End::Deadline); let client = &metrics.clients[&WindowId(1)]; assert_eq!(client.viewport_overlap, ms(56)); assert_eq!(client.other_overlap, ms(56)); assert_eq!(client.barrier_duration.counts[15], 1); assert_eq!(client.deadlines, 1); } }