Skip to content

Commit 24fa284

Browse files
committed
recovery: add unchecked congestion window
Applications that constrain transmission outside quiche need QUIC recovery without congestion-window backpressure. Expose an explicitly opt-in algorithm that keeps the window at usize::MAX while preserving shared ACK, RTT, loss, and bytes-in-flight accounting. Generic accounting now allows this to be a small callback table instead of a Reno copy. Saturate PRR allowance at the maximum window, cover the behavior in feature-enabled CI, and document that optimistic-ACK probes become extremely infrequent.
1 parent 1c8e976 commit 24fa284

10 files changed

Lines changed: 191 additions & 19 deletions

File tree

‎.github/workflows/nightly.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ permissions:
77
pull-requests: write
88

99
env:
10-
FEATURES: "async,ffi,qlog,rpk"
10+
FEATURES: "async,ffi,qlog,rpk,congestion_window_unchecked_available"
1111
RUSTFLAGS: "-D warnings"
1212
RUSTDOCFLAGS: "--cfg docsrs"
1313
RUSTTOOLCHAIN: "nightly"

‎.github/workflows/stable.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ permissions:
77
pull-requests: write
88

99
env:
10-
DEFAULT_OPTIONS: "--features=async,ffi,qlog,rpk --workspace"
10+
DEFAULT_OPTIONS: "--features=async,ffi,qlog,rpk,congestion_window_unchecked_available --workspace"
1111
# Used by `quiche_multiarch`, which can't link `boring` for foreign targets.
1212
NO_BORING_OPTIONS: "--features=ffi,qlog --workspace --exclude h3i --exclude tokio-quiche"
1313
RUSTFLAGS: "-D warnings"

‎quiche/Cargo.toml‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ default = ["boringssl-boring-crate"]
4646
# copy in the dep graph.
4747
boringssl-boring-crate = ["boring", "boring-sys", "foreign-types-shared"]
4848

49+
# Exposes a congestion control algorithm that provides no congestion-window
50+
# protection. ACK processing, RTT estimation, loss detection, and recovery
51+
# remain enabled. Only enable this for deployments that constrain transmission
52+
# elsewhere.
53+
congestion_window_unchecked_available = []
54+
4955
# Allow client connections to provide a custom DCID when initiating a
5056
# connection. Be aware that RFC 9000 places requirements for unpredictability and
5157
# length on the client DCID field. Enabling this feature can be dangerous if these
@@ -82,7 +88,12 @@ tag-prefix = ""
8288

8389
[package.metadata.docs.rs]
8490
no-default-features = true
85-
features = ["boringssl-boring-crate", "qlog", "custom-client-dcid"]
91+
features = [
92+
"boringssl-boring-crate",
93+
"qlog",
94+
"custom-client-dcid",
95+
"congestion_window_unchecked_available",
96+
]
8697
rustdoc-args = ["--cfg", "docsrs"]
8798

8899
[build-dependencies]

‎quiche/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,9 @@
372372
//! initiating a connection. Dangerous if the DCID does not meet QUIC's
373373
//! unpredictability and length requirements.
374374
//!
375+
//! * `congestion_window_unchecked_available`: Expose a congestion control
376+
//! algorithm that provides no congestion-window protection.
377+
//!
375378
//! [feature flags]: https://doc.rust-lang.org/cargo/reference/manifest.html#the-features-section
376379
//! [boring]: https://crates.io/crates/boring
377380
//! [qlog]: https://datatracker.ietf.org/doc/html/draft-ietf-quic-qlog-main-schema

‎quiche/src/recovery/AGENTS.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ gcongestion/ Next-gen CC (BBR2)
7474
## NOTES
7575

7676
- Constants cite RFC 9002: `INITIAL_TIME_THRESHOLD = 9.0/8.0`, `GRANULARITY = 1ms`.
77-
- `CongestionControlAlgorithm` values: Reno=0, CUBIC=1, Bbr2Gcongestion=4. Gap is intentional (removed variants).
78-
- `Recovery::new_with_config` tries `GRecovery::new` first; falls back to `LegacyRecovery` if algo is Reno/CUBIC.
77+
- `CongestionControlAlgorithm` values: Reno=0, CUBIC=1, Bbr2Gcongestion=4,
78+
CongestionWindowUnchecked=5 when enabled. Gaps are intentional (removed variants).
79+
- `Recovery::new_with_config` tries `GRecovery::new` first; falls back to
80+
`LegacyRecovery` for Reno, CUBIC, and CongestionWindowUnchecked.
7981
- `bbr2/` is a deeply nested state machine -- changes require understanding all six substates.
8082
- `enable_relaxed_loss_threshold` experiment adjusts time thresholds dynamically on spurious loss.
8183
- `gcongestion/bbr.rs` is BBRv1 -- mostly vestigial alongside BBR2.

