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
15use crate::ServerId;
16
17/// Default maximum number of concurrent QUIC streams (bidi and uni) per connection.
18pub(crate) const DEFAULT_MAX_STREAMS: u64 = 1024;
19
20/// Default idle timeout before an inactive connection is dropped.
21pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// Default keep-alive ping interval.
24pub(crate) const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(5);
25
26/// The `--client-quic-*` transport section.
27#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
28#[serde(deny_unknown_fields, default)]
29#[non_exhaustive]
30pub struct Client {
31	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
32	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
33	#[serde(skip_serializing_if = "Option::is_none")]
34	#[arg(
35		id = "client-quic-max-streams",
36		long = "client-quic-max-streams",
37		alias = "client-max-streams",
38		env = "MOQ_CLIENT_QUIC_MAX_STREAMS"
39	)]
40	pub max_streams: Option<u64>,
41
42	/// Enable UDP generic segmentation offload (GSO).
43	///
44	/// GSO batches sends into one syscall for throughput, but some NICs and
45	/// middleboxes mangle segmented packets. Defaults to on. Only the quinn and
46	/// noq backends can turn it off; setting `false` errors at init on quiche/iroh.
47	#[serde(skip_serializing_if = "Option::is_none")]
48	#[arg(
49		id = "client-quic-gso",
50		long = "client-quic-gso",
51		env = "MOQ_CLIENT_QUIC_GSO",
52		default_missing_value = "true",
53		num_args = 0..=1,
54		require_equals = true,
55		value_parser = clap::value_parser!(bool),
56	)]
57	pub gso: Option<bool>,
58
59	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
60	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
61	#[arg(
62		id = "client-quic-idle-timeout",
63		long = "client-quic-idle-timeout",
64		env = "MOQ_CLIENT_QUIC_IDLE_TIMEOUT",
65		value_parser = humantime::parse_duration,
66	)]
67	pub idle_timeout: Option<Duration>,
68
69	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
70	/// Ignored by the quiche and iroh backends, which have no keep-alive knob.
71	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
72	#[arg(
73		id = "client-quic-keep-alive",
74		long = "client-quic-keep-alive",
75		env = "MOQ_CLIENT_QUIC_KEEP_ALIVE",
76		value_parser = humantime::parse_duration,
77	)]
78	pub keep_alive: Option<Duration>,
79
80	/// Enable path MTU discovery. Defaults to off.
81	#[serde(skip_serializing_if = "Option::is_none")]
82	#[arg(
83		id = "client-quic-mtu-discovery",
84		long = "client-quic-mtu-discovery",
85		env = "MOQ_CLIENT_QUIC_MTU_DISCOVERY",
86		default_missing_value = "true",
87		num_args = 0..=1,
88		require_equals = true,
89		value_parser = clap::value_parser!(bool),
90	)]
91	pub mtu_discovery: Option<bool>,
92}
93
94impl Client {
95	/// The per-connection knobs with defaults applied, ready to hand to a backend.
96	pub(crate) fn resolve(&self) -> Resolved {
97		Resolved::new(
98			self.max_streams,
99			self.gso,
100			self.idle_timeout,
101			self.keep_alive,
102			self.mtu_discovery,
103		)
104	}
105}
106
107/// The `--server-quic-*` transport section.
108///
109/// Carries the same per-connection knobs as [`Client`] plus the accept-side knobs
110/// (preferred address, QUIC-LB connection IDs).
111#[derive(Clone, Debug, Default, clap::Args, serde::Serialize, serde::Deserialize)]
112#[serde(deny_unknown_fields, default)]
113#[non_exhaustive]
114pub struct Server {
115	/// Maximum number of concurrent QUIC streams per connection (both bidi and uni).
116	/// Defaults to 1024. MoQ opens a stream per group, so busy endpoints want this high.
117	#[serde(skip_serializing_if = "Option::is_none")]
118	#[arg(
119		id = "server-quic-max-streams",
120		long = "server-quic-max-streams",
121		alias = "server-max-streams",
122		env = "MOQ_SERVER_QUIC_MAX_STREAMS"
123	)]
124	pub max_streams: Option<u64>,
125
126	/// Enable UDP generic segmentation offload (GSO). See [`Client::gso`].
127	#[serde(skip_serializing_if = "Option::is_none")]
128	#[arg(
129		id = "server-quic-gso",
130		long = "server-quic-gso",
131		env = "MOQ_SERVER_QUIC_GSO",
132		default_missing_value = "true",
133		num_args = 0..=1,
134		require_equals = true,
135		value_parser = clap::value_parser!(bool),
136	)]
137	pub gso: Option<bool>,
138
139	/// Idle timeout before an inactive connection is dropped. Defaults to 30s.
140	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
141	#[arg(
142		id = "server-quic-idle-timeout",
143		long = "server-quic-idle-timeout",
144		env = "MOQ_SERVER_QUIC_IDLE_TIMEOUT",
145		value_parser = humantime::parse_duration,
146	)]
147	pub idle_timeout: Option<Duration>,
148
149	/// Keep-alive ping interval. Defaults to 5s; set `0s` to disable.
150	/// Ignored by the quiche backend, which has no keep-alive knob.
151	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
152	#[arg(
153		id = "server-quic-keep-alive",
154		long = "server-quic-keep-alive",
155		env = "MOQ_SERVER_QUIC_KEEP_ALIVE",
156		value_parser = humantime::parse_duration,
157	)]
158	pub keep_alive: Option<Duration>,
159
160	/// Enable path MTU discovery. Defaults to off.
161	#[serde(skip_serializing_if = "Option::is_none")]
162	#[arg(
163		id = "server-quic-mtu-discovery",
164		long = "server-quic-mtu-discovery",
165		env = "MOQ_SERVER_QUIC_MTU_DISCOVERY",
166		default_missing_value = "true",
167		num_args = 0..=1,
168		require_equals = true,
169		value_parser = clap::value_parser!(bool),
170	)]
171	pub mtu_discovery: Option<bool>,
172
173	/// IPv4 address advertised as the QUIC preferred_address.
174	///
175	/// Supporting clients (Chrome M131+, native Quinn) migrate to this address
176	/// shortly after the handshake completes. Typical use: handshake on an
177	/// anycast IP, steady-state on this host's unicast IP.
178	///
179	/// Honored by the Quinn and noq backends.
180	#[arg(
181		id = "server-preferred-v4",
182		long = "server-preferred-v4",
183		env = "MOQ_SERVER_PREFERRED_V4"
184	)]
185	#[serde(default, skip_serializing_if = "Option::is_none")]
186	pub preferred_v4: Option<net::SocketAddrV4>,
187
188	/// IPv6 address advertised as the QUIC preferred_address. See [`Self::preferred_v4`].
189	#[arg(
190		id = "server-preferred-v6",
191		long = "server-preferred-v6",
192		env = "MOQ_SERVER_PREFERRED_V6"
193	)]
194	#[serde(default, skip_serializing_if = "Option::is_none")]
195	pub preferred_v6: Option<net::SocketAddrV6>,
196
197	/// Server ID to embed in connection IDs for QUIC-LB compatibility.
198	/// If set, connection IDs will be derived semi-deterministically.
199	#[arg(id = "server-quic-lb-id", long = "server-quic-lb-id", env = "MOQ_SERVER_QUIC_LB_ID")]
200	#[serde(default, skip_serializing_if = "Option::is_none")]
201	pub quic_lb_id: Option<ServerId>,
202
203	/// Number of random nonce bytes in QUIC-LB connection IDs.
204	/// Must be at least 4, and server_id + nonce + 1 must not exceed 20.
205	#[arg(
206		id = "server-quic-lb-nonce",
207		long = "server-quic-lb-nonce",
208		requires = "server-quic-lb-id",
209		env = "MOQ_SERVER_QUIC_LB_NONCE"
210	)]
211	#[serde(default, skip_serializing_if = "Option::is_none")]
212	pub quic_lb_nonce: Option<usize>,
213}
214
215impl Server {
216	/// The per-connection knobs with defaults applied, ready to hand to a backend.
217	pub(crate) fn resolve(&self) -> Resolved {
218		Resolved::new(
219			self.max_streams,
220			self.gso,
221			self.idle_timeout,
222			self.keep_alive,
223			self.mtu_discovery,
224		)
225	}
226}
227
228/// A resolved view of the per-connection knobs (defaults filled in), shared by
229/// [`Client`] and [`Server`] so backends apply them the same way regardless of role.
230///
231/// Internal: the backends consume it and [`crate::iroh::EndpointConfig::bind`]
232/// resolves it from a [`Client`], so it never appears in the public surface.
233#[derive(Clone, Copy, Debug)]
234pub(crate) struct Resolved {
235	/// Max concurrent streams (bidi and uni).
236	pub max_streams: u64,
237	/// GSO override, or `None` to leave the backend default (on).
238	pub gso: Option<bool>,
239	/// Idle timeout.
240	pub idle_timeout: Duration,
241	/// Keep-alive interval, or `None` when disabled.
242	pub keep_alive: Option<Duration>,
243	/// Whether to run path MTU discovery.
244	pub mtu_discovery: bool,
245}
246
247impl Resolved {
248	fn new(
249		max_streams: Option<u64>,
250		gso: Option<bool>,
251		idle_timeout: Option<Duration>,
252		keep_alive: Option<Duration>,
253		mtu_discovery: Option<bool>,
254	) -> Self {
255		// A zero keep-alive means "disabled"; anything else (including unset) keeps
256		// the connection warm, defaulting to 5s.
257		let keep_alive = match keep_alive {
258			Some(d) if d.is_zero() => None,
259			Some(d) => Some(d),
260			None => Some(DEFAULT_KEEP_ALIVE),
261		};
262
263		Self {
264			max_streams: max_streams.unwrap_or(DEFAULT_MAX_STREAMS),
265			gso,
266			idle_timeout: idle_timeout.unwrap_or(DEFAULT_IDLE_TIMEOUT),
267			keep_alive,
268			mtu_discovery: mtu_discovery.unwrap_or(false),
269		}
270	}
271
272	/// Whether the config asks to turn GSO off, which not every backend can honor.
273	pub(crate) fn gso_disabled(&self) -> bool {
274		self.gso == Some(false)
275	}
276}
277
278#[cfg(test)]
279mod tests {
280	use super::*;
281	use clap::Parser;
282
283	/// Minimal parsers so we can exercise the `--client-quic-*` / `--server-quic-*`
284	/// args in isolation (and together, the way relay/cli flatten both).
285	#[derive(Parser)]
286	struct Both {
287		#[command(flatten)]
288		client: Client,
289		#[command(flatten)]
290		server: Server,
291	}
292
293	fn parse(args: &[&str]) -> Both {
294		let mut full = vec!["test"];
295		full.extend_from_slice(args);
296		Both::parse_from(full)
297	}
298
299	#[test]
300	fn defaults_apply_when_unset() {
301		let quic = Client::default().resolve();
302		assert_eq!(quic.max_streams, DEFAULT_MAX_STREAMS);
303		assert_eq!(quic.idle_timeout, DEFAULT_IDLE_TIMEOUT);
304		assert_eq!(quic.keep_alive, Some(DEFAULT_KEEP_ALIVE));
305		assert!(!quic.mtu_discovery);
306		assert_eq!(quic.gso, None);
307		assert!(!quic.gso_disabled());
308	}
309
310	#[test]
311	fn zero_keep_alive_disables_it() {
312		let disabled = Server {
313			keep_alive: Some(Duration::ZERO),
314			..Default::default()
315		};
316		assert_eq!(disabled.resolve().keep_alive, None);
317
318		let explicit = Client {
319			keep_alive: Some(Duration::from_secs(2)),
320			..Default::default()
321		};
322		assert_eq!(explicit.resolve().keep_alive, Some(Duration::from_secs(2)));
323	}
324
325	#[test]
326	fn gso_disabled_only_on_explicit_false() {
327		let off = Client {
328			gso: Some(false),
329			..Default::default()
330		};
331		assert!(off.resolve().gso_disabled());
332		let on = Client {
333			gso: Some(true),
334			..Default::default()
335		};
336		assert!(!on.resolve().gso_disabled());
337	}
338
339	#[test]
340	fn client_and_server_flags_are_distinct() {
341		let both = parse(&["--client-quic-max-streams", "5000", "--server-quic-max-streams", "9000"]);
342		assert_eq!(both.client.max_streams, Some(5000));
343		assert_eq!(both.server.max_streams, Some(9000));
344	}
345
346	#[test]
347	fn server_only_knobs_parse() {
348		let both = parse(&["--server-preferred-v4", "192.0.2.1:443", "--server-quic-lb-id", "ab"]);
349		assert_eq!(both.server.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
350		assert!(both.server.quic_lb_id.is_some());
351		// The accept-side knobs live only on the server section.
352		assert_eq!(both.client.max_streams, None);
353	}
354
355	#[test]
356	fn deprecated_max_streams_aliases() {
357		let both = parse(&["--client-max-streams", "2048", "--server-max-streams", "4096"]);
358		assert_eq!(both.client.max_streams, Some(2048));
359		assert_eq!(both.server.max_streams, Some(4096));
360	}
361
362	#[test]
363	fn toml_round_trips() {
364		let toml = r#"
365			max_streams = 7000
366			gso = false
367			preferred_v4 = "192.0.2.1:443"
368		"#;
369		let quic: Server = toml::from_str(toml).unwrap();
370		assert_eq!(quic.max_streams, Some(7000));
371		assert_eq!(quic.gso, Some(false));
372		assert_eq!(quic.preferred_v4, Some("192.0.2.1:443".parse().unwrap()));
373	}
374}