//! Independent count and time windows must match a filtered replay through //! StatsCollector for ordinary metrics, while directional loss has an independent //! interval model and cumulative accounting retains the complete history. //! Includes delayed timeout discovery and replies with backdated timestamps. use std::time::{Duration, Instant, UNIX_EPOCH}; use irtt_client::{ ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample, ServerTiming, SignedDuration, }; use irtt_stats::{LateReplyMode, SampleMode, Snapshot, StatsCollector, StatsConfig}; use proptest::prelude::*; /// One generated operation. `Advance` moves the shared monotonic clock /// forward without producing an event; other variants produce a normalized /// event, with delayed events timestamped before the clock's current value. #[derive(Debug, Clone)] enum Op { /// Advance the shared clock by this many milliseconds. Advance(u16), /// A probe send. Send, /// An on-time unique reply with the given client-observed RTT. Reply { raw_ms: u16 }, /// A reply processed after later events, but timestamped at receipt. DelayedReply { raw_ms: u16, age_ms: u16 }, /// A late reply matched to retained send state (measurable). LateReplyMatched { raw_ms: u16 }, /// A late reply that could not be matched to retained state. LateReplyUnmatched, /// A duplicate reply for an already-completed sequence. Duplicate, /// A probe timeout / loss. Loss, /// A timeout discovered this many milliseconds after its deadline. DelayedLoss { age_ms: u16 }, /// A diagnostic warning event. Warning, } fn op_strategy() -> impl Strategy { prop_oneof![ 3 => (0u16..40).prop_map(Op::Advance), 4 => Just(Op::Send), 4 => (0u16..60).prop_map(|raw_ms| Op::Reply { raw_ms }), 2 => (0u16..60, 0u16..100).prop_map(|(raw_ms, age_ms)| Op::DelayedReply { raw_ms, age_ms }), 2 => (0u16..60).prop_map(|raw_ms| Op::LateReplyMatched { raw_ms }), 1 => Just(Op::LateReplyUnmatched), 1 => Just(Op::Duplicate), 2 => Just(Op::Loss), 2 => (0u16..100).prop_map(|age_ms| Op::DelayedLoss { age_ms }), 1 => Just(Op::Warning), ] } const ADDR: &str = "127.0.0.1:2112"; /// Builds a `ClientTimestamp` purely by adding a millisecond offset to a /// fixed `base` `Instant` (and to `UNIX_EPOCH` for the wall-clock half). No /// operation below ever reads the real clock more than once (to establish /// `base`), so the resulting timestamps are fully deterministic functions of /// the generated `Op`s, with no wall-clock jitter to make a proptest shrink /// or CI run flaky. fn ts_at(base: Instant, ms: u64) -> ClientTimestamp { ClientTimestamp { mono: base + Duration::from_millis(ms), wall: UNIX_EPOCH + Duration::from_millis(ms), } } fn server_timing(seq: u32) -> ServerTiming { let base_ns = i64::from(seq) * 10_000_000; ServerTiming { receive_mono_ns: Some(base_ns + 1_000_000), send_mono_ns: Some(base_ns + 2_000_000), receive_wall_ns: Some(base_ns + 1_000_000), send_wall_ns: Some(base_ns + 2_000_000), midpoint_mono_ns: None, midpoint_wall_ns: None, processing: Some(Duration::from_micros(100)), } } fn one_way(seq: u32) -> OneWayDelaySample { OneWayDelaySample { client_to_server: Some(SignedDuration::from_nanos(1_000_000 + i128::from(seq))), server_to_client: Some(SignedDuration::from_nanos(2_000_000 + i128::from(seq))), } } /// Builds the `ClientEvent` for one non-`Advance` op. `clock_ms` is the /// shared clock's current value; delayed events use their earlier deadline or /// receipt time as the event's windowing timestamp. fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent { let clock_ms = match *op { Op::DelayedReply { age_ms, .. } => clock_ms.saturating_sub(u64::from(age_ms)), _ => clock_ms, }; match *op { Op::Advance(_) => unreachable!("Advance does not produce an event"), Op::Send => ClientEvent::EchoSent { seq, remote: ADDR.parse().unwrap(), scheduled_at: Some(ts_at(base, clock_ms).mono), sent_at: ts_at(base, clock_ms), bytes: 32, send_call: Duration::from_micros(10), timer_error: Some(Duration::from_micros(2)), }, Op::Reply { raw_ms } | Op::DelayedReply { raw_ms, .. } => { let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms))); let received_at = ts_at(base, clock_ms); ClientEvent::EchoReply { seq, remote: ADDR.parse().unwrap(), sent_at, received_at, rtt: RttSample { raw: Duration::from_millis(u64::from(raw_ms)), adjusted: None, effective: SignedDuration::from_duration(Duration::from_millis(u64::from( raw_ms, ))), }, server_timing: Some(server_timing(seq)), one_way: Some(one_way(seq)), received_stats: Some(ReceivedStatsSample { count: Some(seq + 1), window: Some(0xff), }), bytes: 64, packet_meta: PacketMeta::default(), } } Op::LateReplyMatched { raw_ms } => { let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms))); let received_at = ts_at(base, clock_ms); ClientEvent::LateReply { seq, highest_seen: seq + 1, remote: ADDR.parse().unwrap(), sent_at: Some(sent_at), received_at, rtt: Some(RttSample { raw: Duration::from_millis(u64::from(raw_ms)), adjusted: None, effective: SignedDuration::from_duration(Duration::from_millis(u64::from( raw_ms, ))), }), server_timing: Some(server_timing(seq)), one_way: Some(one_way(seq)), received_stats: Some(ReceivedStatsSample { count: Some(seq + 1), window: Some(0xff), }), bytes: 64, packet_meta: PacketMeta::default(), } } Op::LateReplyUnmatched => ClientEvent::LateReply { seq, highest_seen: seq + 1, remote: ADDR.parse().unwrap(), sent_at: None, received_at: ts_at(base, clock_ms), rtt: None, server_timing: None, one_way: None, received_stats: None, bytes: 64, packet_meta: PacketMeta::default(), }, Op::Duplicate => ClientEvent::DuplicateReply { seq, remote: ADDR.parse().unwrap(), received_at: ts_at(base, clock_ms), bytes: 64, }, Op::Loss => ClientEvent::EchoLoss { seq, sent_at: ts_at(base, clock_ms), timeout_at: ts_at(base, clock_ms).mono - Duration::from_millis(10), }, Op::DelayedLoss { age_ms } => { let deadline_ms = clock_ms.saturating_sub(u64::from(age_ms)); ClientEvent::EchoLoss { seq, sent_at: ts_at(base, deadline_ms.saturating_sub(3)), timeout_at: ts_at(base, deadline_ms).mono, } } Op::Warning => ClientEvent::Warning { kind: irtt_client::WarningKind::UntrackedReply, message: "generated".to_owned(), at: ts_at(base, clock_ms), }, } } /// Public reference: collect exactly the events retained by one window. fn replay(events: &[ClientEvent], late_replies: LateReplyMode) -> Snapshot { let mut collector = StatsCollector::new(StatsConfig { samples: SampleMode::RunningOnly, rolling_count: None, rolling_time: None, late_replies, }); for event in events { collector.process(event); } collector.snapshot() } /// Retain ordinary metrics through replay, but derive directional loss directly /// from the full public event history, including the discarded prefix. fn rolling_reference( history: &[(ClientEvent, u64)], retain: impl Fn(usize, u64) -> bool, late_replies: LateReplyMode, ) -> Snapshot { let retained: Vec = history .iter() .enumerate() .filter(|(index, (_, at))| retain(*index, *at)) .map(|(_, (event, _))| event.clone()) .collect(); let mut snapshot = replay(&retained, late_replies); let packets: Vec<_> = history .iter() .enumerate() .filter(|(_, (event, _))| { matches!( event, ClientEvent::EchoSent { .. } | ClientEvent::EchoReply { .. } | ClientEvent::LateReply { .. } | ClientEvent::DuplicateReply { .. } ) }) .collect(); let selected: Vec<_> = packets .iter() .enumerate() .filter(|(_, (index, (_, at)))| retain(*index, *at)) .collect(); let count = |event: &ClientEvent| match event { ClientEvent::EchoReply { received_stats, .. } | ClientEvent::LateReply { sent_at: Some(_), rtt: Some(_), received_stats, .. } => received_stats.and_then(|stats| stats.count).map(u64::from), _ => None, }; let delta = selected .first() .zip(selected.last()) .and_then(|((first, _), (last, _))| { // Timestamp filtering must not omit a packet between the endpoints. if last - first + 1 != selected.len() { return None; } // Partition the full observed history at ambiguous discontinuities. // This independent oracle keeps observation lists for each segment // rather than relying on the collector's retained metadata. let mut observations = Vec::new(); let mut segment_start = 0; for (index, (_, (event, _))) in packets[..=*last].iter().enumerate() { if let Some(count) = count(event) { if observations .iter() .max() .is_some_and(|highest: &u64| highest.abs_diff(count) >= (1_u64 << 31)) { segment_start = index + 1; observations.clear(); } observations.push(count); } } if segment_start > *first { return None; } let baseline = if *first == 0 { Some(0) } else { // The immediately preceding packet must itself report the // highest count. Earlier observations cannot bound this window. let (_, (previous_event, _)) = packets[*first - 1]; let previous = count(previous_event)?; if packets[segment_start.saturating_sub(1)..*first - 1] .iter() .filter_map(|(_, (event, _))| count(event)) .max() .is_some_and(|highest| highest > previous) { return None; } Some(previous) }?; let endpoint = observations.into_iter().max()?; Some(endpoint - baseline) }); snapshot.loss.upstream_loss_packets = delta.map(|delta| i128::from(snapshot.packets.packets_sent) - i128::from(delta)); snapshot.loss.downstream_loss_packets = delta.map(|delta| i128::from(delta) - i128::from(snapshot.packets.packets_received)); snapshot.loss.upstream_loss_percent = if snapshot.packets.packets_sent == 0 { 0.0 } else { snapshot.loss.upstream_loss_packets.map_or(0.0, |loss| { 100.0 * loss as f64 / snapshot.packets.packets_sent as f64 }) }; snapshot.loss.downstream_loss_percent = match delta { Some(delta) if delta != 0 => { 100.0 * snapshot.loss.downstream_loss_packets.unwrap() as f64 / delta as f64 } _ => 0.0, }; snapshot } #[test] fn collector_equality_ignores_expired_time_window_bookkeeping() { let base = Instant::now(); let config = StatsConfig { rolling_time: Some(Duration::from_millis(3)), ..StatsConfig::continuous() }; let mut left = StatsCollector::new(config); let mut right = StatsCollector::new(config); for at_ms in [1, 2, 4, 5, 6] { left.process(&build_event(&Op::Warning, 0, at_ms, base)); } for at_ms in [1, 4, 2, 5, 6] { right.process(&build_event(&Op::Warning, 0, at_ms, base)); } // Different expired histories leave the same live events in arrival order. assert_eq!(left.snapshot(), right.snapshot()); assert_eq!(left.rolling_time(), right.rolling_time()); assert_eq!(left, right); } #[test] fn time_expiry_preserves_arrival_order_for_server_receive_window() { let base = Instant::now(); let mut collector = StatsCollector::new(StatsConfig { rolling_time: Some(Duration::from_millis(3)), ..StatsConfig::continuous() }); for (seq, at_ms, window) in [(1, 5, 0x5), (0, 4, 0x4)] { let mut event = build_event(&Op::Reply { raw_ms: 1 }, seq, at_ms, base); let ClientEvent::EchoReply { received_stats, .. } = &mut event else { unreachable!(); }; received_stats.as_mut().unwrap().window = Some(window); collector.process(&event); } // The last arrival owns the observation even with an older receive time. assert_eq!( collector .rolling_time() .unwrap() .packets .server_received_window, Some(0x4) ); collector.process(&build_event(&Op::Warning, 2, 8, base)); let rolling = collector.rolling_time().unwrap(); assert_eq!(rolling.rtt.primary.count, 1); assert_eq!(rolling.packets.server_received_window, Some(0x5)); // The hidden, expired observation remains in cumulative accounting. assert_eq!( collector.snapshot().packets.server_received_window, Some(0x4) ); collector.process(&build_event(&Op::Warning, 3, 9, base)); let rolling = collector.rolling_time().unwrap(); assert_eq!(rolling.rtt.primary.count, 0); assert_eq!(rolling.packets.server_received_window, None); } proptest! { #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })] /// With `rolling_count` and `rolling_time` configured simultaneously, /// each window must match a reference model that applies *only its own* /// bound to the full event history, re-filtered from scratch after every /// operation. Any accidental AND/OR coupling between the two bounds (for /// example the count window also dropping events outside the time /// window, or vice versa) would show up here as a mismatch. /// /// The cumulative snapshot is checked too: it must equal a reference /// that replays the *entire* history, proving rolling eviction never /// reaches into the cumulative accounting. #[test] fn rolling_count_and_rolling_time_windows_are_independent( ops in prop::collection::vec(op_strategy(), 1..40), count_limit in 1usize..6, time_limit_ms in 0u64..80, count_only in prop::bool::ANY, ) { let late_replies = if count_only { LateReplyMode::CountOnly } else { LateReplyMode::Measure }; let config = StatsConfig { samples: SampleMode::RunningOnly, rolling_count: Some(count_limit), rolling_time: Some(Duration::from_millis(time_limit_ms)), late_replies, }; let mut collector = StatsCollector::new(config); let base = Instant::now(); let mut clock_ms: u64 = 0; // Full, unfiltered event history alongside each event's windowing // timestamp, so the reference windows below can be recomputed from // scratch after every step. let mut history: Vec<(ClientEvent, u64)> = Vec::new(); // Force a loss whose clamped time evicts the initial send, while its // raw pre-send deadline would retain it. Every generated case must // therefore distinguish the two normalization choices. let ops = [Op::Send, Op::Advance(u16::try_from(time_limit_ms + 5).unwrap()), Op::Loss, Op::Advance(10), Op::Warning] .into_iter().chain(ops).collect::>(); for (idx, op) in ops.iter().enumerate() { if let Op::Advance(delta) = op { clock_ms += u64::from(*delta); } else { let seq = u32::try_from(idx).unwrap(); let event = build_event(op, seq, clock_ms, base); collector.process(&event); let at_ms = match op { Op::DelayedLoss { age_ms } | Op::DelayedReply { age_ms, .. } => clock_ms.saturating_sub(u64::from(*age_ms)), _ => clock_ms, }; history.push((event, at_ms)); } // Cumulative snapshot: unaffected by rolling eviction, so it must // equal a fresh replay of the entire history so far. let full: Vec = history.iter().map(|(event, _)| event.clone()).collect(); prop_assert_eq!( collector.snapshot(), replay(&full, late_replies), "cumulative snapshot diverged from a full replay after op {}", idx ); // Count window reference: the last `count_limit` retained // events, ignoring the time bound entirely. let count_start = history.len().saturating_sub(count_limit); prop_assert_eq!( collector.rolling_count(), Some(rolling_reference(&history, |index, _| index >= count_start, late_replies)), "count window diverged from the count-only reference after op {}", idx ); // Time window reference: every retained event whose timestamp is // within `time_limit_ms` of the maximum observed event timestamp, // ignoring the count bound entirely. Delayed losses cannot move // this anchor backwards. if let Some(latest_at_ms) = history.iter().map(|(_, at_ms)| at_ms).max() { let cutoff = latest_at_ms.checked_sub(time_limit_ms); prop_assert_eq!( collector.rolling_time(), Some(rolling_reference(&history, |_, at_ms| cutoff.is_none_or(|cutoff| at_ms >= cutoff), late_replies)), "time window diverged from the time-only reference after op {}", idx ); } else { prop_assert_eq!( collector.rolling_time(), Some(replay(&[], late_replies)), "time window should be empty before any event after op {}", idx ); } } } }