#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] pubenum Phase { /// In either slow start or congestion avoidance, not recovery. #[strum(to_string = "slow_start")]
SlowStart, /// In congestion avoidance. #[strum(to_string = "congestion_avoidance")]
CongestionAvoidance, /// In a recovery period, but no packets have been sent yet. This is a /// transient phase because we want to exempt the first packet sent after /// entering recovery from the congestion window. #[strum(to_string = "recovery")]
RecoveryStart, /// In a recovery period, with the first packet sent at this time. #[strum(to_string = "recovery")]
Recovery, /// Start of persistent congestion, which is transient, like `RecoveryStart`. #[strum(to_string = "slow_start")]
PersistentCongestion,
}
/// These states are transient, we tell qlog on entry, but not on exit. pubconstfn transient(self) -> bool {
matches!(self, Self::RecoveryStart | Self::PersistentCongestion)
}
/// Update a transient phase to the actual phase. pubfn update(&mutself) {
*self = matchself { Self::PersistentCongestion => Self::SlowStart, Self::RecoveryStart => Self::Recovery,
_ => unreachable!(),
};
}
}
pubtrait WindowAdjustment: Display + Debug { /// This is called when an ack is received. /// The function calculates the amount of acked bytes congestion controller needs /// to collect before increasing its cwnd by `MAX_DATAGRAM_SIZE`. fn bytes_for_cwnd_increase(
&mutself,
curr_cwnd: usize,
new_acked_bytes: usize,
min_rtt: Duration,
max_datagram_size: usize,
now: Instant,
) -> usize; /// This function is called when a congestion event has been detected and it /// returns new (decreased) values of `curr_cwnd` and `acked_bytes`. /// This value can be very small; the calling code is responsible for ensuring that the /// congestion window doesn't drop below the minimum of `CWND_MIN`. fn reduce_cwnd(
&mutself,
curr_cwnd: usize,
acked_bytes: usize,
max_datagram_size: usize,
congestion_trigger: CongestionTrigger,
cc_stats: &mut CongestionControlStats,
) -> (usize, usize); /// Cubic needs this signal to reset its epoch. fn on_app_limited(&mutself); /// Store the current congestion controller state, to be recovered in the case of a spurious /// congestion event. fn save_undo_state(&mutself);
/// Restore the previously stored congestion controller state, to recover from a spurious /// congestion event. fn restore_undo_state(&mutself, cc_stats: &mut CongestionControlStats);
}
/// Trait for slow start exit algorithms. /// /// Implementations define when and if to exit from slow start, how the slow start threshold /// (`ssthresh`) is set on exit and they can influence how fast the exponential congestion window /// growth rate during slow start is. pubtrait SlowStart: Display + Debug { /// Enables a trait implementor to ingest info about sent packets. fn on_packet_sent(&mutself, _sent_pn: packet::Number, _sent_bytes: usize) {}
/// This is needed by SEARCH to keep its cumulative byte counters in sync during app-limited /// periods, when [`SlowStart::on_packets_acked`] is not called. fn record_acked_bytes(&mutself, _new_acked_bytes: usize) {}
/// Handle packets being acknowledged during slow start. Returns the congestion window in bytes /// that slow start should be exited with. If slow start isn't exited returns `None`. fn on_packets_acked(
&mutself,
rtt_est: &RttEstimate,
largest_acked: packet::Number,
curr_cwnd: usize,
cc_stats: &mut CongestionControlStats,
now: Instant,
) -> Option<usize>;
/// Calculates the congestion window increase in bytes during slow start. The default /// implementation returns `new_acked`, i.e. classic exponential slow start growth. fn calc_cwnd_increase(&self, new_acked: usize, _max_datagram_size: usize) -> usize {
new_acked
}
/// Resets slow start state. Is used after persistent congestion so slow start algorithms /// perform cleanly in non-initial slow starts. Can also be used by the implementing algorithm /// for internal state reset when needed. fn reset(&mutself) {}
}
#[derive(Debug, Clone)] struct State {
phase: Phase,
congestion_window: usize,
acked_bytes: usize,
ssthresh: Option<usize>, /// Packet number of the first packet that was sent after a congestion event. When this one is /// acked we will exit [`Phase::Recovery`] and enter [`Phase::CongestionAvoidance`].
recovery_start: Option<packet::Number>,
}
#[derive(Debug)] pubstruct ClassicCongestionController<S, T> {
slow_start: S,
congestion_control: T,
bytes_in_flight: usize, /// Packets that have supposedly been lost. These are used for spurious congestion event /// detection. Gets drained when the same packets are later acked and regularly purged from too /// old packets in [`Self::cleanup_maybe_lost_packets`]. Needs a tuple of `(packet::Number, /// packet::Type)` to identify packets across packet number spaces.
maybe_lost_packets: HashMap<(packet::Number, packet::Type), MaybeLostPacket>, /// `first_app_limited` indicates the packet number after which the application might be /// underutilizing the congestion window. When underutilizing the congestion window due to not /// sending out enough data, we SHOULD NOT increase the congestion window.[1] Packets sent /// before this point are deemed to fully utilize the congestion window and count towards /// increasing the congestion window. /// /// [1]: https://datatracker.ietf.org/doc/html/rfc9002#section-7.8
first_app_limited: Option<packet::Number>,
pmtud: Pmtud,
qlog: Qlog, /// Current congestion controller parameters.
current: State, /// Congestion controller parameters that were stored on a congestion event to restore prior /// state in case the congestion event turns out to be spurious. /// /// For reference: /// - [`State::acked_bytes`] is stored because that is where we accumulate our window increase /// credit and it is also reduced on a congestion event. /// - [`Self::bytes_in_flight`] is not stored because if it was to be restored it might get /// out-of-sync with the actual number of bytes-in-flight on the path.
stored: Option<State>, /// Whether to recover from spurious congestion events by restoring prior state.
spurious_recovery: bool,
}
fn cwnd_avail(&self) -> usize { // BIF can be higher than cwnd due to PTO packets, which are sent even // if avail is 0, but still count towards BIF. self.current
.congestion_window
.saturating_sub(self.bytes_in_flight)
}
#[expect(
clippy::too_many_lines,
reason = "The main congestion control function contains a lot of logic."
)] fn on_packets_acked(
&mutself,
acked_pkts: &[sent::Packet],
rtt_est: &RttEstimate,
now: Instant,
cc_stats: &mut CongestionControlStats,
) { letmut is_app_limited = true; letmut new_acked = 0; let largest_packet_acked = acked_pkts
.first()
.expect("`acked_pkts.first().is_some()` is checked in `Loss::on_ack_received`");
// Initialize the stat to the initial congestion window value. If we early return on // `is_app_limited` the stat is never set on very short connections otherwise.
cc_stats.cwnd.get_or_insert(self.current.congestion_window);
// Supplying `true` for `rtt_est.pto(true)` here is best effort not to have to track // `recovery::Loss::confirmed()` all the way down to the congestion controller. Having too // big a PTO does no harm here. self.cleanup_maybe_lost_packets(now, rtt_est.pto(true));
for pkt in acked_pkts {
qtrace!( "packet_acked this={self:p}, pn={}, ps={}, ignored={}, lost={}, rtt_est={rtt_est:?}",
pkt.pn(),
pkt.len(),
i32::from(!pkt.cc_outstanding()),
i32::from(pkt.lost()),
); if !pkt.cc_outstanding() { continue;
} ifself.first_app_limited.is_some_and(|f| pkt.pn() < f) {
is_app_limited = false;
} // BIF is set to 0 on a path change, but in case that was because of a simple rebinding // event, we may still get ACKs for packets sent before the rebinding. self.bytes_in_flight = self.bytes_in_flight.saturating_sub(pkt.len());
if !self.after_recovery_start(pkt) { // Do not increase congestion window for packets sent before // recovery last started. continue;
}
// This can only happen after persistent congestion when we re-enter slow start // while having a previously established `ssthresh` which has now // been reached. iflet Some(ssthresh) = self.current.ssthresh
&& self.current.congestion_window >= ssthresh
{
qdebug!( "Exited slow start because the threshold was reached, ssthresh: {ssthresh}",
); // Clamp congestion window to ssthresh. self.current.congestion_window = ssthresh; self.set_phase(Phase::CongestionAvoidance, None, now);
}
}
}
// Congestion avoidance, above the slow start threshold. ifself
.current
.ssthresh
.is_some_and(|s| self.current.congestion_window >= s)
{ // The following function return the amount acked bytes a controller needs // to collect to be allowed to increase its cwnd by MAX_DATAGRAM_SIZE. let bytes_for_increase = self.congestion_control.bytes_for_cwnd_increase( self.current.congestion_window,
new_acked,
rtt_est.minimum(), self.max_datagram_size(),
now,
);
debug_assert!(bytes_for_increase > 0); // If enough credit has been accumulated already, apply them gradually. // If we have sudden increase in allowed rate we actually increase cwnd gently. ifself.current.acked_bytes >= bytes_for_increase { self.current.acked_bytes = 0; self.current.congestion_window += self.max_datagram_size();
} self.current.acked_bytes += new_acked; ifself.current.acked_bytes >= bytes_for_increase { self.current.acked_bytes -= bytes_for_increase; self.current.congestion_window += self.max_datagram_size(); // or is this the current MTU?
} // The number of bytes we require can go down over time with Cubic. // That might result in an excessive rate of increase, so limit the number of unused // acknowledged bytes after increasing the congestion window twice. self.current.acked_bytes = min(bytes_for_increase, self.current.acked_bytes);
}
/// Update congestion controller state based on lost packets. fn on_packets_lost(
&mutself,
first_rtt_sample_time: Option<Instant>,
prev_largest_acked_sent: Option<Instant>,
pto: Duration,
lost_packets: &[sent::Packet],
now: Instant,
cc_stats: &mut CongestionControlStats,
) -> bool { if lost_packets.is_empty() { returnfalse;
}
for pkt in lost_packets { if pkt.cc_in_flight() {
qdebug!( "packet_lost this={self:p}, pn={}, ps={}",
pkt.pn(),
pkt.len()
); // bytes_in_flight is set to 0 on a path change, but in case that was because of a // simple rebinding event, we may still declare packets lost that // were sent before the rebinding. self.bytes_in_flight = self.bytes_in_flight.saturating_sub(pkt.len()); if !pkt.is_pmtud_probe() { let present = self.maybe_lost_packets.insert(
(pkt.pn(), pkt.packet_type()),
MaybeLostPacket {
time_sent: pkt.time_sent(),
},
);
qdebug!( "Spurious detection: added MaybeLostPacket: pn {}, type {:?}, time_sent {:?}",
pkt.pn(),
pkt.packet_type(),
pkt.time_sent()
);
debug_assert!(present.is_none());
}
}
}
// Lost PMTUD probes do not elicit congestion control reactions, so this closure filters // them out. let lost_packets_no_pmtud = || lost_packets.iter().filter(|pkt| !pkt.is_pmtud_probe());
// Packets not in flight don't elicit congestion control reactions either, so we early // return if there is no lost in-flight packet left. let Some(last_lost_packet) = lost_packets_no_pmtud().rfind(|pkt| pkt.cc_in_flight()) else { returnfalse;
};
let congestion = self.on_congestion_event(last_lost_packet, Loss, now, cc_stats); // Persistent congestion checks still need to see lost packets that are not in-flight for // continuity checks. That is why only the closure to filter out lost PMTUD probes is used. let persistent_congestion = self.detect_persistent_congestion(
first_rtt_sample_time,
prev_largest_acked_sent,
pto,
lost_packets_no_pmtud(),
now,
cc_stats,
);
qdebug!( "on_packets_lost this={self:p}, bytes_in_flight={}, cwnd={}, phase={:?}", self.bytes_in_flight, self.current.congestion_window, self.current.phase
);
congestion || persistent_congestion
}
/// Report received ECN CE mark(s) to the congestion controller as a /// congestion event. /// /// See <https://datatracker.ietf.org/doc/html/rfc9002#section-b.7>. fn on_ecn_ce_received(
&mutself,
largest_acked_pkt: &sent::Packet,
now: Instant,
cc_stats: &mut CongestionControlStats,
) -> bool { self.on_congestion_event(largest_acked_pkt, Ecn, now, cc_stats)
}
fn on_packet_sent(&mutself, pkt: &sent::Packet, now: Instant, pacing_limited: bool) { // Record the recovery time and exit any transient phase. ifself.current.phase.transient() { self.current.recovery_start = Some(pkt.pn());
qdebug!("set recovery_start to pn={}", pkt.pn()); self.current.phase.update();
}
if !pkt.cc_in_flight() { return;
}
// Pass next packet number to send into slow start algorithm during slow start. ifself.current.phase.in_slow_start() { self.slow_start.on_packet_sent(pkt.pn(), pkt.len());
}
// RFC 9002 §7.8: "A sender SHOULD NOT consider itself application // limited if it would have fully utilized the congestion window without // pacing delay." When the pacer is the reason we cannot send more // right now, cwnd underutilization is from pacing, not the application. if pacing_limited || !self.app_limited() { self.first_app_limited = Some(pkt.pn() + 1);
}
/// Whether a packet can be sent immediately as a result of entering recovery. fn recovery_packet(&self) -> bool { self.current.phase == Phase::RecoveryStart
}
}
/// Accessor for [`ClassicCongestionController::congestion_control`]. Is used to call Cubic /// getters in tests. #[cfg(test)] pubconstfn congestion_control(&self) -> &T {
&self.congestion_control
}
/// Mutable accessor for [`ClassicCongestionController::congestion_control`]. Is used to call /// Cubic setters in tests. #[cfg(test)] pubconstfn congestion_control_mut(&mutself) -> &mut T {
&mutself.congestion_control
}
fn set_phase(
&mutself,
phase: Phase,
trigger: Option<qlog::CongestionStateTrigger>,
now: Instant,
) { ifself.current.phase == phase { return;
}
qdebug!("[{self}] phase -> {phase:?}"); let old_state = self.current.phase; // Only emit a qlog event when a transition changes the qlog state. if !str::eq(old_state.into(), phase.into()) {
qlog::congestion_state_updated(
&mutself.qlog,
Some(old_state.into()),
phase.into(),
trigger,
now,
);
} self.current.phase = phase;
}
// NOTE: Maybe do tracking of lost packets per congestion epoch. Right now if we get a spurious // event and then before the first was recovered get another (or even a real congestion event // because of random loss, path change, ...), it will only be detected as spurious once the old // and new lost packets are recovered. This means we'd have two spurious events counted as one // and would also only be able to recover to the cwnd prior to the second event. fn detect_spurious_congestion_event(
&mutself,
acked_packets: &[sent::Packet],
cc_stats: &mut CongestionControlStats,
) { ifself.maybe_lost_packets.is_empty() { return;
}
// Removes all newly acked packets that are late acks from `maybe_lost_packets`. for acked_packet in acked_packets { ifself
.maybe_lost_packets
.remove(&(acked_packet.pn(), acked_packet.packet_type()))
.is_some()
{
qdebug!( "Spurious detection: removed MaybeLostPacket with pn {}, type {:?}",
acked_packet.pn(),
acked_packet.packet_type(),
);
}
}
// If all of them have been removed we detected a spurious congestion event. ifself.maybe_lost_packets.is_empty() {
qdebug!( "Spurious detection: maybe_lost_packets emptied -> calling on_spurious_congestion_event"
); self.on_spurious_congestion_event(cc_stats);
}
}
/// Cleanup lost packets that we are fairly sure will never be getting a late acknowledgment /// for. fn cleanup_maybe_lost_packets(&mutself, now: Instant, pto: Duration) { // The `pto * 2` maximum age of the lost packets is taken from msquic's implementation: // <https://github.com/microsoft/msquic/blob/2623c07df62b4bd171f469fb29c2714b6735b676/src/core/loss_detection.c#L939-L943> let max_age = pto * 2; self.maybe_lost_packets.retain(|(pn, pt), packet| { let keep = now.saturating_duration_since(packet.time_sent) <= max_age; if !keep {
qdebug!( "Spurious detection: cleaned up old MaybeLostPacket with pn {pn}, type {pt:?}"
);
}
keep
});
}
fn on_spurious_congestion_event(&mutself, cc_stats: &e='color:red'>mut CongestionControlStats) { let Some(stored) = self.stored.take() else {
qdebug!( "[{self}] Spurious cong event -> ABORT, no stored params to restore available."
); return;
};
// The stat is recorded for all cases below.
cc_stats.congestion_events.spurious += 1;
self.congestion_control.restore_undo_state(cc_stats);
qdebug!( "Spurious cong event: recovering cc params from {} to {stored}", self.current
); self.current = stored;
// If we are restoring back to slow start then we should undo the stat recording. ifself.current.phase.in_slow_start() {
cc_stats.slow_start_exit_cwnd = None;
cc_stats.slow_start_exit_reason = None;
}
qinfo!("[{self}] Spurious cong event -> RESTORED;");
}
// Look for the first lost packet after the previous largest acknowledged. // Ignore packets that weren't ack-eliciting for the start of this range. // Also, make sure to ignore any packets sent before we got an RTT estimate // as we might not have sent PTO packets soon enough after those. let cutoff = max(first_rtt_sample_time, prev_largest_acked_sent); for p in lost_packets
.into_iter()
.skip_while(|p| Some(p.time_sent()) < cutoff)
{ if last_pn.is_none_or(|l| p.pn() != l + 1) { // Not a contiguous range of lost packets, start over.
start = None;
}
last_pn = Some(p.pn()); if !p.cc_in_flight() { // Not interesting, keep looking. continue;
} iflet Some(t) = start { let elapsed = p
.time_sent()
.checked_duration_since(t)
.expect("time is monotonic"); if elapsed > pc_period {
qinfo!("[{self}] persistent congestion"); self.current.congestion_window = self.cwnd_min(); self.current.acked_bytes = 0; self.set_phase(
Phase::PersistentCongestion,
Some(qlog::CongestionStateTrigger::PersistentCongestion),
now,
); // We re-enter slow start after persistent congestion, so we need to reset any // state leftover from initial slow start to have it perform correctly. self.slow_start.reset();
#[must_use] fn after_recovery_start(&self, packet: &sent::Packet) -> bool { // At the start of the recovery period, the phase is transient and // all packets will have been sent before recovery. When sending out // the first packet we transition to the non-transient `Recovery` // phase and update the variable `self.recovery_start`. Before the // first recovery, all packets were sent after the recovery event, // allowing to reduce the cwnd on congestion events.
!self.current.phase.transient()
&& self
.current
.recovery_start
.is_none_or(|pn| packet.pn() >= pn)
}
/// Handle a congestion event. /// Returns true if this was a true congestion event. fn on_congestion_event(
&mutself,
last_packet: &sent::Packet,
congestion_trigger: CongestionTrigger,
now: Instant,
cc_stats: &mut CongestionControlStats,
) -> bool { // Start a new congestion event if lost or ECN CE marked packet was sent // after the start of the previous congestion recovery period. if !self.after_recovery_start(last_packet) {
qdebug!( "Called on_congestion_event during recovery -> don't react; last_packet {}, recovery_start {}",
last_packet.pn(), self.current.recovery_start.unwrap_or(0)
); returnfalse;
}
if congestion_trigger != Ecn { self.stored = Some(self.current.clone()); self.congestion_control.save_undo_state();
}
match congestion_trigger {
Loss => cc_stats.congestion_events.loss += 1,
Ecn => cc_stats.congestion_events.ecn += 1,
}
cc_stats.cwnd = Some(self.current.congestion_window); // If we were in slow start when `on_congestion_event` was called we will exit slow start // and should record the exit congestion window. ifself.current.phase.in_slow_start() {
cc_stats.slow_start_exit_cwnd = Some(self.current.congestion_window);
cc_stats.slow_start_exit_reason = Some(SlowStartExitReason::CongestionEvent);
}
fn app_limited(&self) -> bool { ifself.bytes_in_flight >= self.current.congestion_window { false
} elseifself.current.phase.in_slow_start() { // Allow for potential doubling of the congestion window during slow start. // That is, the application might not have been able to send enough to respond // to increases to the congestion window. self.bytes_in_flight < self.current.congestion_window / 2
} else { // We're not limited if the in-flight data is within a single burst of the // congestion window.
(self.bytes_in_flight + self.max_datagram_size() * PACING_BURST_SIZE)
< self.current.congestion_window
}
}
}
#[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::time::{Duration, Instant};
use neqo_common::qinfo; use test_fixture::{new_neqo_qlog, now};
const PTO: Duration = RTT; const ZERO: Duration = Duration::from_secs(0); const EPSILON: Duration = Duration::from_nanos(1); const GAP: Duration = Duration::from_secs(1); /// The largest time between packets without causing persistent congestion. const SUB_PC: Duration = Duration::from_millis(100 * PERSISTENT_CONG_THRESH as u64); /// The minimum time between packets to cause persistent congestion. /// Uses an odd expression because `Duration` arithmetic isn't `const`. const PC: Duration = Duration::from_nanos(100_000_000 * (PERSISTENT_CONG_THRESH as u64) + 1);
fn persistent_congestion(lost_packets: &[sent::Packet], persistent_expected: bool) { let cc = make_cc_newreno(); let cwnd_initial = cc.cwnd_initial();
persistent_congestion_by_algorithm(cc, cwnd_initial / 2, lost_packets, persistent_expected);
let cc = make_cc_cubic(); let cwnd_initial = cc.cwnd_initial();
persistent_congestion_by_algorithm(
cc,
cwnd_initial * Cubic::BETA_USIZE_DIVIDEND / Cubic::BETA_USIZE_DIVISOR,
lost_packets,
persistent_expected,
);
}
/// A span of exactly the PC threshold only reduces the window on loss. #[test] fn persistent_congestion_none() {
persistent_congestion(&[lost(1, true, ZERO), lost(2, true, SUB_PC)], false);
}
/// A span of just more than the PC threshold causes persistent congestion. #[test] fn persistent_congestion_simple() {
persistent_congestion(&[lost(1, true, ZERO), lost(2, true, PC)], true);
}
/// Both packets need to be ack-eliciting. #[test] fn persistent_congestion_non_ack_eliciting() {
persistent_congestion(&[lost(1, false, ZERO), lost(2, true, PC)], false);
persistent_congestion(&[lost(1, true, ZERO), lost(2, false, PC)], false);
}
/// Packets in the middle, of any type, are OK. #[test] fn persistent_congestion_middle() {
persistent_congestion(
&[lost(1, true, ZERO), lost(2, false, RTT), lost(3, true, PC)], true,
);
persistent_congestion(
&[lost(1, true, ZERO), lost(2, true, RTT), lost(3, true, PC)], true,
);
}
/// Gaps in the middle, of any type, restart the count. #[test] fn persistent_congestion_gap_reset() {
persistent_congestion(&[lost(1, true, ZERO), lost(3, true, PC)], false);
persistent_congestion(
&[
lost(1, true, ZERO),
lost(2, true, RTT),
lost(4, true, GAP),
lost(5, true, GAP + PTO * PERSISTENT_CONG_THRESH),
], false,
);
}
/// A span either side of a gap will cause persistent congestion. #[test] fn persistent_congestion_gap_or() {
persistent_congestion(
&[
lost(1, true, ZERO),
lost(2, true, PC),
lost(4, true, GAP),
lost(5, true, GAP + PTO),
], true,
);
persistent_congestion(
&[
lost(1, true, ZERO),
lost(2, true, PTO),
lost(4, true, GAP),
lost(5, true, GAP + PC),
], true,
);
}
/// A gap only restarts after an ack-eliciting packet. #[test] fn persistent_congestion_gap_non_ack_eliciting() {
persistent_congestion(
&[
lost(1, true, ZERO),
lost(2, true, PTO),
lost(4, false, GAP),
lost(5, true, GAP + PC),
], false,
);
persistent_congestion(
&[
lost(1, true, ZERO),
lost(2, true, PTO),
lost(4, false, GAP),
lost(5, true, GAP + RTT),
lost(6, true, GAP + RTT + SUB_PC),
], false,
);
persistent_congestion(
&[
lost(1, true, ZERO),
lost(2, true, PTO),
lost(4, false, GAP),
lost(5, true, GAP + RTT),
lost(6, true, GAP + RTT + PC),
], true,
);
}
/// Get a time, in multiples of `PTO`, relative to `now()`. fn by_pto(t: u32) -> Instant {
now() + (PTO * t)
}
/// Make packets that will be made lost. /// `times` is the time of sending, in multiples of `PTO`, relative to `now()`. fn make_lost(times: &[u32]) -> Vec<sent::Packet> {
times
.iter()
.enumerate()
.map(|(i, &t)| {
sent::Packet::new(
packet::Type::Short,
u64::try_from(i).unwrap(),
by_pto(t), true,
recovery::Tokens::new(), 1000,
)
})
.collect::<Vec<_>>()
}
/// Call `detect_persistent_congestion` using times relative to now and the fixed PTO time. /// `last_ack` and `rtt_time` are times in multiples of `PTO`, relative to `now()`, /// for the time of the largest acknowledged and the first RTT sample, respectively. fn persistent_congestion_by_pto<S: SlowStart, T: WindowAdjustment>( mut cc: ClassicCongestionController<S, T>,
last_ack: u32,
rtt_time: u32,
lost: &[sent::Packet],
) -> bool { let now = now();
assert_eq!(cc.cwnd(), cc.cwnd_initial()); letmut cc_stats = CongestionControlStats::default();
let last_ack = Some(by_pto(last_ack)); let rtt_time = Some(by_pto(rtt_time));
// Persistent congestion is never declared if the RTT time is `None`.
cc.detect_persistent_congestion(None, None, PTO, lost.iter(), now, &mut cc_stats);
assert_eq!(cc.cwnd(), cc.cwnd_initial());
cc.detect_persistent_congestion(None, last_ack, PTO, lost.iter(), now, &mut cc_stats);
assert_eq!(cc.cwnd(), cc.cwnd_initial());
/// No persistent congestion can be had if there are no lost packets. #[test] fn persistent_congestion_no_lost() { let lost = make_lost(&[]);
assert!(!persistent_congestion_by_pto(
make_cc_newreno(), 0, 0,
&lost
));
assert!(!persistent_congestion_by_pto(make_cc_cubic(), 0, 0, &lost));
}
/// No persistent congestion can be had if there is only one lost packet. #[test] fn persistent_congestion_one_lost() { let lost = make_lost(&[1]);
assert!(!persistent_congestion_by_pto(
make_cc_newreno(), 0, 0,
&lost
));
assert!(!persistent_congestion_by_pto(make_cc_cubic(), 0, 0, &lost));
}
/// Persistent congestion can't happen based on old packets. #[test] fn persistent_congestion_past() { // Packets sent prior to either the last acknowledged or the first RTT // sample are not considered. So 0 is ignored. let lost = make_lost(&[0, PERSISTENT_CONG_THRESH + 1, PERSISTENT_CONG_THRESH + 2]);
assert!(!persistent_congestion_by_pto(
make_cc_newreno(), 1, 1,
&lost
));
assert!(!persistent_congestion_by_pto(
make_cc_newreno(), 0, 1,
&lost
));
assert!(!persistent_congestion_by_pto(
make_cc_newreno(), 1, 0,
&lost
));
assert!(!persistent_congestion_by_pto(make_cc_cubic(), 1, 1, &lost));
assert!(!persistent_congestion_by_pto(make_cc_cubic(), 0, 1, &lost));
assert!(!persistent_congestion_by_pto(make_cc_cubic(), 1, 0, &lost));
}
/// Detect persistent congestion. Note that the first lost packet needs to have a time /// greater than the previously acknowledged packet AND the first RTT sample. And the /// difference in times needs to be greater than the persistent congestion threshold. #[test] fn persistent_congestion_min() { let lost = make_lost(&[1, PERSISTENT_CONG_THRESH + 2]);
assert!(persistent_congestion_by_pto(make_cc_newreno(), 0, 0, &lost));
assert!(persistent_congestion_by_pto(make_cc_cubic(), 0, 0, &lost));
}
/// Make sure that not having a previous largest acknowledged also results /// in detecting persistent congestion. (This is not expected to happen, but /// the code permits it). #[test] fn persistent_congestion_no_prev_ack_newreno() { let lost = make_lost(&[1, PERSISTENT_CONG_THRESH + 2]); letmut cc = make_cc_newreno(); letmut cc_stats = CongestionControlStats::default();
cc.detect_persistent_congestion(
Some(by_pto(0)),
None,
PTO,
lost.iter(),
now(),
&mut cc_stats,
);
assert_eq!(cc.cwnd(), cc.cwnd_min());
}
// Fully utilize the congestion window by sending enough packets to // have `bytes_in_flight` above the `app_limited` threshold. letmut pkts = Vec::new(); for _ in0..ABOVE_APP_LIMIT_PKTS { let p = sent::Packet::new(
packet::Type::Short,
next_pn,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
next_pn += 1;
cc.on_packet_sent(&p, now, false);
pkts.push(p);
}
assert_eq!(
cc.bytes_in_flight(),
ABOVE_APP_LIMIT_PKTS * cc.max_datagram_size()
);
now += RTT; // Check if congestion window gets increased for all packets currently in flight for (i, pkt) in pkts.into_iter().enumerate() {
cc.on_packets_acked(
&[pkt],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
assert_eq!(
cc.bytes_in_flight(),
(ABOVE_APP_LIMIT_PKTS - i - 1) * cc.max_datagram_size()
); // increase acked_bytes with each packet
qinfo!( "{} {}",
cc.current.congestion_window,
cwnd + i * cc.max_datagram_size()
);
assert_eq!(
cc.current.congestion_window,
cwnd + (i + 1) * cc.max_datagram_size()
);
assert_eq!(cc.acked_bytes(), 0);
}
}
#[expect(
clippy::too_many_lines,
reason = "A lot of multiline function calls due to formatting"
)] #[test] fn app_limited_congestion_avoidance() { const CWND_PKTS_CA: usize = CWND_INITIAL_PKTS / 2; const BELOW_APP_LIMIT_PKTS: usize = CWND_PKTS_CA - 2; const ABOVE_APP_LIMIT_PKTS: usize = BELOW_APP_LIMIT_PKTS + 1;
letmut cc = make_cc_newreno(); letmut now = now(); letmut cc_stats = CongestionControlStats::default();
// Change phase to congestion avoidance by introducing loss.
let p_lost = sent::Packet::new(
packet::Type::Short, 1,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
cc.on_packet_sent(&p_lost, now, false);
cwnd_is_default(&cc);
now += PTO;
cc.on_packets_lost(Some(now), None, PTO, &[p_lost], now, &mut cc_stats);
cwnd_is_halved(&cc); let p_not_lost = sent::Packet::new(
packet::Type::Short, 2,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
cc.on_packet_sent(&p_not_lost, now, false);
now += RTT;
cc.on_packets_acked(
&[p_not_lost],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
cwnd_is_halved(&cc); // cc is app limited therefore cwnd in not increased.
assert_eq!(cc.acked_bytes(), 0);
// Now we are in the congestion avoidance phase.
assert_eq!(cc.current.phase, Phase::CongestionAvoidance); // simulate packet bursts below app_limit letmut next_pn = 3; for packet_burst_size in1..=BELOW_APP_LIMIT_PKTS { // always stay below app_limit during sent. letmut pkts = Vec::new(); for _ in0..packet_burst_size { let p = sent::Packet::new(
packet::Type::Short,
next_pn,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
next_pn += 1;
cc.on_packet_sent(&p, now, false);
pkts.push(p);
}
assert_eq!(
cc.bytes_in_flight(),
packet_burst_size * cc.max_datagram_size()
);
now += RTT; for (i, pkt) in pkts.into_iter().enumerate() {
cc.on_packets_acked(
&[pkt],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
// Fully utilize the congestion window by sending enough packets to // have `bytes_in_flight` above the `app_limited` threshold. letmut pkts = Vec::new(); for _ in0..ABOVE_APP_LIMIT_PKTS { let p = sent::Packet::new(
packet::Type::Short,
next_pn,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
next_pn += 1;
cc.on_packet_sent(&p, now, false);
pkts.push(p);
}
assert_eq!(
cc.bytes_in_flight(),
ABOVE_APP_LIMIT_PKTS * cc.max_datagram_size()
);
now += RTT; letmut last_acked_bytes = 0; // Check if congestion window gets increased for all packets currently in flight for (i, pkt) in pkts.into_iter().enumerate() {
cc.on_packets_acked(
&[pkt],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
assert_eq!(
cc.bytes_in_flight(),
(ABOVE_APP_LIMIT_PKTS - i - 1) * cc.max_datagram_size()
); // The cwnd doesn't increase, but the acked_bytes do, which will eventually lead to an // increase, once the number of bytes reaches the necessary level
cwnd_is_halved(&cc); // increase acked_bytes with each packet
assert_ne!(cc.acked_bytes(), last_acked_bytes);
last_acked_bytes = cc.acked_bytes();
}
}
#[test] fn ecn_ce() { let now = now(); letmut cc = make_cc_cubic(); letmut cc_stats = CongestionControlStats::default(); let p_ce = sent::Packet::new(
packet::Type::Short, 1,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
cc.on_packet_sent(&p_ce, now, false);
assert_eq!(cc.cwnd(), cc.cwnd_initial());
assert_eq!(cc.ssthresh(), None);
assert_eq!(cc.current.phase, Phase::SlowStart);
assert_eq!(cc_stats.congestion_events.ecn, 0);
/// This tests spurious congestion event detection, stat counting and the recovery mechanism. /// /// 1. Send packets (1, 2) --> `SlowStart`, no events /// 2. Lose packets (1, 2) --> `RecoveryStart`, 1 event /// 3. Send packet (3) --> `Recovery`, 1 event /// 4. Ack packet (3) --> `CongestionAvoidance`, 1 event /// 5. Ack packet (1) --> `CongestionAvoidance`, 1 event, not a spurious event as not all /// lost packets were recovered /// 6. Ack packet (2) --> all lost packets have been recovered so now we've detected a /// spurious congestion event #[test] fn spurious_congestion_event_detection_and_undo() { letmut cc = make_cc_cubic(); let now = now(); letmut cc_stats = CongestionControlStats::default();
// 5. Ack packet (1) --> `CongestionAvoidance`, 1 event, not a spurious event as not // all lost packets were recovered
cc.on_packets_acked(
&[pkt1],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
assert_eq!(cc.current.phase, Phase::CongestionAvoidance);
assert_eq!(cc_stats.congestion_events.loss, 1);
assert_eq!(cc_stats.congestion_events.spurious, 0);
// 6. Ack packet (2) --> all lost packets have been recovered so now we've detected a // spurious congestion event and reset to previous state
cc.on_packets_acked(
&[pkt2],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
assert_eq!(cc.current.phase, Phase::SlowStart);
assert_eq!(cc_stats.slow_start_exit_cwnd, None);
assert_eq!(cc_stats.slow_start_exit_reason, None);
assert_eq!(cc_stats.congestion_events.loss, 1);
assert_eq!(cc_stats.congestion_events.spurious, 1);
assert_eq!(cc.cwnd(), cc.cwnd_initial());
assert_eq!(cc_stats.w_max, None);
}
/// Same scenario as `spurious_congestion_event_detection_and_undo` but with recovery disabled. /// Detection still fires and the spurious counter is incremented, but cc parameters are not /// restored. #[test] fn spurious_congestion_event_detection_recovery_disabled() { letmut cc = ClassicCongestionController::new(
ClassicSlowStart::default(),
Cubic::default(),
Pmtud::new(IP_ADDR, MTU), false,
); let now = now(); letmut cc_stats = CongestionControlStats::default();
// 5. Ack packet (1) --> not all lost packets recovered yet
cc.on_packets_acked(
&[pkt1],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
assert_eq!(cc_stats.congestion_events.spurious, 0);
// 6. Ack packet (2) --> spurious event detected, counter incremented, but NO recovery // because pref is turned off. Assert that nothing is reset.
cc.on_packets_acked(
&[pkt2],
&RttEstimate::new(crate::DEFAULT_INITIAL_RTT),
now,
&mut cc_stats,
);
assert_eq!(cc_stats.congestion_events.spurious, 1);
assert_eq!(cc.cwnd(), cwnd_after_loss);
assert_eq!(cc.current.phase, Phase::CongestionAvoidance);
assert!(cc_stats.slow_start_exit_cwnd.is_some());
assert!(cc_stats.slow_start_exit_reason.is_some());
assert!(cc_stats.w_max.is_some());
}
/// This tests a scenario where spurious detection happens late, after cwnd has recovered and /// surpassed the previous cwnd naturally. In that case the spurious congestion event shouldn't /// be undone. #[test] fn late_spurious_congestion_event_without_undo() { letmut cc = make_cc_newreno(); let now = now(); letmut cc_stats = CongestionControlStats::default(); let rtt_estimate = RttEstimate::new(crate::DEFAULT_INITIAL_RTT);
// Cause congestion event let pkt = sent::make_packet(1, now, 1000);
cc.on_packet_sent(&pkt, now, false); let pkt_lost = pkt.clone();
cc.on_packets_lost(Some(now), None, PTO, &[pkt_lost], now, &mut cc_stats);
assert!(cc.cwnd() < cc.cwnd_initial(), "cwnd should have decreased");
let cwnd_recovered = cc.cwnd();
assert!(
cwnd_recovered >= cc.cwnd_initial(), "cwnd should have grown back, but cwnd_recovered is less than cwnd_initial {cwnd_recovered} < {}",
cc.cwnd_initial()
);
// Now detect spurious (late)
cc.on_packets_acked(&[pkt], &rtt_estimate, now, &mut cc_stats);
// Detects the spurious congestion event but should NOT restore old params because cwnd has // recovered naturally.
assert_eq!(cc.cwnd(), cwnd_recovered, "cwnd should not be restored");
assert_eq!(cc_stats.congestion_events.spurious, 1);
}
/// Test that losses during recovery don't cause double-counting of spurious events. /// This happened when detection was implemented but the recovery mechanism wasn't, as that /// meant we weren't leaving recovery when detecting a spurious event. The test confirms /// that the bug doesn't occur anymore now that the recovery is implemented. /// /// Scenario: /// 1. Send packets 1,2 /// 2. Lose packet 1 → congestion event #1 /// 3. Send packet 3 → enter Recovery phase /// 4. Late ack packet 1 → spurious event #1 detected (we would not leave recovery here, thus /// 5. wouldn't trigger a congestion event) /// 5. Lose packet 2 → congestion event #2 /// 6. Ack packet 2 → should trigger spurious event #2 (but not without also having an actual /// congestion event in 4.) #[test] fn spurious_no_double_detection_in_recovery() { letmut cc = make_cc_newreno(); let now = now(); letmut cc_stats = CongestionControlStats::default(); let rtt_estimate = RttEstimate::new(RTT);
// Step 1: Send packets 1,2 let pkt1 = sent::make_packet(1, now, 1000); let pkt2 = sent::make_packet(2, now, 1000);
// Step 5. Lose packet 2 → New congestion event as we left recovery when restoring the // previous params.
cc.on_packets_lost(
Some(now),
None,
rtt_estimate.pto(true),
&[lost_pkt2],
now,
&mut cc_stats,
);
// Still only 1 spurious event (but a new loss event)
assert_eq!(cc_stats.congestion_events.loss, 2);
assert_eq!(cc_stats.congestion_events.spurious, 1);
// 6. Ack packet 2 → should trigger spurious event #2 because we left recovery when // recovering from spurious event #1
cc.on_packets_acked(&[pkt2], &rtt_estimate, now, &mut cc_stats);
// Should now be 2 loss events and 2 spurious events, no double counting occured
assert_eq!(cc_stats.congestion_events.loss, 2);
assert_eq!(cc_stats.congestion_events.spurious, 2,);
}
#[test] fn spurious_congestion_event_detection_cleanup() { letmut cc = make_cc_newreno(); letmut now = now(); letmut cc_stats = CongestionControlStats::default(); let rtt_estimate = RttEstimate::new(crate::DEFAULT_INITIAL_RTT);
let pkt1 = sent::make_packet(1, now, 1000);
cc.on_packet_sent(&pkt1, now, false);
// The lost should be added now.
assert!(!cc.maybe_lost_packets.is_empty());
// Packets older than 2 * PTO are removed, so we increase by exactly that.
now += 2 * rtt_estimate.pto(true);
// The cleanup is called when we ack packets, so we send and ack a new one. let pkt2 = sent::make_packet(2, now, 1000);
cc.on_packet_sent(&pkt2, now, false);
cc.on_packets_acked(&[pkt2], &rtt_estimate, now, &mut cc_stats);
// The packet is exactly the maximum age, so it shouldn't be removed yet. This assert makes // sure we don't clean up too early.
assert!(!cc.maybe_lost_packets.is_empty());
// Increase by 1ms to get over the maximum age.
now += Duration::from_millis(1);
// Send and ack another packet to trigger cleanup. let pkt3 = sent::make_packet(3, now, 1000);
cc.on_packet_sent(&pkt3, now, false);
cc.on_packets_acked(&[pkt3], &rtt_estimate, now, &mut cc_stats);
// Now the packet should be removed.
assert!(cc.maybe_lost_packets.is_empty());
}
fn slow_start_exit_stats(congestion_trigger: CongestionTrigger) { letmut cc = make_cc_newreno(); let now = now(); letmut cc_stats = CongestionControlStats::default(); let rtt_estimate = RttEstimate::new(RTT);
// Should have exited slow start with cwnd captured AFTER reduction.
assert!(!cc.current.phase.in_slow_start());
assert_eq!(cc_stats.slow_start_exit_cwnd, Some(cc.cwnd()));
assert_eq!(
cc_stats.slow_start_exit_reason,
Some(SlowStartExitReason::CongestionEvent)
);
// For loss, test that a spurious congestion event resets the stats. if congestion_trigger == Loss { // Send recovery packet and ack it to exit recovery. let pkt2 = sent::make_packet(2, now, 1000);
cc.on_packet_sent(&pkt2, now, false);
cc.on_packets_acked(&[pkt2], &rtt_estimate, now, &mut cc_stats);
// Late ack of pkt1 triggers spurious congestion detection - should reset to None.
cc.on_packets_acked(&[pkt1], &rtt_estimate, now, &mut cc_stats);
#[test] fn cwnd_stat() { letmut cc = make_cc_newreno(); let now = now(); letmut cc_stats = CongestionControlStats::default(); let rtt_estimate = RttEstimate::new(crate::DEFAULT_INITIAL_RTT);
let cwnd_initial = cc.cwnd();
// Grow cwnd in slow start by filling the congestion window letmut next_pn = 0; letmut sent_packets = Vec::new(); while cc.bytes_in_flight < cc.cwnd() { let pkt = sent::make_packet(next_pn, now, cc.max_datagram_size());
cc.on_packet_sent(&pkt, now, false);
sent_packets.push(pkt);
next_pn += 1;
}
cc.on_packets_acked(&sent_packets, &rtt_estimate, now, &mut cc_stats); let cwnd_after_growth = cc.cwnd();
assert!(cwnd_after_growth > cwnd_initial);
assert_eq!(cc_stats.cwnd, Some(cwnd_after_growth));
// Tracks cwnd after persistent congestion let lost = make_lost(&[1, PERSISTENT_CONG_THRESH + 2]);
cc.detect_persistent_congestion(Some(now), None, PTO, lost.iter(), now, &mut cc_stats);
assert_eq!(cc_stats.cwnd, Some(cc.cwnd_min()));
}
#[test] // There was a bug in the stat logic that it never got initialized if a connection never made it // past the point of being app-limited, i.e. it returned `0` if a connection never grew the // congestion window. This test asserts that it is getting initialized to the initial window // size on the first ack, even if the congestion window doesn't grow. fn cwnd_stat_app_limited() { letmut cc = make_cc_cubic(); let now = now(); letmut cc_stats = CongestionControlStats::default(); let rtt_estimate = RttEstimate::new(crate::DEFAULT_INITIAL_RTT);
let cwnd_initial = cc.cwnd();
// Send and ack a single packet — not enough to fill cwnd, so app-limited. let pkt = sent::make_packet(0, now, cc.max_datagram_size());
cc.on_packet_sent(&pkt, now, false);
cc.on_packets_acked(&[pkt], &rtt_estimate, now, &mut cc_stats);
// HyStart state should be reset, so current_round_min_rtt is None again.
assert!(cc.slow_start.current_round_min_rtt().is_none());
}
/// Set up a `ClassicCongestionController` with qlog enabled, run `f`, then assert /// that the qlog output contains the given `trigger` string in a /// `CongestionStateUpdated` event. fn assert_congestion_state_trigger(
trigger: &str,
f: impl FnOnce(
&mut ClassicCongestionController<ClassicSlowStart, NewReno>,
&mut CongestionControlStats,
),
) { let (log, contents) = new_neqo_qlog(); letmut cc = make_cc_newreno();
cc.set_qlog(log); letmut cc_stats = CongestionControlStats::default();
f(&mut cc, &mut cc_stats);
drop(cc);
assert!(
contents
.to_string()
.contains(&format!(r#""trigger":"{trigger}""#)), "Expected {trigger} trigger in qlog"
);
}
/// An ECN congestion event should log `CongestionStateUpdated` with `trigger = "ecn"`. #[test] fn congestion_state_updated_ecn_trigger() {
assert_congestion_state_trigger("ecn", |cc, stats| { let now = now(); let p_ce = sent::Packet::new(
packet::Type::Short, 1,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
cc.on_packet_sent(&p_ce, now, false);
cc.on_ecn_ce_received(&p_ce, now, stats);
});
}
/// Persistent congestion should log `CongestionStateUpdated` with /// `trigger = "persistent_congestion"`, even though a preceding /// `on_congestion_event` left the phase in the transient `RecoveryStart`. #[test] fn congestion_state_updated_persistent_congestion_trigger() {
assert_congestion_state_trigger("persistent_congestion", |cc, stats| { let lost_pkts = [lost(1, true, ZERO), lost(2, true, PC)]; for p in &lost_pkts {
cc.on_packet_sent(p, now(), false);
}
assert_ne!(cc.cwnd(), cc.cwnd_min());
cc.on_packets_lost(Some(now()), None, PTO, &lost_pkts, now(), stats);
assert_eq!(
cc.cwnd(),
cc.cwnd_min(), "persistent congestion should have been detected"
);
});
} /// RFC 9002 6.1: only in-flight (ack-eliciting) packets should be declared lost. /// Non-ack-eliciting packets (e.g., only containing ACK-frames) must not trigger loss detection /// or congestion events. This reproduces a bug where ACK-only packets sent after a /// 0-RTT handshake were declared lost because the server (rightfully so) didn't send an ACK, /// causing early slow start exit and setting ssthresh to `initial_cwnd * 0.7`. #[test] fn no_congestion_event_if_lost_packet_not_in_flight() { letmut cc = make_cc_cubic(); let cc_stats = &mut CongestionControlStats::default();
// create a packet that is not ack-eliciting, so won't count as in flight let lost_pkt = sent::Packet::new(
packet::Type::Short, 0,
now(), false,
recovery::Tokens::new(),
MIN_INITIAL_PACKET_SIZE,
);
let initial_cwnd = cc.cwnd();
// call `on_packets_lost` on the non ack-eliciting packet
cc.on_packets_lost(Some(now()), None, PTO, &[lost_pkt], now(), cc_stats);
// there should be no reaction to the non-ack-eliciting packet
assert_eq!(cc.cwnd(), initial_cwnd);
assert_eq!(cc_stats.slow_start_exit_cwnd, None);
}
fn send_single_packet_and_ack(pacing_limited: bool) -> (usize, usize) { letmut cc = make_cc_newreno(); let now = now(); letmut cc_stats = CongestionControlStats::default(); let cwnd_before = cc.cwnd();
let p = sent::Packet::new(
packet::Type::Short, 0,
now, true,
recovery::Tokens::new(),
cc.max_datagram_size(),
);
cc.on_packet_sent(&p, now, pacing_limited);
#[test] fn pacing_limited_overrides_app_limited() { let (before, after) = send_single_packet_and_ack(true);
assert!(
after > before, "cwnd should grow when pacing-limited: {after} vs {before}"
);
}
#[test] fn genuinely_app_limited_no_pacing() { let (before, after) = send_single_packet_and_ack(false);
assert_eq!(
after, before, "cwnd should not grow when genuinely app-limited"
);
}
}
Messung V0.5 in Prozent
¤ Diese beiden folgenden Angebotsgruppen bietet das Unternehmen0.46Angebot
(Wie Sie bei der Firma Beratungs- und Dienstleistungen beauftragen können 2026-08-25)
¤
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.