Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m11s
302 lines
12 KiB
Rust
302 lines
12 KiB
Rust
//! Independent count and time windows must match a filtered replay through
|
|
//! StatsCollector, while cumulative accounting retains the complete history.
|
|
//! Generated events have non-decreasing normalized window 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; every other variant produces exactly
|
|
/// one normalized event at 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 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 diagnostic warning event.
|
|
Warning,
|
|
}
|
|
|
|
fn op_strategy() -> impl Strategy<Value = Op> {
|
|
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).prop_map(|raw_ms| Op::LateReplyMatched { raw_ms }),
|
|
1 => Just(Op::LateReplyUnmatched),
|
|
1 => Just(Op::Duplicate),
|
|
2 => Just(Op::Loss),
|
|
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, used as the event's windowing timestamp
|
|
/// (`at()`, see `normalization.rs`) for every variant.
|
|
fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent {
|
|
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 } => {
|
|
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::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()
|
|
}
|
|
|
|
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 1u64..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::<Vec<_>>();
|
|
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);
|
|
history.push((event, clock_ms));
|
|
}
|
|
|
|
// Cumulative snapshot: unaffected by rolling eviction, so it must
|
|
// equal a fresh replay of the entire history so far.
|
|
let full: Vec<ClientEvent> = 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);
|
|
let count_ref: Vec<ClientEvent> = history[count_start..]
|
|
.iter()
|
|
.map(|(event, _)| event.clone())
|
|
.collect();
|
|
prop_assert_eq!(
|
|
collector.rolling_count(),
|
|
Some(replay(&count_ref, 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 *latest processed event's*
|
|
// timestamp, ignoring the count bound entirely. This recompute-
|
|
// from-scratch filter is equivalent to the production window's
|
|
// incremental sliding-eviction only because `clock_ms` is
|
|
// non-decreasing across pushes (enforced by construction above:
|
|
// `Advance` never subtracts).
|
|
if let Some((_, latest_at_ms)) = history.last() {
|
|
let cutoff = latest_at_ms.checked_sub(time_limit_ms);
|
|
let time_ref: Vec<ClientEvent> = history
|
|
.iter()
|
|
.filter(|(_, at_ms)| cutoff.is_none_or(|cutoff| *at_ms >= cutoff))
|
|
.map(|(event, _)| event.clone())
|
|
.collect();
|
|
prop_assert_eq!(
|
|
collector.rolling_time(),
|
|
Some(replay(&time_ref, 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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|