Skip to main content

quiche/
pmtud.rs

1//! Path MTU Discovery ([RFC 8899] DPLPMTUD).
2//!
3//! Discovers the path MTU using loss-based inference: probe packets are sent
4//! and their acknowledgment (or lack thereof) determines path capacity.
5//!
6//! # Algorithm
7//!
8//! Optimistic binary search between [`MIN_PLPMTU`] (1200) and max supported
9//! MTU:
10//! 1. Probe at max MTU
11//! 2. On max_probes consecutive failures, record as smallest failed size
12//! 3. Binary search between largest success and smallest failure
13//! 4. Complete when difference ≤ 1 byte
14//!
15//! A successful probe at any point resets the failure counter and updates
16//! the largest known working size.
17//!
18//! [RFC 8899]: https://datatracker.ietf.org/doc/html/rfc8899
19
20/// Maximum number of probe attempts before treating a size as failed.
21/// https://datatracker.ietf.org/doc/html/rfc8899#section-5.1.2
22pub(crate) const MAX_PROBES_DEFAULT: u8 = 3;
23
24/// Min Packetization Layer Path MTU (PLPMTU).
25/// https://datatracker.ietf.org/doc/html/rfc8899#section-5.1.2
26/// For QUIC, this is 1200 bytes per https://datatracker.ietf.org/doc/html/rfc9000#section-14.1
27const MIN_PLPMTU: usize = crate::MIN_CLIENT_INITIAL_LEN;
28
29#[derive(Default)]
30pub struct Pmtud {
31    /// The PMTU after the completion of PMTUD.
32    /// Will be [`None`] if the PMTU is less than the minimum supported MTU.
33    pmtu: Option<usize>,
34
35    /// The current PMTUD probe size. Set to maximum_supported_mtu at
36    /// initialization.
37    probe_size: usize,
38
39    /// The maximum supported MTU.
40    maximum_supported_mtu: usize,
41
42    /// The size of the smallest failed probe.
43    smallest_failed_probe_size: Option<usize>,
44
45    /// The size of the largest successful probe.
46    largest_successful_probe_size: Option<usize>,
47
48    /// Indicates if a PMTUD probe is in flight. Used to limit probes to 1/RTT.
49    in_flight: bool,
50
51    /// The number of times the current probe size has failed.
52    probe_failure_count: u8,
53
54    /// The maximum number of failed probe attempts before treating a size as
55    /// failed.
56    max_probes: u8,
57}
58
59impl Pmtud {
60    /// Creates new PMTUD instance.
61    ///
62    /// If `max_probes` is 0, uses the default value of [`MAX_PROBES_DEFAULT`].
63    pub fn new(maximum_supported_mtu: usize, max_probes: u8) -> Self {
64        let max_probes = if max_probes == 0 {
65            warn!(
66                "max_probes is 0, using default value {}",
67                MAX_PROBES_DEFAULT
68            );
69            MAX_PROBES_DEFAULT
70        } else {
71            max_probes
72        };
73
74        Self {
75            maximum_supported_mtu,
76            probe_size: maximum_supported_mtu,
77            max_probes,
78            ..Default::default()
79        }
80    }
81
82    /// Indicates whether probing should continue on the connection.
83    ///
84    /// Checks there are no probes in flight, that a PMTU has not been
85    /// found, and that the minimum supported MTU has not been reached.
86    pub fn should_probe(&self) -> bool {
87        !self.in_flight &&
88            self.pmtu.is_none() &&
89            self.smallest_failed_probe_size != Some(MIN_PLPMTU)
90    }
91
92    /// Sets the PMTUD probe size.
93    fn set_probe_size(&mut self, probe_size: usize) {
94        self.probe_size = std::cmp::min(probe_size, self.maximum_supported_mtu);
95    }
96
97    /// Returns the PMTUD probe size.
98    pub fn get_probe_size(&self) -> usize {
99        self.probe_size
100    }
101
102    /// Returns the largest successful PMTUD probe size if one exists, otherwise
103    /// returns the minimum supported MTU.
104    pub fn get_current_mtu(&self) -> usize {
105        self.largest_successful_probe_size.unwrap_or(MIN_PLPMTU)
106    }
107
108    /// Returns the PMTU.
109    pub fn get_pmtu(&self) -> Option<usize> {
110        self.pmtu
111    }
112
113    /// Selects PMTU probe size based on the binary search algorithm.
114    ///
115    /// Based on the Optimistic Binary algorithm defined in:
116    /// Ref: <https://www.hb.fh-muenster.de/opus4/frontdoor/deliver/index/docId/14965/file/dplpmtudQuicPaper.pdf>
117    fn update_probe_size(&mut self) {
118        match (
119            self.smallest_failed_probe_size,
120            self.largest_successful_probe_size,
121        ) {
122            // Binary search between successful and failed probes
123            (Some(failed_probe_size), Some(successful_probe_size)) => {
124                // Something has changed along the path that invalidates
125                // previous PMTUD probes. Restart PMTUD
126                if failed_probe_size <= successful_probe_size {
127                    warn!(
128                        "Inconsistent PMTUD probing results. Restarting PMTUD. \
129                        failed_probe_size: {failed_probe_size}, \
130                        successful_probe_size: {successful_probe_size}",
131                    );
132
133                    return self.restart_pmtud();
134                }
135
136                // Found the PMTU
137                if failed_probe_size - successful_probe_size <= 1 {
138                    debug!("Found PMTU: {successful_probe_size}");
139                    self.set_pmtu(successful_probe_size);
140                } else {
141                    self.probe_size =
142                        (successful_probe_size + failed_probe_size) / 2
143                }
144            },
145
146            // With only failed probes, binary search between the smallest failed
147            // probe and the minimum supported MTU
148            (Some(failed_probe_size), None) =>
149                self.probe_size = (MIN_PLPMTU + failed_probe_size) / 2,
150
151            // As the algorithm is optimistic in that the initial probe size
152            // is the maximum supported MTU, then having only a successful probe
153            // means the maximum supported MTU is <= PMTU
154            (None, Some(successful_probe_size)) => {
155                self.set_pmtu(successful_probe_size);
156            },
157
158            // Use the initial probe size if no record of success/failures
159            (None, None) => self.probe_size = self.maximum_supported_mtu,
160        }
161    }
162
163    /// Sets whether a probe is currently in flight for this connection.
164    pub fn set_in_flight(&mut self, in_flight: bool) {
165        self.in_flight = in_flight;
166    }
167
168    /// Records a successful probe and returns the largest successful probe size
169    pub fn successful_probe(&mut self, probe_size: usize) -> Option<usize> {
170        self.probe_failure_count = 0;
171
172        self.largest_successful_probe_size = std::cmp::max(
173            // make sure we don't exceed the maximum supported MTU
174            Some(probe_size.min(self.maximum_supported_mtu)),
175            self.largest_successful_probe_size,
176        );
177
178        self.update_probe_size();
179        self.in_flight = false;
180
181        self.largest_successful_probe_size
182    }
183
184    /// Records a failed probe
185    pub fn failed_probe(&mut self, probe_size: usize) {
186        // Treat errant probes as if they failed at the minimum supported MTU
187        let probe_size = std::cmp::max(probe_size, MIN_PLPMTU);
188        self.probe_failure_count += 1;
189
190        if self.probe_failure_count < self.max_probes {
191            debug!(
192                "Probe size {} failed ({}/{}), will retry",
193                probe_size, self.probe_failure_count, self.max_probes
194            );
195            self.in_flight = false;
196            return;
197        }
198
199        debug!(
200            "Probe size {} failed {} times, treating as MTU limitation",
201            probe_size, self.probe_failure_count
202        );
203
204        // Check if we have one instance of a failed probe so that a min
205        // comparison can be made otherwise if this is the first failed
206        // probe just record it
207        self.smallest_failed_probe_size = Some(
208            self.smallest_failed_probe_size
209                .map_or(probe_size, |s| s.min(probe_size)),
210        );
211
212        self.probe_failure_count = 0;
213        self.update_probe_size();
214        self.in_flight = false;
215    }
216
217    // Resets PMTUD internals such that PMTUD will be recalculated
218    // on the next opportunity
219    fn restart_pmtud(&mut self) {
220        self.set_probe_size(self.maximum_supported_mtu);
221        self.smallest_failed_probe_size = None;
222        self.largest_successful_probe_size = None;
223        self.pmtu = None;
224        self.probe_failure_count = 0;
225    }
226
227    // Checks that a probe of PMTU size can be ack'd by enabling
228    // a probe on the next opportunity. If this probe is dropped
229    // PMTUD will restart from a fresh state
230    pub fn revalidate_pmtu(&mut self) {
231        if let Some(pmtu) = self.pmtu {
232            self.set_probe_size(pmtu);
233            self.pmtu = None;
234            self.probe_failure_count = 0;
235            self.largest_successful_probe_size = None;
236        }
237    }
238
239    fn set_pmtu(&mut self, successful_probe_size: usize) {
240        self.pmtu = Some(successful_probe_size);
241        self.probe_size = successful_probe_size;
242        self.probe_failure_count = 0;
243    }
244}
245
246impl std::fmt::Debug for Pmtud {
247    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
248        write!(f, "pmtu={:?} ", self.pmtu)?;
249        write!(f, "probe_size={:?} ", self.probe_size)?;
250        write!(f, "should_probe={:?} ", self.should_probe())?;
251        write!(
252            f,
253            "failures={}/{} ",
254            self.probe_failure_count, self.max_probes
255        )?;
256        Ok(())
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn pmtud_initial_state() {
266        let pmtud = Pmtud::new(1350, 1);
267        assert_eq!(pmtud.get_current_mtu(), 1200);
268        assert_eq!(pmtud.get_probe_size(), 1350);
269        assert!(pmtud.should_probe());
270    }
271
272    #[test]
273    fn pmtud_max_probes_zero_uses_default() {
274        let pmtud = Pmtud::new(1500, 0);
275        assert_eq!(pmtud.max_probes, MAX_PROBES_DEFAULT);
276    }
277
278    #[test]
279    fn pmtud_max_probes_set_to_provided_value() {
280        let pmtud = Pmtud::new(1500, 5);
281        assert_eq!(pmtud.max_probes, 5);
282        assert_ne!(pmtud.max_probes, MAX_PROBES_DEFAULT);
283    }
284
285    #[test]
286    fn pmtud_binary_search_algorithm() {
287        let mut pmtud = Pmtud::new(1500, 1);
288
289        // Set initial probe size to 1500
290        assert_eq!(pmtud.get_probe_size(), 1500);
291
292        // Simulate probe loss - should update to midpoint
293        pmtud.failed_probe(1500);
294        // Expected: 1200 + ((1500 - 1200) / 2) = 1200 + 150 = 1350
295        assert_eq!(pmtud.get_probe_size(), 1350);
296
297        // Another probe loss
298        pmtud.failed_probe(1350);
299        // Expected: 1200 + ((1350 - 1200) / 2) = 1200 + 75 = 1275
300        assert_eq!(pmtud.get_probe_size(), 1275);
301
302        pmtud.failed_probe(1275);
303        // Expected: 1200 + ((1275 - 1200) / 2) = 1200 + 37 = 1237
304        assert_eq!(pmtud.get_probe_size(), 1237);
305
306        pmtud.failed_probe(1237);
307        // Expected: 1200 + ((1237 - 1200) / 2) = 1200 + 18 = 1218
308        assert_eq!(pmtud.get_probe_size(), 1218);
309
310        pmtud.failed_probe(1218);
311        // Expected: 1200 + ((1218 - 1200) / 2) = 1200 + 9 = 1209
312        assert_eq!(pmtud.get_probe_size(), 1209);
313
314        pmtud.failed_probe(1209);
315        // Expected: 1200 + ((1209 - 1200) / 2) = 1200 + 4 = 1204
316        assert_eq!(pmtud.get_probe_size(), 1204);
317
318        pmtud.failed_probe(1204);
319        // Expected: 1200 + ((1204 - 1200) / 2) = 1200 + 2 = 1202
320        assert_eq!(pmtud.get_probe_size(), 1202);
321
322        pmtud.failed_probe(1202);
323        // Expected: 1200 + ((1202 - 1200) / 2) = 1200 + 1 = 1201
324        assert_eq!(pmtud.get_probe_size(), 1201);
325
326        pmtud.failed_probe(1201);
327        // Expected: 1200 + ((1201 - 1200) / 2) = 1200 + 0 = 1200
328        assert_eq!(pmtud.get_probe_size(), 1200);
329    }
330
331    #[test]
332    fn pmtud_successful_probe() {
333        let mut pmtud = Pmtud::new(1400, 1);
334
335        // Simulate successful probe
336        pmtud.successful_probe(1400);
337
338        assert_eq!(pmtud.get_current_mtu(), 1400);
339    }
340
341    /// Test case for resetting the PMTUD state.
342    ///
343    /// This test initializes the PMTUD instance, performs a successful probe,
344    /// recalculates the PMTU, and then uses the `pmtud_test_runner` function
345    /// to verify the PMTU discovery process.
346    #[test]
347    fn test_pmtud_reset() {
348        let mut pmtud = Pmtud::new(1350, 1);
349        pmtud.successful_probe(1350);
350        assert_eq!(pmtud.pmtu, Some(1350));
351        assert!(!pmtud.should_probe());
352
353        // Restart PMTUD and expect the state to reset
354        pmtud.restart_pmtud();
355
356        // Run the PMTUD test runner with the reset state
357        pmtud_test_runner(&mut pmtud, 1237);
358    }
359
360    /// Test case for receiving a probe outside the defined supported MTU range.
361    #[test]
362    fn test_pmtud_errant_probe() {
363        let mut pmtud = Pmtud::new(1350, 1);
364        pmtud.successful_probe(1500);
365        // Even though we've received a probe larger than supported
366        // maximum MTU, the PMTU should still respect the configured maximum
367        assert_eq!(pmtud.pmtu, Some(1350));
368        assert!(!pmtud.should_probe());
369
370        pmtud.restart_pmtud();
371
372        // A failed probe of a value less than the minimum supported MTU
373        // should stop probing
374        pmtud.failed_probe(1100);
375        assert_eq!(pmtud.pmtu, None);
376        assert_eq!(pmtud.get_probe_size(), 1200);
377        assert!(!pmtud.should_probe());
378    }
379
380    /// Test case for PMTU equal to the minimum supported MTU.
381    ///
382    /// This test verifies that the PMTU discovery process correctly identifies
383    /// when the PMTU is equal to the minimum supported MTU.
384    #[test]
385    fn test_pmtu_equal_to_min_supported_mtu() {
386        let mut pmtud = Pmtud::new(1350, 1);
387        pmtud_test_runner(&mut pmtud, 1200);
388    }
389
390    /// Test case for PMTU greater than the minimum supported MTU.
391    ///
392    /// This test verifies that the PMTU discovery process correctly identifies
393    /// when the PMTU is greater than the minimum supported MTU.
394    #[test]
395    fn test_pmtu_greater_than_min_supported_mtu() {
396        let mut pmtud = Pmtud::new(1350, 1);
397        pmtud_test_runner(&mut pmtud, 1500);
398    }
399
400    /// Test case for PMTU less than the minimum supported MTU.
401    ///
402    /// This test verifies that the PMTU discovery process correctly handles
403    /// the case when the PMTU is less than the minimum supported MTU.
404    #[test]
405    fn test_pmtu_less_than_min_supported_mtu() {
406        let mut pmtud = Pmtud::new(1350, 1);
407        pmtud_test_runner(&mut pmtud, 1100);
408    }
409
410    /// Test case for PMTU revalidation.
411    ///
412    /// This test verifies that the PMTU recalculation logic correctly resets
413    /// the PMTUD state and identifies the correct PMTU after a failed
414    /// validation probe.
415    #[test]
416    fn test_pmtu_revalidation() {
417        let mut pmtud = Pmtud::new(1350, 1);
418        pmtud.set_probe_size(1350);
419        pmtud.successful_probe(1350);
420
421        // Simulate a case where an established PMTU probe is dropped repeatedly
422        pmtud.revalidate_pmtu();
423        fail_probe_max_times(&mut pmtud, 1350);
424
425        // Run the PMTUD test runner with the reset state
426        pmtud_test_runner(&mut pmtud, 1250);
427    }
428
429    #[test]
430    fn pmtud_revalidation_tolerates_random_packet_loss() {
431        let mut pmtud = Pmtud::new(1500, MAX_PROBES_DEFAULT);
432
433        pmtud.successful_probe(1500);
434        assert_eq!(pmtud.get_pmtu(), Some(1500));
435
436        pmtud.revalidate_pmtu();
437        assert_eq!(pmtud.get_pmtu(), None);
438        assert!(pmtud.largest_successful_probe_size.is_none());
439
440        pmtud.failed_probe(1500);
441        assert_eq!(pmtud.probe_failure_count, 1);
442        assert!(pmtud.pmtu.is_none());
443
444        pmtud.failed_probe(1500);
445        assert_eq!(pmtud.probe_failure_count, 2);
446
447        pmtud.successful_probe(1500);
448        assert_eq!(pmtud.get_pmtu(), Some(1500));
449        assert_eq!(pmtud.probe_failure_count, 0);
450    }
451
452    /// Test that when revalidating PMTU, if the revalidation probe fails,
453    /// PMTUD should binary search down, not restart.
454    #[test]
455    fn pmtud_revalidation_failure_binary_searches_not_restarts() {
456        let mut pmtud = Pmtud::new(1500, 1);
457
458        pmtud.successful_probe(1500);
459        assert_eq!(pmtud.get_pmtu(), Some(1500));
460
461        // Revalidation clears largest_successful_probe_size
462        pmtud.revalidate_pmtu();
463        assert!(pmtud.largest_successful_probe_size.is_none());
464
465        // Revalidation probe fails - should binary search down, not restart
466        pmtud.failed_probe(1500);
467
468        assert_eq!(pmtud.smallest_failed_probe_size, Some(1500));
469        assert!(pmtud.largest_successful_probe_size.is_none());
470        assert_eq!(pmtud.get_probe_size(), 1350); // (1200 + 1500) / 2
471    }
472
473    #[test]
474    fn pmtud_tolerates_initial_packet_loss() {
475        let mut pmtud = Pmtud::new(1500, MAX_PROBES_DEFAULT);
476
477        pmtud.failed_probe(1500);
478        assert_eq!(pmtud.probe_failure_count, 1);
479        assert!(pmtud.smallest_failed_probe_size.is_none());
480
481        pmtud.failed_probe(1500);
482        assert_eq!(pmtud.probe_failure_count, 2);
483        assert!(pmtud.smallest_failed_probe_size.is_none());
484
485        pmtud.successful_probe(1500);
486        assert_eq!(pmtud.get_pmtu(), Some(1500));
487        assert_eq!(pmtud.probe_failure_count, 0);
488    }
489
490    #[test]
491    fn pmtud_confirms_failure_after_max_probes() {
492        let mut pmtud = Pmtud::new(1500, 1);
493
494        pmtud.failed_probe(1500);
495
496        assert_eq!(pmtud.smallest_failed_probe_size, Some(1500));
497        assert!(pmtud.pmtu.is_none());
498        assert!(pmtud.get_probe_size() < 1500);
499        assert!(pmtud.get_probe_size() >= MIN_PLPMTU);
500    }
501
502    #[test]
503    fn pmtud_binary_search_no_slowdown() {
504        let mut pmtud = Pmtud::new(1500, 2);
505
506        fail_probe_max_times(&mut pmtud, 1500);
507        assert!(pmtud.pmtu.is_none());
508
509        let search_size_1 = pmtud.get_probe_size();
510        assert!(search_size_1 < 1500);
511
512        pmtud.successful_probe(search_size_1);
513        assert_eq!(pmtud.probe_failure_count, 0);
514
515        let search_size_2 = pmtud.get_probe_size();
516        pmtud.failed_probe(search_size_2);
517
518        assert!(pmtud.pmtu.is_none());
519        assert_eq!(pmtud.probe_failure_count, 1);
520    }
521
522    /// Test convergence to correct MTU with intermittent packet loss.
523    ///
524    /// Simulates a scenario where the first probe at each size fails but the
525    /// second succeeds (random loss, not MTU limitation). Verifies that:
526    /// 1. probe_failure_count resets to 0 on success
527    /// 2. probe_failure_count resets to 0 when probe size changes
528    /// 3. Algorithm converges to the correct MTU of 1337
529    #[test]
530    fn pmtud_convergence_with_intermittent_loss() {
531        let mut pmtud = Pmtud::new(1500, 3);
532        let target_mtu = 1337;
533
534        while pmtud.get_pmtu().is_none() {
535            let probe_size = pmtud.get_probe_size();
536
537            if probe_size <= target_mtu {
538                // First probe fails (random loss)
539                pmtud.failed_probe(probe_size);
540                assert_eq!(pmtud.probe_failure_count, 1);
541
542                // Second probe succeeds
543                pmtud.successful_probe(probe_size);
544                assert_eq!(pmtud.probe_failure_count, 0); // Reset on success
545            } else {
546                // Size exceeds MTU - all probes fail
547                let old_probe_size = probe_size;
548                fail_probe_max_times(&mut pmtud, probe_size);
549
550                // After max failures, probe_failure_count resets and size changes
551                assert_eq!(pmtud.probe_failure_count, 0);
552                if pmtud.get_pmtu().is_none() {
553                    assert!(pmtud.get_probe_size() < old_probe_size);
554                }
555            }
556        }
557
558        assert_eq!(pmtud.get_pmtu(), Some(target_mtu));
559    }
560
561    #[test]
562    fn pmtud_failure_at_min_plpmtu() {
563        let mut pmtud = Pmtud::new(1500, MAX_PROBES_DEFAULT);
564
565        pmtud.failed_probe(100);
566        pmtud.failed_probe(100);
567        pmtud.failed_probe(100);
568
569        assert_eq!(pmtud.smallest_failed_probe_size, Some(MIN_PLPMTU));
570    }
571
572    #[test]
573    fn pmtud_in_flight_cleared_on_all_outcomes() {
574        let mut pmtud = Pmtud::new(1500, 1);
575
576        pmtud.set_in_flight(true);
577        assert!(pmtud.in_flight);
578
579        pmtud.failed_probe(1500);
580        assert!(!pmtud.in_flight);
581
582        pmtud.set_in_flight(true);
583
584        pmtud.successful_probe(1500);
585        assert!(!pmtud.in_flight);
586    }
587
588    #[test]
589    fn pmtud_update_probe_size_initial_state() {
590        let mut pmtud = Pmtud::new(1500, 1);
591
592        // Manually set probe_size to something else to verify update_probe_size
593        // resets it
594        pmtud.probe_size = 1200;
595
596        // With no successful or failed probes, should reset to
597        // maximum_supported_mtu
598        pmtud.update_probe_size();
599
600        assert_eq!(pmtud.probe_size, 1500);
601    }
602
603    // Test utilities
604
605    fn fail_probe_max_times(pmtud: &mut Pmtud, size: usize) {
606        for _ in 0..pmtud.max_probes {
607            pmtud.failed_probe(size);
608        }
609    }
610
611    /// Runs a test for the PMTUD algorithm, given a target PMTU `target_mtu`.
612    ///
613    /// The test iteratively sends probes until the PMTU is found or the minimum
614    /// supported MTU is reached. Verifies that the PMTU is equal to the target
615    /// PMTU.
616    fn pmtud_test_runner(pmtud: &mut Pmtud, test_pmtu: usize) {
617        // Loop until the PMTU is found or the minimum supported MTU is reached
618        while pmtud.get_probe_size() >= MIN_PLPMTU {
619            // Send a probe with the current probe size
620            let probe_size = pmtud.get_probe_size();
621
622            if probe_size <= test_pmtu {
623                pmtud.successful_probe(probe_size);
624            } else {
625                fail_probe_max_times(pmtud, probe_size);
626            }
627
628            // Update the probe size based on the result
629            pmtud.update_probe_size();
630
631            // If the probe size hasn't changed and is equal to the minimum
632            // supported MTU, break the loop
633            if pmtud.get_probe_size() == probe_size && probe_size == MIN_PLPMTU {
634                break;
635            }
636
637            // If the PMTU is found, break the loop
638            if pmtud.get_pmtu().is_some() {
639                break;
640            }
641        }
642
643        // Verify that the PMTU is correct
644        if test_pmtu < MIN_PLPMTU {
645            assert_eq!(pmtud.get_pmtu(), None);
646        } else if test_pmtu > pmtud.maximum_supported_mtu {
647            assert_eq!(pmtud.get_pmtu(), Some(pmtud.maximum_supported_mtu));
648        } else {
649            assert_eq!(pmtud.get_pmtu(), Some(test_pmtu));
650        }
651    }
652}