noq_proto/connection/
mtud.rs

1use super::SpaceKind;
2use crate::{Instant, MAX_UDP_PAYLOAD, MtuDiscoveryConfig};
3use std::cmp;
4use tracing::trace;
5
6/// Implements Datagram Packetization Layer Path Maximum Transmission Unit Discovery
7///
8/// See [`MtuDiscoveryConfig`] for details
9#[derive(Clone, Debug)]
10pub(crate) struct MtuDiscovery {
11    /// Detected MTU for the path
12    current_mtu: u16,
13    /// The state of the MTU discovery, if enabled
14    state: Option<EnabledMtuDiscovery>,
15    /// The state of the black hole detector
16    black_hole_detector: BlackHoleDetector,
17}
18
19impl MtuDiscovery {
20    pub(crate) fn new(
21        initial_plpmtu: u16,
22        min_mtu: u16,
23        peer_max_udp_payload_size: Option<u16>,
24        config: MtuDiscoveryConfig,
25    ) -> Self {
26        debug_assert!(
27            initial_plpmtu >= min_mtu,
28            "initial_max_udp_payload_size must be at least {min_mtu}"
29        );
30
31        let mut mtud = Self::with_state(
32            initial_plpmtu,
33            min_mtu,
34            Some(EnabledMtuDiscovery::new(config)),
35        );
36
37        // We might be migrating an existing connection to a new path, in which case the transport
38        // parameters have already been transmitted, and we already know the value of
39        // `peer_max_udp_payload_size`
40        if let Some(peer_max_udp_payload_size) = peer_max_udp_payload_size {
41            mtud.on_peer_max_udp_payload_size_received(peer_max_udp_payload_size);
42        }
43
44        mtud
45    }
46
47    /// MTU discovery will be disabled and the current MTU will be fixed to the provided value
48    pub(crate) fn disabled(plpmtu: u16, min_mtu: u16) -> Self {
49        Self::with_state(plpmtu, min_mtu, None)
50    }
51
52    fn with_state(current_mtu: u16, min_mtu: u16, state: Option<EnabledMtuDiscovery>) -> Self {
53        Self {
54            current_mtu,
55            state,
56            black_hole_detector: BlackHoleDetector::new(min_mtu),
57        }
58    }
59
60    /// Returns the current MTU
61    pub(crate) fn current_mtu(&self) -> u16 {
62        self.current_mtu
63    }
64
65    /// Returns the amount of bytes that should be sent as an MTU probe, if any.
66    ///
67    /// Returns [`None`] if MTUD discovery is disabled. Otherwise delegates to
68    /// [`EnabledMtuDiscovery::poll_transmit`].
69    pub(crate) fn poll_transmit(&mut self, now: Instant, next_pn: u64) -> Option<u16> {
70        self.state
71            .as_mut()
72            .and_then(|state| state.poll_transmit(now, self.current_mtu, next_pn))
73    }
74
75    /// Notifies the [`MtuDiscovery`] that the peer's `max_udp_payload_size` transport parameter has
76    /// been received
77    pub(crate) fn on_peer_max_udp_payload_size_received(&mut self, peer_max_udp_payload_size: u16) {
78        self.current_mtu = self.current_mtu.min(peer_max_udp_payload_size);
79
80        if let Some(state) = self.state.as_mut() {
81            // It is possible for black hole detection to trigger before the connection has been
82            // fully established, if the initial MTU is greater the minimum MTU. We should never
83            // send probes before the connection has been fully established and we have received
84            // the peer's transport parameters though.
85            debug_assert!(
86                !matches!(state.phase, Phase::Searching(_)),
87                "Transport parameters received after MTU probing started"
88            );
89            state.peer_max_udp_payload_size = peer_max_udp_payload_size;
90        }
91    }
92
93    /// Notifies the [`MtuDiscovery`] that a packet has been ACKed
94    ///
95    /// Returns true if the packet was an MTU probe
96    pub(crate) fn on_acked(&mut self, space: SpaceKind, pn: u64, len: u16) -> bool {
97        // MTU probes are only sent in application data space
98        if space != SpaceKind::Data {
99            return false;
100        }
101
102        // Update the state of the MTU search
103        if let Some(new_mtu) = self
104            .state
105            .as_mut()
106            .and_then(|state| state.on_probe_acked(pn))
107        {
108            self.current_mtu = new_mtu;
109            trace!(current_mtu = self.current_mtu, "new MTU detected");
110
111            self.black_hole_detector.on_probe_acked(pn, len);
112            true
113        } else {
114            self.black_hole_detector.on_non_probe_acked(pn, len);
115            false
116        }
117    }
118
119    /// Returns the packet number of the in-flight MTU probe, if any
120    pub(crate) fn in_flight_mtu_probe(&self) -> Option<u64> {
121        match &self.state {
122            Some(EnabledMtuDiscovery {
123                phase: Phase::Searching(search_state),
124                ..
125            }) => search_state.in_flight_probe,
126            _ => None,
127        }
128    }
129
130    /// Notifies the [`MtuDiscovery`] that the in-flight MTU probe was lost
131    pub(crate) fn on_probe_lost(&mut self) {
132        if let Some(state) = &mut self.state {
133            state.on_probe_lost();
134        }
135    }
136
137    /// Notifies the [`MtuDiscovery`] that a non-probe packet was lost
138    ///
139    /// When done notifying of lost packets, [`MtuDiscovery::black_hole_detected`] must be called,
140    /// to ensure the last loss burst is properly processed and to trigger black hole recovery
141    /// logic if necessary.
142    pub(crate) fn on_non_probe_lost(&mut self, pn: u64, len: u16) {
143        self.black_hole_detector.on_non_probe_lost(pn, len);
144    }
145
146    /// Returns true if a black hole was detected
147    ///
148    /// Calling this function will close the previous loss burst. If a black hole is detected, the
149    /// current MTU will be reset to `min_mtu`.
150    pub(crate) fn black_hole_detected(&mut self, now: Instant) -> bool {
151        if !self.black_hole_detector.black_hole_detected() {
152            return false;
153        }
154
155        self.current_mtu = self.black_hole_detector.min_mtu;
156
157        if let Some(state) = &mut self.state {
158            state.on_black_hole_detected(now);
159        }
160
161        true
162    }
163}
164
165/// Additional state for enabled MTU discovery
166#[derive(Debug, Clone)]
167struct EnabledMtuDiscovery {
168    phase: Phase,
169    peer_max_udp_payload_size: u16,
170    config: MtuDiscoveryConfig,
171}
172
173impl EnabledMtuDiscovery {
174    fn new(config: MtuDiscoveryConfig) -> Self {
175        Self {
176            phase: Phase::Initial,
177            peer_max_udp_payload_size: MAX_UDP_PAYLOAD,
178            config,
179        }
180    }
181
182    /// Returns the amount of bytes that should be sent as an MTU probe, if any.
183    ///
184    /// A probe only needs to be sent if:
185    ///
186    /// - There is no current in-flight probe.
187    /// - A search for a new MTU is in progress.
188    /// - The MTU discovery was completed but the [`MtuDiscoveryConfig::interval`] expired, this
189    ///   re-starts a n MTU search.
190    fn poll_transmit(&mut self, now: Instant, current_mtu: u16, next_pn: u64) -> Option<u16> {
191        if let Phase::Initial = &self.phase {
192            // Start the first search
193            self.phase = Phase::Searching(SearchState::new(
194                current_mtu,
195                self.peer_max_udp_payload_size,
196                &self.config,
197            ));
198        } else if let Phase::Complete(next_mtud_activation) = &self.phase {
199            if now < *next_mtud_activation {
200                return None;
201            }
202
203            // Start a new search (we have reached the next activation time)
204            self.phase = Phase::Searching(SearchState::new(
205                current_mtu,
206                self.peer_max_udp_payload_size,
207                &self.config,
208            ));
209        }
210
211        if let Phase::Searching(state) = &mut self.phase {
212            // Nothing to do while there is a probe in flight
213            if state.in_flight_probe.is_some() {
214                return None;
215            }
216
217            // Retransmit lost probes, if any
218            if 0 < state.lost_probe_count && state.lost_probe_count < MAX_PROBE_RETRANSMITS {
219                state.in_flight_probe = Some(next_pn);
220                return Some(state.last_probed_mtu);
221            }
222
223            let last_probe_succeeded = state.lost_probe_count == 0;
224
225            // The probe is definitely lost (we reached the MAX_PROBE_RETRANSMITS threshold)
226            if !last_probe_succeeded {
227                state.lost_probe_count = 0;
228                state.in_flight_probe = None;
229            }
230
231            if let Some(probe_udp_payload_size) = state.next_mtu_to_probe(last_probe_succeeded) {
232                state.in_flight_probe = Some(next_pn);
233                state.last_probed_mtu = probe_udp_payload_size;
234                return Some(probe_udp_payload_size);
235            } else {
236                let next_mtud_activation = now + self.config.interval;
237                self.phase = Phase::Complete(next_mtud_activation);
238                return None;
239            }
240        }
241
242        None
243    }
244
245    /// Called when a packet is acknowledged in [`SpaceId::Data`]
246    ///
247    /// Returns the new `current_mtu` if the packet number corresponds to the in-flight MTU probe
248    ///
249    /// [`SpaceId::Data`]: crate::packet::SpaceId
250    fn on_probe_acked(&mut self, pn: u64) -> Option<u16> {
251        match &mut self.phase {
252            Phase::Searching(state) if state.in_flight_probe == Some(pn) => {
253                state.in_flight_probe = None;
254                state.lost_probe_count = 0;
255                Some(state.last_probed_mtu)
256            }
257            _ => None,
258        }
259    }
260
261    /// Called when the in-flight MTU probe was lost
262    fn on_probe_lost(&mut self) {
263        // We might no longer be searching, e.g. if a black hole was detected
264        if let Phase::Searching(state) = &mut self.phase {
265            state.in_flight_probe = None;
266            state.lost_probe_count += 1;
267        }
268    }
269
270    /// Called when a black hole is detected
271    fn on_black_hole_detected(&mut self, now: Instant) {
272        // Stop searching, if applicable, and reset the timer
273        let next_mtud_activation = now + self.config.black_hole_cooldown;
274        self.phase = Phase::Complete(next_mtud_activation);
275    }
276}
277
278#[derive(Debug, Clone, Copy)]
279enum Phase {
280    /// We haven't started polling yet
281    Initial,
282    /// We are currently searching for a higher PMTU
283    Searching(SearchState),
284    /// Searching has completed and will be triggered again at the provided instant
285    Complete(Instant),
286}
287
288#[derive(Debug, Clone, Copy)]
289struct SearchState {
290    /// The lower bound for the current binary search
291    lower_bound: u16,
292    /// The upper bound for the current binary search
293    upper_bound: u16,
294    /// The minimum change to stop the current binary search
295    minimum_change: u16,
296    /// The UDP payload size we last sent a probe for
297    last_probed_mtu: u16,
298    /// Packet number of an in-flight probe (if any)
299    in_flight_probe: Option<u64>,
300    /// Lost probes at the current probe size
301    lost_probe_count: usize,
302}
303
304impl SearchState {
305    /// Creates a new search state, with the specified lower bound (the upper bound is derived from
306    /// the config and the peer's `max_udp_payload_size` transport parameter)
307    fn new(
308        mut lower_bound: u16,
309        peer_max_udp_payload_size: u16,
310        config: &MtuDiscoveryConfig,
311    ) -> Self {
312        lower_bound = lower_bound.min(peer_max_udp_payload_size);
313        let upper_bound = config
314            .upper_bound
315            .clamp(lower_bound, peer_max_udp_payload_size);
316
317        Self {
318            in_flight_probe: None,
319            lost_probe_count: 0,
320            lower_bound,
321            upper_bound,
322            minimum_change: config.minimum_change,
323            // During initialization, we consider the lower bound to have already been
324            // successfully probed
325            last_probed_mtu: lower_bound,
326        }
327    }
328
329    /// Determines the next MTU to probe using binary search
330    fn next_mtu_to_probe(&mut self, last_probe_succeeded: bool) -> Option<u16> {
331        debug_assert_eq!(self.in_flight_probe, None);
332
333        if last_probe_succeeded {
334            self.lower_bound = self.last_probed_mtu;
335        } else {
336            self.upper_bound = self.last_probed_mtu - 1;
337        }
338
339        let next_mtu = (self.lower_bound as i32 + self.upper_bound as i32) / 2;
340
341        // Binary search stopping condition
342        if ((next_mtu - self.last_probed_mtu as i32).unsigned_abs() as u16) < self.minimum_change {
343            // Special case: if the upper bound is far enough, we want to probe it as a last
344            // step (otherwise we will never achieve the upper bound)
345            if self.upper_bound.saturating_sub(self.last_probed_mtu) >= self.minimum_change {
346                return Some(self.upper_bound);
347            }
348
349            return None;
350        }
351
352        Some(next_mtu as u16)
353    }
354}
355
356/// Judges whether packet loss might indicate a drop in MTU
357///
358/// Our MTU black hole detection scheme is a heuristic based on the order in which packets were sent
359/// (the packet number order), their sizes, and which are deemed lost.
360///
361/// First, contiguous groups of lost packets ("loss bursts") are aggregated, because a group of
362/// packets all lost together were probably lost for the same reason.
363///
364/// A loss burst is deemed "suspicious" if it contains no packets that are (a) smaller than the
365/// minimum MTU or (b) smaller than a more recent acknowledged packet, because such a burst could be
366/// fully explained by a reduction in MTU.
367///
368/// When the number of suspicious loss bursts exceeds [`BLACK_HOLE_THRESHOLD`], we judge the
369/// evidence for an MTU black hole to be sufficient.
370#[derive(Clone, Debug)]
371struct BlackHoleDetector {
372    /// Packet loss bursts currently considered suspicious
373    suspicious_loss_bursts: Vec<LossBurst>,
374    /// Loss burst currently being aggregated, if any
375    current_loss_burst: Option<CurrentLossBurst>,
376    /// Packet number of the biggest packet larger than `min_mtu` which we've received
377    /// acknowledgment of more recently than any suspicious loss burst, if any
378    largest_post_loss_packet: u64,
379    /// The maximum of `min_mtu` and the size of `largest_post_loss_packet`, or exactly `min_mtu`
380    /// if no larger packets have been received since the most recent loss burst.
381    acked_mtu: u16,
382    /// The UDP payload size guaranteed to be supported by the network
383    min_mtu: u16,
384}
385
386impl BlackHoleDetector {
387    fn new(min_mtu: u16) -> Self {
388        Self {
389            suspicious_loss_bursts: Vec::with_capacity(BLACK_HOLE_THRESHOLD + 1),
390            current_loss_burst: None,
391            largest_post_loss_packet: 0,
392            acked_mtu: min_mtu,
393            min_mtu,
394        }
395    }
396
397    fn on_probe_acked(&mut self, pn: u64, len: u16) {
398        // MTU probes are always larger than the previous MTU, so no previous loss bursts are
399        // suspicious. At most one MTU probe is in flight at a time, so we don't need to worry about
400        // reordering between them.
401        self.suspicious_loss_bursts.clear();
402        self.acked_mtu = len;
403        // This might go backwards, but that's okay: a successful ACK means we haven't yet judged a
404        // more recently sent packet lost, and we just want to track the largest packet that's been
405        // successfully delivered more recently than a loss.
406        self.largest_post_loss_packet = pn;
407    }
408
409    fn on_non_probe_acked(&mut self, pn: u64, len: u16) {
410        if len <= self.acked_mtu {
411            // We've already seen a larger packet since the most recent suspicious loss burst;
412            // nothing to do.
413            return;
414        }
415        self.acked_mtu = len;
416        // This might go backwards, but that's okay as described in `on_probe_acked`.
417        self.largest_post_loss_packet = pn;
418        // Loss bursts packets smaller than this are retroactively deemed non-suspicious.
419        self.suspicious_loss_bursts
420            .retain(|burst| burst.smallest_packet_size > len);
421    }
422
423    fn on_non_probe_lost(&mut self, pn: u64, len: u16) {
424        // A loss burst is a group of consecutive packets that are declared lost, so a distance
425        // greater than 1 indicates a new burst
426        let end_last_burst = self
427            .current_loss_burst
428            .as_ref()
429            .is_some_and(|current| pn - current.latest_non_probe != 1);
430
431        if end_last_burst {
432            self.finish_loss_burst();
433        }
434
435        self.current_loss_burst = Some(CurrentLossBurst {
436            latest_non_probe: pn,
437            smallest_packet_size: self
438                .current_loss_burst
439                .map_or(len, |prev| cmp::min(prev.smallest_packet_size, len)),
440        });
441    }
442
443    fn black_hole_detected(&mut self) -> bool {
444        self.finish_loss_burst();
445
446        if self.suspicious_loss_bursts.len() <= BLACK_HOLE_THRESHOLD {
447            return false;
448        }
449
450        self.suspicious_loss_bursts.clear();
451
452        true
453    }
454
455    /// Marks the end of the current loss burst, checking whether it was suspicious
456    fn finish_loss_burst(&mut self) {
457        let Some(burst) = self.current_loss_burst.take() else {
458            return;
459        };
460        // If a loss burst contains a packet smaller than the minimum MTU or a more recently
461        // transmitted packet, it is not suspicious.
462        if burst.smallest_packet_size <= self.min_mtu
463            || (burst.latest_non_probe < self.largest_post_loss_packet
464                && burst.smallest_packet_size <= self.acked_mtu)
465        {
466            return;
467        }
468        // The loss burst is now deemed suspicious.
469
470        // A suspicious loss burst more recent than `largest_post_loss_packet` invalidates it. This
471        // makes `acked_mtu` a conservative approximation. Ideally we'd update `safe_mtu` and
472        // `largest_post_loss_packet` to describe the largest acknowledged packet sent later than
473        // this burst, but that would require tracking the size of an unpredictable number of
474        // recently acknowledged packets, and erring on the side of false positives is safe.
475        if burst.latest_non_probe > self.largest_post_loss_packet {
476            self.acked_mtu = self.min_mtu;
477        }
478
479        let burst = LossBurst {
480            smallest_packet_size: burst.smallest_packet_size,
481        };
482
483        if self.suspicious_loss_bursts.len() <= BLACK_HOLE_THRESHOLD {
484            self.suspicious_loss_bursts.push(burst);
485            return;
486        }
487
488        // To limit memory use, only track the most suspicious loss bursts.
489        let smallest = self
490            .suspicious_loss_bursts
491            .iter_mut()
492            .min_by_key(|prev| prev.smallest_packet_size)
493            .filter(|prev| prev.smallest_packet_size < burst.smallest_packet_size);
494        if let Some(smallest) = smallest {
495            *smallest = burst;
496        }
497    }
498
499    #[cfg(test)]
500    fn suspicious_loss_burst_count(&self) -> usize {
501        self.suspicious_loss_bursts.len()
502    }
503
504    #[cfg(test)]
505    fn largest_non_probe_lost(&self) -> Option<u64> {
506        self.current_loss_burst.as_ref().map(|x| x.latest_non_probe)
507    }
508}
509
510#[derive(Copy, Clone, Debug)]
511struct LossBurst {
512    smallest_packet_size: u16,
513}
514
515#[derive(Copy, Clone, Debug)]
516struct CurrentLossBurst {
517    smallest_packet_size: u16,
518    latest_non_probe: u64,
519}
520
521// Corresponds to the RFC's `MAX_PROBES` constant (see
522// https://www.rfc-editor.org/rfc/rfc8899#section-5.1.2)
523const MAX_PROBE_RETRANSMITS: usize = 3;
524/// Maximum number of suspicious loss bursts that will not trigger black hole detection
525const BLACK_HOLE_THRESHOLD: usize = 3;
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::Duration;
531    use crate::MAX_UDP_PAYLOAD;
532    use assert_matches::assert_matches;
533
534    fn default_mtud() -> MtuDiscovery {
535        let config = MtuDiscoveryConfig::default();
536        MtuDiscovery::new(1_200, 1_200, None, config)
537    }
538
539    fn completed(mtud: &MtuDiscovery) -> bool {
540        matches!(mtud.state.as_ref().unwrap().phase, Phase::Complete(_))
541    }
542
543    /// Drives mtud until it reaches `Phase::Completed`
544    fn drive_to_completion(
545        mtud: &mut MtuDiscovery,
546        now: Instant,
547        link_payload_size_limit: u16,
548    ) -> Vec<u16> {
549        let mut probed_sizes = Vec::new();
550        for probe_pn in 1..100 {
551            let result = mtud.poll_transmit(now, probe_pn);
552
553            if completed(mtud) {
554                break;
555            }
556
557            // "Send" next probe
558            assert!(result.is_some());
559            let probe_size = result.unwrap();
560            probed_sizes.push(probe_size);
561
562            if probe_size <= link_payload_size_limit {
563                mtud.on_acked(SpaceKind::Data, probe_pn, probe_size);
564            } else {
565                mtud.on_probe_lost();
566            }
567        }
568        probed_sizes
569    }
570
571    #[test]
572    fn black_hole_detector_ignores_burst_containing_non_suspicious_packet() {
573        let mut mtud = default_mtud();
574        mtud.on_non_probe_lost(2, 1300);
575        mtud.on_non_probe_lost(3, 1300);
576        assert_eq!(mtud.black_hole_detector.largest_non_probe_lost(), Some(3));
577        assert_eq!(mtud.black_hole_detector.suspicious_loss_burst_count(), 0);
578
579        mtud.on_non_probe_lost(4, 800);
580        assert!(!mtud.black_hole_detected(Instant::now()));
581        assert_eq!(mtud.black_hole_detector.largest_non_probe_lost(), None);
582        assert_eq!(mtud.black_hole_detector.suspicious_loss_burst_count(), 0);
583    }
584
585    #[test]
586    fn black_hole_detector_counts_burst_containing_only_suspicious_packets() {
587        let mut mtud = default_mtud();
588        mtud.on_non_probe_lost(2, 1300);
589        mtud.on_non_probe_lost(3, 1300);
590        assert_eq!(mtud.black_hole_detector.largest_non_probe_lost(), Some(3));
591        assert_eq!(mtud.black_hole_detector.suspicious_loss_burst_count(), 0);
592
593        assert!(!mtud.black_hole_detected(Instant::now()));
594        assert_eq!(mtud.black_hole_detector.largest_non_probe_lost(), None);
595        assert_eq!(mtud.black_hole_detector.suspicious_loss_burst_count(), 1);
596    }
597
598    #[test]
599    fn black_hole_detector_ignores_empty_burst() {
600        let mut mtud = default_mtud();
601        assert!(!mtud.black_hole_detected(Instant::now()));
602        assert_eq!(mtud.black_hole_detector.suspicious_loss_burst_count(), 0);
603    }
604
605    #[test]
606    fn mtu_discovery_disabled_does_nothing() {
607        let mut mtud = MtuDiscovery::disabled(1_200, 1_200);
608        let probe_size = mtud.poll_transmit(Instant::now(), 0);
609        assert_eq!(probe_size, None);
610    }
611
612    #[test]
613    fn mtu_discovery_disabled_lost_four_packet_bursts_triggers_black_hole_detection() {
614        let mut mtud = MtuDiscovery::disabled(1_400, 1_250);
615        let now = Instant::now();
616
617        for i in 0..4 {
618            // The packets are never contiguous, so each one has its own burst
619            mtud.on_non_probe_lost(i * 2, 1300);
620        }
621
622        assert!(mtud.black_hole_detected(now));
623        assert_eq!(mtud.current_mtu, 1250);
624        assert_matches!(mtud.state, None);
625    }
626
627    #[test]
628    fn mtu_discovery_lost_two_packet_bursts_does_not_trigger_black_hole_detection() {
629        let mut mtud = default_mtud();
630        let now = Instant::now();
631
632        for i in 0..2 {
633            mtud.on_non_probe_lost(i, 1300);
634            assert!(!mtud.black_hole_detected(now));
635        }
636    }
637
638    #[test]
639    fn mtu_discovery_lost_four_packet_bursts_triggers_black_hole_detection_and_resets_timer() {
640        let mut mtud = default_mtud();
641        let now = Instant::now();
642
643        for i in 0..4 {
644            // The packets are never contiguous, so each one has its own burst
645            mtud.on_non_probe_lost(i * 2, 1300);
646        }
647
648        assert!(mtud.black_hole_detected(now));
649        assert_eq!(mtud.current_mtu, 1200);
650        if let Phase::Complete(next_mtud_activation) = mtud.state.unwrap().phase {
651            assert_eq!(next_mtud_activation, now + Duration::from_secs(60));
652        } else {
653            panic!("Unexpected MTUD phase!");
654        }
655    }
656
657    #[test]
658    fn mtu_discovery_after_complete_reactivates_when_interval_elapsed() {
659        let mut config = MtuDiscoveryConfig::default();
660        config.upper_bound(9_000);
661        let mut mtud = MtuDiscovery::new(1_200, 1_200, None, config);
662        let now = Instant::now();
663        drive_to_completion(&mut mtud, now, 1_500);
664
665        // Polling right after completion does not cause new packets to be sent
666        assert_eq!(mtud.poll_transmit(now, 42), None);
667        assert!(completed(&mtud));
668        assert_eq!(mtud.current_mtu, 1_471);
669
670        // Polling after the interval has passed does (taking the current mtu as lower bound)
671        assert_eq!(
672            mtud.poll_transmit(now + Duration::from_secs(600), 43),
673            Some(5235)
674        );
675
676        match mtud.state.unwrap().phase {
677            Phase::Searching(state) => {
678                assert_eq!(state.lower_bound, 1_471);
679                assert_eq!(state.upper_bound, 9_000);
680            }
681            _ => {
682                panic!("Unexpected MTUD phase!")
683            }
684        }
685    }
686
687    #[test]
688    fn mtu_discovery_lost_three_probes_lowers_probe_size() {
689        let mut mtud = default_mtud();
690
691        let mut probe_sizes = (0..4).map(|i| {
692            let probe_size = mtud.poll_transmit(Instant::now(), i);
693            assert!(probe_size.is_some(), "no probe returned for packet {i}");
694
695            mtud.on_probe_lost();
696            probe_size.unwrap()
697        });
698
699        // After the first probe is lost, it gets retransmitted twice
700        let first_probe_size = probe_sizes.next().unwrap();
701        for _ in 0..2 {
702            assert_eq!(probe_sizes.next().unwrap(), first_probe_size)
703        }
704
705        // After the third probe is lost, we decrement our probe size
706        let fourth_probe_size = probe_sizes.next().unwrap();
707        assert!(fourth_probe_size < first_probe_size);
708        assert_eq!(
709            fourth_probe_size,
710            first_probe_size - (first_probe_size - 1_200) / 2 - 1
711        );
712    }
713
714    #[test]
715    fn mtu_discovery_with_peer_max_udp_payload_size_clamps_upper_bound() {
716        let mut mtud = default_mtud();
717
718        mtud.on_peer_max_udp_payload_size_received(1300);
719        let probed_sizes = drive_to_completion(&mut mtud, Instant::now(), 1500);
720
721        assert_eq!(mtud.state.as_ref().unwrap().peer_max_udp_payload_size, 1300);
722        assert_eq!(mtud.current_mtu, 1300);
723        let expected_probed_sizes = &[1250, 1275, 1300];
724        assert_eq!(probed_sizes, expected_probed_sizes);
725        assert!(completed(&mtud));
726    }
727
728    #[test]
729    fn mtu_discovery_with_previous_peer_max_udp_payload_size_clamps_upper_bound() {
730        let mut mtud = MtuDiscovery::new(1500, 1_200, Some(1400), MtuDiscoveryConfig::default());
731
732        assert_eq!(mtud.current_mtu, 1400);
733        assert_eq!(mtud.state.as_ref().unwrap().peer_max_udp_payload_size, 1400);
734
735        let probed_sizes = drive_to_completion(&mut mtud, Instant::now(), 1500);
736
737        assert_eq!(mtud.current_mtu, 1400);
738        assert!(probed_sizes.is_empty());
739        assert!(completed(&mtud));
740    }
741
742    #[cfg(debug_assertions)]
743    #[test]
744    #[should_panic(expected = "Transport parameters received after MTU probing started")]
745    fn mtu_discovery_with_peer_max_udp_payload_size_during_search_panics() {
746        let mut mtud = default_mtud();
747        assert!(mtud.poll_transmit(Instant::now(), 0).is_some());
748        assert!(matches!(
749            mtud.state.as_ref().unwrap().phase,
750            Phase::Searching(_)
751        ));
752        mtud.on_peer_max_udp_payload_size_received(1300);
753    }
754
755    #[test]
756    fn mtu_discovery_with_1500_limit() {
757        let mut mtud = default_mtud();
758
759        let probed_sizes = drive_to_completion(&mut mtud, Instant::now(), 1500);
760
761        let expected_probed_sizes = &[1326, 1389, 1420, 1452];
762        assert_eq!(probed_sizes, expected_probed_sizes);
763        assert_eq!(mtud.current_mtu, 1452);
764        assert!(completed(&mtud));
765    }
766
767    #[test]
768    fn mtu_discovery_with_1500_limit_and_10000_upper_bound() {
769        let mut config = MtuDiscoveryConfig::default();
770        config.upper_bound(10_000);
771        let mut mtud = MtuDiscovery::new(1_200, 1_200, None, config);
772
773        let probed_sizes = drive_to_completion(&mut mtud, Instant::now(), 1500);
774
775        let expected_probed_sizes = &[
776            5600, 5600, 5600, 3399, 3399, 3399, 2299, 2299, 2299, 1749, 1749, 1749, 1474, 1611,
777            1611, 1611, 1542, 1542, 1542, 1507, 1507, 1507,
778        ];
779        assert_eq!(probed_sizes, expected_probed_sizes);
780        assert_eq!(mtud.current_mtu, 1474);
781        assert!(completed(&mtud));
782    }
783
784    #[test]
785    fn mtu_discovery_no_lost_probes_finds_maximum_udp_payload() {
786        let mut config = MtuDiscoveryConfig::default();
787        config.upper_bound(MAX_UDP_PAYLOAD);
788        let mut mtud = MtuDiscovery::new(1200, 1200, None, config);
789
790        drive_to_completion(&mut mtud, Instant::now(), u16::MAX);
791
792        assert_eq!(mtud.current_mtu, 65527);
793        assert!(completed(&mtud));
794    }
795
796    #[test]
797    fn mtu_discovery_lost_half_of_probes_finds_maximum_udp_payload() {
798        let mut config = MtuDiscoveryConfig::default();
799        config.upper_bound(MAX_UDP_PAYLOAD);
800        let mut mtud = MtuDiscovery::new(1200, 1200, None, config);
801
802        let now = Instant::now();
803        let mut iterations = 0;
804        for i in 1..100 {
805            iterations += 1;
806
807            let probe_pn = i * 2 - 1;
808            let other_pn = i * 2;
809
810            let result = mtud.poll_transmit(Instant::now(), probe_pn);
811
812            if completed(&mtud) {
813                break;
814            }
815
816            // "Send" next probe
817            assert!(result.is_some());
818            assert!(mtud.in_flight_mtu_probe().is_some());
819
820            // Nothing else to send while the probe is in-flight
821            assert_matches!(mtud.poll_transmit(now, other_pn), None);
822
823            if i % 2 == 0 {
824                // ACK probe and ensure it results in an increase of current_mtu
825                let previous_max_size = mtud.current_mtu;
826                mtud.on_acked(SpaceKind::Data, probe_pn, result.unwrap());
827                println!(
828                    "ACK packet {}. Previous MTU = {previous_max_size}. New MTU = {}",
829                    result.unwrap(),
830                    mtud.current_mtu
831                );
832                // assert!(mtud.current_mtu > previous_max_size);
833            } else {
834                mtud.on_probe_lost();
835            }
836        }
837
838        assert_eq!(iterations, 25);
839        assert_eq!(mtud.current_mtu, 65527);
840        assert!(completed(&mtud));
841    }
842
843    #[test]
844    fn search_state_lower_bound_higher_than_upper_bound_clamps_upper_bound() {
845        let mut config = MtuDiscoveryConfig::default();
846        config.upper_bound(1400);
847
848        let state = SearchState::new(1500, u16::MAX, &config);
849        assert_eq!(state.lower_bound, 1500);
850        assert_eq!(state.upper_bound, 1500);
851    }
852
853    #[test]
854    fn search_state_lower_bound_higher_than_peer_max_udp_payload_size_clamps_lower_bound() {
855        let mut config = MtuDiscoveryConfig::default();
856        config.upper_bound(9000);
857
858        let state = SearchState::new(1500, 1300, &config);
859        assert_eq!(state.lower_bound, 1300);
860        assert_eq!(state.upper_bound, 1300);
861    }
862
863    #[test]
864    fn search_state_upper_bound_higher_than_peer_max_udp_payload_size_clamps_upper_bound() {
865        let mut config = MtuDiscoveryConfig::default();
866        config.upper_bound(9000);
867
868        let state = SearchState::new(1200, 1450, &config);
869        assert_eq!(state.lower_bound, 1200);
870        assert_eq!(state.upper_bound, 1450);
871    }
872
873    // Loss of packets larger than have been acknowledged should indicate a black hole
874    #[test]
875    fn simple_black_hole_detection() {
876        let mut bhd = BlackHoleDetector::new(1200);
877        bhd.on_non_probe_acked((BLACK_HOLE_THRESHOLD + 1) as u64 * 2, 1300);
878        for i in 0..BLACK_HOLE_THRESHOLD {
879            bhd.on_non_probe_lost(i as u64 * 2, 1400);
880        }
881        // But not before `BLACK_HOLE_THRESHOLD + 1` bursts
882        assert!(!bhd.black_hole_detected());
883        bhd.on_non_probe_lost(BLACK_HOLE_THRESHOLD as u64 * 2, 1400);
884        assert!(bhd.black_hole_detected());
885    }
886
887    // Loss of packets followed in transmission order by confirmation of a larger packet should not
888    // indicate a black hole
889    #[test]
890    fn non_suspicious_bursts() {
891        let mut bhd = BlackHoleDetector::new(1200);
892        bhd.on_non_probe_acked((BLACK_HOLE_THRESHOLD + 1) as u64 * 2, 1500);
893        for i in 0..(BLACK_HOLE_THRESHOLD + 1) {
894            bhd.on_non_probe_lost(i as u64 * 2, 1400);
895        }
896        assert!(!bhd.black_hole_detected());
897    }
898
899    // Loss of packets smaller than have been acknowledged previously should still indicate a black
900    // hole
901    #[test]
902    fn dynamic_mtu_reduction() {
903        let mut bhd = BlackHoleDetector::new(1200);
904        bhd.on_non_probe_acked(0, 1500);
905        for i in 0..(BLACK_HOLE_THRESHOLD + 1) {
906            bhd.on_non_probe_lost(i as u64 * 2, 1400);
907        }
908        assert!(bhd.black_hole_detected());
909    }
910
911    // Bursts containing heterogeneous packets are judged based on the smallest
912    #[test]
913    fn mixed_non_suspicious_bursts() {
914        let mut bhd = BlackHoleDetector::new(1200);
915        bhd.on_non_probe_acked((BLACK_HOLE_THRESHOLD + 1) as u64 * 3, 1400);
916        for i in 0..(BLACK_HOLE_THRESHOLD + 1) {
917            bhd.on_non_probe_lost(i as u64 * 3, 1500);
918            bhd.on_non_probe_lost(i as u64 * 3 + 1, 1300);
919        }
920        assert!(!bhd.black_hole_detected());
921    }
922
923    // Multi-packet bursts are only counted once
924    #[test]
925    fn bursts_count_once() {
926        let mut bhd = BlackHoleDetector::new(1200);
927        bhd.on_non_probe_acked((BLACK_HOLE_THRESHOLD + 1) as u64 * 3, 1400);
928        for i in 0..(BLACK_HOLE_THRESHOLD) {
929            bhd.on_non_probe_lost(i as u64 * 3, 1500);
930            bhd.on_non_probe_lost(i as u64 * 3 + 1, 1500);
931        }
932        assert!(!bhd.black_hole_detected());
933        bhd.on_non_probe_lost(BLACK_HOLE_THRESHOLD as u64 * 3, 1500);
934        assert!(bhd.black_hole_detected());
935    }
936
937    // Non-suspicious bursts don't interfere with detection of suspicious bursts
938    #[test]
939    fn interleaved_bursts() {
940        let mut bhd = BlackHoleDetector::new(1200);
941        bhd.on_non_probe_acked((BLACK_HOLE_THRESHOLD + 1) as u64 * 4, 1400);
942        for i in 0..(BLACK_HOLE_THRESHOLD + 1) {
943            bhd.on_non_probe_lost(i as u64 * 4, 1500);
944            bhd.on_non_probe_lost(i as u64 * 4 + 2, 1300);
945        }
946        assert!(bhd.black_hole_detected());
947    }
948
949    // Bursts that are non-suspicious before a delivered packet become suspicious past it
950    #[test]
951    fn suspicious_after_acked() {
952        let mut bhd = BlackHoleDetector::new(1200);
953        bhd.on_non_probe_acked((BLACK_HOLE_THRESHOLD + 1) as u64 * 2, 1400);
954        for i in 0..(BLACK_HOLE_THRESHOLD + 1) {
955            bhd.on_non_probe_lost(i as u64 * 2, 1300);
956        }
957        assert!(
958            !bhd.black_hole_detected(),
959            "1300 byte losses preceding a 1400 byte delivery are not suspicious"
960        );
961        for i in 0..(BLACK_HOLE_THRESHOLD + 1) {
962            bhd.on_non_probe_lost((BLACK_HOLE_THRESHOLD as u64 + 1 + i as u64) * 2, 1300);
963        }
964        assert!(
965            bhd.black_hole_detected(),
966            "1300 byte losses following a 1400 byte delivery are suspicious"
967        );
968    }
969
970    // Acknowledgment of a packet marks prior loss bursts with the same packet size as
971    // non-suspicious
972    #[test]
973    fn retroactively_non_suspicious() {
974        let mut bhd = BlackHoleDetector::new(1200);
975        for i in 0..BLACK_HOLE_THRESHOLD {
976            bhd.on_non_probe_lost(i as u64 * 2, 1400);
977        }
978        bhd.on_non_probe_acked(BLACK_HOLE_THRESHOLD as u64 * 2, 1400);
979        bhd.on_non_probe_lost(BLACK_HOLE_THRESHOLD as u64 * 2 + 1, 1400);
980        assert!(!bhd.black_hole_detected());
981    }
982}