Skip to content

Commit b4ed807

Browse files
authored
recovery: add unchecked congestion window controller
Some deployments constrain transmission outside quiche but still need QUIC recovery. Add an opt-in controller with a fixed congestion window that does not adapt to ACKs or loss. The controller preserves ACK processing, RTT estimation, loss detection and bytes-in-flight accounting. Its window defaults to usize::MAX, and callers can set a finite byte window through the Rust, C and tokio-quiche configuration APIs. The setting has no effect unless the controller is enabled and selected. Restore the configured window in controller callbacks and after PMTU updates. This prevents shared recovery code from changing the policy permanently. The controller is behind the non-default congestion_window_unchecked_available feature in quiche and tokio-quiche. With the default window, callers must enforce their own transmission limits.
1 parent aa3c4e2 commit b4ed807

17 files changed

Lines changed: 617 additions & 22 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: 7 additions & 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"
@@ -62,6 +62,12 @@ jobs:
6262
- name: Run cargo test
6363
run: cargo test --verbose --all-targets ${{ env.DEFAULT_OPTIONS }}
6464

65+
- name: Run recovery and settings tests without unchecked congestion window
66+
if: matrix.boring_version == 'latest'
67+
run: |
68+
cargo test --verbose --lib ${DEFAULT_OPTIONS/,congestion_window_unchecked_available/} 'recovery::'
69+
cargo test --verbose --lib ${DEFAULT_OPTIONS/,congestion_window_unchecked_available/} 'settings::'
70+
6571
# Need to run doc tests separately.
6672
# (https://github.com/rust-lang/cargo/issues/6669)
6773
- name: Run cargo doc test

‎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/include/quiche.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ int quiche_config_set_cc_algorithm_name(quiche_config *config, const char *algo)
237237
// Sets the initial cwnd for the connection in terms of packet count.
238238
void quiche_config_set_initial_congestion_window_packets(quiche_config *config, size_t packets);
239239

