From d202b31c57ffc5ae646e9e35b0522052cb55f6ab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 26 May 2026 12:32:57 -0700 Subject: [PATCH 1/9] moq-net: map MoQ versions to required qmux versions The MoQ WG decided qmux's draft version is tied to the moq-transport version. moq-transport-18 requires qmux-01; moq-transport-14..17 stay on qmux-00. moq-lite is unconstrained: existing Lite01..Lite04 advertise both for back-compat, and future moq-lite versions should pin to a single qmux version like moq-transport does. Add a local QmuxVersion enum, Version::qmux_versions (the spec mapping), Version::accepts_qmux (server-side validation), and Versions::qmux_alpns (ordered, dedup'd (qmux_version, app_alpn) pairs for building the Sec-WebSocket-Protocol list). Lite arms are listed by variant rather than wildcard so adding a new Lite variant fails to compile until someone makes a deliberate choice. Wiring through moq-native (and the qmux 0.0.8 bump) follows in a separate change once upstream PR moq-dev/web-transport#226 lands and exposes an API for explicit (version, protocol) pairs instead of the default cross-product. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/moq-net/src/version.rs | 173 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 7c4872c1f..d37c9aa41 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -36,6 +36,37 @@ pub(crate) const ALPN_16: &str = "moqt-16"; pub(crate) const ALPN_17: &str = "moqt-17"; pub(crate) const ALPN_18: &str = "moqt-18"; +/// The qmux draft version used to carry a MoQ ALPN over WebSocket / TLS. +/// +/// The MoQ WG decided that qmux's version is tied to the moq-transport draft +/// (moq-transport-18 requires qmux-01; moq-transport-14..17 use qmux-00). +/// moq-lite is unconstrained and may ride on either. +/// +/// Mirrors `qmux::Version` but kept local so `moq-net` stays independent of +/// the `qmux` crate; the `moq-native` layer converts at the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum QmuxVersion { + QMux00, + QMux01, +} + +impl QmuxVersion { + /// The bare ALPN string for this qmux version. + pub fn alpn(&self) -> &'static str { + match self { + Self::QMux00 => "qmux-00", + Self::QMux01 => "qmux-01", + } + } +} + +impl fmt::Display for QmuxVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.alpn()) + } +} + /// A MoQ protocol version. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] @@ -117,6 +148,31 @@ impl Version { ) } + /// The qmux versions this MoQ version may ride on, in preference order. + /// + /// moq-transport-18 requires qmux-01; moq-transport-14..17 require qmux-00. + /// Existing moq-lite versions (Lite01..Lite04) advertise both for back-compat. + /// Future moq-lite versions should pin to a single qmux version, like moq-transport. + pub fn qmux_versions(&self) -> &'static [QmuxVersion] { + use ietf::Version as I; + use lite::Version as L; + match self { + Self::Ietf(I::Draft18) => &[QmuxVersion::QMux01], + Self::Ietf(I::Draft14 | I::Draft15 | I::Draft16 | I::Draft17) => &[QmuxVersion::QMux00], + Self::Lite(L::Lite01 | L::Lite02 | L::Lite03 | L::Lite04) => { + &[QmuxVersion::QMux01, QmuxVersion::QMux00] + } + } + } + + /// Whether this MoQ version is permitted to ride on the given qmux version. + /// + /// Use server-side after the qmux/app pair has been negotiated to reject + /// pairings the moq-transport spec forbids (e.g. `qmux-00.moqt-18`). + pub fn accepts_qmux(&self, qv: QmuxVersion) -> bool { + self.qmux_versions().contains(&qv) + } + /// Whether this is a lite protocol version. pub fn is_lite(&self) -> bool { match self { @@ -242,6 +298,26 @@ impl Versions { alpns } + /// Compute the `(qmux_version, app_alpn)` pairs to advertise over WebSocket / TLS, + /// in preference order, dedup'd. + /// + /// Each MoQ version is paired only with the qmux versions it's permitted to ride on + /// (see [`Version::qmux_versions`]). Use this to build the `Sec-WebSocket-Protocol` + /// list (or TLS ALPN list) when fronting a qmux session. + pub fn qmux_alpns(&self) -> Vec<(QmuxVersion, &'static str)> { + let mut pairs = Vec::new(); + for v in &self.0 { + let alpn = v.alpn(); + for &qv in v.qmux_versions() { + let pair = (qv, alpn); + if !pairs.contains(&pair) { + pairs.push(pair); + } + } + } + pairs + } + /// Return only versions present in both self and other, or `None` if the intersection is empty. pub fn filter(&self, other: &Versions) -> Option { let filtered: Vec = self.0.iter().filter(|v| other.0.contains(v)).copied().collect(); @@ -296,3 +372,100 @@ impl From for coding::Versions { coding::Versions::from(inner) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qmux_versions_for_each_moq_version() { + assert_eq!( + Version::Ietf(ietf::Version::Draft18).qmux_versions(), + &[QmuxVersion::QMux01] + ); + for v in [ + ietf::Version::Draft14, + ietf::Version::Draft15, + ietf::Version::Draft16, + ietf::Version::Draft17, + ] { + assert_eq!(Version::Ietf(v).qmux_versions(), &[QmuxVersion::QMux00], "{v}"); + } + for v in [ + lite::Version::Lite01, + lite::Version::Lite02, + lite::Version::Lite03, + lite::Version::Lite04, + ] { + assert_eq!( + Version::Lite(v).qmux_versions(), + &[QmuxVersion::QMux01, QmuxVersion::QMux00], + "{v}" + ); + } + } + + #[test] + fn accepts_qmux_is_consistent() { + assert!(Version::Ietf(ietf::Version::Draft18).accepts_qmux(QmuxVersion::QMux01)); + assert!(!Version::Ietf(ietf::Version::Draft18).accepts_qmux(QmuxVersion::QMux00)); + assert!(Version::Ietf(ietf::Version::Draft17).accepts_qmux(QmuxVersion::QMux00)); + assert!(!Version::Ietf(ietf::Version::Draft17).accepts_qmux(QmuxVersion::QMux01)); + assert!(Version::Lite(lite::Version::Lite04).accepts_qmux(QmuxVersion::QMux01)); + assert!(Version::Lite(lite::Version::Lite04).accepts_qmux(QmuxVersion::QMux00)); + } + + #[test] + fn qmux_alpns_all_matches_table() { + let pairs = Versions::all().qmux_alpns(); + assert_eq!( + pairs, + vec![ + (QmuxVersion::QMux01, "moq-lite-04"), + (QmuxVersion::QMux00, "moq-lite-04"), + (QmuxVersion::QMux01, "moq-lite-03"), + (QmuxVersion::QMux00, "moq-lite-03"), + (QmuxVersion::QMux01, "moql"), + (QmuxVersion::QMux00, "moql"), + (QmuxVersion::QMux01, "moqt-18"), + (QmuxVersion::QMux00, "moqt-17"), + (QmuxVersion::QMux00, "moqt-16"), + (QmuxVersion::QMux00, "moqt-15"), + (QmuxVersion::QMux00, "moq-00"), + ] + ); + } + + #[test] + fn qmux_alpns_singleton_moqt_18() { + assert_eq!( + Versions::from(Version::Ietf(ietf::Version::Draft18)).qmux_alpns(), + vec![(QmuxVersion::QMux01, "moqt-18")] + ); + } + + #[test] + fn qmux_alpns_singleton_moqt_17() { + assert_eq!( + Versions::from(Version::Ietf(ietf::Version::Draft17)).qmux_alpns(), + vec![(QmuxVersion::QMux00, "moqt-17")] + ); + } + + #[test] + fn qmux_alpns_singleton_lite_offers_both() { + assert_eq!( + Versions::from(Version::Lite(lite::Version::Lite04)).qmux_alpns(), + vec![ + (QmuxVersion::QMux01, "moq-lite-04"), + (QmuxVersion::QMux00, "moq-lite-04"), + ] + ); + } + + #[test] + fn qmux_version_alpn_strings() { + assert_eq!(QmuxVersion::QMux00.alpn(), "qmux-00"); + assert_eq!(QmuxVersion::QMux01.alpn(), "qmux-01"); + } +} From 8999f9646192ae34c4b55e900fe4d1b1c493ad13 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 26 May 2026 12:42:24 -0700 Subject: [PATCH 2/9] moq-net: fix rustfmt for Lite qmux match arm CI's rustfmt collapses single-expr match arms; local fmt didn't flag it. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/moq-net/src/version.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index d37c9aa41..9fa7f6cff 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -159,9 +159,7 @@ impl Version { match self { Self::Ietf(I::Draft18) => &[QmuxVersion::QMux01], Self::Ietf(I::Draft14 | I::Draft15 | I::Draft16 | I::Draft17) => &[QmuxVersion::QMux00], - Self::Lite(L::Lite01 | L::Lite02 | L::Lite03 | L::Lite04) => { - &[QmuxVersion::QMux01, QmuxVersion::QMux00] - } + Self::Lite(L::Lite01 | L::Lite02 | L::Lite03 | L::Lite04) => &[QmuxVersion::QMux01, QmuxVersion::QMux00], } } From 32bd4da302c05cbe97e86c1e31faf8ccca5a9dda Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 27 May 2026 14:17:12 -0700 Subject: [PATCH 3/9] moq-native: drive qmux 0.1 with explicit (qmux, app) pair list qmux 0.1 pins a single Version per Client/Server. To keep advertising qmux-01.* and qmux-00.* on the same WebSocket connection (which moq-lite needs for back-compat), moq-native now owns the multi-version Sec-WebSocket-Protocol composition: - moq-net exposes Versions::qmux_alpn_strings() that formats the ordered (QmuxVersion, app_alpn) pairs as "qmux-XX.app". - moq-native::websocket::connect builds the request itself and hands the upgraded socket to qmux::ws::Bare with the version inferred from the negotiated header. - WebSocketListener does the accept handshake the same way: pick the first server-supported pair, then wrap with Bare pinned to that version. - moq-relay::serve_ws lets axum filter on the same string list, reads socket.protocol() before adapter conversion, and passes the resolved qmux::Version through to handle_socket. Cargo.toml temporarily points qmux at the local qmux-01 worktree and adds a [patch.crates-io] entry for web-transport-{trait,proto} so the whole web-transport family shares one crate instance. Drop both once qmux 0.1.0 ships on crates.io. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 57 +++------------ Cargo.toml | 8 ++- rs/moq-native/src/client.rs | 8 +-- rs/moq-native/src/websocket.rs | 122 ++++++++++++++++++++++++++++----- rs/moq-net/src/version.rs | 9 +++ rs/moq-relay/src/websocket.rs | 32 +++++++-- 6 files changed, 158 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d14687f9..45cd423bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,7 +365,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -5177,9 +5177,7 @@ dependencies = [ [[package]] name = "qmux" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a625edac9a3021654a955444ca602b7b66b6764c7372196df08af53779ffbe7" +version = "0.1.0" dependencies = [ "bytes", "futures", @@ -5187,7 +5185,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rustls", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tracing", "web-transport-proto", "web-transport-trait", @@ -6924,9 +6922,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -6935,19 +6933,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.28.0", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -7332,25 +7318,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -7363,6 +7330,8 @@ dependencies = [ "httparse", "log", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.18", ] @@ -7588,12 +7557,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -7895,8 +7858,6 @@ dependencies = [ [[package]] name = "web-transport-proto" version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0225d295c8ac00a2e9a498aefeaf3f3c6186da12a251c938189b15b82ea22808" dependencies = [ "bytes", "http", @@ -7950,8 +7911,6 @@ dependencies = [ [[package]] name = "web-transport-trait" version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc5f83d19cf6c8ba147f4e1e5935a8a115c91f6abbf714d740a83b967d558e6e" dependencies = [ "bytes", ] diff --git a/Cargo.toml b/Cargo.toml index ebad98951..11e7f4717 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ moq-mux = { version = "0.5", path = "rs/moq-mux" } moq-native = { version = "0.15", path = "rs/moq-native", default-features = false } moq-net = { version = "0.1", path = "rs/moq-net" } moq-token = { version = "0.6", path = "rs/moq-token" } -qmux = { version = "0.0.7", default-features = false } +qmux = { path = "/Users/kixelated/work/web-transport/.claude/worktrees/sad-pasteur-2b0cd5/rs/qmux", default-features = false } serde = { version = "1", features = ["derive"] } tokio = "1.48" @@ -64,6 +64,12 @@ web-transport-quiche = "0.2" web-transport-quinn = "0.11" web-transport-trait = "0.3.4" +# Temporary: link the qmux-01 worktree so the whole web-transport family shares one crate instance. +# Drop once qmux 0.1.0 ships on crates.io. +[patch.crates-io] +web-transport-trait = { path = "/Users/kixelated/work/web-transport/.claude/worktrees/sad-pasteur-2b0cd5/rs/web-transport-trait" } +web-transport-proto = { path = "/Users/kixelated/work/web-transport/.claude/worktrees/sad-pasteur-2b0cd5/rs/web-transport-proto" } + [profile.dev] panic = "abort" diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 09fcade0e..119de4c04 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -392,7 +392,7 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.alpns(); + let alpns = self.versions.qmux_alpn_strings(); let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, &alpns); return Ok(tokio::select! { @@ -423,7 +423,7 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.alpns(); + let alpns = self.versions.qmux_alpn_strings(); let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, &alpns); return Ok(tokio::select! { @@ -453,7 +453,7 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.alpns(); + let alpns = self.versions.qmux_alpn_strings(); let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, &alpns); return Ok(tokio::select! { @@ -472,7 +472,7 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.alpns(); + let alpns = self.versions.qmux_alpn_strings(); let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?; return Ok(self.moq.connect(session).await?); } diff --git a/rs/moq-native/src/websocket.rs b/rs/moq-native/src/websocket.rs index b5ee23202..0f879e8f2 100644 --- a/rs/moq-native/src/websocket.rs +++ b/rs/moq-native/src/websocket.rs @@ -1,5 +1,6 @@ use anyhow::Context; use qmux::tokio_tungstenite; +use qmux::tungstenite; use std::collections::HashSet; use std::sync::{Arc, LazyLock, Mutex}; use std::{net, time}; @@ -45,11 +46,21 @@ impl Default for ClientWebSocket { } } +/// Pick the qmux version implied by a negotiated `Sec-WebSocket-Protocol` value. +/// +/// Returns `None` for unknown prefixes; we accept anything starting with a +/// known qmux ALPN ("qmux-01", "qmux-00") with or without an app suffix. +fn qmux_version_from_alpn(alpn: &str) -> Option { + [qmux::Version::QMux01, qmux::Version::QMux00] + .into_iter() + .find(|v| alpn == v.alpn() || alpn.starts_with(v.prefix())) +} + pub(crate) async fn race_handle( config: &ClientWebSocket, tls: &rustls::ClientConfig, url: Url, - alpns: &[&str], + alpns: &[String], ) -> Option> { if !config.enabled { return None; @@ -73,9 +84,10 @@ pub(crate) async fn connect( config: &ClientWebSocket, tls: &rustls::ClientConfig, mut url: Url, - alpns: &[&str], + alpns: &[String], ) -> anyhow::Result { anyhow::ensure!(config.enabled, "WebSocket support is disabled"); + anyhow::ensure!(!alpns.is_empty(), "no WebSocket subprotocols to offer"); let host = url.host_str().context("missing hostname")?.to_string(); let port = url.port().unwrap_or_else(|| match url.scheme() { @@ -121,14 +133,32 @@ pub(crate) async fn connect( tokio_tungstenite::Connector::Plain }; - let session = qmux::Client::new() - .with_protocols(alpns) - .with_connector(connector) - .connect(url.as_str()) + // Build the request ourselves so we can advertise the full `qmux-XX.app` + // pair list in a single connection. qmux is one-version-per-connection; + // moq-native owns the multi-version multiplexing. + use tungstenite::client::IntoClientRequest; + let mut request = url.as_str().into_client_request().context("invalid WebSocket URL")?; + let protocol_value = alpns.join(", "); + request.headers_mut().insert( + tungstenite::http::header::SEC_WEBSOCKET_PROTOCOL, + tungstenite::http::HeaderValue::from_str(&protocol_value).context("invalid Sec-WebSocket-Protocol value")?, + ); + + let (ws, response) = tokio_tungstenite::connect_async_tls_with_config(request, None, false, Some(connector)) .await .context("failed to connect WebSocket")?; - tracing::warn!(%url, "using WebSocket fallback"); + let negotiated = response + .headers() + .get(tungstenite::http::header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|h| h.to_str().ok()) + .context("server did not select a Sec-WebSocket-Protocol")?; + let version = qmux_version_from_alpn(negotiated) + .with_context(|| format!("unrecognized negotiated protocol: {negotiated}"))?; + + let session = qmux::ws::Bare::new(ws, version).with_alpn(negotiated).connect(); + + tracing::warn!(%url, ?version, %negotiated, "using WebSocket fallback"); WEBSOCKET_WON.lock().unwrap().insert(key); Ok(session) @@ -140,18 +170,21 @@ pub(crate) async fn connect( /// alongside QUIC connections on a separate port. pub struct WebSocketListener { listener: tokio::net::TcpListener, - server: qmux::Server, + alpns: Arc>, } impl WebSocketListener { pub async fn bind(addr: net::SocketAddr) -> anyhow::Result { - Self::bind_with_alpns(addr, moq_net::ALPNS).await + Self::bind_with_alpns(addr, moq_net::Versions::all().qmux_alpn_strings()).await } - pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> anyhow::Result { + pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: Vec) -> anyhow::Result { + anyhow::ensure!(!alpns.is_empty(), "no WebSocket subprotocols to accept"); let listener = tokio::net::TcpListener::bind(addr).await?; - let server = qmux::Server::new().with_protocols(alpns); - Ok(Self { listener, server }) + Ok(Self { + listener, + alpns: Arc::new(alpns), + }) } pub fn local_addr(&self) -> anyhow::Result { @@ -162,15 +195,66 @@ impl WebSocketListener { match self.listener.accept().await { Ok((stream, addr)) => { tracing::debug!(%addr, "accepted WebSocket TCP connection"); - let server = self.server.clone(); - Some( - server - .accept(stream) - .await - .map_err(|e| anyhow::anyhow!("WebSocket accept failed: {e}")), - ) + let alpns = self.alpns.clone(); + Some(accept_socket(stream, alpns).await) } Err(e) => Some(Err(e.into())), } } } + +async fn accept_socket(stream: tokio::net::TcpStream, alpns: Arc>) -> anyhow::Result { + use std::sync::Mutex; + use tungstenite::handshake::server; + use tungstenite::http; + + let negotiated_slot: Arc>> = Arc::new(Mutex::new(None)); + let slot = negotiated_slot.clone(); + + #[allow(clippy::result_large_err)] + let callback = move |req: &server::Request, + mut response: server::Response| + -> Result { + let header_protocols: Vec<&str> = req + .headers() + .get_all(http::header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|h| h.split(',')) + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .collect(); + + // Pick the first server-supported protocol that the client offered. + let chosen = alpns.iter().find(|s| header_protocols.contains(&s.as_str())); + + match chosen { + Some(picked) => { + response.headers_mut().insert( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_str(picked).expect("alpn must be valid HTTP value"), + ); + *slot.lock().unwrap() = Some(picked.clone()); + Ok(response) + } + None => Err(http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(Some("no supported Sec-WebSocket-Protocol".to_string())) + .unwrap()), + } + }; + + let ws = tokio_tungstenite::accept_hdr_async_with_config(stream, callback, None) + .await + .context("WebSocket handshake failed")?; + + let negotiated = negotiated_slot + .lock() + .unwrap() + .take() + .context("handshake completed without setting negotiated protocol")?; + let version = qmux_version_from_alpn(&negotiated) + .with_context(|| format!("unrecognized negotiated protocol: {negotiated}"))?; + + Ok(qmux::ws::Bare::new(ws, version).with_alpn(&negotiated).accept()) +} diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 9fa7f6cff..b539f311d 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -316,6 +316,15 @@ impl Versions { pairs } + /// Same as [`Self::qmux_alpns`] but formatted as `Sec-WebSocket-Protocol` + /// strings (e.g. `"qmux-01.moqt-18"`), in preference order. + pub fn qmux_alpn_strings(&self) -> Vec { + self.qmux_alpns() + .into_iter() + .map(|(qv, app)| format!("{}.{}", qv.alpn(), app)) + .collect() + } + /// Return only versions present in both self and other, or `None` if the intersection is empty. pub fn filter(&self, other: &Versions) -> Option { let filtered: Vec = self.0.iter().filter(|v| other.0.contains(v)).copied().collect(); diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 9701dec2b..2567b88b7 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -32,7 +32,7 @@ pub(crate) async fn serve_ws( return Ok(landing_response()); }; - let ws = ws.protocols(["webtransport"]); + let ws = ws.protocols(moq_net::Versions::all().qmux_alpn_strings()); let params = AuthParams { path, jwt: query.jwt }; let token = if mtls.is_some() { @@ -57,6 +57,19 @@ pub(crate) async fn serve_ws( Ok(ws.on_upgrade(async move |socket| { let id = state.conn_id.fetch_add(1, Ordering::Relaxed); + // Pull the negotiated subprotocol off the WebSocket before we wrap it + // in adapters. Without it we can't tell which qmux draft the peer + // expects to speak. + let negotiated = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned); + let Some(negotiated) = negotiated else { + tracing::warn!("client connected with no Sec-WebSocket-Protocol"); + return; + }; + let Some(version) = qmux_version_from_alpn(&negotiated) else { + tracing::warn!(%negotiated, "client negotiated an unrecognized Sec-WebSocket-Protocol"); + return; + }; + // Unfortunately, we need to convert from Axum to Tungstenite. // Axum uses Tungstenite internally, but it's not exposed to avoid semvar issues. let socket = socket @@ -67,14 +80,23 @@ pub(crate) async fn serve_ws( tungstenite::Error::ConnectionClosed }) .with(tungstenite_to_axum); - let _ = handle_socket(id, socket, publish, subscribe, stats).await; + let _ = handle_socket(id, socket, version, &negotiated, publish, subscribe, stats).await; })) } -#[tracing::instrument("ws", err, skip_all, fields(id = _id))] +/// Pick the qmux version implied by a negotiated `Sec-WebSocket-Protocol` value. +fn qmux_version_from_alpn(alpn: &str) -> Option { + [qmux::Version::QMux01, qmux::Version::QMux00] + .into_iter() + .find(|v| alpn == v.alpn() || alpn.starts_with(v.prefix())) +} + +#[tracing::instrument("ws", err, skip_all, fields(id = _id, qmux = ?version, alpn = %negotiated))] async fn handle_socket( _id: u64, socket: T, + version: qmux::Version, + negotiated: &str, publish: Option, subscribe: Option, stats: StatsHandle, @@ -86,8 +108,8 @@ where + Unpin + 'static, { - // Wrap the WebSocket in a WebTransport compatibility layer. - let ws = qmux::ws::Bare::new(socket).accept(); + // Wrap the WebSocket in a qmux session pinned to the negotiated draft. + let ws = qmux::ws::Bare::new(socket, version).with_alpn(negotiated).accept(); let session = moq_net::Server::new() .with_publish(subscribe) .with_consume(publish) From 511df4c6fcebd41423c51c1d6bf11a9eadf376fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 27 May 2026 14:54:15 -0700 Subject: [PATCH 4/9] moq-native(ws): validate ALPNs at bind, fail fast on misconfig A bad subprotocol string would otherwise pass the WebSocket handshake (server happily echoes it back) and only fail later when accept_socket tried to map it to a qmux::Version. Reject up front so the listener errors at startup instead of every connection. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/moq-native/src/websocket.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rs/moq-native/src/websocket.rs b/rs/moq-native/src/websocket.rs index 0f879e8f2..6267df6aa 100644 --- a/rs/moq-native/src/websocket.rs +++ b/rs/moq-native/src/websocket.rs @@ -180,6 +180,15 @@ impl WebSocketListener { pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: Vec) -> anyhow::Result { anyhow::ensure!(!alpns.is_empty(), "no WebSocket subprotocols to accept"); + // Reject anything we wouldn't be able to map back to a qmux::Version + // after negotiation; otherwise the handshake could succeed and the + // session would then fail at wrap time. + for alpn in &alpns { + anyhow::ensure!( + qmux_version_from_alpn(alpn).is_some(), + "unsupported WebSocket subprotocol: {alpn}" + ); + } let listener = tokio::net::TcpListener::bind(addr).await?; Ok(Self { listener, From 4be4f52b1a6a1bc4b716e94618fffcfca617ea81 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 27 May 2026 15:01:07 -0700 Subject: [PATCH 5/9] moq-native, moq-relay: track qmux::ws Bare -> Upgraded rename Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/moq-native/src/websocket.rs | 4 ++-- rs/moq-relay/src/websocket.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rs/moq-native/src/websocket.rs b/rs/moq-native/src/websocket.rs index 6267df6aa..67ff935a4 100644 --- a/rs/moq-native/src/websocket.rs +++ b/rs/moq-native/src/websocket.rs @@ -156,7 +156,7 @@ pub(crate) async fn connect( let version = qmux_version_from_alpn(negotiated) .with_context(|| format!("unrecognized negotiated protocol: {negotiated}"))?; - let session = qmux::ws::Bare::new(ws, version).with_alpn(negotiated).connect(); + let session = qmux::ws::Upgraded::new(ws, version).with_alpn(negotiated).connect(); tracing::warn!(%url, ?version, %negotiated, "using WebSocket fallback"); WEBSOCKET_WON.lock().unwrap().insert(key); @@ -265,5 +265,5 @@ async fn accept_socket(stream: tokio::net::TcpStream, alpns: Arc>) - let version = qmux_version_from_alpn(&negotiated) .with_context(|| format!("unrecognized negotiated protocol: {negotiated}"))?; - Ok(qmux::ws::Bare::new(ws, version).with_alpn(&negotiated).accept()) + Ok(qmux::ws::Upgraded::new(ws, version).with_alpn(&negotiated).accept()) } diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 2567b88b7..c6bccbe63 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -109,7 +109,7 @@ where + 'static, { // Wrap the WebSocket in a qmux session pinned to the negotiated draft. - let ws = qmux::ws::Bare::new(socket, version).with_alpn(negotiated).accept(); + let ws = qmux::ws::Upgraded::new(socket, version).with_alpn(negotiated).accept(); let session = moq_net::Server::new() .with_publish(subscribe) .with_consume(publish) From 1b8551a813c8ef6bf06c750c18097a45e04ff02d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 27 May 2026 15:09:33 -0700 Subject: [PATCH 6/9] moq-native: link qmux via git branch (drop machine-local paths) The qmux-01 branch is now pushed to moq-dev/web-transport, so CI can fetch it. Replaces the absolute /Users/... paths with git deps on the same branch (qmux 0.1.0 + web-transport-{trait,proto} 0.3.5 / 0.6.0). Still temporary. Drop the git source and the [patch.crates-io] block once qmux 0.1.0 publishes to crates.io. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 3 +++ Cargo.toml | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45cd423bf..52775f8c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5178,6 +5178,7 @@ dependencies = [ [[package]] name = "qmux" version = "0.1.0" +source = "git+https://github.com/moq-dev/web-transport.git?branch=claude%2Fupdate-qmux-draft-01-sTTN0#fd1289acf7e2c5599745059c8ec37c1dba204d18" dependencies = [ "bytes", "futures", @@ -7858,6 +7859,7 @@ dependencies = [ [[package]] name = "web-transport-proto" version = "0.6.0" +source = "git+https://github.com/moq-dev/web-transport.git?branch=claude%2Fupdate-qmux-draft-01-sTTN0#fd1289acf7e2c5599745059c8ec37c1dba204d18" dependencies = [ "bytes", "http", @@ -7911,6 +7913,7 @@ dependencies = [ [[package]] name = "web-transport-trait" version = "0.3.5" +source = "git+https://github.com/moq-dev/web-transport.git?branch=claude%2Fupdate-qmux-draft-01-sTTN0#fd1289acf7e2c5599745059c8ec37c1dba204d18" dependencies = [ "bytes", ] diff --git a/Cargo.toml b/Cargo.toml index 11e7f4717..f65fbb41b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ moq-mux = { version = "0.5", path = "rs/moq-mux" } moq-native = { version = "0.15", path = "rs/moq-native", default-features = false } moq-net = { version = "0.1", path = "rs/moq-net" } moq-token = { version = "0.6", path = "rs/moq-token" } -qmux = { path = "/Users/kixelated/work/web-transport/.claude/worktrees/sad-pasteur-2b0cd5/rs/qmux", default-features = false } +qmux = { git = "https://github.com/moq-dev/web-transport.git", branch = "claude/update-qmux-draft-01-sTTN0", default-features = false } serde = { version = "1", features = ["derive"] } tokio = "1.48" @@ -64,11 +64,11 @@ web-transport-quiche = "0.2" web-transport-quinn = "0.11" web-transport-trait = "0.3.4" -# Temporary: link the qmux-01 worktree so the whole web-transport family shares one crate instance. +# Temporary: track the qmux-01 branch so the whole web-transport family shares one crate instance. # Drop once qmux 0.1.0 ships on crates.io. [patch.crates-io] -web-transport-trait = { path = "/Users/kixelated/work/web-transport/.claude/worktrees/sad-pasteur-2b0cd5/rs/web-transport-trait" } -web-transport-proto = { path = "/Users/kixelated/work/web-transport/.claude/worktrees/sad-pasteur-2b0cd5/rs/web-transport-proto" } +web-transport-trait = { git = "https://github.com/moq-dev/web-transport.git", branch = "claude/update-qmux-draft-01-sTTN0" } +web-transport-proto = { git = "https://github.com/moq-dev/web-transport.git", branch = "claude/update-qmux-draft-01-sTTN0" } [profile.dev] panic = "abort" From 622155feabe24643e1b170e451e561bedfc8f618 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 27 May 2026 16:04:33 -0700 Subject: [PATCH 7/9] moq-net,native,relay: review feedback on qmux ALPN wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the review on PR #1513: - moq-net: replace `Versions::qmux_alpns()` / `qmux_alpn_strings()` (the per-instance Vec-returning methods) with module-level consts `QMUX_ALPNS: &[(QmuxVersion, &str)]` and `QMUX_ALPN_STRINGS: &[&str]`. The permutation table is small and baked in; no need to recompute. A unit test pins it against the typed list to catch drift. - moq-net: add `Lite05Wip` arm to `Version::qmux_versions` (pinned to qmux-01, opt-in only — excluded from QMUX_ALPNS just like main keeps it out of ALPNS / Versions::all). This is the merge-from-main fix for the CI break introduced by #1518. - moq-native (client): drop the unused `versions` field and switch the websocket helper to take `&[(QmuxVersion, &str)]` (typed pair list). Recover the qmux version after the handshake by index lookup in the pair list rather than re-parsing the prefix. - moq-relay: bundle handle_socket's seven arguments into a `Handler` struct with a `run()` method. axum filters on `QMUX_ALPN_STRINGS.iter().copied()`; the negotiated subprotocol is also resolved by index lookup so the relay no longer has its own prefix-parsing helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- rs/moq-native/src/client.rs | 21 +++--- rs/moq-native/src/websocket.rs | 108 ++++++++++++++++++------------- rs/moq-net/src/version.rs | 113 +++++++++++++++++---------------- rs/moq-relay/src/websocket.rs | 94 ++++++++++++++++----------- 4 files changed, 191 insertions(+), 145 deletions(-) diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 119de4c04..1ecc00679 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -211,7 +211,6 @@ impl Default for ClientConfig { #[derive(Clone)] pub struct Client { moq: moq_net::Client, - versions: moq_net::Versions, backoff: Backoff, #[cfg(feature = "websocket")] websocket: super::ClientWebSocket, @@ -277,10 +276,8 @@ impl Client { _ => None, }; - let versions = config.versions(); Ok(Self { - moq: moq_net::Client::new().with_versions(versions.clone()), - versions, + moq: moq_net::Client::new().with_versions(config.versions()), backoff: config.backoff, #[cfg(feature = "websocket")] websocket: config.websocket, @@ -392,8 +389,8 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.qmux_alpn_strings(); - let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, &alpns); + let alpns = moq_net::QMUX_ALPNS; + let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, alpns); return Ok(tokio::select! { Ok(quic) = quic_handle => self.moq.connect(quic).await?, @@ -423,8 +420,8 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.qmux_alpn_strings(); - let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, &alpns); + let alpns = moq_net::QMUX_ALPNS; + let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, alpns); return Ok(tokio::select! { Ok(quic) = quic_handle => self.moq.connect(quic).await?, @@ -453,8 +450,8 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.qmux_alpn_strings(); - let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, &alpns); + let alpns = moq_net::QMUX_ALPNS; + let ws_handle = crate::websocket::race_handle(&self.websocket, &self.tls, url, alpns); return Ok(tokio::select! { Ok(quic) = quic_handle => self.moq.connect(quic).await?, @@ -472,8 +469,8 @@ impl Client { #[cfg(feature = "websocket")] { - let alpns = self.versions.qmux_alpn_strings(); - let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?; + let alpns = moq_net::QMUX_ALPNS; + let session = crate::websocket::connect(&self.websocket, &self.tls, url, alpns).await?; return Ok(self.moq.connect(session).await?); } diff --git a/rs/moq-native/src/websocket.rs b/rs/moq-native/src/websocket.rs index 67ff935a4..9d40260c2 100644 --- a/rs/moq-native/src/websocket.rs +++ b/rs/moq-native/src/websocket.rs @@ -1,4 +1,5 @@ use anyhow::Context; +use moq_net::QmuxVersion; use qmux::tokio_tungstenite; use qmux::tungstenite; use std::collections::HashSet; @@ -46,21 +47,27 @@ impl Default for ClientWebSocket { } } -/// Pick the qmux version implied by a negotiated `Sec-WebSocket-Protocol` value. -/// -/// Returns `None` for unknown prefixes; we accept anything starting with a -/// known qmux ALPN ("qmux-01", "qmux-00") with or without an app suffix. -fn qmux_version_from_alpn(alpn: &str) -> Option { - [qmux::Version::QMux01, qmux::Version::QMux00] - .into_iter() - .find(|v| alpn == v.alpn() || alpn.starts_with(v.prefix())) +fn qmux_version(qv: QmuxVersion) -> qmux::Version { + // `QmuxVersion` is `#[non_exhaustive]`, so we need a fallthrough even + // though both current variants are listed. New variants will hit this + // arm at runtime, which we'd want to update to handle cleanly. + match qv { + QmuxVersion::QMux00 => qmux::Version::QMux00, + QmuxVersion::QMux01 => qmux::Version::QMux01, + _ => unreachable!("unknown QmuxVersion variant"), + } +} + +/// Format a `(QmuxVersion, app)` pair as the `qmux-XX.app` subprotocol string. +fn pair_to_alpn(qv: QmuxVersion, app: &str) -> String { + format!("{}.{}", qv.alpn(), app) } pub(crate) async fn race_handle( config: &ClientWebSocket, tls: &rustls::ClientConfig, url: Url, - alpns: &[String], + alpns: &[(QmuxVersion, &str)], ) -> Option> { if !config.enabled { return None; @@ -84,7 +91,7 @@ pub(crate) async fn connect( config: &ClientWebSocket, tls: &rustls::ClientConfig, mut url: Url, - alpns: &[String], + alpns: &[(QmuxVersion, &str)], ) -> anyhow::Result { anyhow::ensure!(config.enabled, "WebSocket support is disabled"); anyhow::ensure!(!alpns.is_empty(), "no WebSocket subprotocols to offer"); @@ -138,7 +145,8 @@ pub(crate) async fn connect( // moq-native owns the multi-version multiplexing. use tungstenite::client::IntoClientRequest; let mut request = url.as_str().into_client_request().context("invalid WebSocket URL")?; - let protocol_value = alpns.join(", "); + let formatted: Vec = alpns.iter().map(|(qv, app)| pair_to_alpn(*qv, app)).collect(); + let protocol_value = formatted.join(", "); request.headers_mut().insert( tungstenite::http::header::SEC_WEBSOCKET_PROTOCOL, tungstenite::http::HeaderValue::from_str(&protocol_value).context("invalid Sec-WebSocket-Protocol value")?, @@ -153,12 +161,19 @@ pub(crate) async fn connect( .get(tungstenite::http::header::SEC_WEBSOCKET_PROTOCOL) .and_then(|h| h.to_str().ok()) .context("server did not select a Sec-WebSocket-Protocol")?; - let version = qmux_version_from_alpn(negotiated) - .with_context(|| format!("unrecognized negotiated protocol: {negotiated}"))?; - - let session = qmux::ws::Upgraded::new(ws, version).with_alpn(negotiated).connect(); - - tracing::warn!(%url, ?version, %negotiated, "using WebSocket fallback"); + // The server can only pick something we offered, so we recover the qmux + // version by index in the pair list rather than re-parsing the prefix. + let idx = formatted + .iter() + .position(|s| s == negotiated) + .with_context(|| format!("server picked an alpn we did not offer: {negotiated}"))?; + let (qv, _) = alpns[idx]; + + let session = qmux::ws::Upgraded::new(ws, qmux_version(qv)) + .with_alpn(negotiated) + .connect(); + + tracing::warn!(%url, ?qv, %negotiated, "using WebSocket fallback"); WEBSOCKET_WON.lock().unwrap().insert(key); Ok(session) @@ -170,29 +185,28 @@ pub(crate) async fn connect( /// alongside QUIC connections on a separate port. pub struct WebSocketListener { listener: tokio::net::TcpListener, - alpns: Arc>, + pairs: &'static [(QmuxVersion, &'static str)], + // Pre-formatted `qmux-XX.app` strings, same order as `pairs`. The handshake + // callback matches against these and we look up the qmux version by index. + formatted: Arc>, } impl WebSocketListener { pub async fn bind(addr: net::SocketAddr) -> anyhow::Result { - Self::bind_with_alpns(addr, moq_net::Versions::all().qmux_alpn_strings()).await + Self::bind_with_alpns(addr, moq_net::QMUX_ALPNS).await } - pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: Vec) -> anyhow::Result { + pub async fn bind_with_alpns( + addr: net::SocketAddr, + alpns: &'static [(QmuxVersion, &'static str)], + ) -> anyhow::Result { anyhow::ensure!(!alpns.is_empty(), "no WebSocket subprotocols to accept"); - // Reject anything we wouldn't be able to map back to a qmux::Version - // after negotiation; otherwise the handshake could succeed and the - // session would then fail at wrap time. - for alpn in &alpns { - anyhow::ensure!( - qmux_version_from_alpn(alpn).is_some(), - "unsupported WebSocket subprotocol: {alpn}" - ); - } let listener = tokio::net::TcpListener::bind(addr).await?; + let formatted = alpns.iter().map(|(qv, app)| pair_to_alpn(*qv, app)).collect(); Ok(Self { listener, - alpns: Arc::new(alpns), + pairs: alpns, + formatted: Arc::new(formatted), }) } @@ -204,21 +218,26 @@ impl WebSocketListener { match self.listener.accept().await { Ok((stream, addr)) => { tracing::debug!(%addr, "accepted WebSocket TCP connection"); - let alpns = self.alpns.clone(); - Some(accept_socket(stream, alpns).await) + Some(accept_socket(stream, self.pairs, self.formatted.clone()).await) } Err(e) => Some(Err(e.into())), } } } -async fn accept_socket(stream: tokio::net::TcpStream, alpns: Arc>) -> anyhow::Result { +async fn accept_socket( + stream: tokio::net::TcpStream, + pairs: &'static [(QmuxVersion, &'static str)], + formatted: Arc>, +) -> anyhow::Result { use std::sync::Mutex; use tungstenite::handshake::server; use tungstenite::http; - let negotiated_slot: Arc>> = Arc::new(Mutex::new(None)); - let slot = negotiated_slot.clone(); + // Capture the negotiated string from inside the handshake callback. + let chosen_slot: Arc>> = Arc::new(Mutex::new(None)); + let slot = chosen_slot.clone(); + let supported = formatted.clone(); #[allow(clippy::result_large_err)] let callback = move |req: &server::Request, @@ -235,9 +254,7 @@ async fn accept_socket(stream: tokio::net::TcpStream, alpns: Arc>) - .collect(); // Pick the first server-supported protocol that the client offered. - let chosen = alpns.iter().find(|s| header_protocols.contains(&s.as_str())); - - match chosen { + match supported.iter().find(|s| header_protocols.contains(&s.as_str())) { Some(picked) => { response.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, @@ -257,13 +274,18 @@ async fn accept_socket(stream: tokio::net::TcpStream, alpns: Arc>) - .await .context("WebSocket handshake failed")?; - let negotiated = negotiated_slot + let negotiated = chosen_slot .lock() .unwrap() .take() .context("handshake completed without setting negotiated protocol")?; - let version = qmux_version_from_alpn(&negotiated) - .with_context(|| format!("unrecognized negotiated protocol: {negotiated}"))?; - - Ok(qmux::ws::Upgraded::new(ws, version).with_alpn(&negotiated).accept()) + let idx = formatted + .iter() + .position(|s| *s == negotiated) + .expect("callback only writes strings drawn from `formatted`"); + let (qv, _) = pairs[idx]; + + Ok(qmux::ws::Upgraded::new(ws, qmux_version(qv)) + .with_alpn(&negotiated) + .accept()) } diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 1f086f193..b06f23449 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -41,6 +41,45 @@ pub(crate) const ALPN_16: &str = "moqt-16"; pub(crate) const ALPN_17: &str = "moqt-17"; pub(crate) const ALPN_18: &str = "moqt-18"; +/// Default `(qmux_version, app_alpn)` pairs to advertise over WebSocket / TLS, +/// in preference order. +/// +/// Each pair encodes the spec mapping: moq-transport-18 rides on qmux-01, +/// moq-transport-14..17 on qmux-00, and moq-lite-01..04 are dual-advertised on +/// both for back-compat. Lite05Wip is intentionally absent (opt-in only). +/// +/// See also [`QMUX_ALPN_STRINGS`] for the same list formatted as +/// `Sec-WebSocket-Protocol` strings. +pub const QMUX_ALPNS: &[(QmuxVersion, &str)] = &[ + (QmuxVersion::QMux01, ALPN_LITE_04), + (QmuxVersion::QMux00, ALPN_LITE_04), + (QmuxVersion::QMux01, ALPN_LITE_03), + (QmuxVersion::QMux00, ALPN_LITE_03), + (QmuxVersion::QMux01, ALPN_LITE), + (QmuxVersion::QMux00, ALPN_LITE), + (QmuxVersion::QMux01, ALPN_18), + (QmuxVersion::QMux00, ALPN_17), + (QmuxVersion::QMux00, ALPN_16), + (QmuxVersion::QMux00, ALPN_15), + (QmuxVersion::QMux00, ALPN_14), +]; + +/// [`QMUX_ALPNS`] flattened to `"qmux-XX.app"` strings, ready to drop into a +/// `Sec-WebSocket-Protocol` header (or any TLS ALPN list). +pub const QMUX_ALPN_STRINGS: &[&str] = &[ + "qmux-01.moq-lite-04", + "qmux-00.moq-lite-04", + "qmux-01.moq-lite-03", + "qmux-00.moq-lite-03", + "qmux-01.moql", + "qmux-00.moql", + "qmux-01.moqt-18", + "qmux-00.moqt-17", + "qmux-00.moqt-16", + "qmux-00.moqt-15", + "qmux-00.moq-00", +]; + /// The qmux draft version used to carry a MoQ ALPN over WebSocket / TLS. /// /// The MoQ WG decided that qmux's version is tied to the moq-transport draft @@ -162,6 +201,7 @@ impl Version { /// moq-transport-18 requires qmux-01; moq-transport-14..17 require qmux-00. /// Existing moq-lite versions (Lite01..Lite04) advertise both for back-compat. /// Future moq-lite versions should pin to a single qmux version, like moq-transport. + /// Lite05Wip is opt-in only and pins to qmux-01. pub fn qmux_versions(&self) -> &'static [QmuxVersion] { use ietf::Version as I; use lite::Version as L; @@ -169,6 +209,7 @@ impl Version { Self::Ietf(I::Draft18) => &[QmuxVersion::QMux01], Self::Ietf(I::Draft14 | I::Draft15 | I::Draft16 | I::Draft17) => &[QmuxVersion::QMux00], Self::Lite(L::Lite01 | L::Lite02 | L::Lite03 | L::Lite04) => &[QmuxVersion::QMux01, QmuxVersion::QMux00], + Self::Lite(L::Lite05Wip) => &[QmuxVersion::QMux01], } } @@ -306,35 +347,6 @@ impl Versions { alpns } - /// Compute the `(qmux_version, app_alpn)` pairs to advertise over WebSocket / TLS, - /// in preference order, dedup'd. - /// - /// Each MoQ version is paired only with the qmux versions it's permitted to ride on - /// (see [`Version::qmux_versions`]). Use this to build the `Sec-WebSocket-Protocol` - /// list (or TLS ALPN list) when fronting a qmux session. - pub fn qmux_alpns(&self) -> Vec<(QmuxVersion, &'static str)> { - let mut pairs = Vec::new(); - for v in &self.0 { - let alpn = v.alpn(); - for &qv in v.qmux_versions() { - let pair = (qv, alpn); - if !pairs.contains(&pair) { - pairs.push(pair); - } - } - } - pairs - } - - /// Same as [`Self::qmux_alpns`] but formatted as `Sec-WebSocket-Protocol` - /// strings (e.g. `"qmux-01.moqt-18"`), in preference order. - pub fn qmux_alpn_strings(&self) -> Vec { - self.qmux_alpns() - .into_iter() - .map(|(qv, app)| format!("{}.{}", qv.alpn(), app)) - .collect() - } - /// Return only versions present in both self and other, or `None` if the intersection is empty. pub fn filter(&self, other: &Versions) -> Option { let filtered: Vec = self.0.iter().filter(|v| other.0.contains(v)).copied().collect(); @@ -420,6 +432,10 @@ mod tests { "{v}" ); } + assert_eq!( + Version::Lite(lite::Version::Lite05Wip).qmux_versions(), + &[QmuxVersion::QMux01] + ); } #[test] @@ -433,11 +449,10 @@ mod tests { } #[test] - fn qmux_alpns_all_matches_table() { - let pairs = Versions::all().qmux_alpns(); + fn qmux_alpns_table() { assert_eq!( - pairs, - vec![ + QMUX_ALPNS, + &[ (QmuxVersion::QMux01, "moq-lite-04"), (QmuxVersion::QMux00, "moq-lite-04"), (QmuxVersion::QMux01, "moq-lite-03"), @@ -454,30 +469,20 @@ mod tests { } #[test] - fn qmux_alpns_singleton_moqt_18() { - assert_eq!( - Versions::from(Version::Ietf(ietf::Version::Draft18)).qmux_alpns(), - vec![(QmuxVersion::QMux01, "moqt-18")] - ); - } - - #[test] - fn qmux_alpns_singleton_moqt_17() { - assert_eq!( - Versions::from(Version::Ietf(ietf::Version::Draft17)).qmux_alpns(), - vec![(QmuxVersion::QMux00, "moqt-17")] - ); + fn qmux_alpn_strings_match_pairs() { + // Hand-rolled string list must agree with the typed pair list: same + // length, same order, and each string is `{qv.alpn()}.{app}`. + assert_eq!(QMUX_ALPN_STRINGS.len(), QMUX_ALPNS.len()); + for (s, (qv, app)) in QMUX_ALPN_STRINGS.iter().zip(QMUX_ALPNS) { + assert_eq!(*s, format!("{}.{}", qv.alpn(), app)); + } } #[test] - fn qmux_alpns_singleton_lite_offers_both() { - assert_eq!( - Versions::from(Version::Lite(lite::Version::Lite04)).qmux_alpns(), - vec![ - (QmuxVersion::QMux01, "moq-lite-04"), - (QmuxVersion::QMux00, "moq-lite-04"), - ] - ); + fn qmux_alpns_excludes_lite_05_wip() { + // Lite05Wip is opt-in only; it must not leak into the default list. + assert!(!QMUX_ALPNS.iter().any(|(_, app)| *app == "moq-lite-05-wip")); + assert!(!QMUX_ALPN_STRINGS.iter().any(|s| s.contains("moq-lite-05-wip"))); } #[test] diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index c6bccbe63..f4e82684f 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -15,7 +15,7 @@ use axum::{ http::StatusCode, response::Response, }; -use moq_net::{OriginConsumer, OriginProducer, StatsHandle, Tier}; +use moq_net::{OriginConsumer, OriginProducer, QmuxVersion, StatsHandle, Tier}; use crate::{AuthParams, AuthToken, WebState, web::AuthQuery, web::MtlsPeer, web::landing_response}; @@ -32,7 +32,7 @@ pub(crate) async fn serve_ws( return Ok(landing_response()); }; - let ws = ws.protocols(moq_net::Versions::all().qmux_alpn_strings()); + let ws = ws.protocols(moq_net::QMUX_ALPN_STRINGS.iter().copied()); let params = AuthParams { path, jwt: query.jwt }; let token = if mtls.is_some() { @@ -60,15 +60,20 @@ pub(crate) async fn serve_ws( // Pull the negotiated subprotocol off the WebSocket before we wrap it // in adapters. Without it we can't tell which qmux draft the peer // expects to speak. - let negotiated = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned); - let Some(negotiated) = negotiated else { + let Some(negotiated) = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned) else { tracing::warn!("client connected with no Sec-WebSocket-Protocol"); return; }; - let Some(version) = qmux_version_from_alpn(&negotiated) else { + // Axum filtered to QMUX_ALPN_STRINGS for us, so the negotiated value + // must be one of those entries; recover the qmux draft by index. + let Some(idx) = moq_net::QMUX_ALPN_STRINGS + .iter() + .position(|s| *s == negotiated.as_str()) + else { tracing::warn!(%negotiated, "client negotiated an unrecognized Sec-WebSocket-Protocol"); return; }; + let (qv, _) = moq_net::QMUX_ALPNS[idx]; // Unfortunately, we need to convert from Axum to Tungstenite. // Axum uses Tungstenite internally, but it's not exposed to avoid semvar issues. @@ -80,43 +85,60 @@ pub(crate) async fn serve_ws( tungstenite::Error::ConnectionClosed }) .with(tungstenite_to_axum); - let _ = handle_socket(id, socket, version, &negotiated, publish, subscribe, stats).await; + let handler = Handler { + id, + qv, + negotiated, + publish, + subscribe, + stats, + }; + let _ = handler.run(socket).await; })) } -/// Pick the qmux version implied by a negotiated `Sec-WebSocket-Protocol` value. -fn qmux_version_from_alpn(alpn: &str) -> Option { - [qmux::Version::QMux01, qmux::Version::QMux00] - .into_iter() - .find(|v| alpn == v.alpn() || alpn.starts_with(v.prefix())) -} - -#[tracing::instrument("ws", err, skip_all, fields(id = _id, qmux = ?version, alpn = %negotiated))] -async fn handle_socket( - _id: u64, - socket: T, - version: qmux::Version, - negotiated: &str, +/// Owns the per-connection state for one upgraded WebSocket, ready to be wrapped +/// in a qmux session and handed off to `moq_net::Server`. +struct Handler { + id: u64, + qv: QmuxVersion, + negotiated: String, publish: Option, subscribe: Option, stats: StatsHandle, -) -> anyhow::Result<()> -where - T: futures::Stream> - + futures::Sink - + Send - + Unpin - + 'static, -{ - // Wrap the WebSocket in a qmux session pinned to the negotiated draft. - let ws = qmux::ws::Upgraded::new(socket, version).with_alpn(negotiated).accept(); - let session = moq_net::Server::new() - .with_publish(subscribe) - .with_consume(publish) - .with_stats(stats) - .accept(ws) - .await?; - session.closed().await.map_err(Into::into) +} + +impl Handler { + #[tracing::instrument("ws", err, skip_all, fields(id = self.id, qmux = ?self.qv, alpn = %self.negotiated))] + async fn run(self, socket: T) -> anyhow::Result<()> + where + T: futures::Stream> + + futures::Sink + + Send + + Unpin + + 'static, + { + // Wrap the WebSocket in a qmux session pinned to the negotiated draft. + let ws = qmux::ws::Upgraded::new(socket, qmux_version(self.qv)) + .with_alpn(&self.negotiated) + .accept(); + let session = moq_net::Server::new() + .with_publish(self.subscribe) + .with_consume(self.publish) + .with_stats(self.stats) + .accept(ws) + .await?; + session.closed().await.map_err(Into::into) + } +} + +fn qmux_version(qv: QmuxVersion) -> qmux::Version { + // `QmuxVersion` is `#[non_exhaustive]`, hence the catch-all arm. + match qv { + QmuxVersion::QMux00 => qmux::Version::QMux00, + QmuxVersion::QMux01 => qmux::Version::QMux01, + _ => unreachable!("unknown QmuxVersion variant"), + } } // https://github.com/tokio-rs/axum/discussions/848#discussioncomment-11443587 From b53a92f5769017ea70b4b3d2b4a37f6446bf3a72 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 27 May 2026 16:33:45 -0700 Subject: [PATCH 8/9] moq: pin qmux 0.1.0 from crates.io / npm Now that qmux 0.1.0 has shipped, drop the temporary git/path deps: - Cargo.toml: qmux switches from the local-path/git dep on the qmux-01 branch back to a normal crates.io version pin. The [patch.crates-io] block for web-transport-{trait,proto} is gone too; ^0.3.4 and ^0.6 now resolve cleanly to the published 0.3.5 / 0.6.0. - js/net: bump @moq/qmux to ^0.1.0 and update the WebSocket fallback in connect.ts to the new API. qmux 0.1.0 pins a single QMux version per Session, so the polyfill now constructs `new Session(url, { version: "qmux-01", protocols: [...] })` and offers the QMux01-compatible app protocols (moq-lite-04/03/01-02, moqt-18). Older moq-transport drafts that require QMux00 reach the relay via native WebTransport instead. bun.lock will refresh on the next install once npm has fully propagated @moq/qmux 0.1.0; not committed here because frozen-lockfile would fail. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 37 +++++++++++++++++--------------- Cargo.toml | 8 +------ js/net/package.json | 2 +- js/net/src/connection/connect.ts | 10 ++++++++- 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c5c3ddde..e21c9abd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -123,7 +123,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1357,7 +1357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3239,7 +3239,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4290,7 +4290,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5179,7 +5179,8 @@ dependencies = [ [[package]] name = "qmux" version = "0.1.0" -source = "git+https://github.com/moq-dev/web-transport.git?branch=claude%2Fupdate-qmux-draft-01-sTTN0#fd1289acf7e2c5599745059c8ec37c1dba204d18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb8250ecd8e31dbf7935014a8784eea6e4b971a9d627600b920c09871f2bd768" dependencies = [ "bytes", "futures", @@ -5733,7 +5734,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5792,7 +5793,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5813,7 +5814,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6028,7 +6029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6436,7 +6437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6666,7 +6667,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6675,7 +6676,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7860,7 +7861,8 @@ dependencies = [ [[package]] name = "web-transport-proto" version = "0.6.0" -source = "git+https://github.com/moq-dev/web-transport.git?branch=claude%2Fupdate-qmux-draft-01-sTTN0#fd1289acf7e2c5599745059c8ec37c1dba204d18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0225d295c8ac00a2e9a498aefeaf3f3c6186da12a251c938189b15b82ea22808" dependencies = [ "bytes", "http", @@ -7914,7 +7916,8 @@ dependencies = [ [[package]] name = "web-transport-trait" version = "0.3.5" -source = "git+https://github.com/moq-dev/web-transport.git?branch=claude%2Fupdate-qmux-draft-01-sTTN0#fd1289acf7e2c5599745059c8ec37c1dba204d18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc5f83d19cf6c8ba147f4e1e5935a8a115c91f6abbf714d740a83b967d558e6e" dependencies = [ "bytes", ] @@ -7983,7 +7986,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f65fbb41b..78a998101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ moq-mux = { version = "0.5", path = "rs/moq-mux" } moq-native = { version = "0.15", path = "rs/moq-native", default-features = false } moq-net = { version = "0.1", path = "rs/moq-net" } moq-token = { version = "0.6", path = "rs/moq-token" } -qmux = { git = "https://github.com/moq-dev/web-transport.git", branch = "claude/update-qmux-draft-01-sTTN0", default-features = false } +qmux = { version = "0.1.0", default-features = false } serde = { version = "1", features = ["derive"] } tokio = "1.48" @@ -64,12 +64,6 @@ web-transport-quiche = "0.2" web-transport-quinn = "0.11" web-transport-trait = "0.3.4" -# Temporary: track the qmux-01 branch so the whole web-transport family shares one crate instance. -# Drop once qmux 0.1.0 ships on crates.io. -[patch.crates-io] -web-transport-trait = { git = "https://github.com/moq-dev/web-transport.git", branch = "claude/update-qmux-draft-01-sTTN0" } -web-transport-proto = { git = "https://github.com/moq-dev/web-transport.git", branch = "claude/update-qmux-draft-01-sTTN0" } - [profile.dev] panic = "abort" diff --git a/js/net/package.json b/js/net/package.json index fd22632d5..9bad775e4 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -17,7 +17,7 @@ "release": "bun ../common/release.ts" }, "dependencies": { - "@moq/qmux": "^0.0.6", + "@moq/qmux": "^0.1.0", "@moq/signals": "workspace:*", "async-mutex": "^0.5.0" }, diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 37dc2727e..81acb3f72 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -335,7 +335,15 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): const active = await Promise.race([cancel, timer.then(() => true)]); if (!active) return undefined; - const quic = new Session(url); + // qmux 0.1.0 pins a single QMux version per Session. Pick qmux-01 (the + // latest) and offer the application protocols that the spec allows on it: + // moq-transport-18 requires qmux-01, and moq-lite is unconstrained so the + // modern versions all ride here. Older moq-transport drafts (15/16/17) + // need qmux-00 and aren't reachable via this fallback path. + const quic = new Session(url, { + version: "qmux-01", + protocols: [Lite.ALPN_04, Lite.ALPN_03, Lite.ALPN, Ietf.ALPN.DRAFT_18], + }); // Wait for the WebSocket to connect, or for the cancel promise to resolve. // Close the connection if we lost the race. From 2275a842d21f632edf9c913bab3fecb188a5ecdc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 28 May 2026 09:38:15 -0700 Subject: [PATCH 9/9] moq(js): refresh bun.lock for @moq/qmux@0.1.0 @moq/qmux@0.1.0 is now live on npm; this picks it up so `bun install --frozen-lockfile` passes again. js/net typechecks (`bun run check`) against the new SessionOptions surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index d9d08ee77..c774277ae 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "moq", @@ -145,7 +144,7 @@ "name": "@moq/net", "version": "0.1.1", "dependencies": { - "@moq/qmux": "^0.0.6", + "@moq/qmux": "^0.1.0", "@moq/signals": "workspace:*", "async-mutex": "^0.5.0", }, @@ -509,7 +508,7 @@ "@moq/publish": ["@moq/publish@workspace:js/publish"], - "@moq/qmux": ["@moq/qmux@0.0.6", "", {}, "sha512-ISuGz05lUvf1hzHW3Aw3VnsGRJe1w9Qdog3LQ66KS+l+5mzQsPANvW8yOioEe1Z9dJO2G3sAHoGPnzwnsY9SIQ=="], + "@moq/qmux": ["@moq/qmux@0.1.0", "", {}, "sha512-9Iieb+iV4WmZew62KsOuVwvTwLNQV6by6DT76qDuU0i3yfOYrVs56LGEvxvAn+Da2rlLlA2z4Mjq9HdeuAkxfw=="], "@moq/signals": ["@moq/signals@workspace:js/signals"],