use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::time::Duration;
use bytes::Bytes;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Side {
Left,
Right,
}
impl Side {
#[must_use]
pub fn peer(self) -> Self {
match self {
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Faults {
pub loss: f64,
pub duplicate: f64,
pub latency: Duration,
pub jitter: Duration,
}
impl Faults {
#[must_use]
pub fn losing(loss: f64) -> Self {
Self {
loss,
..Self::default()
}
}
#[must_use]
pub fn delayed(latency: Duration) -> Self {
Self {
latency,
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub struct Delivery {
pub to: Side,
pub bytes: Bytes,
}
#[derive(Debug, PartialEq, Eq)]
struct Scheduled<I> {
at: I,
sequence: u64,
to: Side,
bytes: Bytes,
}
impl<I: Ord> Ord for Scheduled<I> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.at
.cmp(&other.at)
.then_with(|| self.sequence.cmp(&other.sequence))
}
}
impl<I: Ord> PartialOrd for Scheduled<I> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug)]
pub struct Link<I = tokio::time::Instant> {
faults: Faults,
state: u64,
sequence: u64,
in_flight: BinaryHeap<Reverse<Scheduled<I>>>,
dropped: u64,
}
impl<I> Link<I>
where
I: Copy + Ord + std::ops::Add<Duration, Output = I>,
{
#[must_use]
pub fn new(seed: u64, faults: Faults) -> Self {
Self {
faults,
state: seed.wrapping_add(0x9E37_79B9_7F4A_7C15),
sequence: 0,
in_flight: BinaryHeap::new(),
dropped: 0,
}
}
#[must_use]
pub fn perfect() -> Self {
Self::new(0, Faults::default())
}
#[must_use]
pub fn dropped(&self) -> u64 {
self.dropped
}
#[must_use]
pub fn in_flight(&self) -> usize {
self.in_flight.len()
}
pub fn send(&mut self, from: Side, bytes: Bytes, now: I) {
if self.chance() < self.faults.loss {
self.dropped = self.dropped.saturating_add(1);
return;
}
self.schedule(from.peer(), bytes.clone(), now);
if self.chance() < self.faults.duplicate {
self.schedule(from.peer(), bytes, now);
}
}
fn schedule(&mut self, to: Side, bytes: Bytes, now: I) {
let delay = self.delay();
self.sequence = self.sequence.wrapping_add(1);
self.in_flight.push(Reverse(Scheduled {
at: now + delay,
sequence: self.sequence,
to,
bytes,
}));
}
pub fn take_due(&mut self, now: I) -> Vec<Delivery> {
let mut arrived = Vec::new();
while let Some(Reverse(next)) = self.in_flight.peek() {
if next.at > now {
break;
}
let Some(Reverse(scheduled)) = self.in_flight.pop() else {
break;
};
arrived.push(Delivery {
to: scheduled.to,
bytes: scheduled.bytes,
});
}
arrived
}
#[must_use]
pub fn next_arrival(&self) -> Option<I> {
self.in_flight.peek().map(|Reverse(next)| next.at)
}
fn delay(&mut self) -> Duration {
if self.faults.jitter.is_zero() {
return self.faults.latency;
}
let spread = self.faults.jitter.as_nanos().min(u128::from(u64::MAX));
#[expect(
clippy::cast_possible_truncation,
reason = "clamped to u64::MAX on the line above"
)]
let spread = spread as u64;
let offset = self.next_u64() % (spread.saturating_mul(2).saturating_add(1));
let base = Duration::from_nanos(offset);
(self.faults.latency + base).saturating_sub(self.faults.jitter)
}
fn chance(&mut self) -> f64 {
#[expect(
clippy::cast_precision_loss,
reason = "53 bits is exactly what an f64 represents; no precision is lost"
)]
let value = (self.next_u64() >> 11) as f64;
value / 9_007_199_254_740_992.0_f64
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use tokio::time::Instant;
fn datagram(text: &'static str) -> Bytes {
Bytes::from_static(text.as_bytes())
}
#[tokio::test(start_paused = true)]
async fn a_perfect_link_delivers_everything_immediately() {
let mut link = Link::perfect();
let now = Instant::now();
link.send(Side::Left, datagram("one"), now);
link.send(Side::Right, datagram("two"), now);
let arrived = link.take_due(now);
assert_eq!(arrived.len(), 2);
assert_eq!(arrived[0].to, Side::Right, "left's datagram goes right");
assert_eq!(arrived[1].to, Side::Left);
assert_eq!(link.dropped(), 0);
}
#[tokio::test(start_paused = true)]
async fn a_link_that_loses_everything_delivers_nothing() {
let mut link = Link::new(1, Faults::losing(1.0));
let now = Instant::now();
for _ in 0..10u32 {
link.send(Side::Left, datagram("x"), now);
}
assert!(link.take_due(now).is_empty());
assert_eq!(link.dropped(), 10);
}
#[tokio::test(start_paused = true)]
async fn a_delayed_datagram_does_not_arrive_early() {
let mut link = Link::new(1, Faults::delayed(Duration::from_millis(50)));
let now = Instant::now();
link.send(Side::Left, datagram("x"), now);
assert!(link.take_due(now).is_empty(), "not yet");
assert_eq!(link.next_arrival(), Some(now + Duration::from_millis(50)));
assert_eq!(link.take_due(now + Duration::from_millis(50)).len(), 1);
}
#[tokio::test(start_paused = true)]
async fn one_seed_replays_one_trace() {
let trace = |seed: u64| {
let mut link = Link::new(seed, Faults::losing(0.5));
let now = Instant::now();
let mut delivered = Vec::new();
for index in 0..40u32 {
link.send(Side::Left, Bytes::from(index.to_string()), now);
}
for delivery in link.take_due(now) {
delivered.push(String::from_utf8_lossy(&delivery.bytes).into_owned());
}
delivered
};
assert_eq!(trace(7), trace(7), "one seed, one trace");
assert_ne!(
trace(7),
trace(8),
"and different seeds explore different traces, or fuzzing the seed does nothing"
);
}
#[tokio::test(start_paused = true)]
async fn the_loss_rate_is_about_what_was_asked_for() {
let mut link = Link::new(42, Faults::losing(0.25));
let now = Instant::now();
let total = 4000u32;
for _ in 0..total {
link.send(Side::Left, datagram("x"), now);
}
let lost = link.dropped();
assert!(
(800..1200).contains(&lost),
"a quarter of 4000 should be near 1000, got {lost}"
);
}
#[tokio::test(start_paused = true)]
async fn jitter_lets_a_later_datagram_arrive_first() {
let mut link = Link::new(
3,
Faults {
latency: Duration::from_millis(50),
jitter: Duration::from_millis(40),
..Faults::default()
},
);
let now = Instant::now();
for index in 0..20u32 {
link.send(Side::Left, Bytes::from(index.to_string()), now);
}
let order: Vec<String> = link
.take_due(now + Duration::from_millis(200))
.into_iter()
.map(|delivery| String::from_utf8_lossy(&delivery.bytes).into_owned())
.collect();
let sent: Vec<String> = (0..20u32).map(|index| index.to_string()).collect();
assert_eq!(order.len(), sent.len(), "nothing is lost, only reordered");
assert_ne!(order, sent, "with 40ms of jitter something must overtake");
}
#[tokio::test(start_paused = true)]
async fn duplication_delivers_a_datagram_twice() {
let mut link = Link::new(
5,
Faults {
duplicate: 1.0,
..Faults::default()
},
);
let now = Instant::now();
link.send(Side::Left, datagram("x"), now);
assert_eq!(
link.take_due(now).len(),
2,
"a duplicating link delivers the same datagram twice"
);
}
#[tokio::test(start_paused = true)]
async fn datagrams_scheduled_together_arrive_in_the_order_they_were_sent() {
let mut link = Link::new(1, Faults::delayed(Duration::from_millis(10)));
let now = Instant::now();
for index in 0..8u32 {
link.send(Side::Left, Bytes::from(index.to_string()), now);
}
let order: Vec<String> = link
.take_due(now + Duration::from_millis(10))
.into_iter()
.map(|delivery| String::from_utf8_lossy(&delivery.bytes).into_owned())
.collect();
assert_eq!(order, (0..8u32).map(|i| i.to_string()).collect::<Vec<_>>());
}
}