Skip to main content

moq_native/
websocket.rs

1//! WebSocket fallback transport, running the QMux wire format over `ws://` or `wss://`.
2//!
3//! Used when QUIC is unreachable: UDP blocked by a firewall, a proxy in the way, a
4//! network that only passes TCP/443. The client races this against QUIC and gives QUIC
5//! a small head start ([`Client::delay`]), so WebSocket only wins when QUIC can't get
6//! through. Servers accept it on a separate TCP port via [`Listener`].
7
8use qmux::ws::tokio_tungstenite;
9use qmux::ws::tokio_tungstenite::tungstenite::{self, client::IntoClientRequest, http};
10use std::collections::HashSet;
11use std::sync::{Arc, LazyLock, Mutex};
12use std::{net, time};
13use url::Url;
14
15use crate::RedactedUrl;
16
17/// Errors specific to the WebSocket fallback backend.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum Error {
21	/// The TCP socket failed to bind or connect. Not accept: a failed `accept(2)` is
22	/// the listener's own to classify and retry (see [`crate::accept`]).
23	#[error(transparent)]
24	Io(#[from] std::io::Error),
25
26	/// WebSocket fallback was turned off via [`Client::enabled`].
27	#[error("WebSocket support is disabled")]
28	Disabled,
29
30	/// The URL had no host to dial.
31	#[error("missing hostname")]
32	MissingHostname,
33
34	/// The URL scheme can't carry WebSocket. Only `http`, `https`, `ws`, and `wss` work.
35	#[error("unsupported URL scheme for WebSocket: {0}")]
36	UnsupportedScheme(String),
37
38	/// The qmux handshake failed while dialing, including a non-101 upgrade response
39	/// from the server.
40	#[error("failed to connect WebSocket")]
41	Connect(#[source] qmux::Error),
42
43	/// The URL couldn't be turned into a valid WebSocket handshake request.
44	#[error("failed to build WebSocket request")]
45	BuildRequest(#[source] tungstenite::Error),
46
47	/// An ALPN contained bytes that aren't legal in the `Sec-WebSocket-Protocol` header.
48	#[error("failed to build WebSocket protocols header")]
49	ProtocolHeader(#[source] http::header::InvalidHeaderValue),
50
51	/// The TCP/TLS connection or the WebSocket upgrade itself failed.
52	#[error("failed to connect WebSocket")]
53	WebSocketConnect(#[source] tungstenite::Error),
54
55	/// The server refused the connection outright, so retrying won't help.
56	#[error(transparent)]
57	ConnectRejected(#[from] crate::ConnectError),
58
59	/// The qmux handshake failed while accepting an incoming connection.
60	#[error("WebSocket accept failed")]
61	Accept(#[source] qmux::Error),
62}
63
64type Result<T> = std::result::Result<T, Error>;
65
66// Track servers (hostname:port) where WebSocket won the race, so we won't give QUIC a headstart next time
67static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
68
69/// WebSocket configuration for the client.
70#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
71#[serde(default, deny_unknown_fields)]
72#[group(id = "websocket-client")]
73#[non_exhaustive]
74pub struct Client {
75	/// Whether to enable WebSocket support.
76	#[arg(
77		id = "websocket-enabled",
78		long = "websocket-enabled",
79		env = "MOQ_CLIENT_WEBSOCKET_ENABLED",
80		default_value = "true"
81	)]
82	pub enabled: bool,
83
84	/// Delay in milliseconds before attempting WebSocket fallback (default: 200)
85	/// If WebSocket won the previous race for a given server, this will be 0.
86	#[arg(
87		id = "websocket-delay",
88		long = "websocket-delay",
89		env = "MOQ_CLIENT_WEBSOCKET_DELAY",
90		default_value = "200ms",
91		value_parser = humantime::parse_duration,
92	)]
93	#[serde(with = "humantime_serde")]
94	#[serde(skip_serializing_if = "Option::is_none")]
95	pub delay: Option<time::Duration>,
96}
97
98impl Default for Client {
99	fn default() -> Self {
100		Self {
101			enabled: true,
102			delay: Some(time::Duration::from_millis(200)),
103		}
104	}
105}
106
107/// The fallback arm of the QUIC-vs-WebSocket race, so only compiled when there is a
108/// QUIC dial to race against. A WebSocket-only build calls [`connect`] directly.
109#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
110pub(crate) async fn race_handle(
111	config: &Client,
112	tls: &rustls::ClientConfig,
113	tls_host_name: Option<&str>,
114	url: Url,
115	alpns: &[&str],
116) -> Option<Result<qmux::Session>> {
117	if !config.enabled {
118		return None;
119	}
120
121	// Only attempt WebSocket for HTTP-based schemes.
122	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
123	match url.scheme() {
124		"http" | "https" | "ws" | "wss" => {}
125		_ => return None,
126	}
127
128	let res = connect(config, tls, tls_host_name, url, alpns).await;
129	if let Err(err) = &res {
130		tracing::warn!(%err, "WebSocket connection failed");
131	}
132	Some(res)
133}
134
135pub(crate) async fn connect(
136	config: &Client,
137	tls: &rustls::ClientConfig,
138	tls_host_name: Option<&str>,
139	mut url: Url,
140	alpns: &[&str],
141) -> Result<qmux::Session> {
142	if !config.enabled {
143		return Err(Error::Disabled);
144	}
145
146	let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
147	let port = url.port().unwrap_or_else(|| match url.scheme() {
148		"https" | "wss" | "moql" | "moqt" => 443,
149		"http" | "ws" => 80,
150		_ => 443,
151	});
152	let key = (host.clone(), port);
153
154	// Apply a small penalty to WebSocket to improve odds for QUIC to connect first,
155	// unless we've already had to fall back to WebSockets for this server.
156	// TODO if let chain
157	match config.delay {
158		Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
159			tokio::time::sleep(delay).await;
160			tracing::debug!(url = %RedactedUrl::new(&url), delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
161		}
162		_ => {}
163	}
164
165	// Convert URL scheme: http:// -> ws://, https:// -> wss://
166	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
167	let needs_tls = match url.scheme() {
168		"http" => {
169			url.set_scheme("ws").expect("failed to set scheme");
170			false
171		}
172		"https" => {
173			url.set_scheme("wss").expect("failed to set scheme");
174			true
175		}
176		"ws" => false,
177		"wss" => true,
178		_ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
179	};
180
181	tracing::debug!(url = %RedactedUrl::new(&url), "connecting via WebSocket");
182
183	let session = match (needs_tls, tls_host_name) {
184		(true, Some(tls_host_name)) => connect_tls_override(tls, tls_host_name, &url, &host, port, alpns).await?,
185		_ => {
186			// Use the existing TLS config (which respects tls-disable-verify) for secure connections.
187			let connector = if needs_tls {
188				tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
189			} else {
190				tokio_tungstenite::Connector::Plain
191			};
192
193			// Most moq ALPNs can ride on any QMux draft (`&[]` lets the polyfill expand
194			// to every version it knows). `qmux_versions_for` pins the few that the spec
195			// restricts. qmux also offers the bare ALPNs (`qmux-01`, `qmux-00`,
196			// `webtransport`) by default so we still interop with relays that only know a
197			// wire-format version.
198			qmux::ws::Client::new()
199				.with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
200				.with_connector(connector)
201				.with_keep_alive(qmux::ws::KeepAlive::default()) // 5s ping / 30s deadline, parity with QUIC
202				.connect(url.as_str())
203				.await
204				.map_err(Error::Connect)?
205		}
206	};
207
208	tracing::warn!(url = %RedactedUrl::new(&url), "using WebSocket fallback");
209	WEBSOCKET_WON.lock().unwrap().insert(key);
210
211	Ok(session)
212}
213
214/// Dial the URL address while using a different server name for the TLS layer.
215async fn connect_tls_override(
216	tls: &rustls::ClientConfig,
217	tls_host_name: &str,
218	url: &Url,
219	host: &str,
220	port: u16,
221	alpns: &[&str],
222) -> Result<qmux::Session> {
223	let original_request = url.as_str().into_client_request().map_err(Error::BuildRequest)?;
224	let original_host = original_request
225		.headers()
226		.get(http::header::HOST)
227		.cloned()
228		.ok_or(Error::MissingHostname)?;
229	let mut tls_url = url.clone();
230	tls_url
231		.set_host(Some(tls_host_name))
232		.map_err(|_| Error::Connect(qmux::Error::InvalidServerName))?;
233	let mut request = tls_url.as_str().into_client_request().map_err(Error::BuildRequest)?;
234	request.headers_mut().insert(http::header::HOST, original_host);
235	let protocols = supported_subprotocols(alpns).join(", ");
236	request.headers_mut().insert(
237		http::header::SEC_WEBSOCKET_PROTOCOL,
238		http::HeaderValue::from_str(&protocols).map_err(Error::ProtocolHeader)?,
239	);
240
241	let host = host
242		.strip_prefix('[')
243		.and_then(|host| host.strip_suffix(']'))
244		.unwrap_or(host);
245	let stream = tokio::net::TcpStream::connect((host, port)).await?;
246	let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()));
247	let (websocket, response) = tokio_tungstenite::client_async_tls_with_config(request, stream, None, Some(connector))
248		.await
249		.map_err(qmux::Error::from)
250		.map_err(Error::Connect)?;
251
252	let negotiated = response
253		.headers()
254		.get(http::header::SEC_WEBSOCKET_PROTOCOL)
255		.and_then(|value| value.to_str().ok());
256	let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::ws::KeepAlive::default());
257	Ok(match negotiated {
258		Some(protocol) => upgraded.with_alpn(protocol).connect(),
259		None => upgraded.connect(),
260	})
261}
262
263/// The QMux drafts a moq ALPN is allowed to ride on, for `qmux::ws::Client::with_protocols`.
264///
265/// moq-transport-18 and -19 require qmux-01, so we never pair them with qmux-00.
266/// This mirrors the policy in `js/net`'s `connect.ts`. Every other ALPN returns
267/// `&[]`, which qmux expands to every draft it knows about.
268const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19", "moqt-20"];
269
270fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
271	if QMUX01_ONLY_ALPNS.contains(&alpn) {
272		&[qmux::Version::QMux01]
273	} else {
274		&[]
275	}
276}
277
278impl Error {
279	pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
280		match self {
281			Self::ConnectRejected(err) => Some(*err),
282			// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`;
283			// map an auth rejection (401/403) so the caller sees it as terminal.
284			Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
285			_ => None,
286		}
287	}
288
289	/// The HTTP status the server answered the upgrade with, if it answered with one at all.
290	///
291	/// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`. See
292	/// [`crate::Error::status`].
293	pub(crate) fn status(&self) -> Option<u16> {
294		match self {
295			Self::Connect(qmux::Error::Http(status)) => Some(*status),
296			_ => None,
297		}
298	}
299}
300
301/// Listens for incoming WebSocket connections on a TCP port.
302///
303/// Use with [`crate::Server::with_websocket`] to accept WebSocket connections
304/// alongside QUIC connections on a separate port.
305pub struct Listener {
306	listener: tokio::net::TcpListener,
307	protocols: Vec<String>,
308	health: crate::accept::Health,
309}
310
311impl Listener {
312	/// Bind a listener to the given address, accepting every moq ALPN we know about.
313	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
314		Self::bind_with_alpns(addr, moq_net::ALPNS).await
315	}
316
317	/// Bind a listener that only accepts the given moq ALPNs, in preference order.
318	pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
319		let listener = tokio::net::TcpListener::bind(addr).await?;
320		let protocols = supported_subprotocols(alpns);
321		for protocol in &protocols {
322			http::HeaderValue::from_str(protocol).map_err(Error::ProtocolHeader)?;
323		}
324		Ok(Self {
325			listener,
326			protocols,
327			health: crate::accept::Health::new("websocket"),
328		})
329	}
330
331	/// The local address the listener is bound to.
332	pub fn local_addr(&self) -> Result<net::SocketAddr> {
333		Ok(self.listener.local_addr()?)
334	}
335
336	/// A live handle to this listener's accept-loop health, for an embedder that
337	/// publishes it (see [`crate::accept`]).
338	pub fn accept_health(&self) -> crate::accept::Health {
339		self.health.clone()
340	}
341
342	/// Accept the next connection, performing the WebSocket upgrade and qmux handshake.
343	///
344	/// A failed `accept(2)` is handled here rather than yielded: it is classified,
345	/// counted, logged, and paced by [`accept_health`](Self::accept_health), then
346	/// retried, because the caller has no better answer than to ask again. A
347	/// per-connection upgrade failure is still yielded as `Some(Err(..))`.
348	///
349	/// As in [`crate::tcp`], the `Option` has no `None` case left to report.
350	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
351		self.accept_with_url()
352			.await
353			.map(|result| result.map(|(session, _)| session))
354	}
355
356	/// Accept the next connection and retain the WebSocket request URL.
357	pub(crate) async fn accept_with_url(&self) -> Option<Result<(qmux::Session, Url)>> {
358		let (stream, addr) = self.accept_socket().await;
359		tracing::debug!(%addr, "accepted WebSocket TCP connection");
360
361		let accepted = Arc::new(Mutex::new(None::<(Option<String>, Url)>));
362		let accepted_callback = accepted.clone();
363		let protocols = self.protocols.clone();
364		#[allow(clippy::result_large_err)]
365		let callback = move |request: &tungstenite::handshake::server::Request,
366		               mut response: tungstenite::handshake::server::Response|
367		      -> std::result::Result<_, tungstenite::handshake::server::ErrorResponse> {
368			let offered: Vec<_> = request
369				.headers()
370				.get_all(http::header::SEC_WEBSOCKET_PROTOCOL)
371				.iter()
372				.filter_map(|value| value.to_str().ok())
373				.flat_map(|value| value.split(','))
374				.map(str::trim)
375				.filter(|value| !value.is_empty())
376				.collect();
377			let Ok(protocol) = select_subprotocol(&offered, &protocols) else {
378				return Err(http::Response::builder()
379					.status(http::StatusCode::BAD_REQUEST)
380					.body(Some("no supported protocol".to_string()))
381					.expect("valid rejection response"));
382			};
383			let Some(url) = websocket_request_url(request) else {
384				return Err(http::Response::builder()
385					.status(http::StatusCode::BAD_REQUEST)
386					.body(Some("invalid request URL".to_string()))
387					.expect("valid rejection response"));
388			};
389
390			if let Some(protocol) = protocol {
391				response.headers_mut().insert(
392					http::header::SEC_WEBSOCKET_PROTOCOL,
393					http::HeaderValue::from_str(protocol).expect("protocol validated at bind"),
394				);
395			}
396			*accepted_callback.lock().unwrap() = Some((protocol.map(str::to_string), url));
397			Ok(response)
398		};
399
400		let websocket = tokio_tungstenite::accept_hdr_async_with_config(stream, callback, None)
401			.await
402			.map_err(qmux::Error::from)
403			.map_err(Error::Accept);
404		Some(websocket.map(|websocket| {
405			let (protocol, url) = accepted
406				.lock()
407				.unwrap()
408				.take()
409				.expect("successful upgrade selected a protocol");
410			let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::ws::KeepAlive::default());
411			let session = match protocol {
412				Some(protocol) => upgraded.with_alpn(&protocol).accept(),
413				None => upgraded.accept(),
414			};
415			(session, url)
416		}))
417	}
418
419	/// The `accept(2)` half: keep asking until a connection comes back.
420	async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
421		loop {
422			match self.listener.accept().await {
423				Ok(accepted) => {
424					self.health.accepted();
425					return accepted;
426				}
427				Err(err) => {
428					if let Some(delay) = self.health.failed(&err) {
429						tokio::time::sleep(delay).await;
430					}
431				}
432			}
433		}
434	}
435}
436
437/// Select a supported subprotocol, while preserving legacy clients that offer none.
438fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> std::result::Result<Option<&'a str>, ()> {
439	if offered.is_empty() {
440		return Ok(None);
441	}
442
443	supported
444		.iter()
445		.find(|protocol| offered.contains(&protocol.as_str()))
446		.map(|protocol| Some(protocol.as_str()))
447		.ok_or(())
448}
449
450/// Reconstruct the client request URL from an absolute URI or the HTTP Host header.
451fn websocket_request_url(request: &tungstenite::handshake::server::Request) -> Option<Url> {
452	let uri = request.uri();
453	if uri.scheme().is_some() && uri.authority().is_some() {
454		return Url::parse(&uri.to_string()).ok();
455	}
456
457	let host = request.headers().get(http::header::HOST)?.to_str().ok()?;
458	Url::parse(&format!("ws://{host}{uri}")).ok()
459}
460
461/// WebSocket subprotocols accepted for the given MoQ ALPNs, in preference order.
462fn supported_subprotocols(alpns: &[&str]) -> Vec<String> {
463	let mut protocols = Vec::new();
464	for &alpn in alpns {
465		let versions = qmux_versions_for(alpn);
466		let versions = if versions.is_empty() {
467			qmux::Version::ALL
468		} else {
469			versions
470		};
471		protocols.extend(
472			versions
473				.iter()
474				.copied()
475				.filter(|version| version.is_qmux())
476				.map(|version| format!("{}{alpn}", version.prefix())),
477		);
478	}
479	protocols.extend(qmux::ALPNS.iter().map(|protocol| (*protocol).to_string()));
480	protocols
481}
482
483#[cfg(test)]
484mod tests {
485	use super::*;
486	use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
487
488	#[test]
489	fn subprotocol_selection_preserves_legacy_clients() {
490		let supported = vec!["qmux-01.moq-lite-05".to_string()];
491		assert_eq!(select_subprotocol(&[], &supported), Ok(None));
492		assert_eq!(
493			select_subprotocol(&["qmux-01.moq-lite-05"], &supported),
494			Ok(Some("qmux-01.moq-lite-05"))
495		);
496		assert_eq!(select_subprotocol(&["unsupported"], &supported), Err(()));
497	}
498
499	#[tokio::test]
500	async fn listener_accepts_legacy_client_without_subprotocol() {
501		let listener = Listener::bind("127.0.0.1:0".parse().unwrap()).await.unwrap();
502		let addr = listener.local_addr().unwrap();
503		let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap().unwrap() });
504
505		let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
506		let request_url = format!("ws://{addr}/room?jwt=test");
507		let (websocket, response) = tokio_tungstenite::client_async(request_url, stream).await.unwrap();
508		assert!(!response.headers().contains_key(http::header::SEC_WEBSOCKET_PROTOCOL));
509
510		let (session, url) = accepted.await.unwrap();
511		assert_eq!(url.path(), "/room");
512		assert_eq!(url.query(), Some("jwt=test"));
513		drop(session);
514		drop(websocket);
515	}
516
517	#[tokio::test]
518	async fn tls_host_name_override_dials_url_address() {
519		let rcgen::CertifiedKey { cert, signing_key } =
520			rcgen::generate_simple_self_signed(["relay.example".to_string()]).unwrap();
521		let cert = CertificateDer::from(cert);
522		let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
523		let provider = crate::crypto::provider();
524		let server_tls = rustls::ServerConfig::builder_with_provider(provider.clone())
525			.with_safe_default_protocol_versions()
526			.unwrap()
527			.with_no_client_auth()
528			.with_single_cert(vec![cert.clone()], key)
529			.unwrap();
530
531		let mut roots = rustls::RootCertStore::empty();
532		roots.add(cert).unwrap();
533		let client_tls = rustls::ClientConfig::builder_with_provider(provider)
534			.with_safe_default_protocol_versions()
535			.unwrap()
536			.with_root_certificates(roots)
537			.with_no_client_auth();
538
539		let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
540		let addr = listener.local_addr().unwrap();
541		let accepted = tokio::spawn(async move {
542			let (stream, _) = listener.accept().await.unwrap();
543			let tls = tokio_rustls::TlsAcceptor::from(Arc::new(server_tls))
544				.accept(stream)
545				.await
546				.unwrap();
547			let server_name = tls.get_ref().1.server_name().map(str::to_string);
548			let host = Arc::new(Mutex::new(None));
549			let request_host = host.clone();
550			#[allow(clippy::result_large_err)]
551			let callback = move |request: &tungstenite::handshake::server::Request,
552			            mut response: tungstenite::handshake::server::Response| {
553				*request_host.lock().unwrap() = request
554					.headers()
555					.get(http::header::HOST)
556					.and_then(|value| value.to_str().ok())
557					.map(str::to_string);
558				if let Some(protocol) = request
559					.headers()
560					.get(http::header::SEC_WEBSOCKET_PROTOCOL)
561					.and_then(|value| value.to_str().ok())
562					.and_then(|value| value.split(',').next())
563					.map(str::trim)
564				{
565					response.headers_mut().insert(
566						http::header::SEC_WEBSOCKET_PROTOCOL,
567						http::HeaderValue::from_str(protocol).unwrap(),
568					);
569				}
570				Ok(response)
571			};
572			let _websocket = tokio_tungstenite::accept_hdr_async(tls, callback).await.unwrap();
573			let host = host.lock().unwrap().take();
574			(server_name, host)
575		});
576
577		let config = Client {
578			delay: None,
579			..Default::default()
580		};
581		let url = Url::parse(&format!("wss://127.0.0.1:{}/anon", addr.port())).unwrap();
582		let session = connect(&config, &client_tls, Some("relay.example"), url, moq_net::ALPNS)
583			.await
584			.unwrap();
585		drop(session);
586		let (server_name, host) = accepted.await.unwrap();
587		assert_eq!(server_name.as_deref(), Some("relay.example"));
588		assert_eq!(host.as_deref(), Some(format!("127.0.0.1:{}", addr.port()).as_str()));
589	}
590
591	#[test]
592	fn moqt_18_and_19_pin_to_qmux01() {
593		// The literals in `qmux_versions_for` must stay the IETF draft ALPNs;
594		// otherwise the pin silently stops matching.
595		assert_eq!(
596			QMUX01_ONLY_ALPNS
597				.iter()
598				.map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
599				.collect::<Vec<_>>(),
600			vec![Some(0xff000012), Some(0xff000013), Some(0xff000014)]
601		);
602		for &alpn in QMUX01_ONLY_ALPNS {
603			assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
604		}
605
606		// Everything else stays unrestricted (qmux expands `&[]` to all drafts).
607		for &alpn in moq_net::ALPNS {
608			if !QMUX01_ONLY_ALPNS.contains(&alpn) {
609				assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
610			}
611		}
612	}
613}