Skip to main content

sipx_testkit/
link.rs

1//! A byte link between two stacks in one process, with faults you can turn on.
2//!
3//! The loopback transport this crate's documentation has promised since it was written. Two full
4//! stacks talk through it with no sockets, no ports and no sleeping — which is what makes the
5//! behaviour the transaction machines exist for testable at all. A retransmission after a lost
6//! datagram takes 500 milliseconds of real time over a real socket and none at all over this.
7//!
8//! **The link does not read the clock.** `now` is an argument, exactly as it is for
9//! [`sipx_transport::timers::TimerQueue`], so a test drives both from one virtual clock of its own
10//! and a lost packet costs no wall time.
11//!
12//! **Faults are seeded.** The same seed replays the same trace, so a failure found by fuzzing loss
13//! rates is a failure you can re-run. A link whose faults came from a thread RNG would produce bug
14//! reports nobody could reproduce.
15
16use std::cmp::Reverse;
17use std::collections::BinaryHeap;
18use std::time::Duration;
19
20use bytes::Bytes;
21/// Which end of the link.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Side {
24    /// The end that opened the conversation.
25    Left,
26    /// The other one.
27    Right,
28}
29
30impl Side {
31    /// The end a datagram sent from here arrives at.
32    #[must_use]
33    pub fn peer(self) -> Self {
34        match self {
35            Self::Left => Self::Right,
36            Self::Right => Self::Left,
37        }
38    }
39}
40
41/// What the link does to the traffic crossing it.
42///
43/// All zero by default: a link with no faults is a wire, and a test that wants one should not have
44/// to say so.
45#[derive(Debug, Clone, Copy, Default)]
46pub struct Faults {
47    /// Probability in `0.0..=1.0` that a datagram is dropped outright.
48    pub loss: f64,
49    /// Probability that a datagram is delivered twice.
50    ///
51    /// Worth having as its own knob rather than folding into loss: a duplicate is what makes a
52    /// receiver's idempotence testable, and RFC 3261 §17 is largely about absorbing them.
53    pub duplicate: f64,
54    /// The base one-way delay.
55    pub latency: Duration,
56    /// How much the delay varies, uniformly, either side of `latency`.
57    ///
58    /// This is also where **reordering** comes from, and deliberately so: packets do not overtake
59    /// each other because a network chose to reorder them, they overtake because one took longer
60    /// than another. A separate "reorder" probability would model the symptom instead of the cause,
61    /// and would let a test see an ordering no real path could produce.
62    pub jitter: Duration,
63}
64
65impl Faults {
66    /// A link that loses this fraction of datagrams and nothing else.
67    #[must_use]
68    pub fn losing(loss: f64) -> Self {
69        Self {
70            loss,
71            ..Self::default()
72        }
73    }
74
75    /// A link that drops nothing but takes this long.
76    #[must_use]
77    pub fn delayed(latency: Duration) -> Self {
78        Self {
79            latency,
80            ..Self::default()
81        }
82    }
83}
84
85/// A datagram that has arrived.
86#[derive(Debug, Clone)]
87pub struct Delivery {
88    /// Which end it arrived at.
89    pub to: Side,
90    /// The bytes, unaltered — the link corrupts nothing, because a corrupted SIP message is the
91    /// parser's business and there is a fuzzer for that.
92    pub bytes: Bytes,
93}
94
95#[derive(Debug, PartialEq, Eq)]
96struct Scheduled<I> {
97    at: I,
98    /// Breaks ties in arrival order so two datagrams scheduled for the same instant deliver in the
99    /// order they were sent. Without it the heap's tie-break is arbitrary and the same seed
100    /// produces different traces.
101    sequence: u64,
102    to: Side,
103    bytes: Bytes,
104}
105
106impl<I: Ord> Ord for Scheduled<I> {
107    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
108        self.at
109            .cmp(&other.at)
110            .then_with(|| self.sequence.cmp(&other.sequence))
111    }
112}
113
114impl<I: Ord> PartialOrd for Scheduled<I> {
115    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
116        Some(self.cmp(other))
117    }
118}
119
120/// An in-process link between two stacks.
121#[derive(Debug)]
122pub struct Link<I = tokio::time::Instant> {
123    faults: Faults,
124    state: u64,
125    sequence: u64,
126    in_flight: BinaryHeap<Reverse<Scheduled<I>>>,
127    /// Datagrams the link dropped, for a test that wants to assert it dropped one.
128    dropped: u64,
129}
130
131impl<I> Link<I>
132where
133    I: Copy + Ord + std::ops::Add<Duration, Output = I>,
134{
135    /// A link with these faults, replaying the same trace for the same seed.
136    #[must_use]
137    pub fn new(seed: u64, faults: Faults) -> Self {
138        Self {
139            faults,
140            // Any non-zero state; splitmix64 is uniform from anywhere.
141            state: seed.wrapping_add(0x9E37_79B9_7F4A_7C15),
142            sequence: 0,
143            in_flight: BinaryHeap::new(),
144            dropped: 0,
145        }
146    }
147
148    /// A link that loses, duplicates and delays nothing.
149    #[must_use]
150    pub fn perfect() -> Self {
151        Self::new(0, Faults::default())
152    }
153
154    /// How many datagrams the link has dropped.
155    #[must_use]
156    pub fn dropped(&self) -> u64 {
157        self.dropped
158    }
159
160    /// How many datagrams are in flight.
161    #[must_use]
162    pub fn in_flight(&self) -> usize {
163        self.in_flight.len()
164    }
165
166    /// Hand a datagram to the link, to arrive at some point at or after `now`.
167    pub fn send(&mut self, from: Side, bytes: Bytes, now: I) {
168        if self.chance() < self.faults.loss {
169            self.dropped = self.dropped.saturating_add(1);
170            return;
171        }
172        self.schedule(from.peer(), bytes.clone(), now);
173        if self.chance() < self.faults.duplicate {
174            // A second copy, drawn its own delay — so a duplicate can arrive before *or* after the
175            // original, which is what a duplicating path actually does.
176            self.schedule(from.peer(), bytes, now);
177        }
178    }
179
180    fn schedule(&mut self, to: Side, bytes: Bytes, now: I) {
181        let delay = self.delay();
182        self.sequence = self.sequence.wrapping_add(1);
183        self.in_flight.push(Reverse(Scheduled {
184            at: now + delay,
185            sequence: self.sequence,
186            to,
187            bytes,
188        }));
189    }
190
191    /// Everything that has arrived at or before `now`, in arrival order.
192    pub fn take_due(&mut self, now: I) -> Vec<Delivery> {
193        let mut arrived = Vec::new();
194        while let Some(Reverse(next)) = self.in_flight.peek() {
195            if next.at > now {
196                break;
197            }
198            let Some(Reverse(scheduled)) = self.in_flight.pop() else {
199                break;
200            };
201            arrived.push(Delivery {
202                to: scheduled.to,
203                bytes: scheduled.bytes,
204            });
205        }
206        arrived
207    }
208
209    /// When the next datagram arrives, if any is in flight.
210    #[must_use]
211    pub fn next_arrival(&self) -> Option<I> {
212        self.in_flight.peek().map(|Reverse(next)| next.at)
213    }
214
215    /// The one-way delay for the next datagram.
216    fn delay(&mut self) -> Duration {
217        if self.faults.jitter.is_zero() {
218            return self.faults.latency;
219        }
220        let spread = self.faults.jitter.as_nanos().min(u128::from(u64::MAX));
221        #[expect(
222            clippy::cast_possible_truncation,
223            reason = "clamped to u64::MAX on the line above"
224        )]
225        let spread = spread as u64;
226        // Uniform in `latency - jitter ..= latency + jitter`, saturating at zero: a delay cannot be
227        // negative, and clamping is more honest than wrapping into an enormous one.
228        let offset = self.next_u64() % (spread.saturating_mul(2).saturating_add(1));
229        let base = Duration::from_nanos(offset);
230        (self.faults.latency + base).saturating_sub(self.faults.jitter)
231    }
232
233    /// A draw in `0.0..1.0`.
234    fn chance(&mut self) -> f64 {
235        // 53 bits, which is every value an `f64` can represent exactly in this range.
236        #[expect(
237            clippy::cast_precision_loss,
238            reason = "53 bits is exactly what an f64 represents; no precision is lost"
239        )]
240        let value = (self.next_u64() >> 11) as f64;
241        // `2^53` written as a literal rather than cast from `u64`, so the divisor is exact by
242        // construction instead of exact by argument.
243        value / 9_007_199_254_740_992.0_f64
244    }
245
246    /// splitmix64 — small, seedable, and good enough for choosing which packets to drop.
247    fn next_u64(&mut self) -> u64 {
248        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
249        let mut z = self.state;
250        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
251        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
252        z ^ (z >> 31)
253    }
254}
255
256#[cfg(test)]
257#[allow(
258    clippy::unwrap_used,
259    clippy::expect_used,
260    clippy::panic,
261    clippy::indexing_slicing
262)]
263mod tests {
264    use super::*;
265    use tokio::time::Instant;
266
267    fn datagram(text: &'static str) -> Bytes {
268        Bytes::from_static(text.as_bytes())
269    }
270
271    #[tokio::test(start_paused = true)]
272    async fn a_perfect_link_delivers_everything_immediately() {
273        let mut link = Link::perfect();
274        let now = Instant::now();
275        link.send(Side::Left, datagram("one"), now);
276        link.send(Side::Right, datagram("two"), now);
277
278        let arrived = link.take_due(now);
279        assert_eq!(arrived.len(), 2);
280        assert_eq!(arrived[0].to, Side::Right, "left's datagram goes right");
281        assert_eq!(arrived[1].to, Side::Left);
282        assert_eq!(link.dropped(), 0);
283    }
284
285    #[tokio::test(start_paused = true)]
286    async fn a_link_that_loses_everything_delivers_nothing() {
287        let mut link = Link::new(1, Faults::losing(1.0));
288        let now = Instant::now();
289        for _ in 0..10u32 {
290            link.send(Side::Left, datagram("x"), now);
291        }
292        assert!(link.take_due(now).is_empty());
293        assert_eq!(link.dropped(), 10);
294    }
295
296    #[tokio::test(start_paused = true)]
297    async fn a_delayed_datagram_does_not_arrive_early() {
298        let mut link = Link::new(1, Faults::delayed(Duration::from_millis(50)));
299        let now = Instant::now();
300        link.send(Side::Left, datagram("x"), now);
301
302        assert!(link.take_due(now).is_empty(), "not yet");
303        assert_eq!(link.next_arrival(), Some(now + Duration::from_millis(50)));
304        assert_eq!(link.take_due(now + Duration::from_millis(50)).len(), 1);
305    }
306
307    /// The same seed replays the same trace. Without this a failure found at a given loss rate is
308    /// not a failure anybody can re-run.
309    #[tokio::test(start_paused = true)]
310    async fn one_seed_replays_one_trace() {
311        let trace = |seed: u64| {
312            let mut link = Link::new(seed, Faults::losing(0.5));
313            let now = Instant::now();
314            let mut delivered = Vec::new();
315            for index in 0..40u32 {
316                link.send(Side::Left, Bytes::from(index.to_string()), now);
317            }
318            for delivery in link.take_due(now) {
319                delivered.push(String::from_utf8_lossy(&delivery.bytes).into_owned());
320            }
321            delivered
322        };
323        assert_eq!(trace(7), trace(7), "one seed, one trace");
324        assert_ne!(
325            trace(7),
326            trace(8),
327            "and different seeds explore different traces, or fuzzing the seed does nothing"
328        );
329    }
330
331    /// Loss is roughly the rate asked for. A link whose knob does not move is a link that tests
332    /// nothing, and a rate that is silently zero would make every fault test pass.
333    #[tokio::test(start_paused = true)]
334    async fn the_loss_rate_is_about_what_was_asked_for() {
335        let mut link = Link::new(42, Faults::losing(0.25));
336        let now = Instant::now();
337        let total = 4000u32;
338        for _ in 0..total {
339            link.send(Side::Left, datagram("x"), now);
340        }
341        let lost = link.dropped();
342        assert!(
343            (800..1200).contains(&lost),
344            "a quarter of 4000 should be near 1000, got {lost}"
345        );
346    }
347
348    /// Jitter reorders, because one datagram took longer than another — not because the link
349    /// decided to shuffle them.
350    #[tokio::test(start_paused = true)]
351    async fn jitter_lets_a_later_datagram_arrive_first() {
352        let mut link = Link::new(
353            3,
354            Faults {
355                latency: Duration::from_millis(50),
356                jitter: Duration::from_millis(40),
357                ..Faults::default()
358            },
359        );
360        let now = Instant::now();
361        for index in 0..20u32 {
362            link.send(Side::Left, Bytes::from(index.to_string()), now);
363        }
364        let order: Vec<String> = link
365            .take_due(now + Duration::from_millis(200))
366            .into_iter()
367            .map(|delivery| String::from_utf8_lossy(&delivery.bytes).into_owned())
368            .collect();
369        let sent: Vec<String> = (0..20u32).map(|index| index.to_string()).collect();
370        assert_eq!(order.len(), sent.len(), "nothing is lost, only reordered");
371        assert_ne!(order, sent, "with 40ms of jitter something must overtake");
372    }
373
374    #[tokio::test(start_paused = true)]
375    async fn duplication_delivers_a_datagram_twice() {
376        let mut link = Link::new(
377            5,
378            Faults {
379                duplicate: 1.0,
380                ..Faults::default()
381            },
382        );
383        let now = Instant::now();
384        link.send(Side::Left, datagram("x"), now);
385        assert_eq!(
386            link.take_due(now).len(),
387            2,
388            "a duplicating link delivers the same datagram twice"
389        );
390    }
391
392    #[tokio::test(start_paused = true)]
393    async fn datagrams_scheduled_together_arrive_in_the_order_they_were_sent() {
394        // No jitter, so every delay is equal and only the tie-break decides. Without a stable one
395        // the heap's order is arbitrary and a seeded trace is not reproducible.
396        let mut link = Link::new(1, Faults::delayed(Duration::from_millis(10)));
397        let now = Instant::now();
398        for index in 0..8u32 {
399            link.send(Side::Left, Bytes::from(index.to_string()), now);
400        }
401        let order: Vec<String> = link
402            .take_due(now + Duration::from_millis(10))
403            .into_iter()
404            .map(|delivery| String::from_utf8_lossy(&delivery.bytes).into_owned())
405            .collect();
406        assert_eq!(order, (0..8u32).map(|i| i.to_string()).collect::<Vec<_>>());
407    }
408}