From fc75fc132691e0d86e4f7181f27de3c32ca2e945 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 10:53:53 -0700 Subject: [PATCH 1/5] quest(m1): claim raw-stream-codes Co-Authored-By: Claude Opus 5.5 From f603a3a319f50aee4543833f7a0a3dfda5752b5f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 11:22:01 -0700 Subject: [PATCH 2/5] fix: raw QUIC stream codes stay raw moq-uring raw sessions unmap a received stream code in the HTTP/3 WebTransport range, which older web-transport-moq raw peers still send. Adds a moq-tokio test that a refused stream's code arrives verbatim over raw QUIC; it passes once the moq-dev/noq and moq-dev/web-transport fixes are released and locked. The quest records the mixed-version analysis. Co-Authored-By: Claude Opus 5.5 --- quest/m1/raw-stream-codes.md | 48 ++++++------- rs/moq-tokio/tests/stream_code.rs | 113 ++++++++++++++++++++++++++++++ rs/moq-uring/src/quic/web.rs | 47 ++++++++++++- 3 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 rs/moq-tokio/tests/stream_code.rs diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md index 0d9309002a..5540ed9e8e 100644 --- a/quest/m1/raw-stream-codes.md +++ b/quest/m1/raw-stream-codes.md @@ -9,30 +9,26 @@ on stream errors. WebTransport sessions keep the mapping they need. ## Plan -- `web-transport-moq` (moq-dev/noq) runs every stream code through - `web_transport_proto::error_to_http3` and `error_from_http3` in `send.rs` - and `recv.rs`, whether or not the session came from `Session::raw`. - `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do - the same. A raw peer's code 5 reads as `None` or another value, and ours - reaches it as a large HTTP/3 code. -- #4262 fixed the same mix-up for `ApplicationClosed` on `dev`, on - web-transport-trait 0.5, so this follows it there. Let each stream know whether its - session is raw and skip the mapping there, in all three adapters, with a - round-trip test per adapter against a plain QUIC peer. -- Release the fixed crates and bump the pins here in the same quest; published - crates depend on crates.io releases, never a patch. A moq-tokio test over - `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`. -- Mixed versions: two moq peers on raw QUIC agree today because both map, and - wire changes must stay compatible with published versions. An older peer - still sends `error_to_http3(code)`, so a fixed peer that only skips the - mapping reports the large HTTP/3 value, not the code. The legacy form is - detectable, since it lands in the HTTP/3 WebTransport range that no moq - code reaches (`error_from_http3` returns `None` outside it), so the raw - receive path unmaps a code in that range and passes the rest through, with - a legacy-sender test per adapter. The other direction has no fix at the - receiver: an older peer misreads a fixed peer's raw codes. Check which codes - moq-net acts on (group stream resets, subscribe STOP_SENDING): if any drives - behaviour beyond reporting, this is a wire break and retargets to `dev`. +- The adapter fixes are up as moq-dev/noq#27 (`web-transport-moq`) and + moq-dev/web-transport#408 (`web-transport-iroh`, `web-transport-quinn`). + Raw sessions send codes as-is, read a plain code as-is, and still unmap a + code in the HTTP/3 WebTransport range, which only an older raw peer sends. + `moq-uring` already sent raw codes; it now unmaps that legacy range too. +- Remaining: release both adapters, bump the lock here (the `2.0` and `0.8` + requirements already admit the patch releases), and the moq-tokio + `stream_code.rs` test, which asserts a refused stream's code arrives + verbatim over raw QUIC, goes green. +- Mixed versions: this is a wire break, so it stays on `dev`. An older peer + reads a fixed peer's plain codes as invalid, which moq-net reports as + `Error::Transport`. On moq-lite that costs only the stream: a cancel reads + as a failure, a relay forwards INTERNAL_ERROR instead of the peer's code, + and a relay HTTP fetch answers 500 instead of 404. On moq-transport, a + release without #4602 ends the whole session when a stream is reset before + its header, which a fixed peer does routinely. Release #4602 on `main` + before any adapter release that sends raw codes. +- A `main`-compatible subset (read raw and legacy codes, keep sending mapped + ones) could ship on `web-transport-moq` 1.3.x and `web-transport-iroh` + 0.7.x without breaking anyone, if interop needs it before `dev` releases. -Public API: none expected. Wire: raw QUIC stream error codes become the -application's own values; compatible only if older peers merely report them. +Public API: none. Wire: raw QUIC stream error codes become the application's +own values. diff --git a/rs/moq-tokio/tests/stream_code.rs b/rs/moq-tokio/tests/stream_code.rs new file mode 100644 index 0000000000..07fdd5ea36 --- /dev/null +++ b/rs/moq-tokio/tests/stream_code.rs @@ -0,0 +1,113 @@ +//! Over raw QUIC, a stream code reaches the peer as the protocol's own value, not +//! mapped into the HTTP/3 WebTransport code space, so other raw QUIC MoQ stacks +//! read it. +//! +//! The peer here is a plain QUIC client. The server refuses a uni stream opened +//! ahead of the SETUP with STOP_SENDING, and the client reads that code off the wire. + +#![cfg(feature = "noq")] + +use std::{sync::Arc, time::Duration}; + +use web_transport_moq::noq; + +/// moq-lite and moq-transport both send INTERNAL_ERROR (0x0) for a refused stream. +const REFUSED: u32 = 0x0; + +struct Server { + port: u16, + cert: rustls::pki_types::CertificateDer<'static>, + _dir: tempfile::TempDir, +} + +async fn serve() -> Server { + use std::io::Write; + + let dir = tempfile::tempdir().expect("tempdir"); + let key = rcgen::KeyPair::generate().expect("key"); + let cert = rcgen::CertificateParams::new(vec!["localhost".to_string()]) + .expect("params") + .self_signed(&key) + .expect("cert"); + let write = |name: &str, contents: String| { + let path = dir.path().join(name); + std::fs::File::create(&path) + .and_then(|mut file| file.write_all(contents.as_bytes())) + .expect("write pem file"); + path + }; + + let mut config = moq_tokio::server::Config::default(); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); + config.listen.tls.cert = vec![write("server.pem", cert.pem())]; + config.listen.tls.key = vec![write("server.key", key.serialize_pem())]; + let mut listener = config + .init() + .expect("failed to init server") + .listen() + .await + .expect("failed to listen"); + let port = listener.local_addr().expect("no quic addr").port(); + + tokio::spawn(async move { + while let Some(request) = listener.accept().await { + tokio::spawn(async move { + let _session = request.ok().await; + std::future::pending::<()>().await; + }); + } + }); + + Server { + port, + cert: cert.der().clone(), + _dir: dir, + } +} + +/// Dial the server over raw QUIC with `alpn`, open a uni stream that is not a SETUP, +/// and return the code the server stopped it with. +async fn refused_code(server: &Server, alpn: &str) -> Option { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut roots = rustls::RootCertStore::empty(); + roots.add(server.cert.clone()).expect("root"); + let mut tls = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + tls.alpn_protocols = vec![alpn.as_bytes().to_vec()]; + let quic = noq::crypto::rustls::QuicClientConfig::try_from(tls).expect("quic config"); + + let endpoint = noq::Endpoint::client("127.0.0.1:0".parse().unwrap()).expect("endpoint"); + let conn = endpoint + .connect_with( + noq::ClientConfig::new(Arc::new(quic)), + ([127, 0, 0, 1], server.port).into(), + "localhost", + ) + .expect("connect") + .await + .expect("handshake"); + + // A group stream on moq-lite, and anything but SETUP on moq-transport. + let mut send = conn.open_uni().await.expect("open"); + send.write_all(&[0x00]).await.expect("write"); + + tokio::time::timeout(Duration::from_secs(10), send.stopped()) + .await + .expect("the stream was not stopped") + .expect("stopped") +} + +#[tokio::test] +async fn a_refused_stream_code_is_not_mapped() { + let server = serve().await; + + for alpn in ["moq-lite-06", "moqt-17"] { + assert_eq!( + refused_code(&server, alpn).await, + Some(REFUSED.into()), + "{alpn} mapped the stream code" + ); + } +} diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index 957490ab4b..c150b4c5d7 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -12,7 +12,8 @@ //! WebTransport layering disabled, so native peers and browsers run the same //! machinery. Stream and session error codes map through the HTTP/3 error //! space ([`web_transport_proto::error_to_http3`]) in web mode and pass -//! through untouched in raw mode. +//! through untouched in raw mode, except that a received stream code in the +//! HTTP/3 range is unmapped, since older raw peers send that form. use std::cell::RefCell; use std::collections::VecDeque; @@ -811,7 +812,7 @@ impl SendStream { fn map(&self, err: Error) -> Error { match self.web { true => unmap_err(err), - false => err, + false => unmap_legacy(err), } } } @@ -953,7 +954,7 @@ impl RecvStream { fn map(&self, err: Error) -> Error { match self.web { true => unmap_err(err), - false => err, + false => unmap_legacy(err), } } } @@ -998,6 +999,21 @@ impl std::fmt::Debug for RecvStream { } } +/// Unmap a raw-QUIC stream code that arrived in the HTTP/3 code space. +/// +/// Older `web-transport-moq` raw sessions map RESET_STREAM and STOP_SENDING +/// codes as if they were WebTransport. No `u32` application code reaches that +/// range, so a code in it can only be the mapped form. Anything else is the +/// peer's own code and passes through. +fn unmap_legacy(err: Error) -> Error { + let unmap = |code: u64| proto::error_from_http3(code).map_or(code, u64::from); + match err { + Error::Reset(code) => Error::Reset(unmap(code)), + Error::Stop(code) => Error::Stop(unmap(code)), + err => err, + } +} + /// Rewrite HTTP/3-mapped error codes back into WebTransport code space. /// /// Only a sparse range of HTTP/3 codes names a WebTransport error. Anything @@ -1475,6 +1491,31 @@ mod tests { )); } + /// A raw session reads a plain stream code as is, and an older raw peer's + /// HTTP/3-mapped one as the code it stands for. Session closes are left alone. + #[test] + fn a_raw_session_unmaps_legacy_stream_codes() { + assert!(matches!(unmap_legacy(Error::Reset(5)), Error::Reset(5))); + assert!(matches!(unmap_legacy(Error::Stop(5)), Error::Stop(5))); + assert!(matches!( + unmap_legacy(Error::Reset(proto::error_to_http3(5))), + Error::Reset(5) + )); + assert!(matches!( + unmap_legacy(Error::Stop(proto::error_to_http3(5))), + Error::Stop(5) + )); + + let code = proto::error_to_http3(5); + assert!(matches!( + unmap_legacy(Error::App { + code, + reason: String::new() + }), + Error::App { code: c, .. } if c == code + )); + } + /// Wrap `payload` in an HTTP/3 frame of type `typ`. fn frame(typ: u64, payload: &[u8]) -> Vec { let mut buf = Vec::new(); From 139e5cb74bb565c34cf2a66af7c1efe7cc785a48 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 11:38:02 -0700 Subject: [PATCH 3/5] quest(m1): record the raw stream code decisions Co-Authored-By: Claude Opus 5.5 --- quest/m1/raw-stream-codes.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md index 5540ed9e8e..e110bc5964 100644 --- a/quest/m1/raw-stream-codes.md +++ b/quest/m1/raw-stream-codes.md @@ -18,17 +18,14 @@ on stream errors. WebTransport sessions keep the mapping they need. requirements already admit the patch releases), and the moq-tokio `stream_code.rs` test, which asserts a refused stream's code arrives verbatim over raw QUIC, goes green. -- Mixed versions: this is a wire break, so it stays on `dev`. An older peer - reads a fixed peer's plain codes as invalid, which moq-net reports as - `Error::Transport`. On moq-lite that costs only the stream: a cancel reads - as a failure, a relay forwards INTERNAL_ERROR instead of the peer's code, - and a relay HTTP fetch answers 500 instead of 404. On moq-transport, a - release without #4602 ends the whole session when a stream is reset before - its header, which a fixed peer does routinely. Release #4602 on `main` - before any adapter release that sends raw codes. -- A `main`-compatible subset (read raw and legacy codes, keep sending mapped - ones) could ship on `web-transport-moq` 1.3.x and `web-transport-iroh` - 0.7.x without breaking anyone, if interop needs it before `dev` releases. +- Mixed versions: this is a wire break, accepted on `dev` with no `main` + backport. An older peer reads a fixed peer's plain codes as invalid, which + moq-net reports as `Error::Transport`. On moq-lite that costs only the + stream: a cancel reads as a failure, a relay forwards INTERNAL_ERROR instead + of the peer's code, and a relay HTTP fetch answers 500 instead of 404. On + moq-transport, a release without #4602 ends the session when a stream is + reset before its header; moq never speaks moq-transport to itself, so that + case does not matter. Public API: none. Wire: raw QUIC stream error codes become the application's own values. From ae6a7e0caaf81f4c4ae32d553ef41c71f86112e7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 3 Oct 2026 09:28:40 -0700 Subject: [PATCH 4/5] chore: require web-transport-iroh 0.8.1 for raw stream codes Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ce0681dc7..cff9fef571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9864,9 +9864,9 @@ dependencies = [ [[package]] name = "web-transport-iroh" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e70872b1e0cb791a06373859105570e78759dd3286b1f42bccd31f5b812539c" +checksum = "12f6120c66f3ef235e063e716fd0e3014c9ba07901c3a31a6b3cde20f318fa0a" dependencies = [ "bytes", "http", diff --git a/Cargo.toml b/Cargo.toml index 3b0fe9f1fe..5714b75ed8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -230,7 +230,7 @@ url = "2" usage = { package = "usage-rs", version = "6.11.1" } uuid = { version = "1.26", features = ["v7"] } web-async = { version = "0.1.5", features = ["tracing"] } -web-transport-iroh = "0.8" +web-transport-iroh = "0.8.1" # default-features off so the QUIC crypto provider is chosen by moq-tokio's # aws-lc-rs / ring features. # The WebTransport adapter released with moq-noq, from the same repository. From 91b68d53b86f9395d950901dc6ea023c9c326caa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 3 Oct 2026 10:19:12 -0700 Subject: [PATCH 5/5] test(tokio): check raw stream codes on moq-lite only moqt now holds uni streams that arrive before SETUP (#4686), so it no longer refuses one there. The adapter maps codes per raw session, not per ALPN, so the moq-lite case covers it. Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/tests/stream_code.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/rs/moq-tokio/tests/stream_code.rs b/rs/moq-tokio/tests/stream_code.rs index 07fdd5ea36..23775dc55f 100644 --- a/rs/moq-tokio/tests/stream_code.rs +++ b/rs/moq-tokio/tests/stream_code.rs @@ -2,8 +2,9 @@ //! mapped into the HTTP/3 WebTransport code space, so other raw QUIC MoQ stacks //! read it. //! -//! The peer here is a plain QUIC client. The server refuses a uni stream opened +//! The peer here is a plain QUIC client. A moq-lite server refuses a uni stream opened //! ahead of the SETUP with STOP_SENDING, and the client reads that code off the wire. +//! moq-transport holds such a stream instead, but its codes take the same raw session. #![cfg(feature = "noq")] @@ -11,7 +12,7 @@ use std::{sync::Arc, time::Duration}; use web_transport_moq::noq; -/// moq-lite and moq-transport both send INTERNAL_ERROR (0x0) for a refused stream. +/// moq-lite sends INTERNAL_ERROR (0x0) for a refused stream. const REFUSED: u32 = 0x0; struct Server { @@ -89,7 +90,7 @@ async fn refused_code(server: &Server, alpn: &str) -> Option { .await .expect("handshake"); - // A group stream on moq-lite, and anything but SETUP on moq-transport. + // A group stream, which moq-lite refuses before the SETUP. let mut send = conn.open_uni().await.expect("open"); send.write_all(&[0x00]).await.expect("write"); @@ -102,12 +103,5 @@ async fn refused_code(server: &Server, alpn: &str) -> Option { #[tokio::test] async fn a_refused_stream_code_is_not_mapped() { let server = serve().await; - - for alpn in ["moq-lite-06", "moqt-17"] { - assert_eq!( - refused_code(&server, alpn).await, - Some(REFUSED.into()), - "{alpn} mapped the stream code" - ); - } + assert_eq!(refused_code(&server, "moq-lite-06").await, Some(REFUSED.into())); }