‎quiche/src/recovery/congestion/mod.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,24 @@ pub(crate) struct CongestionControlOps {
283283
) -> std::fmt::Result,
284284
}
285285

286+
#[cfg(feature = "congestion_window_unchecked_available")]
287+
static CONGESTION_WINDOW_UNCHECKED: CongestionControlOps = CongestionControlOps {
288+
on_init: |recovery| recovery.congestion_window = usize::MAX,
289+
on_packet_sent: |_, _, _, _| {},
290+
on_packets_acked: |_, _, _, _, _| {},
291+
congestion_event: |recovery, _, _, largest_lost_packet, now| {
292+
if !recovery.in_congestion_recovery(largest_lost_packet.time_sent) {
293+
recovery.congestion_recovery_start_time = Some(now);
294+
recovery.ssthresh.update(recovery.congestion_window, false);
295+
}
296+
},
297+
checkpoint: |_| {},
298+
rollback: |_| true,
299+
#[cfg(feature = "qlog")]
300+
state_str: |_, _| "congestion_window_unchecked",
301+
debug_fmt: |_, _| Ok(()),
302+
};
303+
286304
impl From<CongestionControlAlgorithm> for &'static CongestionControlOps {
287305
fn from(algo: CongestionControlAlgorithm) -> Self {
288306
match algo {
@@ -293,6 +311,9 @@ impl From<CongestionControlAlgorithm> for &'static CongestionControlOps {
293311
// LegacyRecovery never gets a RecoveryConfig with the
294312
// Bbr2Gcongestion algorithm.
295313
CongestionControlAlgorithm::Bbr2Gcongestion => unreachable!(),
314+
#[cfg(feature = "congestion_window_unchecked_available")]
315+
CongestionControlAlgorithm::CongestionWindowUnchecked =>
316+
&CONGESTION_WINDOW_UNCHECKED,
296317
}
297318
}
298319
}

‎quiche/src/recovery/congestion/recovery.rs‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -889,8 +889,9 @@ impl RecoveryOps for LegacyRecovery {
889889
}
890890

891891
// Open more space (snd_cnt) for PRR when allowed.
892-
self.cwnd().saturating_sub(self.bytes_in_flight.get()) +
893-
self.congestion.prr.snd_cnt
892+
self.cwnd()
893+
.saturating_sub(self.bytes_in_flight.get())
894+
.saturating_add(self.congestion.prr.snd_cnt)
894895
}
895896