240+
// Sets the fixed congestion window in bytes for congestion_window_unchecked.
241+
// Select the controller using quiche_config_set_cc_algorithm_name(config,
242+
// "congestion_window_unchecked") in builds with the
243+
// congestion_window_unchecked_available feature.
244+
// Defaults to SIZE_MAX. Other congestion control algorithms are unchanged.
245+
// Recovery probes can bypass the fixed window.
246+
void quiche_config_set_unchecked_congestion_window(quiche_config *config, size_t window);
247+
240248
enum quiche_cc_algorithm {
241249
QUICHE_CC_RENO = 0,
242250
QUICHE_CC_CUBIC = 1,

‎quiche/src/ffi.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,17 @@ pub extern "C" fn quiche_config_set_initial_congestion_window_packets(
368368
config.set_initial_congestion_window_packets(packets);
369369
}
370370

371+
/// Sets the fixed congestion window in bytes for `congestion_window_unchecked`.
372+
///
373+
/// Defaults to `usize::MAX`. Other congestion control algorithms are unchanged.
374+
/// Recovery probes can bypass the fixed window.
375+
#[no_mangle]
376+
pub extern "C" fn quiche_config_set_unchecked_congestion_window(
377+
config: &mut Config, window: size_t,
378+
) {
379+
config.set_unchecked_congestion_window(window);
380+
}
381+
371382
#[no_mangle]
372383
pub extern "C" fn quiche_config_enable_hystart(config: &mut Config, v: bool) {
373384
config.enable_hystart(v);

‎quiche/src/lib.rs‎

Lines changed: 22 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 with a fixed, configurable window that defaults to `usize::MAX`.
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
@@ -578,6 +581,7 @@ pub struct Config {
578581
cc_algorithm: CongestionControlAlgorithm,
579582
custom_bbr_params: Option<BbrParams>,
580583
initial_congestion_window_packets: usize,
584+
unchecked_congestion_window: usize,
581585
enable_relaxed_loss_threshold: bool,
582586
enable_cubic_idle_restart_fix: bool,
583587
enable_send_streams_blocked: bool,
@@ -660,6 +664,7 @@ impl Config {
660664
custom_bbr_params: None,
661665
initial_congestion_window_packets:
662666
DEFAULT_INITIAL_CONGESTION_WINDOW_PACKETS,
667+
unchecked_congestion_window: usize::MAX,
663668
enable_relaxed_loss_threshold: false,
664669
enable_cubic_idle_restart_fix: true,
665670
enable_send_streams_blocked: false,
@@ -1139,6 +1144,23 @@ impl Config {
11391144
self.initial_congestion_window_packets = packets;
11401145
}
11411146

1147+
/// Sets the fixed congestion window in bytes for the
1148+
/// `congestion_window_unchecked` algorithm.
1149+
///
1150+
/// The default is `usize::MAX`. Other congestion control algorithms ignore
1151+
/// this setting. Recovery probes can temporarily bypass this window.
1152+
///
1153+
/// Values are not clamped. Zero can prevent connection establishment, and
1154+
/// windows too small for outgoing packets or PMTU probes can stall
1155+
/// transmission. At least two maximum-sized datagrams are recommended.
1156+
///
1157+
/// This API is available even when the
1158+
/// `congestion_window_unchecked_available` feature is disabled, in which
1159+
/// case the setting has no effect.
1160+
pub fn set_unchecked_congestion_window(&mut self, window: usize) {
1161+
self.unchecked_congestion_window = window;
1162+
}
1163+
11421164
/// Configure whether to enable relaxed loss detection on spurious loss.
11431165
///
11441166
/// The default value is false.

‎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/cubic.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use crate::recovery::MINIMUM_WINDOW_PACKETS;
4747

4848
pub(crate) static CUBIC: CongestionControlOps = CongestionControlOps {
4949
on_init,
50+
on_mtu_update: |_| {},
5051
on_packet_sent,
5152
on_packets_acked,
5253
congestion_event,

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ pub struct Congestion {
113113
/// Initial congestion window size in terms of packet count.
114114
pub(crate) initial_congestion_window_packets: usize,
115115

116+
#[cfg(feature = "congestion_window_unchecked_available")]
117+
/// Fixed congestion window in bytes for the unchecked controller.
118+
pub(crate) unchecked_congestion_window: usize,
119+
116120
max_datagram_size: usize,
117121

118122
pub(crate) lost_count: usize,
@@ -146,6 +150,9 @@ impl Congestion {
146150

147151
initial_congestion_window_packets: recovery_config
148152
.initial_congestion_window_packets,
153+
#[cfg(feature = "congestion_window_unchecked_available")]
154+
unchecked_congestion_window: recovery_config
155+
.unchecked_congestion_window,
149156

150157
max_datagram_size: recovery_config.max_send_udp_payload_size,
151158

@@ -247,6 +254,9 @@ impl Congestion {
247254
pub(crate) struct CongestionControlOps {
248255
pub on_init: fn(r: &mut Congestion),
249256

257+
/// Updates controller state after the packet-size limit changes.
258+
pub on_mtu_update: fn(congestion: &mut Congestion),
259+
250260
pub on_packet_sent: fn(
251261
r: &mut Congestion,
252262
sent_bytes: usize,
@@ -283,6 +293,34 @@ pub(crate) struct CongestionControlOps {
283293
) -> std::fmt::Result,
284294
}
285295

296+
#[cfg(feature = "congestion_window_unchecked_available")]
297+
static CONGESTION_WINDOW_UNCHECKED: CongestionControlOps = CongestionControlOps {
298+
on_init: reset_unchecked_congestion_window,
299+
on_mtu_update: reset_unchecked_congestion_window,
300+
on_packet_sent: |congestion, _, _, _| {
301+
reset_unchecked_congestion_window(congestion)
302+
},
303+
on_packets_acked: |congestion, _, _, _, _| {
304+
reset_unchecked_congestion_window(congestion)
305+
},
306+
congestion_event: |congestion, _, _, _, _| {
307+
reset_unchecked_congestion_window(congestion)
308+
},
309+
checkpoint: reset_unchecked_congestion_window,
310+
rollback: |congestion| {
311+
reset_unchecked_congestion_window(congestion);
312+
true
313+
},
314+
#[cfg(feature = "qlog")]
315+
state_str: |_, _| "congestion_window_unchecked",
316+
debug_fmt: |_, _| Ok(()),
317+
};
318+
319+
#[cfg(feature = "congestion_window_unchecked_available")]
320+
fn reset_unchecked_congestion_window(congestion: &mut Congestion) {
321+
congestion.congestion_window = congestion.unchecked_congestion_window;
322+
}
323+
286324
impl From<CongestionControlAlgorithm> for &'static CongestionControlOps {
287325
fn from(algo: CongestionControlAlgorithm) -> Self {
288326
match algo {
@@ -293,13 +331,80 @@ impl From<CongestionControlAlgorithm> for &'static CongestionControlOps {
293331
// LegacyRecovery never gets a RecoveryConfig with the
294332
// Bbr2Gcongestion algorithm.
295333
CongestionControlAlgorithm::Bbr2Gcongestion => unreachable!(),
334+
#[cfg(feature = "congestion_window_unchecked_available")]
335+
CongestionControlAlgorithm::CongestionWindowUnchecked =>
336+
&CONGESTION_WINDOW_UNCHECKED,
296337
}
297338
}
298339
}
299340

300341
#[cfg(test)]
301342
mod tests {
302343
use super::*;
344+
#[cfg(feature = "congestion_window_unchecked_available")]
345+
use crate::test_utils;
346+
#[cfg(feature = "congestion_window_unchecked_available")]
347+
use crate::Config;
348+
#[cfg(feature = "congestion_window_unchecked_available")]
349+
use rstest::rstest;
350+
#[cfg(feature = "congestion_window_unchecked_available")]
351+
use std::time::Duration;
352+
353+
#[cfg(feature = "congestion_window_unchecked_available")]
354+
#[rstest]
355+
fn unchecked_window_is_restored_by_callbacks(
356+
#[values(usize::MAX, 40 * 1024 * 1024)] window: usize,
357+
) {
358+
let mut config = Config::new(crate::PROTOCOL_VERSION)
359+
.expect("create transport configuration");
360+
config.set_cc_algorithm(
361+
CongestionControlAlgorithm::CongestionWindowUnchecked,
362+
);
363+
config.set_unchecked_congestion_window(window);
364+
let mut congestion =
365+
Congestion::from_config(&RecoveryConfig::from_config(&config));
366+
assert_eq!(congestion.congestion_window(), window);
367+
368+
let callbacks: [fn(&mut Congestion); 5] = [
369+
|congestion| {
370+
(congestion.cc_ops.on_packet_sent)(
371+
congestion,
372+
1_200,
373+
0,
374+
Instant::now(),
375+
);
376+
},
377+
|congestion| {
378+
(congestion.cc_ops.on_packets_acked)(
379+
congestion,
380+
0,
381+
&mut Vec::new(),
382+
Instant::now(),
383+
&RttStats::new(
384+
Duration::from_millis(100),
385+
Duration::from_millis(25),
386+
),
387+
);
388+
},
389+
|congestion| {
390+
let now = Instant::now();
391+
let packet = test_utils::helper_packet_sent(0, now, 1_200);
392+
(congestion.cc_ops.congestion_event)(
393+
congestion, 1_200, 1_200, &packet, now,
394+
);
395+
},
396+
|congestion| (congestion.cc_ops.checkpoint)(congestion),
397+
|congestion| {
398+
assert!((congestion.cc_ops.rollback)(congestion));
399+
},
400+
];
401+
402+
for callback in callbacks {
403+
congestion.congestion_window = 0;
404+
callback(&mut congestion);
405+
assert_eq!(congestion.congestion_window(), window);
406+
}
407+
}
303408

304409
#[test]
305410
fn ssthresh_init() {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,7 @@ impl RecoveryOps for LegacyRecovery {
953953
}
954954

955955
self.max_datagram_size = new_max_datagram_size;
956+
(self.congestion.cc_ops.on_mtu_update)(&mut self.congestion);
956957
}
957958

958959
fn update_max_datagram_size(&mut self, new_max_datagram_size: usize) {

0 commit comments

Comments
 (0)