Skip to main content

moq_native/
quic.rs

1//! QUIC transport tuning, split by role.
2//!
3//! [`Client`] (`--client-quic-*`) and [`Server`] (`--server-quic-*`) carry the
4//! per-connection knobs (stream limits, GSO, timeouts) that each backend applies.
5//! [`Server`] additionally owns the knobs that only make sense when accepting
6//! connections: the QUIC preferred address and the QUIC-LB connection-ID encoding.
7//!
8//! Each is flattened directly onto [`crate::ClientConfig`] / [`crate::ServerConfig`],
9//! so the args parse straight into the config the endpoint is built from. Not
10//! every backend honors every knob, see the field docs.
11
12use std::net;
13use std::path::PathBuf;
14use std::time::Duration;
15
16/// The routable server ID a QUIC-LB load balancer encodes into connection IDs.
17///
18/// Parsed from, and serialized as, a hex string. Its length must match the load
19/// balancer's configured server-ID length.
20#[serde_with::serde_as]
21#[derive(Clone, serde::Serialize, serde::Deserialize)]
22pub struct ServerId(#[serde_as(as = "serde_with::hex::Hex")] pub(crate) Vec<u8>);
23
24impl ServerId {
25	#[allow(dead_code)]
26	pub(crate) fn len(&self) -> usize {
27		self.0.len()
28	}
29}
30
31impl std::fmt::Debug for ServerId {
32	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33		f.debug_tuple("ServerId").field(&hex::encode(&self.0)).finish()
34	}
35}
36
37impl std::str::FromStr for ServerId {
38	type Err = hex::FromHexError;
39
40	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
41		hex::decode(s).map(Self)
42	}
43}
44
45/// The congestion control family for a QUIC connection.
46///
47/// This selects a family rather than a named algorithm because each backend ships a
48/// different generation: BBRv1 on quinn, BBRv2 on quiche, BBRv3 on noq and iroh. A
49/// `Bbr` variant would promise more than any one backend delivers.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "kebab-case")]
52#[non_exhaustive]
53pub enum CongestionControl {
54	/// Loss-based (CUBIC): grows until it drops packets, so the send rate sawtooths.
55	/// Throughput-oriented, and the default on most stacks.
56	Loss,
57	/// Delay-based (BBR): tracks the measured delivery rate and RTT instead of waiting
58	/// for loss, which keeps queues short and the send rate steady enough for an encoder
59	/// to track.
60	Delay,
61}
62
63/// Parses the same spellings the CLI and TOML accept (`loss`, `delay`), case-insensitively.
64impl std::str::FromStr for CongestionControl {
65	type Err = String;
66
67	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
68		<Self as clap::ValueEnum>::from_str(s, true)
69	}
70}
71
72/// Default maximum number of concurrent QUIC streams (bidi and uni) per connection.
73pub(crate) const DEFAULT_MAX_STREAMS: u64 = 1024;
74
75/// Default idle timeout before an inactive connection is dropped.
76pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
77
78/// Default keep-alive ping interval.
79pub(crate) const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(5);
80
81/// The `--client-quic-*` transport section.
82#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
83#[serde(deny_unknown_fields, default)]
84#[non_exhaustive]
85pub struct Client {
86	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
87	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
88	#[serde(skip_serializing_if = "Option::is_none")]
89	#[arg(
90		id = "client-quic-max-streams",
91		long = "client-quic-max-streams",
92		alias = "client-max-streams",
93		env = "MOQ_CLIENT_QUIC_MAX_STREAMS"
94	)]
95	pub max_streams: Option<u64>,
96
97	/// Enable UDP generic segmentation offload (GSO).
98	///
99	/// GSO batches sends into one syscall for throughput, but some NICs and
100	/// middleboxes mangle segmented packets. Defaults to on. The iroh backend
101	/// cannot turn it off and rejects an explicit `false`.
102	#[serde(skip_serializing_if = "Option::is_none")]
103	#[arg(
104		id = "client-quic-gso",
105		long = "client-quic-gso",
106		env = "MOQ_CLIENT_QUIC_GSO",
107		default_missing_value = "true",
108		num_args = 0..=1,
109		require_equals = true,
110		value_parser = clap::value_parser!(bool),
111	)]
112	pub gso: Option<bool>,
113
114	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
115	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
116	#[arg(
117		id = "client-quic-idle-timeout",
118		long = "client-quic-idle-timeout",
119		env = "MOQ_CLIENT_QUIC_IDLE_TIMEOUT",
120		value_parser = humantime::parse_duration,
121	)]
122	pub idle_timeout: Option<Duration>,
123
124	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
125	/// Ignored by the iroh backend, which has no keep-alive knob.
126	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
127	#[arg(
128		id = "client-quic-keep-alive",
129		long = "client-quic-keep-alive",
130		env = "MOQ_CLIENT_QUIC_KEEP_ALIVE",
131		value_parser = humantime::parse_duration,
132	)]
133	pub keep_alive: Option<Duration>,
134
135	/// Enable path MTU discovery. Defaults to off.
136	#[serde(skip_serializing_if = "Option::is_none")]
137	#[arg(
138		id = "client-quic-mtu-discovery",
139		long = "client-quic-mtu-discovery",
140		env = "MOQ_CLIENT_QUIC_MTU_DISCOVERY",
141		default_missing_value = "true",
142		num_args = 0..=1,
143		require_equals = true,
144		value_parser = clap::value_parser!(bool),
145	)]
146	pub mtu_discovery: Option<bool>,
147
148	/// Congestion control family, defaulting to `delay` on every backend. Which BBR
149	/// generation that is depends on the backend (see [`CongestionControl`]).
150	#[serde(skip_serializing_if = "Option::is_none")]
151	#[arg(
152		id = "client-quic-congestion-control",
153		long = "client-quic-congestion-control",
154		env = "MOQ_CLIENT_QUIC_CONGESTION_CONTROL",
155		value_enum
156	)]
157	pub congestion_control: Option<CongestionControl>,
158
159	/// Write qlog traces into this directory. See [`Server::qlog`].
160	#[serde(default, skip_serializing_if = "Option::is_none")]
161	#[arg(id = "client-quic-qlog", long = "client-quic-qlog", env = "MOQ_CLIENT_QUIC_QLOG")]
162	pub qlog: Option<PathBuf>,
163}
164
165/// Reject a qlog directory that this build can't honor.
166///
167/// Erroring beats silently ignoring the flag: the operator asked for traces and would
168/// otherwise go looking for files that were never going to appear. Checked once when
169/// the client/server is built, so the backends can assume the directory is usable.
170fn validate_qlog(qlog: Option<&PathBuf>) -> crate::Result<()> {
171	match qlog {
172		Some(_) if cfg!(not(feature = "qlog")) => Err(crate::Error::QlogUnsupported),
173		_ => Ok(()),
174	}
175}
176
177/// QUIC carries `max_idle_timeout` as a varint of milliseconds, so anything past this
178/// can't go on the wire.
179const MAX_IDLE_TIMEOUT: Duration = Duration::from_millis((1 << 62) - 1);
180
181/// Reject an idle timeout no QUIC connection can express.
182///
183/// Checked here rather than in each backend because the conversion the backends do is
184/// infallible-by-panic, and this config reaches them from a TOML file or a C caller.
185fn validate_idle_timeout(idle_timeout: Option<Duration>) -> crate::Result<()> {
186	match idle_timeout {
187		Some(timeout) if timeout > MAX_IDLE_TIMEOUT => Err(crate::Error::IdleTimeoutRange),
188		_ => Ok(()),
189	}
190}
191
192impl Client {
193	/// Reject knobs this build can't honor. Called when the client is built.
194	pub(crate) fn validate(&self) -> crate::Result<()> {
195		validate_qlog(self.qlog.as_ref())?;
196		validate_idle_timeout(self.idle_timeout)
197	}
198
199	/// The per-connection knobs with defaults applied, ready to hand to a backend.
200	pub fn resolve(&self) -> Resolved {
201		Resolved::new(
202			self.max_streams,
203			self.gso,
204			self.idle_timeout,
205			self.keep_alive,
206			self.mtu_discovery,
207			self.congestion_control,
208			self.qlog.clone(),
209		)
210	}
211}
212
213/// Every knob at its default, which is what an untouched [`Client`] resolves to.
214impl Default for Resolved {
215	fn default() -> Self {
216		Client::default().resolve()
217	}
218}
219
220/// The `--server-quic-*` transport section.
221///
222/// Carries the same per-connection knobs as [`Client`] plus the accept-side knobs
223/// (preferred address, QUIC-LB connection IDs).
224#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
225#[serde(deny_unknown_fields, default)]
226#[non_exhaustive]
227pub struct Server {
228	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
229	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
230	#[serde(skip_serializing_if = "Option::is_none")]
231	#[arg(
232		id = "server-quic-max-streams",
233		long = "server-quic-max-streams",
234		alias = "server-max-streams",
235		env = "MOQ_SERVER_QUIC_MAX_STREAMS"
236	)]
237	pub max_streams: Option<u64>,
238
239	/// Enable UDP generic segmentation offload (GSO). See [`Client::gso`].
240	#[serde(skip_serializing_if = "Option::is_none")]
241	#[arg(
242		id = "server-quic-gso",
243		long = "server-quic-gso",
244		env = "MOQ_SERVER_QUIC_GSO",
245		default_missing_value = "true",
246		num_args = 0..=1,
247		require_equals = true,
248		value_parser = clap::value_parser!(bool),
249	)]
250	pub gso: Option<bool>,
251
252	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
253	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
254	#[arg(
255		id = "server-quic-idle-timeout",
256		long = "server-quic-idle-timeout",
257		env = "MOQ_SERVER_QUIC_IDLE_TIMEOUT",
258		value_parser = humantime::parse_duration,
259	)]
260	pub idle_timeout: Option<Duration>,
261
262	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
263	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
264	#[arg(
265		id = "server-quic-keep-alive",
266		long = "server-quic-keep-alive",
267		env = "MOQ_SERVER_QUIC_KEEP_ALIVE",
268		value_parser = humantime::parse_duration,
269	)]
270	pub keep_alive: Option<Duration>,
271
272	/// Enable path MTU discovery. Defaults to off.
273	#[serde(skip_serializing_if = "Option::is_none")]
274	#[arg(
275		id = "server-quic-mtu-discovery",
276		long = "server-quic-mtu-discovery",
277		env = "MOQ_SERVER_QUIC_MTU_DISCOVERY",
278		default_missing_value = "true",
279		num_args = 0..=1,
280		require_equals = true,
281		value_parser = clap::value_parser!(bool),
282	)]
283	pub mtu_discovery: Option<bool>,
284
285	/// Congestion control family, defaulting to `delay` on every backend. Which BBR
286	/// generation that is depends on the backend (see [`CongestionControl`]).
287	#[serde(skip_serializing_if = "Option::is_none")]
288	#[arg(
289		id = "server-quic-congestion-control",
290		long = "server-quic-congestion-control",
291		env = "MOQ_SERVER_QUIC_CONGESTION_CONTROL",
292		value_enum
293	)]
294	pub congestion_control: Option<CongestionControl>,
295
296	/// IPv4 address advertised as the QUIC preferred_address.
297	///
298	/// Supporting clients (Chrome M131+, native Quinn) migrate to this address
299	/// shortly after the handshake completes. Typical use: handshake on an
300	/// anycast IP, steady-state on this host's unicast IP.
301	///
302	/// Honored by the Quinn and noq backends.
303	#[arg(
304		id = "server-preferred-v4",
305		long = "server-preferred-v4",
306		env = "MOQ_SERVER_PREFERRED_V4"
307	)]
308	#[serde(default, skip_serializing_if = "Option::is_none")]
309	pub preferred_v4: Option<net::SocketAddrV4>,
310
311	/// IPv6 address advertised as the QUIC preferred_address. See [`Self::preferred_v4`].
312	#[arg(
313		id = "server-preferred-v6",
314		long = "server-preferred-v6",
315		env = "MOQ_SERVER_PREFERRED_V6"
316	)]
317	#[serde(default, skip_serializing_if = "Option::is_none")]
318	pub preferred_v6: Option<net::SocketAddrV6>,
319
320	/// Server ID to embed in connection IDs for QUIC-LB compatibility.
321	/// If set, connection IDs will be derived semi-deterministically.
322	#[arg(id = "server-quic-lb-id", long = "server-quic-lb-id", env = "MOQ_SERVER_QUIC_LB_ID")]
323	#[serde(default, skip_serializing_if = "Option::is_none")]
324	pub quic_lb_id: Option<ServerId>,
325
326	/// Number of random nonce bytes in QUIC-LB connection IDs.
327	/// Must be at least 4, and server_id + nonce + 1 must not exceed 20.
328	#[arg(
329		id = "server-quic-lb-nonce",
330		long = "server-quic-lb-nonce",
331		requires = "server-quic-lb-id",
332		env = "MOQ_SERVER_QUIC_LB_NONCE"
333	)]
334	#[serde(default, skip_serializing_if = "Option::is_none")]
335	pub quic_lb_nonce: Option<usize>,
336
337	/// Write qlog traces into this directory, which must already exist.
338	///
339	/// The layout is backend-specific: quiche and noq write one file per connection,
340	/// while quinn writes one file per endpoint and tags each event with the qlog
341	/// `group_id` of the connection it belongs to.
342	///
343	/// Requires the `qlog` feature; setting it errors at init otherwise.
344	#[serde(default, skip_serializing_if = "Option::is_none")]
345	#[arg(id = "server-quic-qlog", long = "server-quic-qlog", env = "MOQ_SERVER_QUIC_QLOG")]
346	pub qlog: Option<PathBuf>,
347}
348
349impl Server {
350	/// Reject knobs this build can't honor. Called when the server is built.
351	pub(crate) fn validate(&self) -> crate::Result<()> {
352		validate_qlog(self.qlog.as_ref())?;
353		validate_idle_timeout(self.idle_timeout)
354	}
355
356	/// The per-connection knobs with defaults applied, ready to hand to a backend.
357	#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
358	pub(crate) fn resolve(&self) -> Resolved {
359		Resolved::new(
360			self.max_streams,
361			self.gso,
362			self.idle_timeout,
363			self.keep_alive,
364			self.mtu_discovery,
365			self.congestion_control,
366			self.qlog.clone(),
367		)
368	}
369}
370
371/// A resolved view of the per-connection knobs (defaults filled in), shared by
372/// [`Client`] and [`Server`] so backends apply them the same way regardless of role.
373///
374/// The backends consume it, and [`Client::resolve`] produces it. [`Resolved::default`]
375/// is therefore the canonical statement of what every QUIC knob defaults to, which is
376/// what a UI should show rather than repeating the numbers.
377///
378/// Non-exhaustive because it gains a field for every knob [`Client`] gains, so build it
379/// from [`Client::resolve`] or [`Resolved::default`] rather than a struct literal.
380#[derive(Clone, Debug)]
381#[non_exhaustive]
382pub struct Resolved {
383	/// Max concurrent streams (bidi and uni).
384	pub max_streams: u64,
385	/// GSO override, or `None` to leave the backend default (on).
386	pub gso: Option<bool>,
387	/// Idle timeout.
388	pub idle_timeout: Duration,
389	/// Keep-alive interval, or `None` when disabled.
390	pub keep_alive: Option<Duration>,
391	/// Whether to run path MTU discovery.
392	pub mtu_discovery: bool,
393	/// Congestion control override, or `None` for the default. The backends read it
394	/// through `Resolved::congestion`, which applies that default once for all of them.
395	pub congestion_control: Option<CongestionControl>,
396	/// Directory to write qlog traces into, or `None` to not capture them.
397	pub qlog: Option<PathBuf>,
398}
399
400impl Resolved {
401	fn new(
402		max_streams: Option<u64>,
403		gso: Option<bool>,
404		idle_timeout: Option<Duration>,
405		keep_alive: Option<Duration>,
406		mtu_discovery: Option<bool>,
407		congestion_control: Option<CongestionControl>,
408		qlog: Option<PathBuf>,
409	) -> Self {
410		// A zero keep-alive means "disabled"; anything else (including unset) keeps
411		// the connection warm, defaulting to 5s.
412		let keep_alive = match keep_alive {
413			Some(d) if d.is_zero() => None,
414			Some(d) => Some(d),
415			None => Some(DEFAULT_KEEP_ALIVE),
416		};
417
418		Self {
419			max_streams: max_streams.unwrap_or(DEFAULT_MAX_STREAMS),
420			gso,
421			idle_timeout: idle_timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT),
422			keep_alive,
423			mtu_discovery: mtu_discovery.unwrap_or(false),
424			congestion_control,
425			qlog,
426		}
427	}
428
429	/// The congestion control family to install.
430	///
431	/// Delay-based unless the operator says otherwise: BBR keeps queues short and the
432	/// send rate steady enough for a live encoder to track, which is what this stack
433	/// carries. Every backend resolves the default here rather than each picking its
434	/// own, so the answer can't drift between them.
435	#[cfg_attr(
436		not(any(feature = "quinn", feature = "noq", feature = "quiche", feature = "iroh")),
437		allow(dead_code)
438	)]
439	pub(crate) fn congestion(&self) -> CongestionControl {
440		self.congestion_control.unwrap_or(CongestionControl::Delay)
441	}
442
443	/// The directory to write qlog traces into, if any.
444	///
445	/// Only meaningful once [`Client::validate`] / [`Server::validate`] has passed; a
446	/// build without the `qlog` feature never gets here with a directory set.
447	#[cfg_attr(not(any(feature = "quinn", feature = "noq", feature = "quiche")), allow(dead_code))]
448	pub(crate) fn qlog_dir(&self) -> Option<&std::path::Path> {
449		self.qlog.as_deref()
450	}
451
452	/// Whether the config asks to turn GSO off, which not every backend can honor.
453	///
454	/// Only the iroh backend consults this, to reject a GSO-off request it can't
455	/// satisfy; the other backends toggle GSO directly.
456	#[cfg_attr(not(feature = "iroh"), allow(dead_code))]
457	pub(crate) fn gso_disabled(&self) -> bool {
458		self.gso == Some(false)
459	}
460}
461
462#[cfg(test)]
463mod tests {
464	use super::*;
465	use clap::Parser;
466
467	/// Minimal parsers so we can exercise the `--client-quic-*` / `--server-quic-*`
468	/// args in isolation (and together, the way relay/cli flatten both).
469	#[derive(Parser)]
470	struct Both {
471		#[command(flatten)]
472		client: Client,
473		#[command(flatten)]
474		server: Server,
475	}
476
477	fn parse(args: &[&str]) -> Both {
478		let mut full = vec!["test"];
479		full.extend_from_slice(args);
480		Both::parse_from(full)
481	}
482
483	#[test]
484	fn defaults_apply_when_unset() {
485		let quic = Client::default().resolve();
486		assert_eq!(quic.max_streams, DEFAULT_MAX_STREAMS);
487		assert_eq!(quic.idle_timeout, DEFAULT_IDLE_TIMEOUT);
488		assert_eq!(quic.keep_alive, Some(DEFAULT_KEEP_ALIVE));
489		assert!(!quic.mtu_discovery);
490		assert_eq!(quic.gso, None);
491		assert!(!quic.gso_disabled());
492	}
493
494	// `Server::resolve` only exists where a backend consumes it.
495	#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
496	#[test]
497	fn zero_keep_alive_disables_it() {
498		let disabled = Server {
499			keep_alive: Some(Duration::ZERO),
500			..Default::default()
501		};
502		assert_eq!(disabled.resolve().keep_alive, None);
503
504		let explicit = Client {
505			keep_alive: Some(Duration::from_secs(2)),
506			..Default::default()
507		};
508		assert_eq!(explicit.resolve().keep_alive, Some(Duration::from_secs(2)));
509	}
510
511	#[test]
512	fn gso_disabled_only_on_explicit_false() {
513		let off = Client {
514			gso: Some(false),
515			..Default::default()
516		};
517		assert!(off.resolve().gso_disabled());
518		let on = Client {
519			gso: Some(true),
520			..Default::default()
521		};
522		assert!(!on.resolve().gso_disabled());
523	}
524
525	#[test]
526	fn client_and_server_flags_are_distinct() {
527		let both = parse(&["--client-quic-max-streams", "5000", "--server-quic-max-streams", "9000"]);
528		assert_eq!(both.client.max_streams, Some(5000));
529		assert_eq!(both.server.max_streams, Some(9000));
530	}
531
532	#[test]
533	fn server_only_knobs_parse() {
534		let both = parse(&["--server-preferred-v4", "192.0.2.1:443", "--server-quic-lb-id", "ab"]);
535		assert_eq!(both.server.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
536		assert!(both.server.quic_lb_id.is_some());
537		// The accept-side knobs live only on the server section.
538		assert_eq!(both.client.max_streams, None);
539	}
540
541	#[test]
542	fn deprecated_max_streams_aliases() {
543		let both = parse(&["--client-max-streams", "2048", "--server-max-streams", "4096"]);
544		assert_eq!(both.client.max_streams, Some(2048));
545		assert_eq!(both.server.max_streams, Some(4096));
546	}
547
548	#[test]
549	fn qlog_flags_are_distinct_per_role() {
550		let both = parse(&["--client-quic-qlog", "/tmp/client", "--server-quic-qlog", "/tmp/server"]);
551		assert_eq!(both.client.qlog.as_deref(), Some(std::path::Path::new("/tmp/client")));
552		assert_eq!(both.server.qlog.as_deref(), Some(std::path::Path::new("/tmp/server")));
553
554		assert_eq!(
555			both.client.resolve().qlog_dir(),
556			Some(std::path::Path::new("/tmp/client"))
557		);
558		assert_eq!(Client::default().resolve().qlog_dir(), None);
559	}
560
561	/// A build that can't capture must reject the flag rather than ignore it, so an
562	/// operator isn't left waiting on trace files that will never appear.
563	#[test]
564	fn qlog_requires_the_feature() {
565		let unset = Client::default().validate();
566		assert!(unset.is_ok(), "no directory configured is always fine");
567
568		let set = Client {
569			qlog: Some("/tmp/qlog".into()),
570			..Default::default()
571		};
572
573		if cfg!(feature = "qlog") {
574			assert!(set.validate().is_ok());
575		} else {
576			assert!(matches!(set.validate(), Err(crate::Error::QlogUnsupported)));
577		}
578	}
579
580	/// The backends convert this duration with an infallible-by-panic `expect`, and the
581	/// value arrives from a TOML file or a C caller, so validation has to catch it here.
582	#[test]
583	fn idle_timeout_beyond_the_varint_is_rejected() {
584		let over = Client {
585			idle_timeout: Some(MAX_IDLE_TIMEOUT + Duration::from_millis(1)),
586			..Default::default()
587		};
588		assert!(matches!(over.validate(), Err(crate::Error::IdleTimeoutRange)));
589
590		let server = Server {
591			idle_timeout: Some(Duration::from_millis(u64::MAX)),
592			..Default::default()
593		};
594		assert!(matches!(server.validate(), Err(crate::Error::IdleTimeoutRange)));
595
596		let at_limit = Client {
597			idle_timeout: Some(MAX_IDLE_TIMEOUT),
598			..Default::default()
599		};
600		assert!(at_limit.validate().is_ok());
601	}
602
603	#[test]
604	fn toml_round_trips() {
605		let toml = r#"
606			max_streams = 7000
607			gso = false
608			preferred_v4 = "192.0.2.1:443"
609			congestion_control = "delay"
610			qlog = "/tmp/qlog"
611		"#;
612		let quic: Server = toml::from_str(toml).unwrap();
613		assert_eq!(quic.max_streams, Some(7000));
614		assert_eq!(quic.gso, Some(false));
615		assert_eq!(quic.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
616		assert_eq!(quic.congestion_control, Some(CongestionControl::Delay));
617		assert_eq!(quic.qlog.as_deref(), Some(std::path::Path::new("/tmp/qlog")));
618	}
619
620	#[test]
621	fn congestion_control_flags_parse() {
622		let both = parse(&[
623			"--client-quic-congestion-control",
624			"delay",
625			"--server-quic-congestion-control",
626			"loss",
627		]);
628		assert_eq!(both.client.congestion_control, Some(CongestionControl::Delay));
629		assert_eq!(both.server.congestion_control, Some(CongestionControl::Loss));
630
631		// Unset stays None on the wire; `Resolved::congestion` applies the default.
632		assert_eq!(Client::default().resolve().congestion_control, None);
633	}
634
635	/// One default for every backend, so a knob left unset can't mean CUBIC on one
636	/// and BBR on another.
637	#[test]
638	fn congestion_defaults_to_delay() {
639		let mut quic = Client::default();
640		assert_eq!(quic.resolve().congestion(), CongestionControl::Delay);
641
642		// An explicit request still gets through.
643		quic.congestion_control = Some(CongestionControl::Loss);
644		assert_eq!(quic.resolve().congestion(), CongestionControl::Loss);
645	}
646}