From 9e6c10c0834250c9318e12299093b4e4d173440a Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Wed, 7 Oct 2026 12:41:49 +0700 Subject: [PATCH] refactor(tui): compact graph history and retain 500k samples --- crates/irtt-app/src/cmd/tui/ui.rs | 130 ++++++++++++++++++++---------- docs/irtt-tui.md | 13 ++- 2 files changed, 99 insertions(+), 44 deletions(-) diff --git a/crates/irtt-app/src/cmd/tui/ui.rs b/crates/irtt-app/src/cmd/tui/ui.rs index 7795f41..5259ae3 100644 --- a/crates/irtt-app/src/cmd/tui/ui.rs +++ b/crates/irtt-app/src/cmd/tui/ui.rs @@ -43,7 +43,7 @@ use crate::{ shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup}, }; -const HISTORY_LIMIT: usize = 100_000; +const HISTORY_LIMIT: usize = 500_000; const RECENT_EVENT_LIMIT: usize = 80; const MIN_WIDTH: u16 = 56; const MIN_HEIGHT: u16 = 18; @@ -405,7 +405,7 @@ impl TuiState { server_timing, .. } => { - target.push_graph_sample(GraphSample { + target.push_graph_sample(LatestSample { generation: target.generation.expect("client event has a generation"), timestamp: *received_at, seq: *seq, @@ -615,7 +615,7 @@ struct TuiTargetState { status: TargetStatus, negotiated: Option, graph_history: VecDeque, - last_sample: Option, + last_sample: Option, last_warning: Option, stats: StatsCollector, } @@ -656,9 +656,20 @@ impl TuiTargetState { } } - fn push_graph_sample(&mut self, sample: GraphSample) { + fn push_graph_sample(&mut self, sample: LatestSample) { + let history = GraphSample { + timestamp: sample.timestamp, + values_ms: GraphMetric::ALL.map(|metric| { + metric + .value_ns(&sample) + .map_or(f64::NAN, |ns| ns as f64 / 1_000_000.0) + }), + break_before: self + .last_sample + .is_none_or(|last| last.generation != sample.generation), + }; self.last_sample = Some(sample); - push_bounded(&mut self.graph_history, sample, HISTORY_LIMIT); + push_bounded(&mut self.graph_history, history, HISTORY_LIMIT); } } @@ -926,7 +937,8 @@ enum GraphMetric { } impl GraphMetric { - const ALL: &[Self] = &[ + // Enum order also indexes the retained graph values. + const ALL: [Self; 6] = [ Self::EffectiveRtt, Self::RawRtt, Self::AdjustedRtt, @@ -966,7 +978,7 @@ impl GraphMetric { } } - fn value_ns(self, sample: &GraphSample) -> Option { + fn value_ns(self, sample: &LatestSample) -> Option { match self { Self::EffectiveRtt => Some(sample.rtt.effective.as_nanos()), Self::RawRtt => Some(SignedDuration::from(sample.rtt.raw).as_nanos()), @@ -996,8 +1008,25 @@ impl GraphMetric { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// Retained values use the same nanoseconds-to-f64 milliseconds conversion as +// the chart. NaN means absent and never reaches the canvas as a finite point. +// Keep both clocks: geometry is monotonic, while labels use local wall time. +#[derive(Debug, Clone, Copy)] struct GraphSample { + timestamp: ClientTimestamp, + values_ms: [f64; 6], + break_before: bool, +} + +impl GraphSample { + fn value_ms(&self, metric: GraphMetric) -> Option { + let value = self.values_ms[metric as usize]; + value.is_finite().then_some(value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LatestSample { generation: u64, timestamp: ClientTimestamp, seq: u32, @@ -1426,40 +1455,31 @@ fn target_metric_series( let first = target .graph_history .partition_point(|sample| sample.timestamp.mono < viewport.start); - let predecessor_generation = target - .graph_history - .get(first.saturating_sub(1)) - .map(|sample| sample.generation); - let mut previous = target - .graph_history - .range(..first) - .rev() - .take_while(|sample| Some(sample.generation) == predecessor_generation) - .find_map(|sample| { - metric.value_ns(sample).map(|ns| { - ( - sample.generation, - (sample.timestamp.mono, ns as f64 / 1_000_000.0), - ) - }) - }); - let points = target.graph_history.range(first..).filter_map(|sample| { - metric.value_ns(sample).map(|ns| { - ( - sample.generation, - (sample.timestamp.mono, ns as f64 / 1_000_000.0), - ) - }) - }); + let mut previous = None; + for sample in target.graph_history.range(..first).rev() { + if let Some(value) = sample.value_ms(metric) { + previous = Some((sample.timestamp.mono, value)); + break; + } + if sample.break_before { + break; + } + } let mut data = Vec::new(); - for (generation, point) in points { - if previous.is_some_and(|(before_generation, _)| before_generation != generation) { - if !data.is_empty() { + for sample in target.graph_history.range(first..) { + // Consume boundaries even when this metric is absent in the first + // replies of a new session; filtering them out would bridge an outage. + if sample.break_before { + if !data.is_empty() && data.last().is_some_and(|(x, _): &(f64, f64)| x.is_finite()) { data.push((f64::NAN, f64::NAN)); } previous = None; } - if let Some((_, before)) = previous { + let Some(value) = sample.value_ms(metric) else { + continue; + }; + let point = (sample.timestamp.mono, value); + if let Some(before) = previous { // Only genuine crossing segments contribute boundary points. In // particular, never extend the latest sample to the live edge. if before.0 < viewport.start && point.0 > viewport.start { @@ -1481,7 +1501,7 @@ fn target_metric_series( point.1, )); } - previous = Some((generation, point)); + previous = Some(point); } // A boundary beyond the viewport needs no trailing break. if data.last().is_some_and(|(x, _)| !x.is_finite()) { @@ -2032,7 +2052,7 @@ mod tests { let mut target = TuiTargetState::new("monitor".into(), stats_config(true)); for (generation, seconds) in [(0, 0), (0, 2), (1, 6), (1, 8)] { let raw = Duration::from_millis(seconds + 1); - target.push_graph_sample(GraphSample { + target.push_graph_sample(LatestSample { generation, timestamp: ClientTimestamp { mono: start + Duration::from_secs(seconds), @@ -2041,7 +2061,8 @@ mod tests { seq: 0, rtt: RttSample { raw, - adjusted: None, + adjusted: (seconds != 6) + .then_some(SignedDuration::from_nanos(-(raw.as_nanos() as i128))), effective: SignedDuration::from_nanos(raw.as_nanos() as i128), }, one_way: None, @@ -2110,10 +2131,35 @@ mod tests { ); assert!(series(3, 5).is_none(), "no interpolation across sessions"); assert!(series(9, 10).is_none(), "no extrapolation to the live edge"); - let template = target.graph_history[0]; + let adjusted = |left, right| { + target_metric_series( + &target, + 0, + GraphViewportRange { + start: start + Duration::from_secs(left), + end: start + Duration::from_secs(right), + window: Duration::from_secs(right - left), + }, + GraphMetric::AdjustedRtt, + ) + }; + let missing = adjusted(1, 9).unwrap(); + assert_eq!(&missing.data[..2], [(0.0, -2.0), (1.0, -3.0)]); + assert!(missing.data[2].0.is_nan()); + assert_eq!(&missing.data[3..], [(7.0, -9.0)]); + assert!( + adjusted(3, 7).is_none(), + "absent metric cannot bridge sessions" + ); + assert_eq!( + adjusted(7, 9).unwrap().data, + [(1.0, -9.0)], + "predecessor search stops at an absent boundary metric" + ); + let template = target.last_sample.unwrap(); target.graph_history.clear(); for generation in 0..10_000 { - target.push_graph_sample(GraphSample { + target.push_graph_sample(LatestSample { generation, timestamp: ClientTimestamp { mono: start + Duration::from_secs(generation), diff --git a/docs/irtt-tui.md b/docs/irtt-tui.md index 7586802..d2b538d 100644 --- a/docs/irtt-tui.md +++ b/docs/irtt-tui.md @@ -108,11 +108,20 @@ controls appropriate to the currently open graph or details sheet. The TUI keeps its own bounded presentation state on top of the statistics retention described in `irtt-client(1)`: -- Up to 100,000 graph samples per target. +- Up to 500,000 graph samples per target. - Up to 80 recent status/log messages. +At 500,000 replies per target, retention is approximately 13h53m20s at +100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph +samples store both clocks, six graph-ready values, and a session-boundary +flag; latest details keep the full typed timing values and sequence number. +On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target +at the cap, excluding statistics and temporary rendering buffers. Histories +grow on demand and reserve exactly to the cap on their final growth step; +they do not preallocate the full history for every target at startup. + `r` clears graph history for all targets and returns the viewport to -live; it does not change the 100,000-sample cap itself. These are +live; it does not change the 500,000-sample cap itself. These are presentation bounds on top of the client's own statistics retention (see `irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's total memory is dominated by whichever of the two is larger for your target