896897
fn rtt(&self) -> Duration {
@@ -1159,6 +1160,18 @@ mod tests {
11591160
use crate::recovery::RecoveryConfig;
11601161
use std::time::Instant;
11611162

1163+
#[test]
1164+
fn congestion_window_unchecked_saturates_prr_allowance() {
1165+
let config = crate::Config::new(crate::PROTOCOL_VERSION)
1166+
.expect("configuration should be valid");
1167+
let recovery_config = RecoveryConfig::from_config(&config);
1168+
let mut recovery = LegacyRecovery::new_with_config(&recovery_config);
1169+
recovery.congestion.congestion_window = usize::MAX;
1170+
recovery.congestion.prr.snd_cnt = 1;
1171+
1172+
assert_eq!(recovery.cwnd_available(), usize::MAX);
1173+
}
1174+
11621175
#[test]
11631176
fn test_high_pto_count_no_panic() {
11641177
let config = crate::Config::new(crate::PROTOCOL_VERSION).unwrap();

‎quiche/src/recovery/mod.rs‎

Lines changed: 131 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ impl Recovery {
371371
/// algorithms.
372372
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
373373
#[repr(C)]
374+
#[non_exhaustive]
374375
pub enum CongestionControlAlgorithm {
375376
/// Reno congestion control algorithm. `reno` in a string form.
376377
Reno = 0,
@@ -379,6 +380,18 @@ pub enum CongestionControlAlgorithm {
379380
/// BBRv2 congestion control algorithm implementation from gcongestion
380381
/// branch. `bbr2_gcongestion` in a string form.
381382
Bbr2Gcongestion = 4,
383+
/// Keeps the congestion window at `usize::MAX` instead of reducing send
384+
/// capacity in response to congestion. ACK processing, RTT estimation,
385+
/// loss detection, and recovery remain enabled. This provides no
386+
/// congestion-window protection and makes optimistic-ACK probes extremely
387+
/// infrequent.
388+
/// `congestion_window_unchecked` in string form.
389+
#[cfg(feature = "congestion_window_unchecked_available")]
390+
#[cfg_attr(
391+
docsrs,
392+
doc(cfg(feature = "congestion_window_unchecked_available"))
393+
)]
394+
CongestionWindowUnchecked = 5,
382395
}
383396

384397
impl FromStr for CongestionControlAlgorithm {
@@ -394,6 +407,9 @@ impl FromStr for CongestionControlAlgorithm {
394407
"bbr" => Ok(CongestionControlAlgorithm::Bbr2Gcongestion),
395408
"bbr2" => Ok(CongestionControlAlgorithm::Bbr2Gcongestion),
396409
"bbr2_gcongestion" => Ok(CongestionControlAlgorithm::Bbr2Gcongestion),
410+
#[cfg(feature = "congestion_window_unchecked_available")]
411+
"congestion_window_unchecked" =>
412+
Ok(CongestionControlAlgorithm::CongestionWindowUnchecked),
397413
_ => Err(crate::Error::CongestionControl),
398414
}
399415
}
@@ -953,10 +969,94 @@ mod tests {
953969
);
954970
}
955971

972+
#[cfg(feature = "congestion_window_unchecked_available")]
973+
fn congestion_window_unchecked_recovery() -> Recovery {
974+
let mut config = Config::new(crate::PROTOCOL_VERSION)
975+
.expect("configuration should be valid");
976+
config
977+
.set_cc_algorithm_name("congestion_window_unchecked")
978+
.expect("congestion_window_unchecked should be available");
979+
Recovery::new(&config)
980+
}
981+
982+
#[cfg(feature = "congestion_window_unchecked_available")]
983+
#[test]
984+
fn congestion_window_unchecked_keeps_the_send_window_open() {
985+
let mut recovery = congestion_window_unchecked_recovery();
986+
let now = Instant::now();
987+
recovery.on_packet_sent(
988+
test_utils::helper_packet_sent(0, now, 1_200),
989+
packet::Epoch::Application,
990+
HandshakeStatus::default(),
991+
now,
992+
"",
993+
);
994+
995+
assert_eq!(recovery.cwnd(), usize::MAX);
996+
assert_eq!(recovery.bytes_in_flight(), 1_200);
997+
assert_eq!(recovery.cwnd_available(), usize::MAX - 1_200);
998+
}
999+
1000+
#[cfg(feature = "congestion_window_unchecked_available")]
1001+
#[test]
1002+
fn congestion_window_unchecked_retains_recovery_accounting() {
1003+
let mut recovery = congestion_window_unchecked_recovery();
1004+
let now = Instant::now();
1005+
recovery.on_packet_sent(
1006+
test_utils::helper_packet_sent(0, now, 1_200),
1007+
packet::Epoch::Application,
1008+
HandshakeStatus::default(),
1009+
now,
1010+
"",
1011+
);
1012+
1013+
let mut acked = RangeSet::default();
1014+
acked.insert(0..1);
1015+
let outcome = recovery
1016+
.on_ack_received(
1017+
&acked,
1018+
0,
1019+
packet::Epoch::Application,
1020+
HandshakeStatus::default(),
1021+
now + Duration::from_millis(10),
1022+
None,
1023+
"",
1024+
)
1025+
.expect("ACK should be valid");
1026+
assert_eq!(outcome.acked_bytes, 1_200);
1027+
assert_eq!(recovery.bytes_in_flight(), 0);
1028+
assert_eq!(recovery.rtt(), Duration::from_millis(10));
1029+
assert_eq!(recovery.min_rtt(), Some(Duration::from_millis(10)));
1030+
1031+
let mut recovery = congestion_window_unchecked_recovery();
1032+
recovery.on_packet_sent(
1033+
test_utils::helper_packet_sent(
1034+
0,
1035+
now + Duration::from_secs(2),
1036+
1_200,
1037+
),
1038+
packet::Epoch::Initial,
1039+
HandshakeStatus::default(),
1040+
now + Duration::from_secs(2),
1041+
"",
1042+
);
1043+
recovery.on_pkt_num_space_discarded(
1044+
packet::Epoch::Initial,
1045+
HandshakeStatus::default(),
1046+
now + Duration::from_secs(3),
1047+
);
1048+
assert_eq!(recovery.bytes_in_flight(), 0);
1049+
}
1050+
9561051
#[rstest]
957-
fn loss_on_pto(
958-
#[values("reno", "cubic", "bbr2_gcongestion")] cc_algorithm_name: &str,
959-
) {
1052+
#[case::reno("reno")]
1053+
#[case::cubic("cubic")]
1054+
#[case::bbr2_gcongestion("bbr2_gcongestion")]
1055+
#[cfg_attr(
1056+
feature = "congestion_window_unchecked_available",
1057+
case::congestion_window_unchecked("congestion_window_unchecked")
1058+
)]
1059+
fn loss_on_pto(#[case] cc_algorithm_name: &str) {
9601060
let mut cfg = Config::new(crate::PROTOCOL_VERSION).unwrap();
9611061
assert_eq!(cfg.set_cc_algorithm_name(cc_algorithm_name), Ok(()));
9621062

@@ -1231,7 +1331,10 @@ mod tests {
12311331
);
12321332

12331333
assert_eq!(r.sent_packets_len(packet::Epoch::Application), 0);
1234-
if cc_algorithm_name == "reno" || cc_algorithm_name == "cubic" {
1334+
if matches!(
1335+
cc_algorithm_name,
1336+
"reno" | "cubic" | "congestion_window_unchecked"
1337+
) {
12351338
assert!(r.startup_exit().is_some());
12361339
assert_eq!(r.startup_exit().unwrap().reason, StartupExitReason::Loss);
12371340
} else {
@@ -1240,9 +1343,14 @@ mod tests {
12401343
}
12411344

12421345
#[rstest]
1243-
fn loss_on_timer(
1244-
#[values("reno", "cubic", "bbr2_gcongestion")] cc_algorithm_name: &str,
1245-
) {
1346+
#[case::reno("reno")]
1347+
#[case::cubic("cubic")]
1348+
#[case::bbr2_gcongestion("bbr2_gcongestion")]
1349+
#[cfg_attr(
1350+
feature = "congestion_window_unchecked_available",
1351+
case::congestion_window_unchecked("congestion_window_unchecked")
1352+
)]
1353+
fn loss_on_timer(#[case] cc_algorithm_name: &str) {
12461354
let mut cfg = Config::new(crate::PROTOCOL_VERSION).unwrap();
12471355
assert_eq!(cfg.set_cc_algorithm_name(cc_algorithm_name), Ok(()));
12481356

@@ -1427,7 +1535,10 @@ mod tests {
14271535
);
14281536

14291537
assert_eq!(r.sent_packets_len(packet::Epoch::Application), 0);
1430-
if cc_algorithm_name == "reno" || cc_algorithm_name == "cubic" {
1538+
if matches!(
1539+
cc_algorithm_name,
1540+
"reno" | "cubic" | "congestion_window_unchecked"
1541+
) {
14311542
assert!(r.startup_exit().is_some());
14321543
assert_eq!(r.startup_exit().unwrap().reason, StartupExitReason::Loss);
14331544
} else {
@@ -1436,9 +1547,14 @@ mod tests {
14361547
}
14371548

14381549
#[rstest]
1439-
fn loss_on_reordering(
1440-
#[values("reno", "cubic", "bbr2_gcongestion")] cc_algorithm_name: &str,
1441-
) {
1550+
#[case::reno("reno")]
1551+
#[case::cubic("cubic")]
1552+
#[case::bbr2_gcongestion("bbr2_gcongestion")]
1553+
#[cfg_attr(
1554+
feature = "congestion_window_unchecked_available",
1555+
case::congestion_window_unchecked("congestion_window_unchecked")
1556+
)]
1557+
fn loss_on_reordering(#[case] cc_algorithm_name: &str) {
14421558
let mut cfg = Config::new(crate::PROTOCOL_VERSION).unwrap();
14431559
assert_eq!(cfg.set_cc_algorithm_name(cc_algorithm_name), Ok(()));
14441560

@@ -1543,7 +1659,10 @@ mod tests {
15431659
);
15441660
assert_eq!(r.sent_packets_len(packet::Epoch::Application), 0);
15451661

1546-
if cc_algorithm_name == "reno" || cc_algorithm_name == "cubic" {
1662+
if matches!(
1663+
cc_algorithm_name,
1664+
"reno" | "cubic" | "congestion_window_unchecked"
1665+
) {
15471666
assert!(r.startup_exit().is_some());
15481667
assert_eq!(r.startup_exit().unwrap().reason, StartupExitReason::Loss);
15491668
} else {

‎tokio-quiche/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ build = "build.rs"
1515
default = ["qlog-gzip", "qlog-zstd"]
1616

1717
# Forwarded quiche features for re-exports
18+
congestion_window_unchecked = ["quiche/congestion_window_unchecked_available"]
1819
fuzzing = ["quiche/fuzzing"]
1920
quiche_internal = ["quiche/internal"]
2021

‎tokio-quiche/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@
9393
//!
9494
//! Off by default:
9595
//!
96+
//! - `congestion_window_unchecked`: Expose quiche's congestion control
97+
//! algorithm that provides no congestion-window protection.
9698
//! - `rpk`: Support for raw public keys (RPK) in QUIC handshakes (via
9799
//! [boring]).
98100
//! - `gcongestion`: Replace quiche's original congestion control implementation

0 commit comments

Comments
 (0)