use std::collections::VecDeque; use std::fmt; use std::time::{Duration, Instant}; use super::super::macros::log_probe_bitrate_estimate; use super::ProbeClusterConfig; use crate::rtp_::{Bitrate, DataSize, TwccClusterId, TwccSendRecord}; use crate::util::not_happening; /// Minimum ratio of packets we need to receive for a valid probe (80%). const MIN_RECEIVED_PROBES_RATIO: f64 = 0.80; /// Minimum ratio of bytes we need to receive for a valid probe (80%). const MIN_RECEIVED_BYTES_RATIO: f64 = 0.80; /// Minimum packet count for a valid probe cluster (WebRTC's kMinClusterSize). const MIN_CLUSTER_SIZE: usize = 4; /// The maximum valid duration between first and last probe packet on send/receive side. /// Matches WebRTC's `kMaxProbeInterval` in `probe_bitrate_estimator.cc`. const MAX_PROBE_INTERVAL: Duration = Duration::from_secs(1); /// The maximum |receive rate| / |send rate| ratio for a valid estimate. /// Matches WebRTC's `kMaxValidRatio`. const MAX_VALID_RATIO: f64 = 2.0; /// Minimum |receive rate| / |send rate| ratio to consider the link unsaturated. /// Matches WebRTC's `kMinRatioForUnsaturatedLink`. const MIN_RATIO_FOR_UNSATURATED_LINK: f64 = 0.9; /// Target utilization when we believe we've found the true capacity. /// Matches WebRTC's `kTargetUtilizationFraction`. const TARGET_UTILIZATION_FRACTION: f64 = 0.95; /// Maximum number of active probes before we trigger cleanup. const MAX_ACTIVE_PROBES: usize = 20; /// Probes older than this are considered stale and will be removed when hitting the cap. const STALE_PROBE_THRESHOLD: Duration = Duration::from_secs(5); /// Analyzes probe cluster results from TWCC feedback. /// /// This component takes packets tagged with a `TwccClusterId` and calculates the /// achieved bitrate for each probe cluster. /// /// **Important:** This follows WebRTC's `ProbeBitrateEstimator` semantics: /// only packets with a known remote receive timestamp are included in the probe /// result. Probe packets reported as lost (no remote receive timestamp) are ignored. #[derive(Debug)] pub struct ProbeEstimator { /// Active probe states (VecDeque for efficient front removal). states: VecDeque, /// Clusters that were updated in the last call to `update`. did_update: VecDeque, } #[derive(Debug)] struct ProbeEstimatorState { /// Configuration of the active probe (targets for validation). config: ProbeClusterConfig, /// When this probe was created. created_at: Instant, /// When to erase this cluster's state (cluster history expiry). finalize_at: Instant, /// First (earliest) send time among packets included in this probe. first_send_time: Option, /// Last (latest) send time among packets included in this probe. last_send_time: Option, /// Size of the packet with the last send time (excluded from send-rate calculation). size_last_send: DataSize, /// First (earliest) receive time among packets included in this probe. first_recv_time: Option, /// Last (latest) receive time among packets included in this probe. last_recv_time: Option, /// Size of the packet with the first receive time (excluded from receive-rate calculation). size_first_receive: DataSize, /// Total bytes for packets included in this probe (received packets only). total_bytes: DataSize, /// Number of packets included in this probe (received packets only). packet_count: usize, } impl ProbeEstimator { pub fn new() -> Self { Self { states: VecDeque::new(), did_update: VecDeque::with_capacity(10), } } /// Start analyzing a new probe cluster. /// /// Resets all accumulated state and begins watching for packets with the /// given cluster ID. Returns `true` if the probe was started, `false` if /// it was rejected due to too many active probes. pub fn probe_start(&mut self, config: ProbeClusterConfig, now: Instant) -> bool { // Under normal operation, we expect at most 2-4 active probes: // - Initial exponential probing: 2 probes (3×, 6×) // - Further probing: 1-2 additional probes // - Allocation/recovery probing: 1-2 more // Even with rapid probe sequences and 1-second cleanup delay, we shouldn't // accumulate more than ~8 probes. If we hit 20, it indicates a bug in probe // lifecycle management (missing end_probe() calls or handle_timeout() not running). // If we've hit the cap, try to clean up stale probes first. if self.states.len() >= MAX_ACTIVE_PROBES { let before_cleanup = self.states.len(); self.states .retain(|s| now.saturating_duration_since(s.created_at) < STALE_PROBE_THRESHOLD); let removed = before_cleanup - self.states.len(); if removed > 0 { debug!( removed, remaining = self.states.len(), "Cleaned up stale probes" ); } // If still at cap after cleanup, reject the new probe. if self.states.len() >= MAX_ACTIVE_PROBES { debug!("Rejecting new probe: too many active probes with none stale"); return false; } } self.states.push_back(ProbeEstimatorState::new(config, now)); true } /// Process TWCC feedback records. /// /// Only accumulates packets that match the active cluster ID. All other /// packets are ignored. pub fn update<'t>( &mut self, records: impl Iterator, ) -> impl Iterator + '_ { // Keep track of which clusters were updated in this call. self.did_update.clear(); for record in records { let Some(cluster) = record.cluster() else { continue; }; // Find the state for this cluster. let maybe_state = self .states .iter_mut() .find(|s| s.config.cluster() == cluster); let Some(state) = maybe_state else { continue; }; let did_update = state.update(record); if did_update { // The correct behavior is that the _last updated_ // is emitted last, so that the consumer of the returned // iterator gets the latest probe result last. self.did_update.retain(|c| *c != cluster); self.did_update.push_back(cluster); } } self.did_update .iter() .filter_map(|cluster| self.states.iter().find(|s| s.config.cluster() == *cluster)) .filter_map(|s| s.calculate_bitrate()) } /// Mark the probe as ended. /// /// The probe will continue collecting feedback during a cluster history /// period after the probe is finished. This period must be shorter than /// the time between probe clusters to avoid overlap. pub fn end_probe(&mut self, now: Instant, cluster_id: TwccClusterId) { let maybe_state = self .states .iter_mut() .find(|s| s.config.cluster() == cluster_id); let Some(state) = maybe_state else { return; }; state.end_probe(now); } pub fn poll_timeout(&self) -> Instant { self.states .iter() .map(|s| s.finalize_at) .min() .unwrap_or(not_happening()) } /// Finalize probes that are ready. pub fn handle_timeout(&mut self, now: Instant) { self.states.retain(|s| { let do_keep = now < s.finalize_at; if do_keep { return true; } let result = s.do_calculate_bitrate(); if let ProbeResult::Estimate(_) = result { // Already logged in calculate_bitrate() during update(). } else { // Log the final rejection reason for the probe. trace!(%result, "Probe result"); } false }); } /// Clear all active probes. /// /// This should be called when probing is no longer possible. pub fn clear_probes(&mut self) { self.states.clear(); } } impl ProbeEstimatorState { pub fn new(config: ProbeClusterConfig, now: Instant) -> Self { Self { config, created_at: now, finalize_at: not_happening(), first_send_time: None, last_send_time: None, size_last_send: DataSize::ZERO, first_recv_time: None, last_recv_time: None, size_first_receive: DataSize::ZERO, total_bytes: DataSize::ZERO, packet_count: 0, } } fn update(&mut self, record: &TwccSendRecord) -> bool { // Only packets with a known remote receive time participate in probe estimation. let Some(recv_time) = record.remote_recv_time() else { return false; // lost/unreceived packet -> ignore for probe result }; let packet_size = DataSize::from(record.size()); let send_time = record.local_send_time(); // Track min/max send time among included packets. let first = self.first_send_time.get_or_insert(send_time); *first = (*first).min(send_time); let last = self.last_send_time.get_or_insert(send_time); if send_time >= *last { *last = send_time; self.size_last_send = packet_size; } // Track min/max receive time among included packets. let first_recv = self.first_recv_time.get_or_insert(recv_time); if recv_time <= *first_recv { *first_recv = recv_time; self.size_first_receive = packet_size; } let last_recv = self.last_recv_time.get_or_insert(recv_time); *last_recv = (*last_recv).max(recv_time); self.total_bytes += packet_size; self.packet_count += 1; true } fn calculate_bitrate(&self) -> Option<(ProbeClusterConfig, Bitrate)> { let result = self.do_calculate_bitrate(); let ProbeResult::Estimate(bitrate) = result else { return None; }; // Log the estimates continuously during the probe. trace!(%result, "Probe result"); log_probe_bitrate_estimate!(bitrate.as_f64()); Some((self.config, bitrate)) } /// Calculate the estimated bitrate for this probe cluster. fn do_calculate_bitrate(&self) -> ProbeResult { // WebRTC requires at least kMinClusterSize (4) packets received. // We may send more, but packet loss can result in fewer received packets. if self.packet_count < MIN_CLUSTER_SIZE { return ProbeResult::ClusterTooSmall { recv: self.packet_count, limit: MIN_CLUSTER_SIZE, }; } // Also check we received enough of what was sent let min_packets = (self.config.min_packet_count() as f64 * MIN_RECEIVED_PROBES_RATIO) as usize; let min_bytes = DataSize::bytes( (self.config.target_bytes().as_bytes_usize() as f64 * MIN_RECEIVED_BYTES_RATIO) as i64, ); if self.packet_count < min_packets { return ProbeResult::InsufficientPackets { recv: self.packet_count, limit: min_packets, }; } if self.total_bytes < min_bytes { return ProbeResult::InsufficientBytes { recv: self.total_bytes, limit: min_bytes, }; } // Get timing bounds let Some(first_send) = self.first_send_time else { return ProbeResult::MissingTimingInfo; }; let Some(last_send) = self.last_send_time else { return ProbeResult::MissingTimingInfo; }; let send_interval = last_send.saturating_duration_since(first_send); let Some(first_recv) = self.first_recv_time else { return ProbeResult::MissingTimingInfo; }; let Some(last_recv) = self.last_recv_time else { return ProbeResult::MissingTimingInfo; }; let recv_interval = last_recv.saturating_duration_since(first_recv); // Intervals must be positive and within bounds. if send_interval.is_zero() { return ProbeResult::SendIntervalInvalid { interval: send_interval, }; } if send_interval > MAX_PROBE_INTERVAL { return ProbeResult::SendIntervalTooLong { interval: send_interval, }; } if recv_interval.is_zero() || recv_interval > MAX_PROBE_INTERVAL { return ProbeResult::RecvIntervalInvalid { interval: recv_interval, }; } // WebRTC boundary exclusions: // - exclude the last sent packet size when computing send rate // - exclude the first received packet size when computing receive rate let send_size = self.total_bytes.saturating_sub(self.size_last_send); let recv_size = self.total_bytes.saturating_sub(self.size_first_receive); if send_size <= DataSize::ZERO || recv_size <= DataSize::ZERO { return ProbeResult::InvalidDataSize; } let recv_rate = recv_size / recv_interval; let send_rate = send_size / send_interval; // WebRTC validation: reject if receive/send ratio is too high. let ratio = recv_rate.as_f64() / send_rate.as_f64(); if ratio > MAX_VALID_RATIO { return ProbeResult::InvalidSendReceiveRatio { ratio, limit: MAX_VALID_RATIO, }; } // Match WebRTC semantics: // - estimate is the min(send_rate, recv_rate) // - if recv_rate is significantly lower than send_rate, assume saturation and // return a conservative fraction of recv_rate. let mut estimate = send_rate.min(recv_rate); if recv_rate < send_rate * MIN_RATIO_FOR_UNSATURATED_LINK { estimate = recv_rate * TARGET_UTILIZATION_FRACTION; } ProbeResult::Estimate(estimate) } fn end_probe(&mut self, now: Instant) { self.finalize_at = now + Duration::from_secs(1); } } /// Result of a probe cluster estimation. #[derive(Debug, Clone, Copy, PartialEq)] enum ProbeResult { /// Successfully estimated bitrate Estimate(Bitrate), /// Not enough packets in cluster (< 4) ClusterTooSmall { recv: usize, limit: usize }, /// Insufficient packets received (< 80% of sent) InsufficientPackets { recv: usize, limit: usize }, /// Insufficient bytes received (< 80% of sent) InsufficientBytes { recv: DataSize, limit: DataSize }, /// Send interval too long (> 1 second) SendIntervalTooLong { interval: Duration }, /// Send interval invalid (zero) SendIntervalInvalid { interval: Duration }, /// Receive interval invalid (zero or > 1 second) RecvIntervalInvalid { interval: Duration }, /// Invalid receive/send ratio (recv_rate / send_rate too high) InvalidSendReceiveRatio { ratio: f64, limit: f64 }, /// Calculated data size is zero InvalidDataSize, /// Missing timing information MissingTimingInfo, } impl fmt::Display for ProbeResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ProbeResult::Estimate(bitrate) => write!(f, "estimate={}", bitrate), ProbeResult::ClusterTooSmall { recv: received, limit: required, } => { write!(f, "cluster too small ({} < {})", received, required) } ProbeResult::InsufficientPackets { recv: received, limit: required, } => { write!(f, "insufficient packets ({} < {})", received, required) } ProbeResult::InsufficientBytes { recv: received, limit: required, } => { write!(f, "insufficient bytes ({} < {})", received, required) } ProbeResult::SendIntervalTooLong { interval } => { write!(f, "send interval too long ({:?})", interval) } ProbeResult::SendIntervalInvalid { interval } => { write!(f, "send interval invalid ({:?})", interval) } ProbeResult::RecvIntervalInvalid { interval } => { write!(f, "recv interval invalid ({:?})", interval) } ProbeResult::InvalidSendReceiveRatio { ratio, limit } => { write!(f, "invalid receive/send ratio ({ratio:.3} > {limit:.3})") } ProbeResult::InvalidDataSize => write!(f, "invalid data size"), ProbeResult::MissingTimingInfo => write!(f, "missing timing info"), } } } #[cfg(test)] mod test { use super::*; use crate::bwe_::probe::ProbeKind; use crate::rtp_::{TwccPacketId, TwccSeq}; #[test] fn probe_estimator_starts_with_no_active_probe() { let estimator = ProbeEstimator::new(); assert_eq!(estimator.poll_timeout(), not_happening()); } #[test] fn probe_estimator_lifecycle() { let mut estimator = ProbeEstimator::new(); let now = Instant::now(); // Start probe let config = ProbeClusterConfig::new(1.into(), Bitrate::mbps(2), ProbeKind::Initial); assert!(estimator.probe_start(config, now)); assert!(estimator.states.len() == 1, "Should have one active probe"); assert_eq!(estimator.poll_timeout(), not_happening()); // End probe with 1 second cluster history retention estimator.end_probe(now, config.cluster()); let timeout = estimator.poll_timeout(); assert!( timeout > now && timeout <= now + Duration::from_secs(1), "Expected timeout between now and now+1s, got: {:?}", timeout.duration_since(now) ); // Handle timeout clears expired probes estimator.handle_timeout(now + Duration::from_secs(1)); assert!(estimator.states.is_empty(), "All probes should be cleared"); assert_eq!(estimator.poll_timeout(), not_happening()); } #[test] fn lost_probe_packets_do_not_affect_estimate() { let mut estimator = ProbeEstimator::new(); let cluster: TwccClusterId = 7.into(); let config = ProbeClusterConfig::new(cluster, Bitrate::mbps(2), ProbeKind::Initial); let base = Instant::now(); let received = (0..5).map(|i| { let seq: TwccSeq = (1000 + i).into(); let pid = TwccPacketId::with_cluster(seq, cluster); // send spaced 4ms, recv spaced 4ms (same ordering) crate::rtp_::TwccSendRecord::test_new( pid, base + Duration::from_millis(i as u64 * 4), 1200, base + Duration::from_millis(i as u64 * 4 + 1), Some(base + Duration::from_millis(i as u64 * 4 + 2)), ) }); // Add extra lost probe packets with later send times. These should not change the result. let lost = (0..20).map(|i| { let seq: TwccSeq = (2000 + i).into(); let pid = TwccPacketId::with_cluster(seq, cluster); crate::rtp_::TwccSendRecord::test_new( pid, base + Duration::from_millis(100 + i as u64), 1200, base + Duration::from_millis(150 + i as u64), None, // lost ) }); // First run: only received packets estimator.probe_start(config, base); let recv_vec: Vec<_> = received.collect(); let results: Vec<_> = estimator.update(recv_vec.iter()).collect(); let estimate_only_received = results .last() .map(|(_, bitrate)| *bitrate) .expect("expected a probe estimate"); // Second run: received + lost let mut estimator2 = ProbeEstimator::new(); estimator2.probe_start(config, base); let mut all_vec = recv_vec; all_vec.extend(lost); let results: Vec<_> = estimator2.update(all_vec.iter()).collect(); let estimate_with_lost = results .last() .map(|(_, bitrate)| *bitrate) .expect("expected a probe estimate"); assert_eq!( estimate_only_received, estimate_with_lost, "lost packets must not change probe estimate" ); } #[test] fn invalid_receive_send_ratio_is_rejected() { let mut estimator = ProbeEstimator::new(); let cluster: TwccClusterId = 9.into(); let config = ProbeClusterConfig::new(cluster, Bitrate::mbps(2), ProbeKind::Initial); let base = Instant::now(); // Make send times span 200ms, but receive times span only 1ms. // This yields receive_rate >> send_rate. WebRTC would reject this via ratio check. let records: Vec<_> = (0..5) .map(|i| { let seq: TwccSeq = (3000 + i).into(); let pid = TwccPacketId::with_cluster(seq, cluster); crate::rtp_::TwccSendRecord::test_new( pid, base + Duration::from_millis(i as u64 * 50), 1200, base + Duration::from_millis(250 + i as u64), Some(base + Duration::from_millis(300 + (i as u64 % 2))), // ~0-1ms spread ) }) .collect(); estimator.probe_start(config, base); let results: Vec<_> = estimator.update(records.iter()).collect(); assert!( results.is_empty(), "probe should be rejected by ratio validation, got: {:?}", results ); } #[test] fn send_interval_zero_is_rejected() { let mut estimator = ProbeEstimator::new(); let cluster: TwccClusterId = 10.into(); let config = ProbeClusterConfig::new(cluster, Bitrate::mbps(2), ProbeKind::Initial); let base = Instant::now(); // All packets have the same send time -> send_interval == 0. let records: Vec<_> = (0..5) .map(|i| { let seq: TwccSeq = (4000 + i).into(); let pid = TwccPacketId::with_cluster(seq, cluster); crate::rtp_::TwccSendRecord::test_new( pid, base, // identical send time for all 1200, base + Duration::from_millis(10 + i as u64), Some(base + Duration::from_millis(20 + i as u64)), ) }) .collect(); estimator.probe_start(config, base); let results: Vec<_> = estimator.update(records.iter()).collect(); assert!( results.is_empty(), "send_interval == 0 should be rejected, got: {:?}", results ); } #[test] fn stale_probes_cleaned_up_at_capacity() { let mut estimator = ProbeEstimator::new(); let base = Instant::now(); // Fill up to MAX_ACTIVE_PROBES with probes created at `base` for i in 0..MAX_ACTIVE_PROBES { let config = ProbeClusterConfig::new((i as u64).into(), Bitrate::mbps(2), ProbeKind::Initial); assert!( estimator.probe_start(config, base), "should accept probe {i}" ); } assert_eq!(estimator.states.len(), MAX_ACTIVE_PROBES); // Try to add another probe at the same time - should be rejected let config = ProbeClusterConfig::new(100.into(), Bitrate::mbps(2), ProbeKind::Initial); assert!( !estimator.probe_start(config, base), "should reject probe when at capacity with no stale probes" ); assert_eq!(estimator.states.len(), MAX_ACTIVE_PROBES); // Now try adding a probe 6 seconds later - all existing probes are stale let later = base + Duration::from_secs(6); let config = ProbeClusterConfig::new(101.into(), Bitrate::mbps(2), ProbeKind::Initial); assert!( estimator.probe_start(config, later), "should accept probe after cleaning stale ones" ); // All old probes should be cleaned, leaving only the new one assert_eq!(estimator.states.len(), 1); } #[test] fn non_stale_probes_preserved_during_cleanup() { let mut estimator = ProbeEstimator::new(); let base = Instant::now(); // Add some old probes for i in 0..15 { let config = ProbeClusterConfig::new((i as u64).into(), Bitrate::mbps(2), ProbeKind::Initial); estimator.probe_start(config, base); } // Add some newer probes (3 seconds later, still within threshold) let mid = base + Duration::from_secs(3); for i in 15..MAX_ACTIVE_PROBES { let config = ProbeClusterConfig::new((i as u64).into(), Bitrate::mbps(2), ProbeKind::Initial); estimator.probe_start(config, mid); } assert_eq!(estimator.states.len(), MAX_ACTIVE_PROBES); // Try to add at 6 seconds - old probes are stale, mid probes are not let later = base + Duration::from_secs(6); let config = ProbeClusterConfig::new(100.into(), Bitrate::mbps(2), ProbeKind::Initial); assert!( estimator.probe_start(config, later), "should accept after cleaning only stale probes" ); // 15 old probes removed, 5 mid probes kept, 1 new probe added = 6 assert_eq!(estimator.states.len(), 6); } }