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::time::Duration;
14
15/// The routable server ID a QUIC-LB load balancer encodes into connection IDs.
16///
17/// Parsed from, and serialized as, a hex string. Its length must match the load
18/// balancer's configured server-ID length.
19#[serde_with::serde_as]
20#[derive(Clone, serde::Serialize, serde::Deserialize)]
21pub struct ServerId(#[serde_as(as = "serde_with::hex::Hex")] pub(crate) Vec<u8>);
22
23impl ServerId {
24	#[allow(dead_code)]
25	pub(crate) fn len(&self) -> usize {
26		self.0.len()
27	}
28}
29
30impl std::fmt::Debug for ServerId {
31	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32		f.debug_tuple("ServerId").field(&hex::encode(&self.0)).finish()
33	}
34}
35
36impl std::str::FromStr for ServerId {
37	type Err = hex::FromHexError;
38
39	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
40		hex::decode(s).map(Self)
41	}
42}
43
44/// The congestion control family for a QUIC connection.
45///
46/// This selects a family rather than a named algorithm because each backend ships a
47/// different generation: BBRv1 on quinn, BBRv2 on quiche, BBRv3 on noq and iroh. A
48/// `Bbr` variant would promise more than any one backend delivers.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize)]
50#[serde(rename_all = "kebab-case")]
51#[non_exhaustive]
52pub enum CongestionControl {
53	/// Loss-based (CUBIC): grows until it drops packets, so the send rate sawtooths.
54	/// Throughput-oriented, and the default on most stacks.
55	Loss,
56	/// Delay-based (BBR): tracks the measured delivery rate and RTT instead of waiting
57	/// for loss, which keeps queues short and the send rate steady enough for an encoder
58	/// to track.
59	Delay,
60}
61
62/// Default maximum number of concurrent QUIC streams (bidi and uni) per connection.
63pub(crate) const DEFAULT_MAX_STREAMS: u64 = 1024;
64
65/// Default idle timeout before an inactive connection is dropped.
66pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
67
68/// Default keep-alive ping interval.
69pub(crate) const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(5);
70
71/// The `--client-quic-*` transport section.
72#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
73#[serde(deny_unknown_fields, default)]
74#[non_exhaustive]
75pub struct Client {
76	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
77	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
78	#[serde(skip_serializing_if = "Option::is_none")]
79	#[arg(
80		id = "client-quic-max-streams",
81		long = "client-quic-max-streams",
82		alias = "client-max-streams",
83		env = "MOQ_CLIENT_QUIC_MAX_STREAMS"
84	)]
85	pub max_streams: Option<u64>,
86
87	/// Enable UDP generic segmentation offload (GSO).
88	///
89	/// GSO batches sends into one syscall for throughput, but some NICs and
90	/// middleboxes mangle segmented packets. Defaults to on. Only the quinn and
91	/// noq backends can turn it off; setting `false` errors at init on quiche/iroh.
92	#[serde(skip_serializing_if = "Option::is_none")]
93	#[arg(
94		id = "client-quic-gso",
95		long = "client-quic-gso",
96		env = "MOQ_CLIENT_QUIC_GSO",
97		default_missing_value = "true",
98		num_args = 0..=1,
99		require_equals = true,
100		value_parser = clap::value_parser!(bool),
101	)]
102	pub gso: Option<bool>,
103
104	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
105	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
106	#[arg(
107		id = "client-quic-idle-timeout",
108		long = "client-quic-idle-timeout",
109		env = "MOQ_CLIENT_QUIC_IDLE_TIMEOUT",
110		value_parser = humantime::parse_duration,
111	)]
112	pub idle_timeout: Option<Duration>,
113
114	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
115	/// Ignored by the quiche and iroh backends, which have no keep-alive knob.
116	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
117	#[arg(
118		id = "client-quic-keep-alive",
119		long = "client-quic-keep-alive",
120		env = "MOQ_CLIENT_QUIC_KEEP_ALIVE",
121		value_parser = humantime::parse_duration,
122	)]
123	pub keep_alive: Option<Duration>,
124
125	/// Enable path MTU discovery. Defaults to off.
126	#[serde(skip_serializing_if = "Option::is_none")]
127	#[arg(
128		id = "client-quic-mtu-discovery",
129		long = "client-quic-mtu-discovery",
130		env = "MOQ_CLIENT_QUIC_MTU_DISCOVERY",
131		default_missing_value = "true",
132		num_args = 0..=1,
133		require_equals = true,
134		value_parser = clap::value_parser!(bool),
135	)]
136	pub mtu_discovery: Option<bool>,
137
138	/// Congestion control family. Unset keeps the backend's own default: CUBIC on
139	/// quinn and quiche, BBRv3 on noq and iroh.
140	#[serde(skip_serializing_if = "Option::is_none")]
141	#[arg(
142		id = "client-quic-congestion-control",
143		long = "client-quic-congestion-control",
144		env = "MOQ_CLIENT_QUIC_CONGESTION_CONTROL",
145		value_enum
146	)]
147	pub congestion_control: Option<CongestionControl>,
148}
149
150impl Client {
151	/// The per-connection knobs with defaults applied, ready to hand to a backend.
152	pub(crate) fn resolve(&self) -> Resolved {
153		Resolved::new(
154			self.max_streams,
155			self.gso,
156			self.idle_timeout,
157			self.keep_alive,
158			self.mtu_discovery,
159			self.congestion_control,
160		)
161	}
162}
163
164/// The `--server-quic-*` transport section.
165///
166/// Carries the same per-connection knobs as [`Client`] plus the accept-side knobs
167/// (preferred address, QUIC-LB connection IDs).
168#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
169#[serde(deny_unknown_fields, default)]
170#[non_exhaustive]
171pub struct Server {
172	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
173	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
174	#[serde(skip_serializing_if = "Option::is_none")]
175	#[arg(
176		id = "server-quic-max-streams",
177		long = "server-quic-max-streams",
178		alias = "server-max-streams",
179		env = "MOQ_SERVER_QUIC_MAX_STREAMS"
180	)]
181	pub max_streams: Option<u64>,
182
183	/// Enable UDP generic segmentation offload (GSO). See [`Client::gso`].
184	#[serde(skip_serializing_if = "Option::is_none")]
185	#[arg(
186		id = "server-quic-gso",
187		long = "server-quic-gso",
188		env = "MOQ_SERVER_QUIC_GSO",
189		default_missing_value = "true",
190		num_args = 0..=1,
191		require_equals = true,
192		value_parser = clap::value_parser!(bool),
193	)]
194	pub gso: Option<bool>,
195
196	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
197	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
198	#[arg(
199		id = "server-quic-idle-timeout",
200		long = "server-quic-idle-timeout",
201		env = "MOQ_SERVER_QUIC_IDLE_TIMEOUT",
202		value_parser = humantime::parse_duration,
203	)]
204	pub idle_timeout: Option<Duration>,
205
206	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
207	/// Ignored by the quiche backend, which has no keep-alive knob.
208	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
209	#[arg(
210		id = "server-quic-keep-alive",
211		long = "server-quic-keep-alive",
212		env = "MOQ_SERVER_QUIC_KEEP_ALIVE",
213		value_parser = humantime::parse_duration,
214	)]
215	pub keep_alive: Option<Duration>,
216
217	/// Enable path MTU discovery. Defaults to off.
218	#[serde(skip_serializing_if = "Option::is_none")]
219	#[arg(
220		id = "server-quic-mtu-discovery",
221		long = "server-quic-mtu-discovery",
222		env = "MOQ_SERVER_QUIC_MTU_DISCOVERY",
223		default_missing_value = "true",
224		num_args = 0..=1,
225		require_equals = true,
226		value_parser = clap::value_parser!(bool),
227	)]
228	pub mtu_discovery: Option<bool>,
229
230	/// Congestion control family. Unset keeps the backend's own default: CUBIC on
231	/// quinn and quiche, BBRv3 on noq.
232	#[serde(skip_serializing_if = "Option::is_none")]
233	#[arg(
234		id = "server-quic-congestion-control",
235		long = "server-quic-congestion-control",
236		env = "MOQ_SERVER_QUIC_CONGESTION_CONTROL",
237		value_enum
238	)]
239	pub congestion_control: Option<CongestionControl>,
240
241	/// IPv4 address advertised as the QUIC preferred_address.
242	///
243	/// Supporting clients (Chrome M131+, native Quinn) migrate to this address
244	/// shortly after the handshake completes. Typical use: handshake on an
245	/// anycast IP, steady-state on this host's unicast IP.
246	///
247	/// Honored by the Quinn and noq backends.
248	#[arg(
249		id = "server-preferred-v4",
250		long = "server-preferred-v4",
251		env = "MOQ_SERVER_PREFERRED_V4"
252	)]
253	#[serde(default, skip_serializing_if = "Option::is_none")]
254	pub preferred_v4: Option<net::SocketAddrV4>,
255
256	/// IPv6 address advertised as the QUIC preferred_address. See [`Self::preferred_v4`].
257	#[arg(
258		id = "server-preferred-v6",
259		long = "server-preferred-v6",
260		env = "MOQ_SERVER_PREFERRED_V6"
261	)]
262	#[serde(default, skip_serializing_if = "Option::is_none")]
263	pub preferred_v6: Option<net::SocketAddrV6>,
264
265	/// Server ID to embed in connection IDs for QUIC-LB compatibility.
266	/// If set, connection IDs will be derived semi-deterministically.
267	#[arg(id = "server-quic-lb-id", long = "server-quic-lb-id", env = "MOQ_SERVER_QUIC_LB_ID")]
268	#[serde(default, skip_serializing_if = "Option::is_none")]
269	pub quic_lb_id: Option<ServerId>,
270
271	/// Number of random nonce bytes in QUIC-LB connection IDs.
272	/// Must be at least 4, and server_id + nonce + 1 must not exceed 20.
273	#[arg(
274		id = "server-quic-lb-nonce",
275		long = "server-quic-lb-nonce",
276		requires = "server-quic-lb-id",
277		env = "MOQ_SERVER_QUIC_LB_NONCE"
278	)]
279	#[serde(default, skip_serializing_if = "Option::is_none")]
280	pub quic_lb_nonce: Option<usize>,
281}
282
283impl Server {
284	/// The per-connection knobs with defaults applied, ready to hand to a backend.
285	pub(crate) fn resolve(&self) -> Resolved {
286		Resolved::new(
287			self.max_streams,
288			self.gso,
289			self.idle_timeout,
290			self.keep_alive,
291			self.mtu_discovery,
292			self.congestion_control,
293		)
294	}
295}
296
297/// A resolved view of the per-connection knobs (defaults filled in), shared by
298/// [`Client`] and [`Server`] so backends apply them the same way regardless of role.
299///
300/// Internal: the backends consume it and [`crate::iroh::EndpointConfig::bind`]
301/// resolves it from a [`Client`], so it never appears in the public surface.
302#[derive(Clone, Copy, Debug)]
303pub(crate) struct Resolved {
304	/// Max concurrent streams (bidi and uni).
305	pub max_streams: u64,
306	/// GSO override, or `None` to leave the backend default (on).
307	pub gso: Option<bool>,
308	/// Idle timeout.
309	pub idle_timeout: Duration,
310	/// Keep-alive interval, or `None` when disabled.
311	pub keep_alive: Option<Duration>,
312	/// Whether to run path MTU discovery.
313	pub mtu_discovery: bool,
314	/// Congestion control override, or `None` to leave the backend's own default.
315	pub congestion_control: Option<CongestionControl>,
316}
317
318impl Resolved {
319	fn new(
320		max_streams: Option<u64>,
321		gso: Option<bool>,
322		idle_timeout: Option<Duration>,
323		keep_alive: Option<Duration>,
324		mtu_discovery: Option<bool>,
325		congestion_control: Option<CongestionControl>,
326	) -> Self {
327		// A zero keep-alive means "disabled"; anything else (including unset) keeps
328		// the connection warm, defaulting to 5s.
329		let keep_alive = match keep_alive {
330			Some(d) if d.is_zero() => None,
331			Some(d) => Some(d),
332			None => Some(DEFAULT_KEEP_ALIVE),
333		};
334
335		Self {
336			max_streams: max_streams.unwrap_or(DEFAULT_MAX_STREAMS),
337			gso,
338			idle_timeout: idle_timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT),
339			keep_alive,
340			mtu_discovery: mtu_discovery.unwrap_or(false),
341			congestion_control,
342		}
343	}
344
345	/// Whether the config asks to turn GSO off, which not every backend can honor.
346	///
347	/// Only the quiche and iroh backends consult this, to reject a GSO-off request
348	/// they can't satisfy; quinn and noq toggle GSO directly. A default build
349	/// compiles neither, so the method is intentionally unused there.
350	#[cfg_attr(not(any(feature = "quiche", feature = "iroh")), allow(dead_code))]
351	pub(crate) fn gso_disabled(&self) -> bool {
352		self.gso == Some(false)
353	}
354}
355
356#[cfg(test)]
357mod tests {
358	use super::*;
359	use clap::Parser;
360
361	/// Minimal parsers so we can exercise the `--client-quic-*` / `--server-quic-*`
362	/// args in isolation (and together, the way relay/cli flatten both).
363	#[derive(Parser)]
364	struct Both {
365		#[command(flatten)]
366		client: Client,
367		#[command(flatten)]
368		server: Server,
369	}
370
371	fn parse(args: &[&str]) -> Both {
372		let mut full = vec!["test"];
373		full.extend_from_slice(args);
374		Both::parse_from(full)
375	}
376
377	#[test]
378	fn defaults_apply_when_unset() {
379		let quic = Client::default().resolve();
380		assert_eq!(quic.max_streams, DEFAULT_MAX_STREAMS);
381		assert_eq!(quic.idle_timeout, DEFAULT_IDLE_TIMEOUT);
382		assert_eq!(quic.keep_alive, Some(DEFAULT_KEEP_ALIVE));
383		assert!(!quic.mtu_discovery);
384		assert_eq!(quic.gso, None);
385		assert!(!quic.gso_disabled());
386	}
387
388	#[test]
389	fn zero_keep_alive_disables_it() {
390		let disabled = Server {
391			keep_alive: Some(Duration::ZERO),
392			..Default::default()
393		};
394		assert_eq!(disabled.resolve().keep_alive, None);
395
396		let explicit = Client {
397			keep_alive: Some(Duration::from_secs(2)),
398			..Default::default()
399		};
400		assert_eq!(explicit.resolve().keep_alive, Some(Duration::from_secs(2)));
401	}
402
403	#[test]
404	fn gso_disabled_only_on_explicit_false() {
405		let off = Client {
406			gso: Some(false),
407			..Default::default()
408		};
409		assert!(off.resolve().gso_disabled());
410		let on = Client {
411			gso: Some(true),
412			..Default::default()
413		};
414		assert!(!on.resolve().gso_disabled());
415	}
416
417	#[test]
418	fn client_and_server_flags_are_distinct() {
419		let both = parse(&["--client-quic-max-streams", "5000", "--server-quic-max-streams", "9000"]);
420		assert_eq!(both.client.max_streams, Some(5000));
421		assert_eq!(both.server.max_streams, Some(9000));
422	}
423
424	#[test]
425	fn server_only_knobs_parse() {
426		let both = parse(&["--server-preferred-v4", "192.0.2.1:443", "--server-quic-lb-id", "ab"]);
427		assert_eq!(both.server.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
428		assert!(both.server.quic_lb_id.is_some());
429		// The accept-side knobs live only on the server section.
430		assert_eq!(both.client.max_streams, None);
431	}
432
433	#[test]
434	fn deprecated_max_streams_aliases() {
435		let both = parse(&["--client-max-streams", "2048", "--server-max-streams", "4096"]);
436		assert_eq!(both.client.max_streams, Some(2048));
437		assert_eq!(both.server.max_streams, Some(4096));
438	}
439
440	#[test]
441	fn toml_round_trips() {
442		let toml = r#"
443			max_streams = 7000
444			gso = false
445			preferred_v4 = "192.0.2.1:443"
446			congestion_control = "delay"
447		"#;
448		let quic: Server = toml::from_str(toml).unwrap();
449		assert_eq!(quic.max_streams, Some(7000));
450		assert_eq!(quic.gso, Some(false));
451		assert_eq!(quic.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
452		assert_eq!(quic.congestion_control, Some(CongestionControl::Delay));
453	}
454
455	#[test]
456	fn congestion_control_flags_parse() {
457		let both = parse(&[
458			"--client-quic-congestion-control",
459			"delay",
460			"--server-quic-congestion-control",
461			"loss",
462		]);
463		assert_eq!(both.client.congestion_control, Some(CongestionControl::Delay));
464		assert_eq!(both.server.congestion_control, Some(CongestionControl::Loss));
465
466		// Unset stays None, which leaves each backend's own default.
467		assert_eq!(Client::default().resolve().congestion_control, None);
468	}
469}