282 lines
11 KiB
Rust
282 lines
11 KiB
Rust
use std::time::{Duration, Instant, UNIX_EPOCH};
|
|
|
|
use irtt_client::{
|
|
ClientEvent, ClientTimestamp, PacketMeta, ReceivedStatsSample, RttSample, SignedDuration,
|
|
};
|
|
use irtt_stats::{LateReplyMode, Snapshot, StatsCollector, StatsConfig};
|
|
|
|
fn ts(base: Instant, ms: u64) -> ClientTimestamp {
|
|
ClientTimestamp {
|
|
mono: base + Duration::from_millis(ms),
|
|
wall: UNIX_EPOCH + Duration::from_millis(ms),
|
|
}
|
|
}
|
|
|
|
fn sent(base: Instant, seq: u32) -> ClientEvent {
|
|
ClientEvent::EchoSent {
|
|
seq,
|
|
remote: "127.0.0.1:2112".parse().unwrap(),
|
|
scheduled_at: None,
|
|
sent_at: ts(base, u64::from(seq) * 10),
|
|
bytes: 32,
|
|
send_call: Duration::ZERO,
|
|
timer_error: None,
|
|
}
|
|
}
|
|
|
|
fn reply(base: Instant, seq: u32, count: u32) -> ClientEvent {
|
|
ClientEvent::EchoReply {
|
|
seq,
|
|
remote: "127.0.0.1:2112".parse().unwrap(),
|
|
sent_at: ts(base, u64::from(seq) * 10),
|
|
received_at: ts(base, u64::from(seq) * 10 + 1),
|
|
rtt: RttSample {
|
|
raw: Duration::from_millis(1),
|
|
adjusted: None,
|
|
effective: SignedDuration::from_duration(Duration::from_millis(1)),
|
|
},
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats: Some(ReceivedStatsSample {
|
|
count: Some(count),
|
|
window: None,
|
|
}),
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
}
|
|
}
|
|
|
|
fn assert_directional(snapshot: &Snapshot, upstream: Option<i128>, downstream: Option<i128>) {
|
|
assert_eq!(snapshot.loss.upstream_loss_packets, upstream);
|
|
assert_eq!(snapshot.loss.downstream_loss_packets, downstream);
|
|
}
|
|
|
|
#[test]
|
|
fn lossless_count_and_time_windows_use_the_server_counter_increase() {
|
|
let base = Instant::now();
|
|
let mut collector = StatsCollector::new(StatsConfig {
|
|
rolling_count: Some(2),
|
|
rolling_time: Some(Duration::from_millis(2)),
|
|
..StatsConfig::continuous()
|
|
});
|
|
for seq in 0..100 {
|
|
collector.process(&sent(base, seq));
|
|
collector.process(&reply(base, seq, seq + 1));
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
let rolling = rolling.unwrap();
|
|
assert_eq!(rolling.packets.packets_sent, 1);
|
|
assert_eq!(rolling.packets.packets_received, 1);
|
|
// Preserve the raw cumulative field, independently of the loss delta.
|
|
assert_eq!(
|
|
rolling.packets.server_packets_received,
|
|
Some(u64::from(seq + 1))
|
|
);
|
|
assert_directional(&rolling, Some(0), Some(0));
|
|
assert_eq!(rolling.loss.upstream_loss_percent, 0.0);
|
|
assert_eq!(rolling.loss.downstream_loss_percent, 0.0);
|
|
}
|
|
}
|
|
let cumulative = collector.snapshot();
|
|
assert_eq!(cumulative.packets.packets_sent, 100);
|
|
assert_directional(&cumulative, Some(0), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn rolling_windows_distinguish_upstream_and_downstream_loss() {
|
|
let base = Instant::now();
|
|
// After the first pair expires, three sends and two replies remain.
|
|
// A server delta of two means upstream loss; three means downstream loss.
|
|
// Four also protects signed estimates when server counts exceed local sends.
|
|
for (final_count, upstream, downstream, upstream_percent, downstream_percent) in [
|
|
(3, 1, 0, 100.0 / 3.0, 0.0),
|
|
(4, 0, 1, 0.0, 100.0 / 3.0),
|
|
(5, -1, 2, -100.0 / 3.0, 50.0),
|
|
] {
|
|
let mut collector = StatsCollector::new(StatsConfig {
|
|
rolling_count: Some(5),
|
|
rolling_time: Some(Duration::from_millis(25)),
|
|
..StatsConfig::continuous()
|
|
});
|
|
for event in [
|
|
sent(base, 0),
|
|
reply(base, 0, 1),
|
|
sent(base, 1),
|
|
reply(base, 1, 2),
|
|
sent(base, 2),
|
|
sent(base, 3),
|
|
reply(base, 3, final_count),
|
|
] {
|
|
collector.process(&event);
|
|
}
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
let rolling = rolling.unwrap();
|
|
assert_eq!(rolling.packets.packets_sent, 3);
|
|
assert_eq!(rolling.packets.packets_received, 2);
|
|
assert_directional(&rolling, Some(upstream), Some(downstream));
|
|
assert_eq!(rolling.loss.upstream_loss_percent, upstream_percent);
|
|
assert_eq!(rolling.loss.downstream_loss_percent, downstream_percent);
|
|
}
|
|
assert_directional(&collector.snapshot(), Some(upstream), Some(downstream));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn split_probe_boundaries_keep_signed_estimates_and_require_a_baseline() {
|
|
let base = Instant::now();
|
|
let mut collector = StatsCollector::new(StatsConfig {
|
|
rolling_count: Some(1),
|
|
rolling_time: Some(Duration::ZERO),
|
|
..StatsConfig::continuous()
|
|
});
|
|
collector.process(&sent(base, 0));
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
assert_directional(&rolling.unwrap(), None, None);
|
|
}
|
|
collector.process(&reply(base, 0, 1));
|
|
// The first send has gone and no earlier server observation exists.
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
let rolling = rolling.unwrap();
|
|
assert_eq!(rolling.packets.server_packets_received, Some(1));
|
|
assert_directional(&rolling, None, None);
|
|
assert_eq!(rolling.loss.upstream_loss_percent, 0.0);
|
|
assert_eq!(rolling.loss.downstream_loss_percent, 0.0);
|
|
}
|
|
collector.process(&sent(base, 1));
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
// No new server observation yet: one outstanding send, no counter increase.
|
|
assert_directional(&rolling.unwrap(), Some(1), Some(0));
|
|
}
|
|
collector.process(&reply(base, 1, 2));
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
let rolling = rolling.unwrap();
|
|
assert_eq!(rolling.packets.packets_sent, 0);
|
|
assert_eq!(rolling.packets.packets_received, 1);
|
|
// The retained reply advances the count, but its send is outside the window.
|
|
assert_directional(&rolling, Some(-1), Some(0));
|
|
}
|
|
assert_directional(&collector.snapshot(), Some(0), Some(0));
|
|
|
|
// A first observation after many evicted sends does not reveal how much
|
|
// of the cumulative counter belongs to the final retained send/reply pair.
|
|
let mut collector = StatsCollector::new(StatsConfig {
|
|
rolling_count: Some(2),
|
|
rolling_time: Some(Duration::from_millis(2)),
|
|
..StatsConfig::continuous()
|
|
});
|
|
for seq in 0..100 {
|
|
collector.process(&sent(base, seq));
|
|
}
|
|
collector.process(&reply(base, 99, 100));
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
let rolling = rolling.unwrap();
|
|
assert_eq!(rolling.packets.packets_sent, 1);
|
|
assert_eq!(rolling.packets.packets_received, 1);
|
|
assert_eq!(rolling.packets.server_packets_received, Some(100));
|
|
assert_directional(&rolling, None, None);
|
|
}
|
|
assert_directional(&collector.snapshot(), Some(0), Some(99));
|
|
}
|
|
|
|
#[test]
|
|
fn time_filtering_with_an_interior_packet_gap_has_no_directional_estimate() {
|
|
let base = Instant::now();
|
|
let mut collector = StatsCollector::new(StatsConfig {
|
|
rolling_count: Some(3),
|
|
rolling_time: Some(Duration::from_millis(2)),
|
|
..StatsConfig::continuous()
|
|
});
|
|
collector.process(&sent(base, 0));
|
|
collector.process(&reply(base, 0, 1));
|
|
collector.process(&sent(base, 1));
|
|
// Arrives after send 1, but the time window excludes its backdated receipt.
|
|
collector.process(&reply(base, 0, 2));
|
|
collector.process(&reply(base, 1, 3));
|
|
let rolling = collector.rolling_time().unwrap();
|
|
assert_eq!(rolling.packets.packets_sent, 1);
|
|
assert_eq!(rolling.packets.packets_received, 1);
|
|
assert_directional(&rolling, None, None);
|
|
// Count retention has no gap, and the anomalous server increase stays signed.
|
|
assert_directional(&collector.rolling_count().unwrap(), Some(-1), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn reordered_late_and_nonprimary_replies_preserve_directional_accounting() {
|
|
let base = Instant::now();
|
|
for policy in [LateReplyMode::Measure, LateReplyMode::CountOnly] {
|
|
let mut collector = StatsCollector::new(StatsConfig {
|
|
rolling_count: Some(6),
|
|
rolling_time: Some(Duration::from_millis(25)),
|
|
late_replies: policy,
|
|
..StatsConfig::continuous()
|
|
});
|
|
collector.process(&sent(base, 0));
|
|
collector.process(&reply(base, 0, 1));
|
|
collector.process(&sent(base, 1));
|
|
collector.process(&sent(base, 2));
|
|
collector.process(&reply(base, 2, 3));
|
|
let ClientEvent::EchoReply {
|
|
sent_at,
|
|
rtt,
|
|
received_stats,
|
|
remote,
|
|
..
|
|
} = reply(base, 1, 2)
|
|
else {
|
|
unreachable!();
|
|
};
|
|
collector.process(&ClientEvent::LateReply {
|
|
seq: 1,
|
|
highest_seen: 2,
|
|
remote,
|
|
sent_at: Some(sent_at),
|
|
received_at: ts(base, 22),
|
|
rtt: Some(rtt),
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats,
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
});
|
|
collector.process(&ClientEvent::DuplicateReply {
|
|
seq: 2,
|
|
remote,
|
|
received_at: ts(base, 23),
|
|
bytes: 64,
|
|
});
|
|
// Even a supplied large counter on an untracked late reply is not a
|
|
// primary server measurement under the existing normalization policy.
|
|
collector.process(&ClientEvent::LateReply {
|
|
seq: 0,
|
|
highest_seen: 2,
|
|
remote,
|
|
sent_at: None,
|
|
received_at: ts(base, 30),
|
|
rtt: None,
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats: Some(ReceivedStatsSample {
|
|
count: Some(100),
|
|
window: None,
|
|
}),
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
});
|
|
for rolling in [collector.rolling_count(), collector.rolling_time()] {
|
|
let rolling = rolling.unwrap();
|
|
assert_eq!(rolling.packets.unique_replies, 2);
|
|
assert_eq!(rolling.packets.packets_received, 4);
|
|
assert_eq!(rolling.packets.server_packets_received, Some(3));
|
|
assert_directional(&rolling, Some(0), Some(-2));
|
|
assert_eq!(
|
|
rolling.rtt.primary.count,
|
|
if policy == LateReplyMode::Measure {
|
|
2
|
|
} else {
|
|
1
|
|
}
|
|
);
|
|
}
|
|
assert_directional(&collector.snapshot(), Some(0), Some(-2));
|
|
}
|
|
}
|