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/// Default maximum number of concurrent QUIC streams (bidi and uni) per connection.
64pub(crate) const DEFAULT_MAX_STREAMS: u64 = 1024;
65
66/// Default idle timeout before an inactive connection is dropped.
67pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
68
69/// Default keep-alive ping interval.
70pub(crate) const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(5);
71
72/// The `--client-quic-*` transport section.
73#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
74#[serde(deny_unknown_fields, default)]
75#[non_exhaustive]
76pub struct Client {
77	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
78	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
79	#[serde(skip_serializing_if = "Option::is_none")]
80	#[arg(
81		id = "client-quic-max-streams",
82		long = "client-quic-max-streams",
83		alias = "client-max-streams",
84		env = "MOQ_CLIENT_QUIC_MAX_STREAMS"
85	)]
86	pub max_streams: Option<u64>,
87
88	/// Enable UDP generic segmentation offload (GSO).
89	///
90	/// GSO batches sends into one syscall for throughput, but some NICs and
91	/// middleboxes mangle segmented packets. Defaults to on. Only the quinn and
92	/// noq backends can turn it off; setting `false` errors at init on quiche/iroh.
93	#[serde(skip_serializing_if = "Option::is_none")]
94	#[arg(
95		id = "client-quic-gso",
96		long = "client-quic-gso",
97		env = "MOQ_CLIENT_QUIC_GSO",
98		default_missing_value = "true",
99		num_args = 0..=1,
100		require_equals = true,
101		value_parser = clap::value_parser!(bool),
102	)]
103	pub gso: Option<bool>,
104
105	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
106	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
107	#[arg(
108		id = "client-quic-idle-timeout",
109		long = "client-quic-idle-timeout",
110		env = "MOQ_CLIENT_QUIC_IDLE_TIMEOUT",
111		value_parser = humantime::parse_duration,
112	)]
113	pub idle_timeout: Option<Duration>,
114
115	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
116	/// Ignored by the quiche and iroh backends, which have no keep-alive knob.
117	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
118	#[arg(
119		id = "client-quic-keep-alive",
120		long = "client-quic-keep-alive",
121		env = "MOQ_CLIENT_QUIC_KEEP_ALIVE",
122		value_parser = humantime::parse_duration,
123	)]
124	pub keep_alive: Option<Duration>,
125
126	/// Enable path MTU discovery. Defaults to off.
127	#[serde(skip_serializing_if = "Option::is_none")]
128	#[arg(
129		id = "client-quic-mtu-discovery",
130		long = "client-quic-mtu-discovery",
131		env = "MOQ_CLIENT_QUIC_MTU_DISCOVERY",
132		default_missing_value = "true",
133		num_args = 0..=1,
134		require_equals = true,
135		value_parser = clap::value_parser!(bool),
136	)]
137	pub mtu_discovery: Option<bool>,
138
139	/// Congestion control family. Defaults to `delay` on quinn and quiche, and to
140	/// `loss` on noq and iroh, whose shared BBRv3 can panic on packet loss and take
141	/// the process with it. Selecting `delay` there is for deliberate testing only.
142	#[serde(skip_serializing_if = "Option::is_none")]
143	#[arg(
144		id = "client-quic-congestion-control",
145		long = "client-quic-congestion-control",
146		env = "MOQ_CLIENT_QUIC_CONGESTION_CONTROL",
147		value_enum
148	)]
149	pub congestion_control: Option<CongestionControl>,
150
151	/// Write qlog traces into this directory. See [`Server::qlog`].
152	#[serde(default, skip_serializing_if = "Option::is_none")]
153	#[arg(id = "client-quic-qlog", long = "client-quic-qlog", env = "MOQ_CLIENT_QUIC_QLOG")]
154	pub qlog: Option<PathBuf>,
155}
156
157/// Reject a qlog directory that this build can't honor.
158///
159/// Erroring beats silently ignoring the flag: the operator asked for traces and would
160/// otherwise go looking for files that were never going to appear. Checked once when
161/// the client/server is built, so the backends can assume the directory is usable.
162fn validate_qlog(qlog: Option<&PathBuf>) -> crate::Result<()> {
163	match qlog {
164		Some(_) if cfg!(not(feature = "qlog")) => Err(crate::Error::QlogUnsupported),
165		_ => Ok(()),
166	}
167}
168
169impl Client {
170	/// Reject knobs this build can't honor. Called when the client is built.
171	pub(crate) fn validate(&self) -> crate::Result<()> {
172		validate_qlog(self.qlog.as_ref())
173	}
174
175	/// The per-connection knobs with defaults applied, ready to hand to a backend.
176	pub(crate) fn resolve(&self) -> Resolved {
177		Resolved::new(
178			self.max_streams,
179			self.gso,
180			self.idle_timeout,
181			self.keep_alive,
182			self.mtu_discovery,
183			self.congestion_control,
184			self.qlog.clone(),
185		)
186	}
187}
188
189/// The `--server-quic-*` transport section.
190///
191/// Carries the same per-connection knobs as [`Client`] plus the accept-side knobs
192/// (preferred address, QUIC-LB connection IDs).
193#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
194#[serde(deny_unknown_fields, default)]
195#[non_exhaustive]
196pub struct Server {
197	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
198	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
199	#[serde(skip_serializing_if = "Option::is_none")]
200	#[arg(
201		id = "server-quic-max-streams",
202		long = "server-quic-max-streams",
203		alias = "server-max-streams",
204		env = "MOQ_SERVER_QUIC_MAX_STREAMS"
205	)]
206	pub max_streams: Option<u64>,
207
208	/// Enable UDP generic segmentation offload (GSO). See [`Client::gso`].
209	#[serde(skip_serializing_if = "Option::is_none")]
210	#[arg(
211		id = "server-quic-gso",
212		long = "server-quic-gso",
213		env = "MOQ_SERVER_QUIC_GSO",
214		default_missing_value = "true",
215		num_args = 0..=1,
216		require_equals = true,
217		value_parser = clap::value_parser!(bool),
218	)]
219	pub gso: Option<bool>,
220
221	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
222	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
223	#[arg(
224		id = "server-quic-idle-timeout",
225		long = "server-quic-idle-timeout",
226		env = "MOQ_SERVER_QUIC_IDLE_TIMEOUT",
227		value_parser = humantime::parse_duration,
228	)]
229	pub idle_timeout: Option<Duration>,
230
231	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
232	/// Ignored by the quiche backend, which has no keep-alive knob.
233	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
234	#[arg(
235		id = "server-quic-keep-alive",
236		long = "server-quic-keep-alive",
237		env = "MOQ_SERVER_QUIC_KEEP_ALIVE",
238		value_parser = humantime::parse_duration,
239	)]
240	pub keep_alive: Option<Duration>,
241
242	/// Enable path MTU discovery. Defaults to off.
243	#[serde(skip_serializing_if = "Option::is_none")]
244	#[arg(
245		id = "server-quic-mtu-discovery",
246		long = "server-quic-mtu-discovery",
247		env = "MOQ_SERVER_QUIC_MTU_DISCOVERY",
248		default_missing_value = "true",
249		num_args = 0..=1,
250		require_equals = true,
251		value_parser = clap::value_parser!(bool),
252	)]
253	pub mtu_discovery: Option<bool>,
254
255	/// Congestion control family. Defaults to `delay` on quinn and quiche, and to
256	/// `loss` on noq, whose BBRv3 can panic on packet loss and take the process with
257	/// it. Selecting `delay` there is for deliberate testing only.
258	#[serde(skip_serializing_if = "Option::is_none")]
259	#[arg(
260		id = "server-quic-congestion-control",
261		long = "server-quic-congestion-control",
262		env = "MOQ_SERVER_QUIC_CONGESTION_CONTROL",
263		value_enum
264	)]
265	pub congestion_control: Option<CongestionControl>,
266
267	/// IPv4 address advertised as the QUIC preferred_address.
268	///
269	/// Supporting clients (Chrome M131+, native Quinn) migrate to this address
270	/// shortly after the handshake completes. Typical use: handshake on an
271	/// anycast IP, steady-state on this host's unicast IP.
272	///
273	/// Honored by the Quinn and noq backends.
274	#[arg(
275		id = "server-preferred-v4",
276		long = "server-preferred-v4",
277		env = "MOQ_SERVER_PREFERRED_V4"
278	)]
279	#[serde(default, skip_serializing_if = "Option::is_none")]
280	pub preferred_v4: Option<net::SocketAddrV4>,
281
282	/// IPv6 address advertised as the QUIC preferred_address. See [`Self::preferred_v4`].
283	#[arg(
284		id = "server-preferred-v6",
285		long = "server-preferred-v6",
286		env = "MOQ_SERVER_PREFERRED_V6"
287	)]
288	#[serde(default, skip_serializing_if = "Option::is_none")]
289	pub preferred_v6: Option<net::SocketAddrV6>,
290
291	/// Server ID to embed in connection IDs for QUIC-LB compatibility.
292	/// If set, connection IDs will be derived semi-deterministically.
293	#[arg(id = "server-quic-lb-id", long = "server-quic-lb-id", env = "MOQ_SERVER_QUIC_LB_ID")]
294	#[serde(default, skip_serializing_if = "Option::is_none")]
295	pub quic_lb_id: Option<ServerId>,
296
297	/// Number of random nonce bytes in QUIC-LB connection IDs.
298	/// Must be at least 4, and server_id + nonce + 1 must not exceed 20.
299	#[arg(
300		id = "server-quic-lb-nonce",
301		long = "server-quic-lb-nonce",
302		requires = "server-quic-lb-id",
303		env = "MOQ_SERVER_QUIC_LB_NONCE"
304	)]
305	#[serde(default, skip_serializing_if = "Option::is_none")]
306	pub quic_lb_nonce: Option<usize>,
307
308	/// Write qlog traces into this directory, which must already exist.
309	///
310	/// The layout is backend-specific: quiche and noq write one file per connection,
311	/// while quinn writes one file per endpoint and tags each event with the qlog
312	/// `group_id` of the connection it belongs to.
313	///
314	/// Requires the `qlog` feature; setting it errors at init otherwise.
315	#[serde(default, skip_serializing_if = "Option::is_none")]
316	#[arg(id = "server-quic-qlog", long = "server-quic-qlog", env = "MOQ_SERVER_QUIC_QLOG")]
317	pub qlog: Option<PathBuf>,
318}
319
320impl Server {
321	/// Reject knobs this build can't honor. Called when the server is built.
322	pub(crate) fn validate(&self) -> crate::Result<()> {
323		validate_qlog(self.qlog.as_ref())
324	}
325
326	/// The per-connection knobs with defaults applied, ready to hand to a backend.
327	pub(crate) fn resolve(&self) -> Resolved {
328		Resolved::new(
329			self.max_streams,
330			self.gso,
331			self.idle_timeout,
332			self.keep_alive,
333			self.mtu_discovery,
334			self.congestion_control,
335			self.qlog.clone(),
336		)
337	}
338}
339
340/// A resolved view of the per-connection knobs (defaults filled in), shared by
341/// [`Client`] and [`Server`] so backends apply them the same way regardless of role.
342///
343/// Internal: the backends consume it and [`crate::iroh::EndpointConfig::bind`]
344/// resolves it from a [`Client`], so it never appears in the public surface.
345#[derive(Clone, Debug)]
346pub(crate) struct Resolved {
347	/// Max concurrent streams (bidi and uni).
348	pub max_streams: u64,
349	/// GSO override, or `None` to leave the backend default (on).
350	pub gso: Option<bool>,
351	/// Idle timeout.
352	pub idle_timeout: Duration,
353	/// Keep-alive interval, or `None` when disabled.
354	pub keep_alive: Option<Duration>,
355	/// Whether to run path MTU discovery.
356	pub mtu_discovery: bool,
357	/// Congestion control override, or `None` for the backend's own default. Each
358	/// backend picks that default itself, since they don't all agree.
359	pub congestion_control: Option<CongestionControl>,
360	/// Directory to write qlog traces into, or `None` to not capture them.
361	pub qlog: Option<PathBuf>,
362}
363
364impl Resolved {
365	fn new(
366		max_streams: Option<u64>,
367		gso: Option<bool>,
368		idle_timeout: Option<Duration>,
369		keep_alive: Option<Duration>,
370		mtu_discovery: Option<bool>,
371		congestion_control: Option<CongestionControl>,
372		qlog: Option<PathBuf>,
373	) -> Self {
374		// A zero keep-alive means "disabled"; anything else (including unset) keeps
375		// the connection warm, defaulting to 5s.
376		let keep_alive = match keep_alive {
377			Some(d) if d.is_zero() => None,
378			Some(d) => Some(d),
379			None => Some(DEFAULT_KEEP_ALIVE),
380		};
381
382		Self {
383			max_streams: max_streams.unwrap_or(DEFAULT_MAX_STREAMS),
384			gso,
385			idle_timeout: idle_timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT),
386			keep_alive,
387			mtu_discovery: mtu_discovery.unwrap_or(false),
388			congestion_control,
389			qlog,
390		}
391	}
392
393	/// The directory to write qlog traces into, if any.
394	///
395	/// Only meaningful once [`Client::validate`] / [`Server::validate`] has passed; a
396	/// build without the `qlog` feature never gets here with a directory set.
397	#[cfg_attr(not(any(feature = "quinn", feature = "noq", feature = "quiche")), allow(dead_code))]
398	pub(crate) fn qlog_dir(&self) -> Option<&std::path::Path> {
399		self.qlog.as_deref()
400	}
401
402	/// Whether the config asks to turn GSO off, which not every backend can honor.
403	///
404	/// Only the quiche and iroh backends consult this, to reject a GSO-off request
405	/// they can't satisfy; quinn and noq toggle GSO directly. A default build
406	/// compiles neither, so the method is intentionally unused there.
407	#[cfg_attr(not(any(feature = "quiche", feature = "iroh")), allow(dead_code))]
408	pub(crate) fn gso_disabled(&self) -> bool {
409		self.gso == Some(false)
410	}
411}
412
413#[cfg(test)]
414mod tests {
415	use super::*;
416	use clap::Parser;
417
418	/// Minimal parsers so we can exercise the `--client-quic-*` / `--server-quic-*`
419	/// args in isolation (and together, the way relay/cli flatten both).
420	#[derive(Parser)]
421	struct Both {
422		#[command(flatten)]
423		client: Client,
424		#[command(flatten)]
425		server: Server,
426	}
427
428	fn parse(args: &[&str]) -> Both {
429		let mut full = vec!["test"];
430		full.extend_from_slice(args);
431		Both::parse_from(full)
432	}
433
434	#[test]
435	fn defaults_apply_when_unset() {
436		let quic = Client::default().resolve();
437		assert_eq!(quic.max_streams, DEFAULT_MAX_STREAMS);
438		assert_eq!(quic.idle_timeout, DEFAULT_IDLE_TIMEOUT);
439		assert_eq!(quic.keep_alive, Some(DEFAULT_KEEP_ALIVE));
440		assert!(!quic.mtu_discovery);
441		assert_eq!(quic.gso, None);
442		assert!(!quic.gso_disabled());
443	}
444
445	#[test]
446	fn zero_keep_alive_disables_it() {
447		let disabled = Server {
448			keep_alive: Some(Duration::ZERO),
449			..Default::default()
450		};
451		assert_eq!(disabled.resolve().keep_alive, None);
452
453		let explicit = Client {
454			keep_alive: Some(Duration::from_secs(2)),
455			..Default::default()
456		};
457		assert_eq!(explicit.resolve().keep_alive, Some(Duration::from_secs(2)));
458	}
459
460	#[test]
461	fn gso_disabled_only_on_explicit_false() {
462		let off = Client {
463			gso: Some(false),
464			..Default::default()
465		};
466		assert!(off.resolve().gso_disabled());
467		let on = Client {
468			gso: Some(true),
469			..Default::default()
470		};
471		assert!(!on.resolve().gso_disabled());
472	}
473
474	#[test]
475	fn client_and_server_flags_are_distinct() {
476		let both = parse(&["--client-quic-max-streams", "5000", "--server-quic-max-streams", "9000"]);
477		assert_eq!(both.client.max_streams, Some(5000));
478		assert_eq!(both.server.max_streams, Some(9000));
479	}
480
481	#[test]
482	fn server_only_knobs_parse() {
483		let both = parse(&["--server-preferred-v4", "192.0.2.1:443", "--server-quic-lb-id", "ab"]);
484		assert_eq!(both.server.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
485		assert!(both.server.quic_lb_id.is_some());
486		// The accept-side knobs live only on the server section.
487		assert_eq!(both.client.max_streams, None);
488	}
489
490	#[test]
491	fn deprecated_max_streams_aliases() {
492		let both = parse(&["--client-max-streams", "2048", "--server-max-streams", "4096"]);
493		assert_eq!(both.client.max_streams, Some(2048));
494		assert_eq!(both.server.max_streams, Some(4096));
495	}
496
497	#[test]
498	fn qlog_flags_are_distinct_per_role() {
499		let both = parse(&["--client-quic-qlog", "/tmp/client", "--server-quic-qlog", "/tmp/server"]);
500		assert_eq!(both.client.qlog.as_deref(), Some(std::path::Path::new("/tmp/client")));
501		assert_eq!(both.server.qlog.as_deref(), Some(std::path::Path::new("/tmp/server")));
502
503		assert_eq!(
504			both.client.resolve().qlog_dir(),
505			Some(std::path::Path::new("/tmp/client"))
506		);
507		assert_eq!(Client::default().resolve().qlog_dir(), None);
508	}
509
510	/// A build that can't capture must reject the flag rather than ignore it, so an
511	/// operator isn't left waiting on trace files that will never appear.
512	#[test]
513	fn qlog_requires_the_feature() {
514		let unset = Client::default().validate();
515		assert!(unset.is_ok(), "no directory configured is always fine");
516
517		let set = Client {
518			qlog: Some("/tmp/qlog".into()),
519			..Default::default()
520		};
521
522		if cfg!(feature = "qlog") {
523			assert!(set.validate().is_ok());
524		} else {
525			assert!(matches!(set.validate(), Err(crate::Error::QlogUnsupported)));
526		}
527	}
528
529	#[test]
530	fn toml_round_trips() {
531		let toml = r#"
532			max_streams = 7000
533			gso = false
534			preferred_v4 = "192.0.2.1:443"
535			congestion_control = "delay"
536			qlog = "/tmp/qlog"
537		"#;
538		let quic: Server = toml::from_str(toml).unwrap();
539		assert_eq!(quic.max_streams, Some(7000));
540		assert_eq!(quic.gso, Some(false));
541		assert_eq!(quic.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
542		assert_eq!(quic.congestion_control, Some(CongestionControl::Delay));
543		assert_eq!(quic.qlog.as_deref(), Some(std::path::Path::new("/tmp/qlog")));
544	}
545
546	#[test]
547	fn congestion_control_flags_parse() {
548		let both = parse(&[
549			"--client-quic-congestion-control",
550			"delay",
551			"--server-quic-congestion-control",
552			"loss",
553		]);
554		assert_eq!(both.client.congestion_control, Some(CongestionControl::Delay));
555		assert_eq!(both.server.congestion_control, Some(CongestionControl::Loss));
556
557		// Unset stays None; each backend then picks its own default.
558		assert_eq!(Client::default().resolve().congestion_control, None);
559	}
560}