Skip to main content

phantom_protocol/transport/
bandwidth_estimator.rs

1//! BBR-like Bandwidth Estimator
2//!
3//! Implements a simplified BBR (Bottleneck Bandwidth and Round-trip propagation time)
4//! congestion-control state machine. It consumes a `DeliverySample` per ACKed packet
5//! (fed from the PhantomUDP reliable-stream ACK path) and produces a recommended pacing
6//! rate (bytes/sec) plus a congestion window — the inputs the [`Pacer`](super::pacer)
7//! and the send loop use to decide how fast to put bytes on the wire.
8//!
9//! # BBR States
10//!
11//! ```text
12//!   ┌─────────┐        ┌──────────┐        ┌────────┐
13//!   │ Startup │───────▶│  Drain   │───────▶│ ProbeBW│
14//!   └─────────┘        └──────────┘        └────────┘
15//!                         ▲    │              │
16//!                         │    └──────────────┘
17//!                         │       ▲
18//!                      ┌──┴───────┴──┐
19//!                      │  ProbeRTT   │  (every 10s for 200ms)
20//!                      └─────────────┘
21//! ```
22//!
23//! - **Startup:** Double sending rate exponentially until bottleneck bandwidth is found
24//! - **Drain:** Reduce rate until inflight ≤ BDP (drain queues built during Startup)
25//! - **ProbeBW:** Cycle through pacing gains (1.25, 0.75, 1.0, 1.0) to probe bandwidth
26//! - **ProbeRTT:** Every 10s, reduce CWND to 4 packets for 200ms to measure true min RTT
27//! - **FastRecovery:** Entered on explicit packet loss (BBRv3-style) — back off pacing to
28//!   0.5x and tighten CWND to 1x BDP until the pipe drains; not shown in the diagram above
29//!   because any state except Startup/ProbeRTT can enter it on loss.
30//!
31//! # Integration
32//!
33//! The estimator feeds the [`Pacer`](super::pacer::Pacer) with target rates; the pacer
34//! then meters bytes onto the PhantomUDP socket:
35//! ```text
36//!   BandwidthEstimator ──rate──▶ Pacer ──paced_send──▶ UdpClientTransport / UdpServerTransport
37//! ```
38
39use std::collections::VecDeque;
40use std::time::{Duration, Instant};
41
42/// BBR state machine states
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum BbrState {
45    /// Exponentially probe for bandwidth
46    Startup,
47    /// Probe for more bandwidth (cycle through pacing gains)
48    ProbeBW,
49    /// Drain queues that built up during Startup
50    Drain,
51    /// Probe for shorter RTT (reduce CWND to 4 packets for 200ms)
52    ProbeRTT,
53    /// Explicit packet loss detected — reduce rate and CWND proportionally
54    FastRecovery,
55}
56
57/// A single delivery sample (attached to each ACKed packet)
58#[derive(Debug, Clone, Copy)]
59pub struct DeliverySample {
60    /// Bytes delivered at time of sending
61    pub delivered_bytes: u64,
62    /// Timestamp when packet was sent
63    pub sent_at: Instant,
64    /// Timestamp when ACK was received
65    pub acked_at: Instant,
66    /// Bytes in this packet
67    pub packet_bytes: u64,
68    /// Whether the sender was application-limited when this packet was sent
69    pub is_app_limited: bool,
70    /// ACK delay reported by the receiver (microseconds).
71    /// The receiver measures time between packet receipt and ACK send;
72    /// subtracting this from the observed RTT gives the propagation delay.
73    pub ack_delay_us: u64,
74}
75
76/// Sliding window to track min/max of a value
77#[derive(Debug)]
78struct WindowFilter {
79    window: VecDeque<(Instant, u64)>,
80    window_size: Duration,
81}
82
83impl WindowFilter {
84    fn new(window_size: Duration) -> Self {
85        Self {
86            window: VecDeque::new(),
87            window_size,
88        }
89    }
90
91    fn update_max(&mut self, now: Instant, value: u64) -> u64 {
92        // Remove expired entries
93        while let Some(&(ts, _)) = self.window.front() {
94            if now.duration_since(ts) > self.window_size {
95                self.window.pop_front();
96            } else {
97                break;
98            }
99        }
100        // Remove entries smaller than the new value (they're dominated)
101        while let Some(&(_, v)) = self.window.back() {
102            if v <= value {
103                self.window.pop_back();
104            } else {
105                break;
106            }
107        }
108        self.window.push_back((now, value));
109        // The maximum is always at the front
110        self.window.front().map(|&(_, v)| v).unwrap_or(value)
111    }
112
113    fn update_min(&mut self, now: Instant, value: u64) -> u64 {
114        while let Some(&(ts, _)) = self.window.front() {
115            if now.duration_since(ts) > self.window_size {
116                self.window.pop_front();
117            } else {
118                break;
119            }
120        }
121        while let Some(&(_, v)) = self.window.back() {
122            if v >= value {
123                self.window.pop_back();
124            } else {
125                break;
126            }
127        }
128        self.window.push_back((now, value));
129        self.window.front().map(|&(_, v)| v).unwrap_or(value)
130    }
131}
132
133// ─── Constants ──────────────────────────────────────────────────────────────
134
135/// Probe cycle gains for ProbeBW phase (BBR cycle: 1.25, 0.75, 1.0, 1.0)
136const PROBE_BW_GAINS: [f64; 4] = [1.25, 0.75, 1.0, 1.0];
137
138/// Startup growth threshold — if BW growth < 25%, consider pipe filled
139const STARTUP_GROWTH_THRESHOLD: f64 = 0.25;
140
141/// Rounds without growth before exiting Startup
142const STARTUP_ROUNDS_LIMIT: u32 = 3;
143
144/// ProbeRTT interval — enter ProbeRTT every 10 seconds
145const PROBE_RTT_INTERVAL: Duration = Duration::from_secs(10);
146
147/// ProbeRTT duration — stay in ProbeRTT for 200ms
148const PROBE_RTT_DURATION: Duration = Duration::from_millis(200);
149
150/// Minimum CWND in ProbeRTT mode (4 packets)
151const PROBE_RTT_CWND_PACKETS: u64 = 4;
152
153/// Minimum packet size assumption (bytes)
154const MIN_PACKET_SIZE: u64 = 1400;
155
156/// FastRecovery: pacing gain during loss recovery (BBRv3: backs off to 0.5)
157const FAST_RECOVERY_PACING_GAIN: f64 = 0.5;
158
159/// FastRecovery: exit when inflight < BDP * this fraction  
160const FAST_RECOVERY_EXIT_FRACTION: f64 = 1.0;
161
162// ─── Estimator ──────────────────────────────────────────────────────────────
163
164/// BBR-like Bandwidth Estimator
165pub struct BandwidthEstimator {
166    /// Current BBR state
167    state: BbrState,
168    /// Estimated bottleneck bandwidth (bytes/sec)
169    btl_bw: u64,
170    /// Minimum observed RTT
171    min_rtt: Duration,
172    /// Sliding-window max filter for bandwidth (10-second window — see `new`)
173    bw_filter: WindowFilter,
174    /// Sliding-window min filter for RTT (10-second window — see `new`)
175    rtt_filter: WindowFilter,
176    /// Total bytes delivered (monotonically increasing)
177    delivered_bytes: u64,
178    /// Timestamp of last delivery
179    last_delivery: Instant,
180    /// Pacing gain multiplier (1.0 = 100%, 1.25 = probe, 0.75 = drain)
181    pacing_gain: f64,
182    /// CWND gain multiplier
183    cwnd_gain: f64,
184    /// Round counter (ticks on each ACK in Startup, cycles in ProbeBW)
185    round_count: u32,
186    /// Whether we've found the bottleneck bandwidth
187    filled_pipe: bool,
188    /// Previous bandwidth sample for startup exit decision
189    prev_bw: u64,
190    /// Number of rounds with insufficient BW increase (startup exit condition)
191    rounds_without_growth: u32,
192
193    // ── Inflight tracking ──
194    /// Current bytes in flight
195    inflight_bytes: u64,
196
197    // ── ProbeRTT timer ──
198    /// Timestamp of last ProbeRTT exit (or Startup start)
199    last_probe_rtt_time: Instant,
200    /// When we entered ProbeRTT (for duration tracking)
201    probe_rtt_entered: Option<Instant>,
202    /// State to return to after ProbeRTT
203    prior_state: BbrState,
204
205    // ── App-limited detection ──
206    /// Whether the sender is currently application-limited
207    app_limited: bool,
208    /// Delivered bytes at the time app-limited was last set
209    app_limited_at_delivered: u64,
210
211    // ── FastRecovery ──
212    /// When we entered FastRecovery (for duration-based exit)
213    fast_recovery_entered: Option<Instant>,
214    /// Total bytes lost during this recovery window
215    recovery_lost_bytes: u64,
216}
217
218impl BandwidthEstimator {
219    /// Create a new estimator starting in Startup state.
220    pub fn new() -> Self {
221        let now = Instant::now();
222        Self {
223            state: BbrState::Startup,
224            btl_bw: 0,
225            min_rtt: Duration::from_millis(100), // Conservative initial RTT
226            bw_filter: WindowFilter::new(Duration::from_secs(10)),
227            rtt_filter: WindowFilter::new(Duration::from_secs(10)),
228            delivered_bytes: 0,
229            last_delivery: now,
230            pacing_gain: 2.0, // Startup: double the rate
231            cwnd_gain: 2.0,
232            round_count: 0,
233            filled_pipe: false,
234            prev_bw: 0,
235            rounds_without_growth: 0,
236            inflight_bytes: 0,
237            last_probe_rtt_time: now,
238            probe_rtt_entered: None,
239            prior_state: BbrState::ProbeBW,
240            app_limited: false,
241            app_limited_at_delivered: 0,
242            fast_recovery_entered: None,
243            recovery_lost_bytes: 0,
244        }
245    }
246
247    // ── Public API ──────────────────────────────────────────────────────────
248
249    /// Notify the estimator that `bytes` were sent (increases inflight).
250    pub fn on_send(&mut self, bytes: u64) {
251        self.inflight_bytes = self.inflight_bytes.saturating_add(bytes);
252    }
253
254    /// Process an ACK and update bandwidth estimates.
255    ///
256    /// Returns the new recommended pacing rate (bytes/sec).
257    pub fn on_ack(&mut self, sample: DeliverySample) -> u64 {
258        let now = sample.acked_at;
259
260        // Update inflight tracking
261        self.inflight_bytes = self.inflight_bytes.saturating_sub(sample.packet_bytes);
262
263        // Update delivered bytes counter
264        self.delivered_bytes += sample.packet_bytes;
265        self.last_delivery = now;
266
267        // Calculate delivery rate for this sample.
268        // `send_elapsed` is the full observed RTT. We subtract the receiver's ack_delay
269        // to get the true propagation delay (RTprop), matching QUIC RFC 9002 §5.3.
270        let send_elapsed = sample.acked_at.duration_since(sample.sent_at);
271        let ack_delay = Duration::from_micros(sample.ack_delay_us);
272        let rtt_propagation = send_elapsed.saturating_sub(ack_delay);
273
274        // Update min RTT using the propagation delay (RTprop)
275        let rtt_us = rtt_propagation.as_micros() as u64;
276        if rtt_us > 0 {
277            let min_rtt_us = self.rtt_filter.update_min(now, rtt_us);
278            self.min_rtt = Duration::from_micros(min_rtt_us);
279        }
280
281        // Calculate bandwidth: delivered_bytes / time
282        let delivery_rate = if !send_elapsed.is_zero() {
283            (sample.packet_bytes as f64 / send_elapsed.as_secs_f64()) as u64
284        } else {
285            0
286        };
287
288        // App-limited filtering: only update BW filter with non-app-limited samples.
289        // App-limited samples underestimate the true available bandwidth because
290        // the sender wasn't sending at line rate.
291        if delivery_rate > 0 && !sample.is_app_limited {
292            self.btl_bw = self.bw_filter.update_max(now, delivery_rate);
293        }
294
295        // Check if we've exited app-limited phase
296        if self.app_limited && self.delivered_bytes > self.app_limited_at_delivered {
297            self.app_limited = false;
298        }
299
300        // Run state machine
301        self.update_state(now);
302
303        // Return pacing rate
304        self.pacing_rate()
305    }
306
307    /// Notify a packet loss — triggers BBRv3 Fast Recovery.
308    ///
309    /// Unlike earlier BBR versions that ignored loss, BBRv3 immediately
310    /// backs off pacing rate and CWND relative to the lost bytes fraction.
311    pub fn on_loss(&mut self, bytes: u64) {
312        self.inflight_bytes = self.inflight_bytes.saturating_sub(bytes);
313        self.recovery_lost_bytes = self.recovery_lost_bytes.saturating_add(bytes);
314
315        // Only enter FastRecovery if not already in it or ProbeRTT
316        if self.state != BbrState::FastRecovery && self.state != BbrState::ProbeRTT {
317            self.prior_state = self.state;
318            self.fast_recovery_entered = Some(Instant::now());
319            self.transition_to(BbrState::FastRecovery);
320        }
321    }
322
323    /// Mark the sender as application-limited (not sending at line rate).
324    ///
325    /// Call this when there is no data to send but the CWND has room.
326    /// Samples produced during app-limited periods won't update the BW filter,
327    /// preventing bandwidth underestimation.
328    pub fn set_app_limited(&mut self) {
329        self.app_limited = true;
330        self.app_limited_at_delivered = self.delivered_bytes;
331    }
332
333    /// Whether the sender is currently considered application-limited.
334    pub fn is_app_limited(&self) -> bool {
335        self.app_limited
336    }
337
338    /// Get current recommended pacing rate (bytes/sec).
339    pub fn pacing_rate(&self) -> u64 {
340        let base = self.btl_bw.max(1);
341        (base as f64 * self.pacing_gain) as u64
342    }
343
344    /// Get recommended congestion window size (bytes).
345    pub fn cwnd(&self) -> u64 {
346        if self.state == BbrState::ProbeRTT {
347            // During ProbeRTT, reduce CWND to minimum
348            return PROBE_RTT_CWND_PACKETS * MIN_PACKET_SIZE;
349        }
350        let bdp = self.bdp();
351        (bdp as f64 * self.cwnd_gain).max((PROBE_RTT_CWND_PACKETS * MIN_PACKET_SIZE) as f64) as u64
352    }
353
354    /// Get the Bandwidth-Delay Product (BDP) in bytes.
355    pub fn bdp(&self) -> u64 {
356        (self.btl_bw as f64 * self.min_rtt.as_secs_f64()) as u64
357    }
358
359    /// Get current bytes in flight.
360    pub fn inflight_bytes(&self) -> u64 {
361        self.inflight_bytes
362    }
363
364    /// Get estimated bottleneck bandwidth (bytes/sec).
365    pub fn bottleneck_bandwidth(&self) -> u64 {
366        self.btl_bw
367    }
368
369    /// Get minimum observed RTT.
370    pub fn min_rtt(&self) -> Duration {
371        self.min_rtt
372    }
373
374    /// Get current BBR state.
375    pub fn state(&self) -> BbrState {
376        self.state
377    }
378
379    /// Get total bytes delivered.
380    pub fn delivered_bytes(&self) -> u64 {
381        self.delivered_bytes
382    }
383
384    /// Get the round count.
385    pub fn round_count(&self) -> u32 {
386        self.round_count
387    }
388
389    // ── State Machine ───────────────────────────────────────────────────────
390
391    /// Run BBR state machine transitions.
392    fn update_state(&mut self, now: Instant) {
393        // ── ProbeRTT check: global timer, any state can enter except Startup and FastRecovery ──
394        if self.state != BbrState::ProbeRTT
395            && self.state != BbrState::Startup
396            && self.state != BbrState::FastRecovery
397            && now.duration_since(self.last_probe_rtt_time) >= PROBE_RTT_INTERVAL
398        {
399            self.prior_state = self.state;
400            self.transition_to(BbrState::ProbeRTT);
401            self.probe_rtt_entered = Some(now);
402            return;
403        }
404
405        match self.state {
406            BbrState::Startup => {
407                self.round_count += 1;
408
409                // Check if pipe is filled (BW growth < threshold)
410                if self.prev_bw > 0 {
411                    let growth = (self.btl_bw as f64 - self.prev_bw as f64) / self.prev_bw as f64;
412
413                    if growth < STARTUP_GROWTH_THRESHOLD {
414                        self.rounds_without_growth += 1;
415                    } else {
416                        self.rounds_without_growth = 0;
417                    }
418
419                    if self.rounds_without_growth >= STARTUP_ROUNDS_LIMIT {
420                        self.filled_pipe = true;
421                        self.transition_to(BbrState::Drain);
422                    }
423                }
424                self.prev_bw = self.btl_bw;
425            }
426            BbrState::Drain => {
427                // Stay in Drain until inflight ≤ BDP
428                let bdp = self.bdp();
429                if self.inflight_bytes <= bdp || bdp == 0 {
430                    self.transition_to(BbrState::ProbeBW);
431                }
432            }
433            BbrState::ProbeBW => {
434                // Cycle through pacing gains
435                let cycle_idx = (self.round_count as usize) % PROBE_BW_GAINS.len();
436                self.pacing_gain = PROBE_BW_GAINS[cycle_idx];
437                self.cwnd_gain = 2.0;
438                self.round_count += 1;
439            }
440            BbrState::ProbeRTT => {
441                // Stay for PROBE_RTT_DURATION, then exit
442                if let Some(entered) = self.probe_rtt_entered {
443                    if now.duration_since(entered) >= PROBE_RTT_DURATION {
444                        self.last_probe_rtt_time = now;
445                        self.probe_rtt_entered = None;
446                        self.transition_to(self.prior_state);
447                    }
448                } else {
449                    // Safety: shouldn't happen, but exit gracefully
450                    self.transition_to(BbrState::ProbeBW);
451                }
452            }
453            BbrState::FastRecovery => {
454                // Exit FastRecovery once inflight has drained to ≤ BDP (the pipe is no
455                // longer over-filled), or when BDP is still unknown (== 0). The exit is
456                // purely inflight-driven; `fast_recovery_entered` is recorded only for
457                // diagnostics, not consulted here.
458                let bdp = self.bdp();
459                let should_exit = self.inflight_bytes
460                    <= (bdp as f64 * FAST_RECOVERY_EXIT_FRACTION) as u64
461                    || bdp == 0;
462
463                if should_exit {
464                    self.recovery_lost_bytes = 0;
465                    self.fast_recovery_entered = None;
466                    self.transition_to(self.prior_state);
467                }
468            }
469        }
470    }
471
472    /// Transition to a new BBR state.
473    fn transition_to(&mut self, new_state: BbrState) {
474        match new_state {
475            BbrState::Startup => {
476                self.pacing_gain = 2.0;
477                self.cwnd_gain = 2.0;
478            }
479            BbrState::Drain => {
480                self.pacing_gain = 0.75;
481                self.cwnd_gain = 2.0;
482            }
483            BbrState::ProbeBW => {
484                self.pacing_gain = 1.0;
485                self.cwnd_gain = 2.0;
486            }
487            BbrState::ProbeRTT => {
488                self.pacing_gain = 1.0;
489                self.cwnd_gain = 1.0;
490            }
491            BbrState::FastRecovery => {
492                // BBRv3 recovery: drop pacing to 50% of bottleneck bandwidth,
493                // and tighten CWND to 1x BDP instead of 2x (no inflating).
494                self.pacing_gain = FAST_RECOVERY_PACING_GAIN;
495                self.cwnd_gain = 1.0;
496            }
497        }
498        self.state = new_state;
499    }
500}
501
502impl Default for BandwidthEstimator {
503    fn default() -> Self {
504        Self::new()
505    }
506}
507
508impl std::fmt::Debug for BandwidthEstimator {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        f.debug_struct("BandwidthEstimator")
511            .field("state", &self.state)
512            .field("btl_bw_kbps", &(self.btl_bw / 1024))
513            .field("min_rtt_ms", &self.min_rtt.as_millis())
514            .field("pacing_gain", &self.pacing_gain)
515            .field("inflight_bytes", &self.inflight_bytes)
516            .field("delivered_bytes", &self.delivered_bytes)
517            .field("app_limited", &self.app_limited)
518            .finish()
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn make_sample(sent_at: Instant, rtt_ms: u64, packet_bytes: u64) -> DeliverySample {
527        DeliverySample {
528            delivered_bytes: 0,
529            sent_at,
530            acked_at: sent_at + Duration::from_millis(rtt_ms),
531            packet_bytes,
532            is_app_limited: false,
533            ack_delay_us: 0, // No ACK delay in tests
534        }
535    }
536
537    fn make_app_limited_sample(sent_at: Instant, rtt_ms: u64, packet_bytes: u64) -> DeliverySample {
538        DeliverySample {
539            delivered_bytes: 0,
540            sent_at,
541            acked_at: sent_at + Duration::from_millis(rtt_ms),
542            packet_bytes,
543            is_app_limited: true,
544            ack_delay_us: 0,
545        }
546    }
547
548    #[test]
549    fn test_estimator_starts_in_startup() {
550        let est = BandwidthEstimator::new();
551        assert_eq!(est.state(), BbrState::Startup);
552        assert_eq!(est.delivered_bytes(), 0);
553        assert_eq!(est.inflight_bytes(), 0);
554        assert!(!est.is_app_limited());
555    }
556
557    #[test]
558    fn test_bandwidth_increases_with_acks() {
559        let mut est = BandwidthEstimator::new();
560        let now = Instant::now();
561
562        // Simulate several ACKs at 10ms RTT, 1400 byte packets
563        for i in 0..10 {
564            let sent = now + Duration::from_millis(i * 10);
565            est.on_send(1400);
566            let sample = make_sample(sent, 10, 1400);
567            est.on_ack(sample);
568        }
569
570        // Should have positive bandwidth estimate
571        assert!(
572            est.bottleneck_bandwidth() > 0,
573            "btl_bw = {} should be > 0",
574            est.bottleneck_bandwidth()
575        );
576        assert_eq!(est.delivered_bytes(), 14_000);
577    }
578
579    #[test]
580    fn test_min_rtt_tracking() {
581        let mut est = BandwidthEstimator::new();
582        let now = Instant::now();
583
584        // RTT high first, then low
585        let s1 = make_sample(now, 100, 1400);
586        est.on_ack(s1);
587        assert!(est.min_rtt() <= Duration::from_millis(101));
588
589        let s2 = make_sample(now + Duration::from_millis(200), 5, 1400);
590        est.on_ack(s2);
591        assert!(
592            est.min_rtt() <= Duration::from_millis(6),
593            "min_rtt = {:?}",
594            est.min_rtt()
595        );
596    }
597
598    #[test]
599    fn test_pacing_rate_positive() {
600        let mut est = BandwidthEstimator::new();
601        let now = Instant::now();
602
603        let sample = make_sample(now, 20, 1400);
604        est.on_ack(sample);
605
606        // Pacing rate should be positive
607        assert!(est.pacing_rate() > 0);
608    }
609
610    #[test]
611    fn test_cwnd_at_least_minimum() {
612        let est = BandwidthEstimator::new();
613        // Even with zero BW, CWND should have a minimum floor
614        let cwnd = est.cwnd();
615        assert!(
616            cwnd >= 4 * 1400,
617            "cwnd = {} should be >= {}",
618            cwnd,
619            4 * 1400
620        );
621    }
622
623    #[test]
624    fn test_startup_to_drain_transition() {
625        let mut est = BandwidthEstimator::new();
626        let now = Instant::now();
627
628        // Send many ACKs with constant bandwidth to trigger pipe-filled detection
629        for i in 0..20 {
630            let sent = now + Duration::from_millis(i * 10);
631            est.on_send(1400);
632            let sample = make_sample(sent, 10, 1400);
633            est.on_ack(sample);
634        }
635
636        // After enough rounds with no BW growth, should exit startup
637        assert!(
638            est.state() != BbrState::Startup || est.round_count < 20,
639            "expected startup exit, state = {:?}, rounds = {}",
640            est.state(),
641            est.round_count
642        );
643    }
644
645    // ── New tests for Phase 5 improvements ──
646
647    #[test]
648    fn test_inflight_tracking() {
649        let mut est = BandwidthEstimator::new();
650
651        // Send 3 packets
652        est.on_send(1400);
653        est.on_send(1400);
654        est.on_send(1400);
655        assert_eq!(est.inflight_bytes(), 4200);
656
657        // ACK 1
658        let now = Instant::now();
659        est.on_ack(make_sample(now, 10, 1400));
660        assert_eq!(est.inflight_bytes(), 2800);
661
662        // Loss 1
663        est.on_loss(1400);
664        assert_eq!(est.inflight_bytes(), 1400);
665
666        // ACK last
667        est.on_ack(make_sample(now + Duration::from_millis(10), 10, 1400));
668        assert_eq!(est.inflight_bytes(), 0);
669    }
670
671    #[test]
672    fn test_inflight_cant_go_negative() {
673        let mut est = BandwidthEstimator::new();
674        est.on_loss(5000);
675        assert_eq!(est.inflight_bytes(), 0); // saturating_sub
676    }
677
678    #[test]
679    fn test_app_limited_filtering() {
680        let mut est = BandwidthEstimator::new();
681        let now = Instant::now();
682
683        // Feed real bandwidth samples first (1Mbps)
684        for i in 0..5 {
685            let sent = now + Duration::from_millis(i * 10);
686            est.on_send(1400);
687            est.on_ack(make_sample(sent, 10, 1400));
688        }
689        let real_bw = est.bottleneck_bandwidth();
690        assert!(real_bw > 0);
691
692        // Now feed app-limited samples with very low bandwidth
693        // These should NOT reduce the BW estimate
694        est.set_app_limited();
695        assert!(est.is_app_limited());
696
697        for i in 5..10 {
698            let sent = now + Duration::from_millis(i * 1000);
699            est.on_ack(make_app_limited_sample(sent, 1000, 100)); // very slow
700        }
701
702        // BW should NOT have decreased
703        assert!(
704            est.bottleneck_bandwidth() >= real_bw,
705            "BW should not decrease from app-limited samples: {} < {}",
706            est.bottleneck_bandwidth(),
707            real_bw
708        );
709    }
710
711    #[test]
712    fn test_drain_waits_for_bdp() {
713        let mut est = BandwidthEstimator::new();
714        let now = Instant::now();
715
716        // Drive into Drain state
717        for i in 0..20 {
718            let sent = now + Duration::from_millis(i * 10);
719            est.on_send(1400);
720            est.on_ack(make_sample(sent, 10, 1400));
721        }
722
723        // Artificially set high inflight
724        if est.state() == BbrState::Drain {
725            // Add lots of inflight
726            est.inflight_bytes = est.bdp() * 3;
727            let sent = now + Duration::from_millis(300);
728            est.on_ack(make_sample(sent, 10, 1400));
729            // Should still be in Drain (inflight > BDP)
730            if est.inflight_bytes > est.bdp() {
731                assert_eq!(
732                    est.state(),
733                    BbrState::Drain,
734                    "should stay in Drain while inflight ({}) > BDP ({})",
735                    est.inflight_bytes,
736                    est.bdp()
737                );
738            }
739        }
740    }
741
742    #[test]
743    fn test_bdp_calculation() {
744        let mut est = BandwidthEstimator::new();
745        let now = Instant::now();
746
747        // Feed samples: 1400 bytes / 10ms = 140,000 bytes/sec
748        for i in 0..5 {
749            let sent = now + Duration::from_millis(i * 10);
750            est.on_send(1400);
751            est.on_ack(make_sample(sent, 10, 1400));
752        }
753
754        let bdp = est.bdp();
755        // BDP = btl_bw * min_rtt
756        // btl_bw ≈ 140,000 B/s, min_rtt ≈ 10ms
757        // BDP ≈ 140,000 * 0.01 = 1,400 bytes
758        assert!(bdp > 0, "BDP should be positive, got {}", bdp);
759    }
760
761    #[test]
762    fn test_cwnd_minimum_in_probe_rtt() {
763        let mut est = BandwidthEstimator::new();
764        // Force ProbeRTT state
765        est.state = BbrState::ProbeRTT;
766        let cwnd = est.cwnd();
767        assert_eq!(
768            cwnd,
769            PROBE_RTT_CWND_PACKETS * MIN_PACKET_SIZE,
770            "ProbeRTT CWND should be {} (4 packets), got {}",
771            PROBE_RTT_CWND_PACKETS * MIN_PACKET_SIZE,
772            cwnd
773        );
774    }
775}