Skip to main content

moq_native/
client.rs

1use crate::{Backoff, Error, QuicBackend, Reconnect};
2#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
3use std::future::Future;
4use std::net;
5use url::Url;
6
7const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
8
9/// How long to wait before also dialing the next resolved address, unless
10/// overridden by `--client-failover-delay`. RFC 8305's recommended Connection
11/// Attempt Delay.
12///
13/// Lives here rather than in [`crate::failover`], which is compiled only for the
14/// transports that dial an address themselves: the resolved default is config, so
15/// [`ClientConfig::resolved_failover_delay`] has to answer in every build.
16pub(crate) const DEFAULT_FAILOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(250);
17
18/// How long the first candidate waits for the full DNS answer before settling for
19/// the IPv4-only one, unless overridden by `--client-resolution-delay`. RFC 8305's
20/// recommended Resolution Delay.
21///
22/// Lives here for the same reason as [`DEFAULT_FAILOVER_DELAY`].
23pub(crate) const DEFAULT_RESOLUTION_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
24
25/// Configuration for the MoQ client.
26#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
27#[serde(deny_unknown_fields, default)]
28#[non_exhaustive]
29pub struct ClientConfig {
30	/// The URL to dial.
31	///
32	/// Supports WebTransport (`https`/`http`), WebSocket (`ws`/`wss`), raw QUIC
33	/// (`moqt`/`moql`), qmux over `tcp`/`unix`, and `iroh`. The URL path is the
34	/// request/auth path (e.g. `/anon` for a public relay) and `?jwt=` supplies a
35	/// token. `http://` first fetches `/certificate.sha256` for the (insecure)
36	/// self-signed fingerprint; `https://` connects directly.
37	#[serde(skip_serializing_if = "Option::is_none")]
38	#[arg(id = "client-connect", long = "client-connect", env = "MOQ_CLIENT_CONNECT")]
39	pub connect: Option<Url>,
40
41	/// Listen for UDP packets on the given address.
42	#[arg(
43		id = "client-bind",
44		long = "client-bind",
45		default_value = "[::]:0",
46		env = "MOQ_CLIENT_BIND"
47	)]
48	pub bind: net::SocketAddr,
49
50	/// The QUIC backend to use.
51	/// Auto-detected from compiled features if not specified.
52	#[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
53	pub backend: Option<QuicBackend>,
54
55	/// Delay before also dialing the next resolved address (Happy Eyeballs).
56	///
57	/// When DNS returns multiple addresses, attempts alternate between IPv6 and
58	/// IPv4, each starting this long after the previous one (or immediately when
59	/// it fails), and the first connection to complete wins. `0s` dials every
60	/// address at once. Defaults to 250ms. Applies to the QUIC and `tcp://` dials.
61	///
62	/// This staggers the attempts within one [`Client::connect`]; [`Self::timeout`]
63	/// bounds that call as a whole.
64	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
65	#[arg(
66		id = "client-failover-delay",
67		long = "client-failover-delay",
68		env = "MOQ_CLIENT_FAILOVER_DELAY",
69		value_parser = humantime::parse_duration,
70	)]
71	pub failover_delay: Option<std::time::Duration>,
72
73	/// Delay before dialing an IPv4 address while the full DNS answer is outstanding.
74	///
75	/// A dial runs the usual all-families lookup alongside an IPv4-only one that
76	/// answers without waiting for the AAAA record, and starts on the first answer.
77	/// The full answer is authoritative, including which family to try first, so
78	/// this is how long the IPv4-only one waits for it before going ahead alone.
79	/// Defaults to 50ms; `0s` dials as soon as any address resolves.
80	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
81	#[arg(
82		id = "client-resolution-delay",
83		long = "client-resolution-delay",
84		env = "MOQ_CLIENT_RESOLUTION_DELAY",
85		value_parser = humantime::parse_duration,
86	)]
87	pub resolution_delay: Option<std::time::Duration>,
88
89	/// Maximum time for one [`Client::connect`], covering the dial and the MoQ
90	/// handshake. Defaults to 30 seconds; set to 0 to wait forever.
91	///
92	/// This has to live above the transports rather than inside one: QUIC bounds its
93	/// own dial, but the WebSocket fallback and the handshake that follows either
94	/// transport have no deadline of their own, so a peer that accepts TCP and then
95	/// never speaks would hang the whole connect. [`Client::reconnect`] only re-arms
96	/// its backoff between attempts, so an attempt that never returns stalls the
97	/// retry loop indefinitely.
98	#[arg(
99		id = "client-connect-timeout",
100		long = "client-connect-timeout",
101		env = "MOQ_CLIENT_CONNECT_TIMEOUT",
102		value_parser = humantime::parse_duration,
103	)]
104	#[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
105	pub timeout: Option<std::time::Duration>,
106
107	/// QUIC transport tuning (`--client-quic-*`): stream limits, GSO, timeouts.
108	#[command(flatten)]
109	#[serde(default)]
110	pub quic: crate::quic::Client,
111
112	/// Restrict the client to specific MoQ protocol version(s).
113	///
114	/// By default, the client offers all supported versions and lets the server choose.
115	/// Use this to force a specific version, e.g. `--client-version moq-lite-02`.
116	/// Can be specified multiple times to offer a subset of versions.
117	#[serde(default, skip_serializing_if = "Vec::is_empty")]
118	#[arg(
119		id = "client-version",
120		long = "client-version",
121		env = "MOQ_CLIENT_VERSION",
122		value_parser = crate::version_parser(),
123	)]
124	pub version: Vec<moq_net::Version>,
125
126	/// TLS trust and client-certificate settings (`--client-tls-*`).
127	#[command(flatten)]
128	#[serde(default)]
129	pub tls: crate::tls::Client,
130
131	/// Retry pacing for [`Client::reconnect`] (`--client-backoff-*`).
132	#[command(flatten)]
133	#[serde(default)]
134	pub backoff: Backoff,
135
136	/// WebSocket fallback settings (`--client-websocket-*`), used when QUIC is
137	/// blocked.
138	#[cfg(feature = "websocket")]
139	#[command(flatten)]
140	#[serde(default)]
141	pub websocket: crate::websocket::Client,
142}
143
144impl ClientConfig {
145	/// Build the [`Client`] this config describes.
146	pub fn init(self) -> crate::Result<Client> {
147		Client::new(self)
148	}
149
150	/// Returns the configured versions, defaulting to all if none specified.
151	pub fn versions(&self) -> moq_net::Versions {
152		if self.version.is_empty() {
153			moq_net::Versions::all()
154		} else {
155			moq_net::Versions::from(self.version.clone())
156		}
157	}
158
159	/// The Happy Eyeballs stagger a dial will actually use, resolving the default.
160	///
161	/// Every backend reads it from here so the four dial paths can't drift apart. The
162	/// [`failover_delay`](Self::failover_delay) field is the override; this is the value
163	/// it resolves to, so `ClientConfig::default().resolved_failover_delay()` is the
164	/// default itself.
165	pub fn resolved_failover_delay(&self) -> std::time::Duration {
166		self.failover_delay.unwrap_or(DEFAULT_FAILOVER_DELAY)
167	}
168
169	/// The Resolution Delay a dial will actually use, resolving the default from
170	/// the [`resolution_delay`](Self::resolution_delay) override. Read by every
171	/// backend, like [`resolved_failover_delay`](Self::resolved_failover_delay).
172	pub fn resolved_resolution_delay(&self) -> std::time::Duration {
173		self.resolution_delay.unwrap_or(DEFAULT_RESOLUTION_DELAY)
174	}
175
176	/// The deadline one connection attempt will actually get, dial and handshake
177	/// together, resolving the default from the [`timeout`](Self::timeout) override.
178	pub fn resolved_connect_timeout(&self) -> std::time::Duration {
179		self.timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT)
180	}
181}
182
183impl Default for ClientConfig {
184	fn default() -> Self {
185		Self {
186			connect: None,
187			bind: "[::]:0".parse().unwrap(),
188			backend: None,
189			failover_delay: None,
190			resolution_delay: None,
191			timeout: None,
192			quic: crate::quic::Client::default(),
193			version: Vec::new(),
194			tls: crate::tls::Client::default(),
195			backoff: Backoff::default(),
196			#[cfg(feature = "websocket")]
197			websocket: crate::websocket::Client::default(),
198		}
199	}
200}
201
202/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
203///
204/// Create via [`ClientConfig::init`] or [`Client::new`].
205#[derive(Clone)]
206pub struct Client {
207	moq: moq_net::Client,
208	/// The single resolved set of protocol versions, used to advertise moq ALPNs across
209	/// every transport (passed into the QUIC backends' `connect` and used directly for
210	/// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list
211	/// can't diverge between transports. iroh is the exception: it negotiates over the
212	/// full `moq_net::ALPNS` rather than the configured subset, so it reads nothing here.
213	#[cfg(any(
214		feature = "noq",
215		feature = "quinn",
216		feature = "quiche",
217		feature = "websocket",
218		feature = "tcp",
219		feature = "uds"
220	))]
221	versions: moq_net::Versions,
222	/// The URL from [`ClientConfig::connect`], dialed by [`Client::publish`] / [`Client::consume`].
223	connect: Option<Url>,
224	/// Deadline for one [`Client::connect`], from [`ClientConfig::timeout`]. Zero waits forever.
225	timeout: std::time::Duration,
226	backoff: Backoff,
227	/// The resolved Happy Eyeballs timings, used by the `tcp://` dial here; the
228	/// QUIC backends capture their own copy from the config.
229	#[cfg(feature = "tcp")]
230	failover_delay: std::time::Duration,
231	#[cfg(feature = "tcp")]
232	resolution_delay: std::time::Duration,
233	#[cfg(feature = "websocket")]
234	websocket: crate::websocket::Client,
235	/// Only the rustls-based dials read this. quiche builds its own TLS stack, and the
236	/// plaintext qmux transports have none.
237	#[cfg(any(feature = "noq", feature = "quinn", feature = "websocket"))]
238	tls: rustls::ClientConfig,
239	#[cfg(feature = "noq")]
240	noq: Option<crate::noq::NoqClient>,
241	#[cfg(feature = "quinn")]
242	quinn: Option<crate::quinn::QuinnClient>,
243	#[cfg(feature = "quiche")]
244	quiche: Option<crate::quiche::QuicheClient>,
245	#[cfg(feature = "iroh")]
246	iroh: Option<crate::iroh::Endpoint>,
247	#[cfg(feature = "iroh")]
248	iroh_addrs: Vec<std::net::SocketAddr>,
249}
250
251impl Client {
252	/// Build a client from its config.
253	///
254	/// Errors if no transport feature is compiled in.
255	#[cfg(not(any(
256		feature = "noq",
257		feature = "quinn",
258		feature = "quiche",
259		feature = "iroh",
260		feature = "websocket",
261		feature = "tcp",
262		feature = "uds"
263	)))]
264	pub fn new(_config: ClientConfig) -> crate::Result<Self> {
265		Err(Error::NoBackend(
266			"no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
267		))
268	}
269
270	/// Build a client from its config, binding the QUIC socket up front.
271	#[cfg(any(
272		feature = "noq",
273		feature = "quinn",
274		feature = "quiche",
275		feature = "iroh",
276		feature = "websocket",
277		feature = "tcp",
278		feature = "uds"
279	))]
280	pub fn new(config: ClientConfig) -> crate::Result<Self> {
281		#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
282		let backend = config.backend.clone().unwrap_or_else(crate::default_quic_backend);
283
284		config.quic.validate()?;
285		config.backoff.validate()?;
286
287		// Only the rustls-backed transports use this. Iroh, quiche, and the plaintext
288		// qmux transports must not require a rustls crypto provider.
289		#[cfg(any(feature = "noq", feature = "quinn", feature = "websocket"))]
290		let tls = config.tls.build()?;
291
292		#[cfg(feature = "noq")]
293		#[allow(unreachable_patterns)]
294		let noq = match backend {
295			QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
296			_ => None,
297		};
298
299		#[cfg(feature = "quinn")]
300		#[allow(unreachable_patterns)]
301		let quinn = match backend {
302			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
303			_ => None,
304		};
305
306		#[cfg(feature = "quiche")]
307		#[allow(unreachable_patterns)]
308		let quiche = match backend {
309			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
310			_ => None,
311		};
312
313		let versions = config.versions();
314		// Read before the struct literal below moves fields out of `config`.
315		#[cfg(feature = "tcp")]
316		let failover_delay = config.resolved_failover_delay();
317		#[cfg(feature = "tcp")]
318		let resolution_delay = config.resolved_resolution_delay();
319		let timeout = config.resolved_connect_timeout();
320
321		Ok(Self {
322			moq: moq_net::Client::new().with_versions(versions.clone()),
323			#[cfg(any(
324				feature = "noq",
325				feature = "quinn",
326				feature = "quiche",
327				feature = "websocket",
328				feature = "tcp",
329				feature = "uds"
330			))]
331			versions,
332			connect: config.connect,
333			timeout,
334			backoff: config.backoff,
335			#[cfg(feature = "tcp")]
336			failover_delay,
337			#[cfg(feature = "tcp")]
338			resolution_delay,
339			#[cfg(feature = "websocket")]
340			websocket: config.websocket,
341			#[cfg(any(feature = "noq", feature = "quinn", feature = "websocket"))]
342			tls,
343			#[cfg(feature = "noq")]
344			noq,
345			#[cfg(feature = "quinn")]
346			quinn,
347			#[cfg(feature = "quiche")]
348			quiche,
349			#[cfg(feature = "iroh")]
350			iroh: None,
351			#[cfg(feature = "iroh")]
352			iroh_addrs: Vec::new(),
353		})
354	}
355
356	/// Dial `iroh://` URLs through the given Iroh endpoint.
357	///
358	/// Required before [`connect`](Self::connect) can serve an `iroh://` URL;
359	/// without it those dials fail with [`crate::Error::IrohDisabled`].
360	#[cfg(feature = "iroh")]
361	pub fn with_iroh(mut self, iroh: crate::iroh::Endpoint) -> Self {
362		self.iroh = Some(iroh);
363		self
364	}
365
366	/// Set direct IP addresses for connecting to iroh peers.
367	///
368	/// This is useful when the peer's IP addresses are known ahead of time,
369	/// bypassing the need for peer discovery (e.g. in tests or local networks).
370	#[cfg(feature = "iroh")]
371	pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
372		self.iroh_addrs = addrs;
373		self
374	}
375
376	/// Publish the given origin to every session this client opens.
377	pub fn with_publisher(mut self, publish: impl moq_net::Consume<moq_net::origin::Consumer>) -> Self {
378		self.moq = self.moq.with_publisher(publish);
379		self
380	}
381
382	/// Subscribe to the peer's broadcasts, ingesting them into the given origin.
383	pub fn with_subscriber(mut self, subscribe: moq_net::origin::Producer) -> Self {
384		self.moq = self.moq.with_subscriber(subscribe);
385		self
386	}
387
388	/// Attach a per-connection [`moq_net::stats::Session`] context to all sessions
389	/// opened by this client.
390	pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self {
391		self.moq = self.moq.with_stats(stats);
392		self
393	}
394
395	/// Price the links this client dials; see [`moq_net::Client::with_cost`].
396	pub fn with_cost(mut self, cost: u64) -> Self {
397		self.moq = self.moq.with_cost(cost);
398		self
399	}
400
401	/// Assign an origin (hop) id to the peers this client dials, used whenever a
402	/// peer doesn't declare one itself; see [`moq_net::Client::with_peer_origin`].
403	pub fn with_peer_origin(mut self, origin: moq_net::Origin) -> Self {
404		self.moq = self.moq.with_peer_origin(origin);
405		self
406	}
407
408	/// Start a background reconnect loop that connects to the given URL,
409	/// waits for the session to close, then reconnects with exponential backoff.
410	///
411	/// Returns a [`Reconnect`] handle; drop the last handle to stop the loop.
412	pub fn reconnect(&self, url: Url) -> Reconnect {
413		Reconnect::new(self.clone(), url, self.backoff.clone())
414	}
415
416	/// Dial the configured [`ClientConfig::connect`] URL, publishing `origin` to it
417	/// and reconnecting with backoff until the returned handle is dropped.
418	///
419	/// Returns `None` when no `--client-connect` URL was configured, so a caller
420	/// that may run server-only doesn't have to branch on the URL itself.
421	pub fn publish(self, origin: moq_net::origin::Consumer) -> Option<Reconnect> {
422		let url = self.connect.clone()?;
423		Some(self.with_publisher(origin).reconnect(url))
424	}
425
426	/// Dial the configured [`ClientConfig::connect`] URL, consuming its broadcasts
427	/// into `origin` and reconnecting with backoff until the returned handle is
428	/// dropped.
429	///
430	/// Broadcasts fed by these sessions linger across a session drop for as long
431	/// as the reconnect loop keeps retrying ([`Backoff::linger`]): a relay restart
432	/// is a bounded gap the reconnect splices over, not a teardown. When the loop
433	/// gives up, its error surfaces (via [`Reconnect::closed`]) just before the
434	/// broadcasts abort.
435	///
436	/// Returns `None` when no `--client-connect` URL was configured.
437	pub fn consume(self, origin: moq_net::origin::Producer) -> Option<Reconnect> {
438		let url = self.connect.clone()?;
439		let origin = origin.with_linger(self.backoff.linger());
440		Some(self.with_subscriber(origin).reconnect(url))
441	}
442
443	/// Dial the given URL and complete the MoQ handshake.
444	///
445	/// Errors if no transport feature is compiled in.
446	#[cfg(not(any(
447		feature = "noq",
448		feature = "quinn",
449		feature = "quiche",
450		feature = "iroh",
451		feature = "websocket",
452		feature = "tcp",
453		feature = "uds"
454	)))]
455	pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
456		Err(Error::NoBackend(
457			"no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
458		))
459	}
460
461	/// Dial the given URL and complete the MoQ handshake.
462	///
463	/// The scheme picks the transport, and `https://` races QUIC against the
464	/// WebSocket fallback so a blocked UDP path still connects. The session's
465	/// protocol driver is spawned on the current tokio runtime; the session
466	/// closes once the last returned handle drops.
467	#[cfg(any(
468		feature = "noq",
469		feature = "quinn",
470		feature = "quiche",
471		feature = "iroh",
472		feature = "websocket",
473		feature = "tcp",
474		feature = "uds"
475	))]
476	pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
477		// Each compiled backend adds state to this dispatch future. Keep it off the
478		// caller's stack so all-feature builds remain safe on standard 2 MiB threads.
479		let attempt = Box::pin(self.connect_inner(url));
480
481		// The deadline covers the dial AND the handshake, for every transport: it is the
482		// only bound some of them have. Dropping `attempt` on expiry cancels whichever
483		// arm was still pending.
484		let pair = match self.timeout.is_zero() {
485			true => attempt.await?,
486			false => match tokio::time::timeout(self.timeout, attempt).await {
487				Ok(res) => res?,
488				Err(_) => return Err(Error::ConnectTimeout(self.timeout)),
489			},
490		};
491
492		tracing::info!(version = %pair.0.version(), "connected");
493		Ok(crate::spawn_session(pair))
494	}
495
496	/// The moq client builder, advertising `path` in the SETUP when there is one.
497	#[cfg(any(
498		feature = "noq",
499		feature = "quinn",
500		feature = "quiche",
501		feature = "iroh",
502		feature = "websocket",
503		feature = "tcp",
504		feature = "uds"
505	))]
506	fn moq_with_path(&self, path: Option<String>) -> moq_net::Client {
507		match path {
508			Some(path) => self.moq.clone().with_path(path),
509			None => self.moq.clone(),
510		}
511	}
512
513	#[cfg(any(
514		feature = "noq",
515		feature = "quinn",
516		feature = "quiche",
517		feature = "iroh",
518		feature = "websocket",
519		feature = "tcp",
520		feature = "uds"
521	))]
522	async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> {
523		// Transports with no request URI of their own advertise the request target in the
524		// SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where
525		// sending it again is a protocol violation. An iroh-only build reads none of this:
526		// that dial waits for the negotiated binding and builds its own.
527		#[allow(unused_variables)]
528		let moq = self.moq_with_path(setup_path(&url));
529
530		// Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against
531		// QUIC, which can't speak it. Use only on a trusted network.
532		#[cfg(feature = "tcp")]
533		if url.scheme() == "tcp" {
534			let session =
535				crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?;
536			return Ok(moq.connect(session).await?);
537		}
538
539		// Unix domain socket (qmux, no TLS). Same-host only; the server can
540		// authenticate us by uid/gid via SO_PEERCRED.
541		#[cfg(all(feature = "uds", unix))]
542		if url.scheme() == "unix" {
543			let session = crate::unix::connect(url, &self.versions.alpns()).await?;
544			return Ok(moq.connect(session).await?);
545		}
546
547		// iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw
548		// QUIC, which carries no request URI. The scheme can't tell us which we got, so the
549		// request target waits on the negotiated binding: the SETUP for raw QUIC, the
550		// CONNECT URL for H3 (where a SETUP path would be a protocol violation).
551		#[cfg(feature = "iroh")]
552		if url.scheme() == "iroh" {
553			let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
554			let target = request_target(&url);
555			let (session, binding) = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
556
557			let moq = match binding {
558				crate::iroh::Binding::Raw => self.moq_with_path(target),
559				crate::iroh::Binding::H3 => self.moq.clone(),
560			};
561
562			return Ok(moq.connect(session).await?);
563		}
564
565		#[cfg(feature = "noq")]
566		if let Some(noq) = self.noq.as_ref() {
567			let tls = self.tls.clone();
568			let quic_url = url.clone();
569			let quic_handle = async { noq.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
570
571			#[cfg(feature = "websocket")]
572			{
573				return self.race_moq_connect(&moq, url, quic_handle).await;
574			}
575
576			#[cfg(not(feature = "websocket"))]
577			{
578				let session = quic_handle.await?;
579				return Ok(moq.connect(session).await?);
580			}
581		}
582
583		#[cfg(feature = "quinn")]
584		if let Some(quinn) = self.quinn.as_ref() {
585			let tls = self.tls.clone();
586			let quic_url = url.clone();
587			let quic_handle = async { quinn.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
588
589			#[cfg(feature = "websocket")]
590			{
591				return self.race_moq_connect(&moq, url, quic_handle).await;
592			}
593
594			#[cfg(not(feature = "websocket"))]
595			{
596				let session = quic_handle.await?;
597				return Ok(moq.connect(session).await?);
598			}
599		}
600
601		#[cfg(feature = "quiche")]
602		if let Some(quiche) = self.quiche.as_ref() {
603			let quic_url = url.clone();
604			let quic_handle = async { quiche.connect(quic_url, &self.versions).await.map_err(Error::from) };
605
606			#[cfg(feature = "websocket")]
607			{
608				return self.race_moq_connect(&moq, url, quic_handle).await;
609			}
610
611			#[cfg(not(feature = "websocket"))]
612			{
613				let session = quic_handle.await?;
614				return Ok(moq.connect(session).await?);
615			}
616		}
617
618		#[cfg(feature = "websocket")]
619		{
620			let alpns = self.versions.alpns();
621			let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
622			return Ok(moq.connect(session).await?);
623		}
624
625		#[cfg(not(feature = "websocket"))]
626		return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
627	}
628
629	/// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins.
630	///
631	/// `moq` is the QUIC-side builder, which carries the SETUP path for a raw QUIC dial.
632	/// The WebSocket fallback uses the plain builder: qmux over WebSocket carries the
633	/// path in its request URI, so repeating it in the SETUP is a protocol violation.
634	///
635	/// Only compiled when there is a QUIC dial to race: a WebSocket-only build connects
636	/// over the fallback directly.
637	#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
638	async fn race_moq_connect<Q, S>(
639		&self,
640		moq: &moq_net::Client,
641		url: Url,
642		quic: Q,
643	) -> crate::Result<(moq_net::Session, moq_net::Driver)>
644	where
645		Q: Future<Output = crate::Result<S>>,
646		S: web_transport_trait::Session,
647	{
648		let alpns = self.versions.alpns();
649		let ws_config = self.websocket.clone();
650		let ws_tls = self.tls.clone();
651		let websocket = async move {
652			crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
653				.await
654				.map(|res| res.map_err(Error::from))
655		};
656
657		match race_transport_connect(quic, websocket).await? {
658			TransportRace::Quic(quic) => Ok(moq.connect(quic).await?),
659			TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
660		}
661	}
662}
663
664/// The request target a URI-less transport advertises in its SETUP: the URL path, plus
665/// `?` and the query when there is one (draft-ietf-moq-transport-19, section 10.3.1.2).
666/// That query is how `?jwt=` reaches a relay.
667///
668/// `None` when the result is empty, which means the same as omitting the parameter: the
669/// server's default path. A peer on published lite-05 rejects an empty value outright.
670#[cfg(any(
671	feature = "noq",
672	feature = "quinn",
673	feature = "quiche",
674	feature = "iroh",
675	feature = "websocket",
676	feature = "tcp",
677	feature = "uds"
678))]
679fn request_target(url: &Url) -> Option<String> {
680	// A trailing `?` parses as an empty query, which is not a query: appending it would
681	// spell one target two ways, and `moqt://host?` would yield a bare "?" rather than
682	// the empty value that means the default path.
683	let target = match url.query().filter(|query| !query.is_empty()) {
684		Some(query) => format!("{}?{}", url.path(), query),
685		None => url.path().to_owned(),
686	};
687
688	(!target.is_empty()).then_some(target)
689}
690
691/// The request target to advertise in the SETUP, chosen by the dial URL's scheme.
692///
693/// `None` for the schemes whose transport carries a request URI of its own
694/// (WebTransport, qmux over WebSocket): they convey the target there, and a SETUP path
695/// on top of it is a protocol violation. `iroh` is `None` here because its binding is
696/// picked by ALPN negotiation rather than by the scheme; that dial reads the negotiated
697/// [`crate::iroh::Binding`] and calls [`request_target`] itself.
698#[cfg(any(
699	feature = "noq",
700	feature = "quinn",
701	feature = "quiche",
702	feature = "iroh",
703	feature = "websocket",
704	feature = "tcp",
705	feature = "uds"
706))]
707fn setup_path(url: &Url) -> Option<String> {
708	match url.scheme() {
709		// A Unix socket URL's path is the socket file, so the request target rides in
710		// the `?path=` query, query string and all. It is one form-encoded value, so a
711		// target that carries its own `?query` percent-encodes it.
712		"unix" => url
713			.query_pairs()
714			.find(|(k, _)| k == "path")
715			.map(|(_, v)| v.into_owned())
716			.filter(|path| !path.is_empty()),
717		// Raw QUIC and qmux over TCP negotiate an ALPN and nothing else, so the whole
718		// request target travels in the SETUP.
719		"moqt" | "moql" | "tcp" => request_target(url),
720		_ => None,
721	}
722}
723
724#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
725#[derive(Debug, PartialEq, Eq)]
726enum TransportRace<Q, W> {
727	Quic(Q),
728	WebSocket(W),
729}
730
731#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
732async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
733where
734	Q: Future<Output = crate::Result<QT>>,
735	W: Future<Output = Option<crate::Result<WT>>>,
736{
737	tokio::pin!(quic);
738	tokio::pin!(websocket);
739
740	let mut quic_err = None;
741	let mut websocket_err = None;
742	let mut quic_done = false;
743	let mut websocket_done = false;
744
745	loop {
746		tokio::select! {
747			res = &mut quic, if !quic_done => {
748				match res {
749					Ok(session) => return Ok(TransportRace::Quic(session)),
750					Err(err) if err.is_auth() => return Err(err),
751					Err(err) => {
752						tracing::warn!(%err, "QUIC connection failed");
753						quic_err = Some(err);
754						quic_done = true;
755					}
756				}
757			}
758			res = &mut websocket, if !websocket_done => {
759				match res {
760					Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
761					Some(Err(err)) if err.is_auth() => return Err(err),
762					Some(Err(err)) => {
763						tracing::warn!(%err, "WebSocket connection failed");
764						websocket_err = Some(err);
765						websocket_done = true;
766					}
767					None => {
768						websocket_done = true;
769					}
770				}
771			}
772			else => break,
773		}
774
775		if quic_done && websocket_done {
776			break;
777		}
778	}
779
780	match (quic_err, websocket_err) {
781		(Some(quic), Some(websocket)) => Err(Error::TransportRace {
782			quic: std::sync::Arc::new(quic),
783			websocket: std::sync::Arc::new(websocket),
784		}),
785		(Some(err), None) | (None, Some(err)) => Err(err),
786		(None, None) => Err(Error::ConnectFailed),
787	}
788}
789
790#[cfg(test)]
791mod tests {
792	use super::*;
793	use clap::{CommandFactory, Parser};
794
795	#[cfg(any(
796		feature = "noq",
797		feature = "quinn",
798		feature = "quiche",
799		feature = "iroh",
800		feature = "websocket",
801		feature = "tcp",
802		feature = "uds"
803	))]
804	#[test]
805	fn setup_path_covers_the_uri_less_transports() {
806		// An empty path and an absent one both mean the server's default, so we send
807		// neither. A peer on published lite-05 rejects an empty value outright.
808		let cases = [
809			("unix:///run/moq.sock?path=/room", Some("/room")),
810			// The whole resource path is one form-encoded value, so a `?query` inside it
811			// arrives percent-encoded and comes back out whole.
812			("unix:///run/moq.sock?path=/room%3Fjwt%3Dabc", Some("/room?jwt=abc")),
813			("unix:///run/moq.sock?path=", None),
814			("unix:///run/moq.sock", None),
815			("tcp://localhost:4443/room", Some("/room")),
816			("tcp://localhost:4443/room?jwt=abc", Some("/room?jwt=abc")),
817			("tcp://localhost:4443", None),
818			// Raw QUIC: the URL is ours alone, so the path and query have to ride the
819			// SETUP or the server never sees them.
820			("moqt://relay.example.com/anon", Some("/anon")),
821			("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
822			("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
823			("moqt://relay.example.com", None),
824			// The fragment is processed by the client and never sent (draft-19 3.1.2).
825			("moqt://relay.example.com/anon?jwt=abc#pos:12", Some("/anon?jwt=abc")),
826			("moqt://relay.example.com/anon#pos:12", Some("/anon")),
827			// A trailing `?` is an empty query, not a query.
828			("moqt://relay.example.com/anon?", Some("/anon")),
829			("moqt://relay.example.com?", None),
830			// The transport's own request URI carries the path, so sending one here
831			// would be a protocol violation.
832			("https://relay.example.com/anon?jwt=abc", None),
833			("http://relay.example.com/anon", None),
834			("wss://relay.example.com/anon?jwt=abc", None),
835			// Decided after the ALPN is negotiated, not here.
836			("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None),
837		];
838
839		for (url, want) in cases {
840			let url = Url::parse(url).unwrap();
841			let got = setup_path(&url);
842			assert_eq!(got.as_deref(), want, "{url}");
843		}
844	}
845
846	/// The iroh dial derives its target here rather than through [`setup_path`], since
847	/// only the negotiated binding says whether to send one.
848	#[cfg(any(
849		feature = "noq",
850		feature = "quinn",
851		feature = "quiche",
852		feature = "iroh",
853		feature = "websocket",
854		feature = "tcp",
855		feature = "uds"
856	))]
857	#[test]
858	fn request_target_joins_the_path_and_query() {
859		const PEER: &str = "k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa";
860
861		let cases = [
862			(format!("iroh://{PEER}/room?jwt=abc"), Some("/room?jwt=abc")),
863			(format!("iroh://{PEER}/room"), Some("/room")),
864			(format!("iroh://{PEER}"), None),
865			(format!("iroh://{PEER}/"), Some("/")),
866		];
867
868		for (url, want) in cases {
869			let url = Url::parse(&url).unwrap();
870			let got = request_target(&url);
871			assert_eq!(got.as_deref(), want, "{url}");
872		}
873	}
874
875	#[test]
876	fn test_toml_disable_verify_survives_update_from() {
877		let toml = r#"
878			tls.disable_verify = true
879		"#;
880
881		let mut config: ClientConfig = toml::from_str(toml).unwrap();
882		assert_eq!(config.tls.disable_verify, Some(true));
883
884		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag).
885		config.update_from(["test"]);
886		assert_eq!(config.tls.disable_verify, Some(true));
887	}
888
889	#[test]
890	fn test_cli_disable_verify_flag() {
891		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
892		assert_eq!(config.tls.disable_verify, Some(true));
893	}
894
895	#[test]
896	fn test_cli_disable_verify_explicit_false() {
897		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
898		assert_eq!(config.tls.disable_verify, Some(false));
899	}
900
901	#[test]
902	fn test_cli_disable_verify_explicit_true() {
903		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
904		assert_eq!(config.tls.disable_verify, Some(true));
905	}
906
907	#[test]
908	fn test_cli_deprecated_tls_flags_fold_into_canonical() {
909		// The bare --tls-* forms are deprecated. They parse into a hidden field and
910		// fold into the canonical values via the effective_* accessors build() uses,
911		// so they keep working without touching the public Client fields.
912		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
913		assert_eq!(
914			config.tls.disable_verify, None,
915			"deprecated flag must not set the canonical field"
916		);
917		assert_eq!(config.tls.effective_disable_verify(), Some(true));
918		assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
919	}
920
921	#[test]
922	fn test_canonical_tls_flag_wins_over_deprecated() {
923		// Both spellings given: canonical wins for scalar options, vecs concatenate.
924		let config = ClientConfig::parse_from([
925			"test",
926			"--client-tls-disable-verify=false",
927			"--tls-disable-verify=true",
928			"--client-tls-fingerprint",
929			"aaaa",
930			"--tls-fingerprint",
931			"bbbb",
932		]);
933		assert_eq!(config.tls.effective_disable_verify(), Some(false));
934		assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
935	}
936
937	#[test]
938	fn test_cli_no_disable_verify() {
939		let config = ClientConfig::parse_from(["test"]);
940		assert_eq!(config.tls.disable_verify, None);
941	}
942
943	#[test]
944	fn test_toml_failover_delay_survives_update_from() {
945		let toml = r#"
946			failover_delay = "1s"
947		"#;
948
949		let mut config: ClientConfig = toml::from_str(toml).unwrap();
950		assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
951
952		// Simulate: TOML loaded, then CLI args re-applied (no --client-failover-delay flag).
953		config.update_from(["test"]);
954		assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
955	}
956
957	#[test]
958	fn test_cli_failover_delay() {
959		let config = ClientConfig::parse_from(["test", "--client-failover-delay", "50ms"]);
960		assert_eq!(config.failover_delay, Some(std::time::Duration::from_millis(50)));
961	}
962
963	#[test]
964	fn test_toml_resolution_delay_survives_update_from() {
965		let toml = r#"
966			resolution_delay = "10ms"
967		"#;
968
969		let mut config: ClientConfig = toml::from_str(toml).unwrap();
970		assert_eq!(config.resolution_delay, Some(std::time::Duration::from_millis(10)));
971
972		// Simulate: TOML loaded, then CLI args re-applied (no --client-resolution-delay flag).
973		config.update_from(["test"]);
974		assert_eq!(config.resolution_delay, Some(std::time::Duration::from_millis(10)));
975	}
976
977	#[test]
978	fn test_cli_resolution_delay() {
979		let config = ClientConfig::parse_from(["test", "--client-resolution-delay", "0s"]);
980		assert_eq!(config.resolution_delay, Some(std::time::Duration::ZERO));
981		assert_eq!(config.resolved_resolution_delay(), std::time::Duration::ZERO);
982	}
983
984	#[test]
985	fn resolution_delay_defaults_to_the_rfc_value() {
986		let config = ClientConfig::parse_from(["test"]);
987		assert_eq!(config.resolution_delay, None);
988		assert_eq!(config.resolved_resolution_delay(), std::time::Duration::from_millis(50));
989	}
990
991	#[test]
992	fn test_toml_fingerprint_survives_update_from() {
993		let toml = r#"
994			tls.fingerprint = ["abcd1234", "ef567890"]
995		"#;
996
997		let mut config: ClientConfig = toml::from_str(toml).unwrap();
998		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
999
1000		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag).
1001		config.update_from(["test"]);
1002		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
1003	}
1004
1005	#[test]
1006	fn test_toml_fingerprint_accepts_single_string() {
1007		let toml = r#"
1008			tls.fingerprint = "abcd1234"
1009		"#;
1010
1011		let config: ClientConfig = toml::from_str(toml).unwrap();
1012		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
1013	}
1014
1015	#[test]
1016	fn test_cli_fingerprint() {
1017		let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
1018		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
1019	}
1020
1021	#[test]
1022	fn test_toml_version_survives_update_from() {
1023		let toml = r#"
1024			version = ["moq-lite-02"]
1025		"#;
1026
1027		let mut config: ClientConfig = toml::from_str(toml).unwrap();
1028		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
1029
1030		// Simulate: TOML loaded, then CLI args re-applied (no --client-version flag).
1031		config.update_from(["test"]);
1032		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
1033	}
1034
1035	#[test]
1036	fn test_cli_version() {
1037		let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
1038		assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
1039	}
1040
1041	#[test]
1042	fn test_cli_version_help_lists_every_parseable_name() {
1043		let help = ClientConfig::command().render_long_help().to_string();
1044		for name in moq_net::Version::names() {
1045			assert!(help.contains(name), "missing {name} from --client-version help");
1046		}
1047	}
1048
1049	#[test]
1050	fn test_toml_connect_survives_update_from() {
1051		let toml = r#"
1052			connect = "https://relay.example.com/anon"
1053		"#;
1054
1055		let mut config: ClientConfig = toml::from_str(toml).unwrap();
1056		assert_eq!(
1057			config.connect.as_ref().unwrap().as_str(),
1058			"https://relay.example.com/anon"
1059		);
1060
1061		// Simulate: TOML loaded, then CLI args re-applied (no --client-connect flag).
1062		config.update_from(["test"]);
1063		assert_eq!(
1064			config.connect.as_ref().unwrap().as_str(),
1065			"https://relay.example.com/anon"
1066		);
1067	}
1068
1069	#[test]
1070	fn test_cli_connect() {
1071		let config = ClientConfig::parse_from(["test", "--client-connect", "https://relay.example.com/anon"]);
1072		assert_eq!(
1073			config.connect.as_ref().unwrap().as_str(),
1074			"https://relay.example.com/anon"
1075		);
1076	}
1077
1078	#[test]
1079	fn test_toml_host_name_survives_update_from() {
1080		let toml = r#"
1081			tls.host_name = "example.host"
1082		"#;
1083
1084		let mut config: ClientConfig = toml::from_str(toml).unwrap();
1085		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
1086
1087		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-host-name flag).
1088		config.update_from(["test"]);
1089		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
1090	}
1091
1092	#[test]
1093	fn test_cli_host_name() {
1094		let config = ClientConfig::parse_from(["test", "--client-tls-host-name", "override.example"]);
1095		assert_eq!(config.tls.host_name.as_deref(), Some("override.example"));
1096	}
1097
1098	#[test]
1099	fn test_cli_no_version_defaults_to_all() {
1100		let config = ClientConfig::parse_from(["test"]);
1101		assert!(config.version.is_empty());
1102		// versions() helper returns all when none specified
1103		assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
1104	}
1105
1106	#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
1107	#[tokio::test]
1108	async fn race_transport_connect_stops_on_quic_auth_error() {
1109		let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
1110		let websocket = async {
1111			// This only needs to complete later than the immediately ready QUIC auth error.
1112			tokio::task::yield_now().await;
1113			Some(Ok(1usize))
1114		};
1115
1116		let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
1117		assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
1118	}
1119
1120	#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
1121	#[tokio::test]
1122	async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
1123		let quic = async { Err::<usize, _>(Error::ConnectFailed) };
1124		let websocket = async { Some(Ok(7usize)) };
1125
1126		let value = super::race_transport_connect(quic, websocket).await.unwrap();
1127		assert_eq!(value, super::TransportRace::WebSocket(7));
1128	}
1129
1130	#[cfg(all(feature = "websocket", any(feature = "noq", feature = "quinn", feature = "quiche")))]
1131	#[tokio::test]
1132	async fn race_transport_connect_returns_when_quic_transport_connects() {
1133		let quic = async { Ok("quic") };
1134		let websocket = std::future::pending::<Option<crate::Result<&str>>>();
1135
1136		let value = tokio::time::timeout(
1137			std::time::Duration::from_secs(1),
1138			super::race_transport_connect(quic, websocket),
1139		)
1140		.await
1141		.expect("race waited for WebSocket after QUIC transport connected")
1142		.unwrap();
1143		assert_eq!(value, super::TransportRace::Quic("quic"));
1144	}
1145
1146	/// The resolved default has to exist in every build, including ones that compile
1147	/// no address-racing transport at all, which is what broke in #2773.
1148	#[test]
1149	fn failover_delay_defaults_to_the_rfc_8305_stagger() {
1150		let config = ClientConfig::parse_from(["test"]);
1151		assert_eq!(config.failover_delay, None);
1152		assert_eq!(config.resolved_failover_delay(), std::time::Duration::from_millis(250));
1153	}
1154
1155	/// Iroh carries no rustls state, so constructing its client must not require an
1156	/// application-installed crypto provider.
1157	#[cfg(all(
1158		feature = "iroh",
1159		not(any(feature = "noq", feature = "quinn", feature = "websocket"))
1160	))]
1161	#[test]
1162	fn iroh_only_client_does_not_require_a_tls_provider() {
1163		ClientConfig::default().init().expect("iroh-only client");
1164	}
1165
1166	#[test]
1167	fn connect_timeout_defaults_to_thirty_seconds() {
1168		let config = ClientConfig::parse_from(["test"]);
1169		assert_eq!(config.timeout, None);
1170		assert_eq!(config.resolved_connect_timeout(), DEFAULT_CONNECT_TIMEOUT);
1171	}
1172
1173	/// A peer that completes the TCP handshake and then never speaks: the QUIC arm
1174	/// gives up on its own, but the WebSocket arm has no deadline of its own, so the
1175	/// race stays pending forever. Without the connect timeout this test hangs.
1176	///
1177	/// That is what wedged a publisher against a livelocked relay: `Reconnect` only
1178	/// re-arms its backoff (and checks its give-up timeout) *between* attempts, so an
1179	/// attempt that never returns stalls the retry loop for good.
1180	#[cfg(feature = "websocket")]
1181	#[tokio::test]
1182	async fn connect_times_out_against_a_peer_that_never_speaks() {
1183		let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1184		let addr = listener.local_addr().unwrap();
1185
1186		let timeout = DEFAULT_CONNECT_TIMEOUT;
1187		let mut config = ClientConfig {
1188			timeout: Some(timeout),
1189			..Default::default()
1190		};
1191		config.websocket.delay = Some(std::time::Duration::ZERO);
1192		let client = config.init().unwrap();
1193
1194		// Nothing is listening on UDP, so the QUIC arm fails and leaves the WebSocket
1195		// arm alone against the silent peer.
1196		let url: Url = format!("https://127.0.0.1:{}/", addr.port()).parse().unwrap();
1197
1198		let mut attempt = Box::pin(client.connect(url));
1199		let _silent = tokio::select! {
1200			res = &mut attempt => match res {
1201				Err(err) => panic!("connect failed before the silent peer accepted it: {err}"),
1202				Ok(_) => panic!("connected to a peer that never spoke"),
1203			},
1204			res = listener.accept() => res.unwrap().0,
1205		};
1206
1207		// Freeze only after TCP connected, then advance directly to the deadline. The
1208		// accepted socket stays in scope and silent until the attempt returns.
1209		tokio::time::pause();
1210		tokio::time::advance(timeout).await;
1211
1212		let err = match attempt.await {
1213			Err(err) => err,
1214			Ok(_) => panic!("connected to a peer that never spoke"),
1215		};
1216
1217		assert!(matches!(err, Error::ConnectTimeout(_)), "unexpected error: {err}");
1218	}
1219}