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