From 6ee91b277afdbd72979205e117dec5fb0aa938f6 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:15:40 -0800 Subject: [PATCH 01/18] Add browser interoperability test infrastructure Add a new unpublished workspace crate `web-transport-browser-tests` that provides infrastructure for running WebTransport tests against headless Chromium. The crate launches a shared browser singleton, creates isolated incognito contexts per test, generates short-lived self-signed certs, and wraps user JS snippets with a `connectWebTransport()` helper. Includes a smoke test proving end-to-end browser-to-server connectivity. Browser tests are excluded from the normal `just check`/`just test` workflows and run separately via `just browser-test`. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 + justfile | 16 +- web-transport-browser-tests/Cargo.toml | 32 +++ web-transport-browser-tests/src/browser.rs | 227 +++++++++++++++++++++ web-transport-browser-tests/src/cert.rs | 55 +++++ web-transport-browser-tests/src/harness.rs | 56 +++++ web-transport-browser-tests/src/js.rs | 60 ++++++ web-transport-browser-tests/src/lib.rs | 5 + web-transport-browser-tests/src/server.rs | 149 ++++++++++++++ web-transport-browser-tests/tests/smoke.rs | 25 +++ 10 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 web-transport-browser-tests/Cargo.toml create mode 100644 web-transport-browser-tests/src/browser.rs create mode 100644 web-transport-browser-tests/src/cert.rs create mode 100644 web-transport-browser-tests/src/harness.rs create mode 100644 web-transport-browser-tests/src/js.rs create mode 100644 web-transport-browser-tests/src/lib.rs create mode 100644 web-transport-browser-tests/src/server.rs create mode 100644 web-transport-browser-tests/tests/smoke.rs diff --git a/Cargo.toml b/Cargo.toml index 8b788034..4bee23c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "web-transport", + "web-transport-browser-tests", "web-transport-proto", "web-transport-quiche", "web-transport-quinn", diff --git a/justfile b/justfile index 211b07d6..3920d716 100644 --- a/justfile +++ b/justfile @@ -21,8 +21,8 @@ setup-tools: # Run the CI checks check: - cargo check --workspace --all-targets --all-features - cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo check --workspace --all-targets --all-features --exclude web-transport-browser-tests + cargo clippy --workspace --all-targets --all-features --exclude web-transport-browser-tests -- -D warnings # Do the same but explicitly use the WASM target. cargo check --target wasm32-unknown-unknown -p web-transport --all-targets --all-features @@ -34,7 +34,7 @@ check: cargo fmt --all --check # requires: cargo install cargo-hack - cargo hack check --feature-powerset --workspace --keep-going + cargo hack check --feature-powerset --workspace --keep-going --exclude web-transport-browser-tests cargo hack check --feature-powerset --target wasm32-unknown-unknown -p web-transport --keep-going cargo hack check --feature-powerset --target wasm32-unknown-unknown -p web-transport-wasm --keep-going @@ -50,14 +50,18 @@ check: # Run any CI tests test: - cargo test --workspace --all-targets --all-features + cargo test --workspace --all-targets --all-features --exclude web-transport-browser-tests cargo test --target wasm32-unknown-unknown -p web-transport --all-targets --all-features cargo test --target wasm32-unknown-unknown -p web-transport-wasm --all-targets --all-features +# Run browser interoperability tests (requires Chromium or auto-downloads it) +browser-test: + cargo test -p web-transport-browser-tests + # Automatically fix some issues. fix: - cargo fix --allow-staged --allow-dirty --workspace --all-targets --all-features - cargo clippy --fix --allow-staged --allow-dirty --workspace --all-targets --all-features + cargo fix --allow-staged --allow-dirty --workspace --all-targets --all-features --exclude web-transport-browser-tests + cargo clippy --fix --allow-staged --allow-dirty --workspace --all-targets --all-features --exclude web-transport-browser-tests # Do the same but explicitly use the WASM target. cargo fix --allow-staged --allow-dirty --target wasm32-unknown-unknown -p web-transport --all-targets --all-features diff --git a/web-transport-browser-tests/Cargo.toml b/web-transport-browser-tests/Cargo.toml new file mode 100644 index 00000000..6ce60b16 --- /dev/null +++ b/web-transport-browser-tests/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "web-transport-browser-tests" +description = "Browser interoperability test infrastructure for web-transport" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +chromiumoxide = { version = "0.9", default-features = false, features = [ + "bytes", + "fetcher", + "rustls", + "zip8", +] } +futures = "0.3" +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs"] } +rustls = { version = "0.23", default-features = false, features = [ + "aws-lc-rs", + "std", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = "0.3" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" + +[dependencies.web-transport-quinn] +path = "../web-transport-quinn" + +[dev-dependencies] +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/web-transport-browser-tests/src/browser.rs b/web-transport-browser-tests/src/browser.rs new file mode 100644 index 00000000..5bc16e85 --- /dev/null +++ b/web-transport-browser-tests/src/browser.rs @@ -0,0 +1,227 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chromiumoxide::browser::{Browser, BrowserConfig}; +use chromiumoxide::cdp::browser_protocol::browser::BrowserContextId; +use chromiumoxide::cdp::browser_protocol::target::{ + CreateBrowserContextParams, CreateTargetParams, +}; +use chromiumoxide::fetcher::{BrowserFetcher, BrowserFetcherOptions}; +use chromiumoxide::Page; +use futures::StreamExt; +use serde::Deserialize; +use tokio::sync::OnceCell; + +use crate::js; + +struct SharedBrowser { + browser: Browser, + /// URL of the blank page server (http://localhost:{port}). + page_url: String, + // Keep these alive so the browser and HTTP server stay running. + _handler: tokio::task::JoinHandle<()>, + _http_server: tokio::task::JoinHandle<()>, +} + +static BROWSER: OnceCell = OnceCell::const_new(); + +/// Try to build a BrowserConfig, auto-downloading Chromium via the fetcher if +/// no local executable is detected. +async fn build_browser_config() -> BrowserConfig { + // Use a per-process data dir to avoid stale SingletonLock conflicts. + let data_dir = std::env::temp_dir().join(format!("chromiumoxide-{}", std::process::id())); + + // Note: .arg() auto-prefixes with "--", so pass bare names. + let builder = BrowserConfig::builder() + .new_headless_mode() + .no_sandbox() + .user_data_dir(&data_dir) + .arg("allow-insecure-localhost"); + + match builder.clone().build() { + Ok(config) => return config, + Err(_) => { + tracing::info!("no local Chrome found, downloading via fetcher..."); + } + } + + // Auto-detection failed — download Chromium. + let exe_path = fetch_chromium().await; + + builder + .chrome_executable(exe_path) + .build() + .expect("failed to build browser config with fetched executable") +} + +/// Download Chromium using chromiumoxide_fetcher and return the executable path. +async fn fetch_chromium() -> PathBuf { + // Use the same cache directory as the fetcher's default + // ($XDG_CACHE_HOME or $HOME/.cache)/chromiumoxide. We must create it + // ourselves because the fetcher doesn't create parent directories. + let base = std::env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(std::env::var("HOME").expect("HOME not set")).join(".cache") + }); + let cache_dir = base.join("chromiumoxide"); + std::fs::create_dir_all(&cache_dir).expect("failed to create browser cache directory"); + + let options = BrowserFetcherOptions::builder() + .with_path(&cache_dir) + .build() + .expect("failed to create fetcher options"); + + let fetcher = BrowserFetcher::new(options); + let installation = fetcher + .fetch() + .await + .expect("failed to download Chromium"); + + tracing::info!(path = %installation.executable_path.display(), "downloaded Chromium"); + installation.executable_path +} + +/// Start a tiny HTTP server that serves a blank page on a random port. +/// +/// Chrome treats `http://localhost` as a secure context, so the WebTransport +/// API is available — unlike `about:blank` or `chrome://` pages (which either +/// lack a secure context or block outbound connections via CSP). +async fn start_blank_page_server() -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind blank page server"); + let addr: SocketAddr = listener.local_addr().unwrap(); + let url = format!("http://localhost:{}", addr.port()); + + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + // Read the request (discard it). + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).await; + // Send a minimal HTML response. + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + (url, handle) +} + +async fn init_browser() -> SharedBrowser { + let config = build_browser_config().await; + let (page_url, http_handle) = start_blank_page_server().await; + + let (browser, mut handler) = Browser::launch(config) + .await + .expect("failed to launch browser"); + + let handle = tokio::spawn(async move { + while let Some(event) = handler.next().await { + let _ = event; + } + }); + + SharedBrowser { + browser, + page_url, + _handler: handle, + _http_server: http_handle, + } +} + +async fn get_browser() -> &'static SharedBrowser { + BROWSER.get_or_init(init_browser).await +} + +/// An isolated browser context for a single test. +/// +/// Each context has its own cookies, cache, and page — preventing +/// cross-test interference. +pub struct TestContext { + page: Page, + context_id: Option, +} + +impl TestContext { + /// Create a new isolated browser context. + pub async fn new() -> Result { + let shared = get_browser().await; + + let context_id = shared + .browser + .create_browser_context(CreateBrowserContextParams::default()) + .await + .context("failed to create browser context")?; + + // Navigate to a localhost HTTP page so the JS context has a secure + // origin with the WebTransport API available. + let page = shared + .browser + .new_page( + CreateTargetParams::builder() + .url(&shared.page_url) + .browser_context_id(context_id.clone()) + .build() + .unwrap(), + ) + .await + .context("failed to create page")?; + + Ok(Self { + page, + context_id: Some(context_id), + }) + } + + /// Evaluate a JavaScript test snippet in the browser. + /// + /// The `js_code` is wrapped with `connectWebTransport()` and error handling + /// via [`js::wrap_test_js`]. + pub async fn run_js_test( + &self, + server_url: &str, + fingerprint: &[u8], + js_code: &str, + timeout: Duration, + ) -> Result { + let wrapped = js::wrap_test_js(server_url, fingerprint, js_code); + + let result: String = tokio::time::timeout(timeout, self.page.evaluate(wrapped)) + .await + .context("JS test timed out")? + .context("JS evaluation failed")? + .into_value() + .context("failed to extract JS result value")?; + + let parsed: JsTestResult = + serde_json::from_str(&result).context("failed to parse JS test result")?; + + Ok(parsed) + } + + /// Dispose of the browser context. + pub async fn dispose(mut self) { + if let Some(id) = self.context_id.take() { + let shared = get_browser().await; + let _ = shared.browser.dispose_browser_context(id).await; + } + } +} + +/// The result of a JavaScript test evaluation. +#[derive(Debug, Deserialize)] +pub struct JsTestResult { + pub success: bool, + pub message: String, + #[serde(default)] + pub details: serde_json::Value, +} diff --git a/web-transport-browser-tests/src/cert.rs b/web-transport-browser-tests/src/cert.rs new file mode 100644 index 00000000..9209dac5 --- /dev/null +++ b/web-transport-browser-tests/src/cert.rs @@ -0,0 +1,55 @@ +use rcgen::{CertificateParams, KeyPair, SanType}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use web_transport_quinn::crypto; + +/// A self-signed certificate for use in tests, along with its SHA-256 fingerprint. +pub struct TestCert { + pub chain: Vec>, + pub key: PrivateKeyDer<'static>, + /// 32 raw SHA-256 bytes. + pub fingerprint: Vec, + /// Hex-encoded fingerprint for logging. + pub fingerprint_hex: String, +} + +/// Generate a self-signed EC P-256 certificate valid for 10 days. +/// +/// The certificate has SANs for `localhost` and `127.0.0.1`. +pub fn generate() -> TestCert { + let key_pair = KeyPair::generate().expect("failed to generate key pair"); + + let mut params = CertificateParams::default(); + params.subject_alt_names = vec![ + SanType::DnsName("localhost".try_into().unwrap()), + SanType::IpAddress("127.0.0.1".parse().unwrap()), + ]; + // Chrome requires serverCertificateHashes certs to be valid for at most 14 days. + let now = time::OffsetDateTime::now_utc(); + params.not_before = now; + params.not_after = now + time::Duration::days(10); + + let cert = params + .self_signed(&key_pair) + .expect("failed to self-sign certificate"); + + let cert_der = CertificateDer::from(cert); + let key_der = PrivateKeyDer::from(key_pair); + + // Compute fingerprint using the same function as the server-side code. + let provider = crypto::default_provider(); + let hash = crypto::sha256(&provider, &cert_der); + let fingerprint = hash.as_ref().to_vec(); + let fingerprint_hex = fingerprint + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + + tracing::debug!(fingerprint = %fingerprint_hex, "generated test certificate"); + + TestCert { + chain: vec![cert_der], + key: key_der, + fingerprint, + fingerprint_hex, + } +} diff --git a/web-transport-browser-tests/src/harness.rs b/web-transport-browser-tests/src/harness.rs new file mode 100644 index 00000000..16cb817e --- /dev/null +++ b/web-transport-browser-tests/src/harness.rs @@ -0,0 +1,56 @@ +use std::time::Duration; + +use anyhow::Result; + +use crate::browser::{JsTestResult, TestContext}; +use crate::cert::{self, TestCert}; +use crate::server::{self, ServerHandler, TestServer}; + +/// Orchestrates certificate generation, server startup, and browser context +/// creation for a single test. +pub struct TestHarness { + pub server: TestServer, + pub context: TestContext, + pub cert: TestCert, +} + +/// Set up a complete test environment: generate a cert, start a server with the +/// given handler, launch (or reuse) the browser, and create an isolated context. +pub async fn setup(handler: ServerHandler) -> Result { + let cert = cert::generate(); + let server = server::start(&cert, handler).await?; + let context = TestContext::new().await?; + + Ok(TestHarness { + server, + context, + cert, + }) +} + +impl TestHarness { + /// Evaluate JavaScript test code in the browser and return the result. + pub async fn run_js(&self, js_code: &str, timeout: Duration) -> Result { + self.context + .run_js_test(&self.server.url, &self.cert.fingerprint, js_code, timeout) + .await + } + + /// Evaluate JavaScript test code and assert that it succeeded. + pub async fn run_js_ok(&self, js_code: &str, timeout: Duration) { + let result = self + .run_js(js_code, timeout) + .await + .unwrap_or_else(|e| panic!("JS test failed with error: {e:#}")); + assert!(result.success, "JS test failed: {}", result.message); + } + + /// Dispose of the browser context and shut down the server. + pub async fn teardown(self) { + self.context.dispose().await; + self.server.shutdown().await; + } +} + +// Re-export handler constructors for convenience. +pub use crate::server::{echo_handler, idle_handler, immediate_close_handler}; diff --git a/web-transport-browser-tests/src/js.rs b/web-transport-browser-tests/src/js.rs new file mode 100644 index 00000000..606b397d --- /dev/null +++ b/web-transport-browser-tests/src/js.rs @@ -0,0 +1,60 @@ +/// Wrap user-provided JavaScript test code with a `connectWebTransport()` helper +/// and error handling. +/// +/// The returned string is a self-contained async IIFE that can be evaluated +/// directly via `page.evaluate()`. +pub fn wrap_test_js(server_url: &str, fingerprint: &[u8], user_code: &str) -> String { + let fingerprint_bytes = fingerprint + .iter() + .map(|b| b.to_string()) + .collect::>() + .join(", "); + + format!( + r#"(async () => {{ + const SERVER_URL = "{server_url}"; + const CERT_HASH = new Uint8Array([{fingerprint_bytes}]); + + async function connectWebTransport(url, options) {{ + const wt = new WebTransport(url || SERVER_URL, {{ + serverCertificateHashes: [{{ + algorithm: "sha-256", + value: CERT_HASH, + }}], + ...(options || {{}}), + }}); + await wt.ready; + return wt; + }} + + try {{ + const result = await (async () => {{ {user_code} }})(); + return JSON.stringify(result || {{ success: true, message: "ok" }}); + }} catch (e) {{ + return JSON.stringify({{ + success: false, + message: e.toString(), + details: {{ name: e.name, stack: e.stack }} + }}); + }} +}})()"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_produces_valid_js() { + let js = wrap_test_js( + "https://localhost:4443", + &[0xab, 0xcd, 0x01], + "return { success: true, message: 'hello' };", + ); + assert!(js.contains("SERVER_URL")); + assert!(js.contains("171, 205, 1")); + assert!(js.contains("connectWebTransport")); + assert!(js.contains("return { success: true, message: 'hello' };")); + } +} diff --git a/web-transport-browser-tests/src/lib.rs b/web-transport-browser-tests/src/lib.rs new file mode 100644 index 00000000..d8532e28 --- /dev/null +++ b/web-transport-browser-tests/src/lib.rs @@ -0,0 +1,5 @@ +pub mod browser; +pub mod cert; +pub mod harness; +pub mod js; +pub mod server; diff --git a/web-transport-browser-tests/src/server.rs b/web-transport-browser-tests/src/server.rs new file mode 100644 index 00000000..ff743209 --- /dev/null +++ b/web-transport-browser-tests/src/server.rs @@ -0,0 +1,149 @@ +use std::future::Future; +use std::net::SocketAddr; +use std::pin::Pin; + +use anyhow::Result; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use web_transport_quinn::{RecvStream, SendStream}; + +use crate::cert::TestCert; + +/// A boxed async handler invoked for each accepted WebTransport session. +pub type ServerHandler = Box< + dyn Fn(web_transport_quinn::Session) -> Pin + Send>> + + Send + + Sync + + 'static, +>; + +/// A running WebTransport test server. +pub struct TestServer { + pub addr: SocketAddr, + pub url: String, + shutdown_tx: Option>, + task: Option>, +} + +impl TestServer { + /// Shut down the server and wait for the accept loop to exit. + pub async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +/// Start a WebTransport server on a random port using the given certificate and handler. +pub async fn start(cert: &TestCert, handler: ServerHandler) -> Result { + let addr: SocketAddr = "[::1]:0".parse().unwrap(); + + let server = web_transport_quinn::ServerBuilder::new() + .with_addr(addr) + .with_certificate(cert.chain.clone(), cert.key.clone_key())?; + + let actual_addr = server.local_addr()?; + let url = format!("https://localhost:{}", actual_addr.port()); + + tracing::debug!(%url, "test server listening"); + + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + + let task = tokio::spawn(async move { + let mut server = server; + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + request = server.accept() => { + let Some(request) = request else { break }; + let session = match request.ok().await { + Ok(s) => s, + Err(e) => { + tracing::warn!(?e, "failed to accept session"); + continue; + } + }; + let fut = handler(session); + tokio::spawn(fut); + } + } + } + }); + + Ok(TestServer { + addr: actual_addr, + url, + shutdown_tx: Some(shutdown_tx), + task: Some(task), + }) +} + +/// A handler that echoes bidirectional streams back to the client. +pub fn echo_handler() -> ServerHandler { + Box::new(|session| { + Box::pin(async move { + if let Err(e) = echo_session(session).await { + tracing::debug!(?e, "echo session ended"); + } + }) + }) +} + +async fn echo_session(session: web_transport_quinn::Session) -> Result<()> { + use tokio::io::AsyncWriteExt; + + loop { + tokio::select! { + stream = session.accept_bi() => { + let (mut send, mut recv): (SendStream, RecvStream) = stream?; + tokio::spawn(async move { + match recv.read_to_end(1024 * 1024).await { + Ok(buf) => { + let _ = send.write_all(&buf).await; + let _ = send.shutdown().await; + } + Err(e) => { + tracing::debug!(?e, "echo read failed"); + } + } + }); + } + datagram = session.read_datagram() => { + let data = datagram?; + session.send_datagram(data)?; + } + } + } +} + +/// A handler that accepts the session and immediately closes it. +pub fn immediate_close_handler(code: u32, reason: &'static str) -> ServerHandler { + Box::new(move |session| { + Box::pin(async move { + session.close(code, reason.as_bytes()); + }) + }) +} + +/// A handler that accepts the session and holds it open until the client disconnects. +pub fn idle_handler() -> ServerHandler { + Box::new(|session| { + Box::pin(async move { + let _ = session.closed().await; + }) + }) +} diff --git a/web-transport-browser-tests/tests/smoke.rs b/web-transport-browser-tests/tests/smoke.rs new file mode 100644 index 00000000..727fc87b --- /dev/null +++ b/web-transport-browser-tests/tests/smoke.rs @@ -0,0 +1,25 @@ +use std::time::Duration; + +use web_transport_browser_tests::harness; + +#[tokio::test] +async fn browser_connects_to_server() { + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + wt.close(); + return { success: true, message: "connected and closed" }; + "#, + Duration::from_secs(10), + ) + .await; + + // Teardown before asserting so resources are cleaned up even on failure. + harness.teardown().await; + + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} From 3f4a46ba090c4b7ffe3fd56d32f44a549a5fc9c4 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:29:36 -0800 Subject: [PATCH 02/18] Add comprehensive browser interoperability test suite Expand the browser test infrastructure to support handler panic propagation, expected handler counts, pre-accept request handling, and robust Chrome lifecycle management (dedicated runtime, keepalive page, cleanup watchdog). Add 43 integration tests covering bidirectional/unidirectional streams, datagrams, connection lifecycle, stream error codes, concurrency, and large data transfers against a real Chromium instance. Co-Authored-By: Claude Code --- web-transport-browser-tests/Cargo.toml | 2 + web-transport-browser-tests/src/browser.rs | 183 ++-- web-transport-browser-tests/src/harness.rs | 28 +- web-transport-browser-tests/src/js.rs | 11 +- web-transport-browser-tests/src/server.rs | 175 +++- .../tests/bidi_stream.rs | 378 +++++++++ .../tests/common/mod.rs | 19 + .../tests/concurrent.rs | 589 +++++++++++++ .../tests/connection.rs | 240 ++++++ web-transport-browser-tests/tests/datagram.rs | 170 ++++ .../tests/stream_error.rs | 785 ++++++++++++++++++ .../tests/uni_stream.rs | 295 +++++++ 12 files changed, 2776 insertions(+), 99 deletions(-) create mode 100644 web-transport-browser-tests/tests/bidi_stream.rs create mode 100644 web-transport-browser-tests/tests/common/mod.rs create mode 100644 web-transport-browser-tests/tests/concurrent.rs create mode 100644 web-transport-browser-tests/tests/connection.rs create mode 100644 web-transport-browser-tests/tests/datagram.rs create mode 100644 web-transport-browser-tests/tests/stream_error.rs create mode 100644 web-transport-browser-tests/tests/uni_stream.rs diff --git a/web-transport-browser-tests/Cargo.toml b/web-transport-browser-tests/Cargo.toml index 6ce60b16..1e0543c8 100644 --- a/web-transport-browser-tests/Cargo.toml +++ b/web-transport-browser-tests/Cargo.toml @@ -29,4 +29,6 @@ tracing = "0.1" path = "../web-transport-quinn" [dev-dependencies] +bytes = "1" +http = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/web-transport-browser-tests/src/browser.rs b/web-transport-browser-tests/src/browser.rs index 5bc16e85..146f8a2e 100644 --- a/web-transport-browser-tests/src/browser.rs +++ b/web-transport-browser-tests/src/browser.rs @@ -1,5 +1,6 @@ use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::OnceLock; use std::time::Duration; use anyhow::{Context, Result}; @@ -12,7 +13,6 @@ use chromiumoxide::fetcher::{BrowserFetcher, BrowserFetcherOptions}; use chromiumoxide::Page; use futures::StreamExt; use serde::Deserialize; -use tokio::sync::OnceCell; use crate::js; @@ -20,12 +20,25 @@ struct SharedBrowser { browser: Browser, /// URL of the blank page server (http://localhost:{port}). page_url: String, - // Keep these alive so the browser and HTTP server stay running. - _handler: tokio::task::JoinHandle<()>, - _http_server: tokio::task::JoinHandle<()>, + // A dedicated Tokio runtime that owns the browser event handler and HTTP + // server tasks. This runtime lives as long as the `SharedBrowser` (i.e. + // for the entire process) so that the tasks are not cancelled when + // individual `#[tokio::test]` runtimes shut down. + _runtime: tokio::runtime::Runtime, + // A page that stays open for the lifetime of the browser to prevent + // Chrome from exiting when all test contexts are disposed. + _keepalive_page: Page, + // Holds the stdin pipe to the cleanup watchdog process. When our process + // exits (for any reason, including SIGKILL), the OS closes this FD, + // unblocking the watchdog which then kills Chrome and removes the temp dir. + _watchdog_pipe: Option, } -static BROWSER: OnceCell = OnceCell::const_new(); +// Safety: Browser and Page are Send + Sync; the runtime is only used to keep +// background tasks alive and is never accessed from multiple threads. +unsafe impl Sync for SharedBrowser {} + +static BROWSER: OnceLock = OnceLock::new(); /// Try to build a BrowserConfig, auto-downloading Chromium via the fetcher if /// no local executable is detected. @@ -75,71 +88,121 @@ async fn fetch_chromium() -> PathBuf { .expect("failed to create fetcher options"); let fetcher = BrowserFetcher::new(options); - let installation = fetcher - .fetch() - .await - .expect("failed to download Chromium"); + let installation = fetcher.fetch().await.expect("failed to download Chromium"); tracing::info!(path = %installation.executable_path.display(), "downloaded Chromium"); installation.executable_path } -/// Start a tiny HTTP server that serves a blank page on a random port. -/// -/// Chrome treats `http://localhost` as a secure context, so the WebTransport -/// API is available — unlike `about:blank` or `chrome://` pages (which either -/// lack a secure context or block outbound connections via CSP). -async fn start_blank_page_server() -> (String, tokio::task::JoinHandle<()>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("failed to bind blank page server"); - let addr: SocketAddr = listener.local_addr().unwrap(); - let url = format!("http://localhost:{}", addr.port()); - - let handle = tokio::spawn(async move { - loop { - let Ok((mut stream, _)) = listener.accept().await else { - break; - }; +/// Spawn a background shell that blocks on stdin. When our process exits (for +/// any reason), the OS closes the pipe, `read` gets EOF, and the shell kills +/// Chrome and removes its data directory. +fn spawn_cleanup_watchdog( + chrome_pid: u32, + data_dir: &std::path::Path, +) -> Option { + use std::process::{Command, Stdio}; + + let mut child = Command::new("sh") + .arg("-c") + .arg(format!( + "read _; kill -9 {} 2>/dev/null; rm -rf '{}'", + chrome_pid, + data_dir.display(), + )) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + child.stdin.take() +} + +fn init_browser() -> SharedBrowser { + // Create a dedicated runtime on a separate thread. We cannot call + // `block_on` from within an existing Tokio runtime (which `#[tokio::test]` + // creates), so we spawn a plain OS thread to build and initialise + // everything, then send the results back. + std::thread::spawn(|| { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("failed to build dedicated browser runtime"); + + let (browser, page_url, keepalive_page, chrome_pid) = rt.block_on(async { + let config = build_browser_config().await; + + // Start the blank page server on this runtime. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind blank page server"); + let addr: SocketAddr = listener.local_addr().unwrap(); + let page_url = format!("http://localhost:{}", addr.port()); + tokio::spawn(async move { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - // Read the request (discard it). - let mut buf = [0u8; 1024]; - let _ = stream.read(&mut buf).await; - // Send a minimal HTML response. - let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n"; - let _ = stream.write_all(response.as_bytes()).await; + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).await; + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()).await; + }); + } }); - } - }); - (url, handle) -} + let (mut browser, mut handler) = Browser::launch(config) + .await + .expect("failed to launch browser"); -async fn init_browser() -> SharedBrowser { - let config = build_browser_config().await; - let (page_url, http_handle) = start_blank_page_server().await; + // Extract Chrome's PID before we lose mutability, so the watchdog + // can kill it on abnormal exit. + let chrome_pid = browser + .get_mut_child() + .and_then(|c| c.inner.id()) + .unwrap_or(0); - let (browser, mut handler) = Browser::launch(config) - .await - .expect("failed to launch browser"); + // Spawn the CDP event handler on this runtime. + tokio::spawn(async move { + while let Some(event) = handler.next().await { + let _ = event; + } + }); - let handle = tokio::spawn(async move { - while let Some(event) = handler.next().await { - let _ = event; - } - }); + // Open a keepalive page in the default context so Chrome doesn't + // exit when all test browser contexts are disposed. + let keepalive_page = browser + .new_page(&page_url) + .await + .expect("failed to create keepalive page"); - SharedBrowser { - browser, - page_url, - _handler: handle, - _http_server: http_handle, - } + (browser, page_url, keepalive_page, chrome_pid) + }); + + // Spawn a watchdog that kills Chrome and cleans up the temp dir when + // our process exits. The data dir path matches build_browser_config(). + let data_dir = + std::env::temp_dir().join(format!("chromiumoxide-{}", std::process::id())); + let watchdog_pipe = spawn_cleanup_watchdog(chrome_pid, &data_dir); + + SharedBrowser { + browser, + page_url, + _runtime: rt, + _keepalive_page: keepalive_page, + _watchdog_pipe: watchdog_pipe, + } + }) + .join() + .expect("browser init thread panicked") } -async fn get_browser() -> &'static SharedBrowser { - BROWSER.get_or_init(init_browser).await +fn get_browser() -> &'static SharedBrowser { + BROWSER.get_or_init(init_browser) } /// An isolated browser context for a single test. @@ -154,7 +217,7 @@ pub struct TestContext { impl TestContext { /// Create a new isolated browser context. pub async fn new() -> Result { - let shared = get_browser().await; + let shared = get_browser(); let context_id = shared .browser @@ -189,11 +252,11 @@ impl TestContext { pub async fn run_js_test( &self, server_url: &str, - fingerprint: &[u8], + certificate_hash: &[u8], js_code: &str, timeout: Duration, ) -> Result { - let wrapped = js::wrap_test_js(server_url, fingerprint, js_code); + let wrapped = js::wrap_test_js(server_url, certificate_hash, js_code); let result: String = tokio::time::timeout(timeout, self.page.evaluate(wrapped)) .await @@ -211,7 +274,7 @@ impl TestContext { /// Dispose of the browser context. pub async fn dispose(mut self) { if let Some(id) = self.context_id.take() { - let shared = get_browser().await; + let shared = get_browser(); let _ = shared.browser.dispose_browser_context(id).await; } } diff --git a/web-transport-browser-tests/src/harness.rs b/web-transport-browser-tests/src/harness.rs index 16cb817e..794e0d2b 100644 --- a/web-transport-browser-tests/src/harness.rs +++ b/web-transport-browser-tests/src/harness.rs @@ -4,7 +4,7 @@ use anyhow::Result; use crate::browser::{JsTestResult, TestContext}; use crate::cert::{self, TestCert}; -use crate::server::{self, ServerHandler, TestServer}; +use crate::server::{self, RequestHandler, ServerHandler, TestServer}; /// Orchestrates certificate generation, server startup, and browser context /// creation for a single test. @@ -45,11 +45,33 @@ impl TestHarness { assert!(result.success, "JS test failed: {}", result.message); } - /// Dispose of the browser context and shut down the server. + /// Dispose of the browser context and shut down the server, asserting that + /// exactly one handler invocation completed. pub async fn teardown(self) { self.context.dispose().await; - self.server.shutdown().await; + self.server.shutdown(1).await; } + + /// Dispose of the browser context and shut down the server, asserting that + /// exactly `expected_handlers` handler invocations completed. + pub async fn teardown_expecting(self, expected_handlers: usize) { + self.context.dispose().await; + self.server.shutdown(expected_handlers).await; + } +} + +/// Set up a complete test environment using a [RequestHandler] that receives the +/// raw request before acceptance. +pub async fn setup_with_request_handler(handler: RequestHandler) -> Result { + let cert = cert::generate(); + let server = server::start_with_request_handler(&cert, handler).await?; + let context = TestContext::new().await?; + + Ok(TestHarness { + server, + context, + cert, + }) } // Re-export handler constructors for convenience. diff --git a/web-transport-browser-tests/src/js.rs b/web-transport-browser-tests/src/js.rs index 606b397d..94dd3265 100644 --- a/web-transport-browser-tests/src/js.rs +++ b/web-transport-browser-tests/src/js.rs @@ -3,8 +3,8 @@ /// /// The returned string is a self-contained async IIFE that can be evaluated /// directly via `page.evaluate()`. -pub fn wrap_test_js(server_url: &str, fingerprint: &[u8], user_code: &str) -> String { - let fingerprint_bytes = fingerprint +pub fn wrap_test_js(server_url: &str, certificate_hash: &[u8], user_code: &str) -> String { + let hash_bytes = certificate_hash .iter() .map(|b| b.to_string()) .collect::>() @@ -13,7 +13,7 @@ pub fn wrap_test_js(server_url: &str, fingerprint: &[u8], user_code: &str) -> St format!( r#"(async () => {{ const SERVER_URL = "{server_url}"; - const CERT_HASH = new Uint8Array([{fingerprint_bytes}]); + const CERT_HASH = new Uint8Array([{hash_bytes}]); async function connectWebTransport(url, options) {{ const wt = new WebTransport(url || SERVER_URL, {{ @@ -29,7 +29,10 @@ pub fn wrap_test_js(server_url: &str, fingerprint: &[u8], user_code: &str) -> St try {{ const result = await (async () => {{ {user_code} }})(); - return JSON.stringify(result || {{ success: true, message: "ok" }}); + if (result === undefined || result === null) {{ + return JSON.stringify({{ success: false, message: "test code did not return a result" }}); + }} + return JSON.stringify(result); }} catch (e) {{ return JSON.stringify({{ success: false, diff --git a/web-transport-browser-tests/src/server.rs b/web-transport-browser-tests/src/server.rs index ff743209..97057d6a 100644 --- a/web-transport-browser-tests/src/server.rs +++ b/web-transport-browser-tests/src/server.rs @@ -1,19 +1,21 @@ use std::future::Future; use std::net::SocketAddr; use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::Result; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use web_transport_quinn::{RecvStream, SendStream}; use crate::cert::TestCert; +type TaskHandles = Arc>>>; + /// A boxed async handler invoked for each accepted WebTransport session. pub type ServerHandler = Box< - dyn Fn(web_transport_quinn::Session) -> Pin + Send>> + dyn FnMut(web_transport_quinn::Session) -> Pin + Send>> + Send - + Sync + 'static, >; @@ -23,17 +25,36 @@ pub struct TestServer { pub url: String, shutdown_tx: Option>, task: Option>, + handler_tasks: TaskHandles, } impl TestServer { - /// Shut down the server and wait for the accept loop to exit. - pub async fn shutdown(mut self) { + /// Shut down the server, verify exactly `expected_handlers` ran, and re-panic + /// if any handler panicked. + pub async fn shutdown(mut self, expected_handlers: usize) { if let Some(tx) = self.shutdown_tx.take() { - let _ = tx.send(()); + tx.send(()).expect("shutdown receiver should be alive"); } if let Some(task) = self.task.take() { let _ = task.await; } + let handles: Vec<_> = self.handler_tasks.lock().unwrap().drain(..).collect(); + assert_eq!( + handles.len(), + expected_handlers, + "expected {expected_handlers} handler invocation(s), got {}", + handles.len() + ); + for (i, handle) in handles.into_iter().enumerate() { + let result = tokio::time::timeout(Duration::from_secs(5), handle) + .await + .unwrap_or_else(|_| panic!("handler {i} did not complete within 5s")); + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + } } } @@ -62,23 +83,22 @@ pub async fn start(cert: &TestCert, handler: ServerHandler) -> Result break, request = server.accept() => { let Some(request) = request else { break }; - let session = match request.ok().await { - Ok(s) => s, - Err(e) => { - tracing::warn!(?e, "failed to accept session"); - continue; - } - }; + let session = request.ok().await + .expect("failed to accept session"); let fut = handler(session); - tokio::spawn(fut); + let handle = tokio::spawn(fut); + handler_tasks2.lock().unwrap().push(handle); } } } @@ -89,6 +109,7 @@ pub async fn start(cert: &TestCert, handler: ServerHandler) -> Result Result ServerHandler { Box::new(|session| { Box::pin(async move { - if let Err(e) = echo_session(session).await { - tracing::debug!(?e, "echo session ended"); - } + echo_session(session).await; }) }) } -async fn echo_session(session: web_transport_quinn::Session) -> Result<()> { +async fn echo_session(session: web_transport_quinn::Session) { use tokio::io::AsyncWriteExt; + let mut tasks = tokio::task::JoinSet::new(); + loop { tokio::select! { stream = session.accept_bi() => { - let (mut send, mut recv): (SendStream, RecvStream) = stream?; - tokio::spawn(async move { - match recv.read_to_end(1024 * 1024).await { - Ok(buf) => { - let _ = send.write_all(&buf).await; - let _ = send.shutdown().await; - } - Err(e) => { - tracing::debug!(?e, "echo read failed"); - } + match stream { + Ok((mut send, mut recv)) => { + tasks.spawn(async move { + let buf = recv.read_to_end(1024 * 1024).await + .expect("echo: read_to_end failed"); + send.write_all(&buf).await + .expect("echo: write_all failed"); + send.shutdown().await + .expect("echo: shutdown failed"); + }); } - }); + Err(e) if is_session_closed(&e) => break, + Err(e) => panic!("echo: accept_bi failed unexpectedly: {e}"), + } } datagram = session.read_datagram() => { - let data = datagram?; - session.send_datagram(data)?; + match datagram { + Ok(data) => session.send_datagram(data) + .expect("echo: send_datagram failed"), + Err(e) if is_session_closed(&e) => break, + Err(e) => panic!("echo: read_datagram failed unexpectedly: {e}"), + } + } + Some(result) = tasks.join_next() => { + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } } } } + + // Drain remaining stream tasks and propagate panics. + while let Some(result) = tasks.join_next().await { + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + } +} + +/// Returns true if the error indicates the session was closed (locally or by the peer). +pub fn is_session_closed(e: &web_transport_quinn::SessionError) -> bool { + use web_transport_quinn::SessionError::*; + matches!( + e, + ConnectionError(web_transport_quinn::quinn::ConnectionError::ApplicationClosed(_)) + | ConnectionError(web_transport_quinn::quinn::ConnectionError::LocallyClosed) + ) } /// A handler that accepts the session and immediately closes it. @@ -143,7 +196,65 @@ pub fn immediate_close_handler(code: u32, reason: &'static str) -> ServerHandler pub fn idle_handler() -> ServerHandler { Box::new(|session| { Box::pin(async move { - let _ = session.closed().await; + let err = session.closed().await; + assert!( + is_session_closed(&err), + "idle: unexpected session error: {err}" + ); }) }) } + +/// A boxed async handler invoked with the raw [web_transport_quinn::Request] before +/// the session is accepted. This allows tests to reject or customize the response. +pub type RequestHandler = Box< + dyn FnMut(web_transport_quinn::Request) -> Pin + Send>> + + Send + + 'static, +>; + +/// Start a WebTransport server that passes the raw [web_transport_quinn::Request] +/// to the handler instead of auto-accepting it. +pub async fn start_with_request_handler( + cert: &TestCert, + handler: RequestHandler, +) -> Result { + let addr: SocketAddr = "[::1]:0".parse().unwrap(); + + let server = web_transport_quinn::ServerBuilder::new() + .with_addr(addr) + .with_certificate(cert.chain.clone(), cert.key.clone_key())?; + + let actual_addr = server.local_addr()?; + let url = format!("https://localhost:{}", actual_addr.port()); + + tracing::debug!(%url, "test server listening (request handler)"); + + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let handler_tasks: TaskHandles = Arc::new(Mutex::new(Vec::new())); + let handler_tasks2 = handler_tasks.clone(); + + let task = tokio::spawn(async move { + let mut server = server; + let mut handler = handler; + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + request = server.accept() => { + let Some(request) = request else { break }; + let fut = handler(request); + let handle = tokio::spawn(fut); + handler_tasks2.lock().unwrap().push(handle); + } + } + } + }); + + Ok(TestServer { + addr: actual_addr, + url, + shutdown_tx: Some(shutdown_tx), + task: Some(task), + handler_tasks, + }) +} diff --git a/web-transport-browser-tests/tests/bidi_stream.rs b/web-transport-browser-tests/tests/bidi_stream.rs new file mode 100644 index 00000000..863e18d6 --- /dev/null +++ b/web-transport-browser-tests/tests/bidi_stream.rs @@ -0,0 +1,378 @@ +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; + +mod common; +use common::{init_tracing, TIMEOUT}; + +// --------------------------------------------------------------------------- +// Client-Initiated +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn bidi_stream_echo() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + await writer.write(new TextEncoder().encode("hello world")); + await writer.close(); + + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + wt.close(); + return { + success: received === "hello world", + message: "echoed: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn bidi_stream_binary_data() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + // Build 256-byte array with values 0x00-0xFF + const sent = new Uint8Array(256); + for (let i = 0; i < 256; i++) sent[i] = i; + await writer.write(sent); + await writer.close(); + + const chunks = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + } + const received = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let off = 0; + for (const c of chunks) { received.set(c, off); off += c.length; } + + let ok = received.length === 256; + for (let i = 0; ok && i < 256; i++) ok = received[i] === i; + + wt.close(); + return { success: ok, message: "binary len=" + received.length }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn bidi_stream_empty_write() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + // Immediately close without writing + await writer.close(); + + const { value, done } = await reader.read(); + wt.close(); + return { + success: done === true && !value, + message: "done=" + done + " value=" + value + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn bidi_stream_multiple_writes() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const enc = new TextEncoder(); + await writer.write(enc.encode("aaa")); + await writer.write(enc.encode("bbb")); + await writer.write(enc.encode("ccc")); + await writer.close(); + + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + wt.close(); + return { + success: received === "aaabbbccc", + message: "received: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Server-Initiated +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn bidi_stream_server_initiated() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, _recv) = session.open_bi().await.expect("open_bi failed"); + send.write_all(b"from server") + .await + .expect("write_all failed"); + send.finish().expect("finish failed"); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingBidirectionalStreams.getReader(); + const { value: stream, done } = await reader.read(); + if (done) return { success: false, message: "no incoming stream" }; + + const sr = stream.readable.getReader(); + let received = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + wt.close(); + return { + success: received === "from server", + message: "received: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn bidi_stream_server_initiated_multiple() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + for i in 0u8..3 { + let (mut send, _recv) = session.open_bi().await.expect("open_bi failed"); + send.write_all(&[i]).await.expect("write_all failed"); + send.finish().expect("finish failed"); + } + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingBidirectionalStreams.getReader(); + const values = []; + for (let i = 0; i < 3; i++) { + const { value: stream, done } = await reader.read(); + if (done) break; + const sr = stream.readable.getReader(); + const { value } = await sr.read(); + values.push(value[0]); + } + wt.close(); + values.sort((a, b) => a - b); + const ok = values.length === 3 && values[0] === 0 && values[1] === 1 && values[2] === 2; + return { success: ok, message: "values: " + JSON.stringify(values) }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn bidi_stream_server_initiated_bidirectional_exchange() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, mut recv) = session.open_bi().await.expect("open_bi failed"); + send.write_all(b"ping").await.expect("write_all failed"); + send.finish().expect("finish failed"); + let data = recv.read_to_end(1024).await.expect("read_to_end failed"); + assert_eq!( + String::from_utf8_lossy(&data), + "pong", + "server should receive pong" + ); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingBidirectionalStreams.getReader(); + const { value: stream } = await reader.read(); + + const sr = stream.readable.getReader(); + let msg = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + msg += new TextDecoder().decode(value); + } + + const sw = stream.writable.getWriter(); + await sw.write(new TextEncoder().encode("pong")); + await sw.close(); + wt.close(); + return { success: msg === "ping", message: "received: " + msg }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Edge Cases +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn write_to_closed_transport() { + init_tracing(); + let harness = harness::setup(harness::idle_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + + wt.close(); + // Wait for close to take effect + await wt.closed; + + try { + await writer.write(new TextEncoder().encode("after close")); + return { success: false, message: "write should have failed" }; + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + return { success: true, message: "write failed after close: " + e }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn read_from_cancelled_stream() { + init_tracing(); + let harness = harness::setup(harness::idle_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const reader = stream.readable.getReader(); + + await reader.cancel(); + + const { value, done } = await reader.read(); + wt.close(); + return { + success: done === true, + message: "done=" + done + " value=" + value + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/common/mod.rs b/web-transport-browser-tests/tests/common/mod.rs new file mode 100644 index 00000000..80872371 --- /dev/null +++ b/web-transport-browser-tests/tests/common/mod.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] + +use std::time::Duration; + +/// Maximum time a JS test snippet may run in Chrome before the harness aborts it. +/// Suitable for most tests (simple echo, error handling, connection lifecycle). +pub const TIMEOUT: Duration = Duration::from_secs(10); + +/// Extended timeout for JS test snippets that transfer large payloads, open many +/// streams, or establish multiple sequential sessions. +pub const LONG_TIMEOUT: Duration = Duration::from_secs(30); + +/// Enable `tracing` output filtered by `RUST_LOG`. Safe to call from every test — +/// `try_init` silently no-ops after the first successful initialization. +pub fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); +} diff --git a/web-transport-browser-tests/tests/concurrent.rs b/web-transport-browser-tests/tests/concurrent.rs new file mode 100644 index 00000000..f759bea6 --- /dev/null +++ b/web-transport-browser-tests/tests/concurrent.rs @@ -0,0 +1,589 @@ +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; + +mod common; +use common::{init_tracing, LONG_TIMEOUT, TIMEOUT}; + +// --------------------------------------------------------------------------- +// Multiple Concurrent Streams +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn multiple_bidi_streams_concurrent() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const N = 5; + + // Open N streams and write concurrently + const promises = []; + for (let i = 0; i < N; i++) { + promises.push((async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const msg = "stream" + i; + await writer.write(new TextEncoder().encode(msg)); + await writer.close(); + + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + return received; + })()); + } + + const results = await Promise.all(promises); + const expected = Array.from({ length: N }, (_, i) => "stream" + i); + results.sort(); + expected.sort(); + const ok = JSON.stringify(results) === JSON.stringify(expected); + wt.close(); + return { + success: ok, + message: "results: " + JSON.stringify(results) + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn multiple_streams_mixed_types() { + init_tracing(); + + // Handler that echoes bidi, verifies uni, and echoes datagrams + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + loop { + tokio::select! { + stream = session.accept_bi() => { + let Ok((mut send, mut recv)) = stream else { break }; + tokio::spawn(async move { + let buf = recv.read_to_end(1024 * 1024).await.expect("echo: read_to_end failed"); + send.write_all(&buf).await.expect("echo: write_all failed"); + send.finish().expect("echo: finish failed"); + }); + } + stream = session.accept_uni() => { + let Ok(mut recv) = stream else { break }; + tokio::spawn(async move { + let data = recv.read_to_end(1024 * 1024).await.expect("read_to_end failed"); + assert_eq!( + String::from_utf8_lossy(&data), + "uni", + "server should receive uni stream data" + ); + }); + } + datagram = session.read_datagram() => { + let Ok(data) = datagram else { break }; + session.send_datagram(data).expect("echo: send_datagram failed"); + } + } + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + + // Run bidi, uni, and datagram concurrently + const [bidiResult, uniResult, dgramResult] = await Promise.all([ + // Bidi echo + (async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + await writer.write(new TextEncoder().encode("bidi")); + await writer.close(); + let r = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + r += new TextDecoder().decode(value); + } + return r === "bidi"; + })(), + // Uni write + (async () => { + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + await writer.write(new TextEncoder().encode("uni")); + await writer.close(); + return true; + })(), + // Datagram echo + (async () => { + const writer = wt.datagrams.writable.getWriter(); + const reader = wt.datagrams.readable.getReader(); + await writer.write(new TextEncoder().encode("dg")); + const { value } = await reader.read(); + return new TextDecoder().decode(value) === "dg"; + })() + ]); + + // Give server time to receive uni stream data + await new Promise(r => setTimeout(r, 200)); + wt.close(); + return { + success: bidiResult && uniResult && dgramResult, + message: "bidi=" + bidiResult + " uni=" + uniResult + " dgram=" + dgramResult + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn many_streams_stress() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const N = 20; + const promises = []; + + for (let i = 0; i < N; i++) { + promises.push((async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const payload = new Uint8Array(64).fill(i); + await writer.write(payload); + await writer.close(); + + const chunks = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + } + const received = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let off = 0; + for (const c of chunks) { received.set(c, off); off += c.length; } + + if (received.length !== 64) return false; + for (let j = 0; j < 64; j++) { + if (received[j] !== i) return false; + } + return true; + })()); + } + + const results = await Promise.all(promises); + const allOk = results.every(r => r === true); + wt.close(); + return { + success: allOk, + message: results.filter(r => !r).length + " of " + N + " failed" + }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Large Data Transfer +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn large_bidi_stream_256kb() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const SIZE = 256 * 1024; + const sent = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) sent[i] = i % 251; + await writer.write(sent); + await writer.close(); + + const chunks = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + } + const received = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let off = 0; + for (const c of chunks) { received.set(c, off); off += c.length; } + + let ok = received.length === SIZE; + for (let i = 0; ok && i < SIZE; i += 1024) { + if (received[i] !== (i % 251)) ok = false; + } + wt.close(); + return { success: ok, message: "len=" + received.length }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn large_bidi_stream_1mb() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const SIZE = 1024 * 1024; + const sent = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) sent[i] = i % 251; + + // Write in chunks to avoid memory pressure + const CHUNK = 64 * 1024; + for (let off = 0; off < SIZE; off += CHUNK) { + await writer.write(sent.subarray(off, Math.min(off + CHUNK, SIZE))); + } + await writer.close(); + + const chunks = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + } + const received = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let off = 0; + for (const c of chunks) { received.set(c, off); off += c.length; } + + let ok = received.length === SIZE; + // Spot-check pattern every 4KB + for (let i = 0; ok && i < SIZE; i += 4096) { + if (received[i] !== (i % 251)) ok = false; + } + wt.close(); + return { success: ok, message: "len=" + received.length }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn large_uni_stream_client_to_server_1mb() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut recv = session.accept_uni().await.expect("accept_uni failed"); + let data = recv + .read_to_end(2 * 1024 * 1024) + .await + .expect("read_to_end failed"); + assert_eq!(data.len(), 1024 * 1024, "server should receive 1MB"); + // Spot-check pattern every 4KB: each byte = (index % 251) as u8 + for i in (0..data.len()).step_by(4096) { + assert_eq!(data[i], (i % 251) as u8, "data mismatch at byte {i}"); + } + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + + const SIZE = 1024 * 1024; + const data = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) data[i] = i % 251; + + const CHUNK = 64 * 1024; + for (let off = 0; off < SIZE; off += CHUNK) { + await writer.write(data.subarray(off, Math.min(off + CHUNK, SIZE))); + } + await writer.close(); + await new Promise(r => setTimeout(r, 500)); + wt.close(); + return { success: true, message: "sent 1MB via uni stream" }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Multiple Sessions +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn multiple_sessions_sequential() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const N = 20; + const echoes = []; + for (let i = 0; i < N; i++) { + const wt = await connectWebTransport(); + const s = await wt.createBidirectionalStream(); + const w = s.writable.getWriter(); + const r = s.readable.getReader(); + const msg = "session" + i; + await w.write(new TextEncoder().encode(msg)); + await w.close(); + let echo = ""; + while (true) { + const { value, done } = await r.read(); + if (done) break; + echo += new TextDecoder().decode(value); + } + echoes.push(echo); + wt.close(); + try { await wt.closed; } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + } + } + const expected = Array.from({ length: N }, (_, i) => "session" + i); + const ok = JSON.stringify(echoes) === JSON.stringify(expected); + return { success: ok, message: "echoes: " + JSON.stringify(echoes) }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown_expecting(20).await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn multiple_sessions_concurrent() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const N = 20; + const promises = []; + for (let i = 0; i < N; i++) { + promises.push((async () => { + const wt = await connectWebTransport(); + const s = await wt.createBidirectionalStream(); + const w = s.writable.getWriter(); + const r = s.readable.getReader(); + const msg = "sess-" + i; + await w.write(new TextEncoder().encode(msg)); + await w.close(); + let echo = ""; + while (true) { + const { value, done } = await r.read(); + if (done) break; + echo += new TextDecoder().decode(value); + } + wt.close(); + return { i, echo }; + })()); + } + const results = await Promise.all(promises); + const failed = results.filter(r => r.echo !== "sess-" + r.i); + return { + success: failed.length === 0, + message: failed.length + " of " + N + " failed: " + JSON.stringify(failed) + }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown_expecting(20).await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Bidirectional Open +// --------------------------------------------------------------------------- + +/// Both client and server open bidi streams concurrently. The server opens +/// N streams sending "s0"…"s{N-1}", while the client opens N streams sending +/// "c0"…"c{N-1}". Each side reads the peer's data and echoes it back. +#[tokio::test] +async fn bidirectional_open() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut tasks = tokio::task::JoinSet::new(); + let n = 5usize; + + // Server opens N bidi streams and sends data + for i in 0..n { + let session = session.clone(); + tasks.spawn(async move { + let (mut send, mut recv) = session.open_bi().await.expect("open_bi failed"); + let msg = format!("s{i}"); + send.write_all(msg.as_bytes()) + .await + .expect("write_all failed"); + send.finish().expect("finish failed"); + let data = recv.read_to_end(1024).await.expect("read_to_end failed"); + assert_eq!( + String::from_utf8_lossy(&data), + msg, + "server stream {i}: expected echo" + ); + }); + } + + // Server accepts N client-opened bidi streams and echoes them + for _ in 0..n { + match session.accept_bi().await { + Ok((mut send, mut recv)) => { + tasks.spawn(async move { + let data = + recv.read_to_end(1024).await.expect("read_to_end failed"); + send.write_all(&data).await.expect("write_all failed"); + send.finish().expect("finish failed"); + }); + } + Err(e) => panic!("accept_bi failed: {e}"), + } + } + + // Wait for all tasks, propagate panics + while let Some(result) = tasks.join_next().await { + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + } + + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const N = 5; + const errors = []; + + // Client opens N bidi streams and sends data + const clientOpenPromises = []; + for (let i = 0; i < N; i++) { + clientOpenPromises.push((async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const msg = "c" + i; + await writer.write(new TextEncoder().encode(msg)); + await writer.close(); + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + if (received !== msg) errors.push("client stream " + i + ": expected " + msg + " got " + received); + })()); + } + + // Client accepts N server-opened bidi streams and echoes them + const serverStreamReader = wt.incomingBidirectionalStreams.getReader(); + const clientAcceptPromises = []; + for (let i = 0; i < N; i++) { + clientAcceptPromises.push((async () => { + const { value: stream, done } = await serverStreamReader.read(); + if (done) { errors.push("incoming bidi stream ended early"); return; } + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + // Echo back what the server sent + await writer.write(new TextEncoder().encode(received)); + await writer.close(); + })()); + } + + await Promise.all([...clientOpenPromises, ...clientAcceptPromises]); + wt.close(); + return { + success: errors.length === 0, + message: errors.length === 0 ? "all streams OK" : errors.join("; ") + }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/connection.rs b/web-transport-browser-tests/tests/connection.rs new file mode 100644 index 00000000..04bfc3ab --- /dev/null +++ b/web-transport-browser-tests/tests/connection.rs @@ -0,0 +1,240 @@ +use std::time::Duration; + +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::{is_session_closed, RequestHandler, ServerHandler}; + +mod common; +use common::{init_tracing, TIMEOUT}; + +// --------------------------------------------------------------------------- +// Session Close +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn close_client_server_sees_closed() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + wt.close({ closeCode: 99, reason: "bye" }); + await wt.closed; + return { success: true, message: "client closed session" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +#[ignore = "close code/reason don't round-trip: library uses QUIC CONNECTION_CLOSE instead of CLOSE_WEBTRANSPORT_SESSION capsule"] +async fn close_client_with_code_and_reason() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let err = session.closed().await; + match err { + web_transport_quinn::SessionError::WebTransportError( + web_transport_quinn::WebTransportError::Closed(code, reason), + ) => { + assert_eq!(code, 99, "close code should round-trip"); + assert_eq!(reason, "bye", "close reason should round-trip"); + } + other => { + panic!("expected WebTransportError::Closed, got {other:?}"); + } + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + wt.close({ closeCode: 99, reason: "bye" }); + await wt.closed; + return { success: true, message: "closed with code 99" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn close_server_client_sees_closed() { + init_tracing(); + let harness = harness::setup(harness::immediate_close_handler(7, "server goodbye")) + .await + .unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + try { + await wt.closed; + // FIXME: this should not throw once proper close capsule is implemented + throw new Error("wt.closed should have rejected"); + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + } + return { success: true, message: "server closed session" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +#[ignore = "close code/reason don't round-trip: library uses QUIC CONNECTION_CLOSE instead of CLOSE_WEBTRANSPORT_SESSION capsule"] +async fn connection_closed_resolves_on_server_close() { + init_tracing(); + let harness = harness::setup(harness::immediate_close_handler(7, "server goodbye")) + .await + .unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const info = await wt.closed; + const ok = info.closeCode === 7 && info.reason === "server goodbye"; + return { + success: ok, + message: "closeCode=" + info.closeCode + " reason=" + JSON.stringify(info.reason) + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn close_server_while_streaming() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // Accept a bidi stream to confirm the client started streaming + session.accept_bi().await.expect("accept_bi failed"); + tokio::time::sleep(Duration::from_millis(200)).await; + session.close(55, b"mid-stream"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + try { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + + // Start writing but don't close — server will close the session + await writer.write(new TextEncoder().encode("streaming...")); + + // Wait for the session to close + try { + await wt.closed; + return { success: false, message: "wt.closed should have rejected" }; + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + } + + // Verify we can't write anymore + try { + await writer.write(new TextEncoder().encode("after close")); + return { success: false, message: "write after close should fail" }; + } catch (e) { + return { success: true, message: "write failed after server close: " + e }; + } + } catch (e) { + // Server close may race with stream setup/write — that's still a valid + // demonstration that the server close disrupted the client. + return { success: true, message: "server close interrupted streaming: " + e }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Edge Cases +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn session_rejection_by_server() { + init_tracing(); + + let handler: RequestHandler = Box::new(|request| { + Box::pin(async move { + request + .reject(http::StatusCode::NOT_FOUND) + .await + .expect("reject failed"); + }) + }); + + let harness = harness::setup_with_request_handler(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = new WebTransport(SERVER_URL, { + serverCertificateHashes: [{ + algorithm: "sha-256", + value: CERT_HASH, + }], + }); + try { + await wt.ready; + throw new Error("ready should have rejected"); + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + } + return { success: true, message: "session rejected" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/datagram.rs b/web-transport-browser-tests/tests/datagram.rs new file mode 100644 index 00000000..0e8b76c5 --- /dev/null +++ b/web-transport-browser-tests/tests/datagram.rs @@ -0,0 +1,170 @@ +use std::time::Duration; + +use bytes::Bytes; +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; + +mod common; +use common::{init_tracing, TIMEOUT}; + +#[tokio::test] +async fn datagram_echo() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const writer = wt.datagrams.writable.getWriter(); + const reader = wt.datagrams.readable.getReader(); + + await writer.write(new TextEncoder().encode("dgram hello")); + const { value } = await reader.read(); + const received = new TextDecoder().decode(value); + wt.close(); + return { + success: received === "dgram hello", + message: "echoed: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn datagram_binary_data() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const writer = wt.datagrams.writable.getWriter(); + const reader = wt.datagrams.readable.getReader(); + + const sent = new Uint8Array([0, 128, 255, 42]); + await writer.write(sent); + const { value } = await reader.read(); + + const ok = value.length === 4 && + value[0] === 0 && value[1] === 128 && + value[2] === 255 && value[3] === 42; + wt.close(); + return { success: ok, message: "received: [" + Array.from(value) + "]" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn datagram_multiple_roundtrips() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const writer = wt.datagrams.writable.getWriter(); + const reader = wt.datagrams.readable.getReader(); + + // Send 10 datagrams + for (let i = 0; i < 10; i++) { + await writer.write(new Uint8Array([i])); + } + + // Wait for a single echoed datagram + const { value } = await reader.read(); + const valid = value.length === 1 && value[0] >= 0 && value[0] <= 9; + reader.releaseLock(); + wt.close(); + return { + success: valid, + message: "received datagram with value " + Array.from(value) + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn datagram_max_size() { + init_tracing(); + let harness = harness::setup(harness::idle_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const maxSize = wt.datagrams.maxDatagramSize; + wt.close(); + return { + success: typeof maxSize === "number" && maxSize >= 500, + message: "maxDatagramSize=" + maxSize + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn datagram_server_initiated() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // Small delay to let the client set up a datagram reader + tokio::time::sleep(Duration::from_millis(100)).await; + session + .send_datagram(Bytes::from_static(b"server dgram")) + .expect("send_datagram failed"); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.datagrams.readable.getReader(); + const { value } = await reader.read(); + const received = new TextDecoder().decode(value); + wt.close(); + return { + success: received === "server dgram", + message: "received: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/stream_error.rs b/web-transport-browser-tests/tests/stream_error.rs new file mode 100644 index 00000000..bb5464ce --- /dev/null +++ b/web-transport-browser-tests/tests/stream_error.rs @@ -0,0 +1,785 @@ +use std::time::Duration; + +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; +use web_transport_quinn::{ReadError, WriteError}; + +mod common; +use common::{init_tracing, TIMEOUT}; + +#[tokio::test] +async fn stream_client_abort_sends_reset() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let code = recv.received_reset().await.ok().flatten(); + assert_eq!( + code, + Some(42), + "server should receive RESET_STREAM with code 42" + ); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + + // Construct WebTransportError — try both (message, init) and (init) forms + let err = new WebTransportError({ message: "abort", streamErrorCode: 42 }); + await writer.abort(err); + wt.close(); + return { success: true, message: "writer aborted with code 42" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn stream_client_cancel_sends_stop_sending() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let code = send.stopped().await.ok().flatten(); + assert_eq!( + code, + Some(77), + "server should receive STOP_SENDING with code 77" + ); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const reader = stream.readable.getReader(); + + // Construct WebTransportError — try both (message, init) and (init) forms + let err = new WebTransportError({ message: "cancel", streamErrorCode: 77 }); + await reader.cancel(err); + wt.close(); + return { success: true, message: "reader cancelled with code 77" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn stream_client_reset_server_reader_errors() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let mut buf = [0u8; 1024]; + loop { + match recv.read(&mut buf).await { + Ok(Some(_)) => continue, + Ok(None) => panic!("expected reset, got clean finish"), + Err(ReadError::Reset(code)) => { + assert_eq!(code, 42, "reset code should be 42"); + break; + } + Err(e) => panic!("unexpected read error: {e}"), + } + } + session.close(0, b""); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + + await writer.write(new TextEncoder().encode("some data")); + let err = new WebTransportError({ message: "abort", streamErrorCode: 42 }); + await writer.abort(err); + // Wait for the server to observe the reset and close the session + try { + await wt.closed; + throw new Error("wt.closed should have rejected"); + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + } + return { success: true, message: "writer aborted with code 42" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn stream_client_stop_server_writer_errors() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + // Keep writing until we get a Stopped error from the client's cancel + let chunk = vec![0u8; 1024]; + loop { + match send.write_all(&chunk).await { + Ok(()) => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(WriteError::Stopped(code)) => { + assert_eq!(code, 77, "stop code should be 77"); + break; + } + Err(e) => panic!("unexpected write error: {e}"), + } + } + session.close(0, b""); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + // Write to trigger server accept + await writer.write(new Uint8Array([1])); + // Read to confirm the server has started writing + await reader.read(); + + let err = new WebTransportError({ message: "cancel", streamErrorCode: 77 }); + await reader.cancel(err); + // Wait for the server to observe the stop and close the session + try { + await wt.closed; + throw new Error("wt.closed should have rejected"); + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + } + return { success: true, message: "reader cancelled with code 77" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn stream_server_reset_client_reader_errors() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + // Read to wait until the client starts + let mut buf = [0u8; 1024]; + recv.read(&mut buf).await.expect("read failed"); + send.reset(33).expect("reset failed"); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + try { + const stream = await wt.createBidirectionalStream(); + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1])); + + while (true) { + const { done } = await reader.read(); + if (done) { + wt.close(); + return { success: false, message: "expected reader to error on reset" }; + } + } + } catch (e) { + const isWTE = e instanceof WebTransportError; + const code = isWTE ? e.streamErrorCode : null; + wt.close(); + return { + success: isWTE && e.source === "stream" && code === 33, + message: "reader errored: isWebTransportError=" + isWTE + " code=" + code + " " + e, + details: { streamErrorCode: code } + }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn stream_server_stop_client_writer_errors() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + // Read so QUIC acknowledges the stream before calling stop() + let mut buf = [0u8; 1024]; + recv.read(&mut buf).await.expect("read failed"); + recv.stop(88).expect("stop failed"); + // small delay to make sure stop propagates + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + try { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1])); + + // Keep writing until we get an error from STOP_SENDING + for (let i = 0; i < 100; i++) { + await writer.write(new Uint8Array(1024)); + await new Promise(r => setTimeout(r, 10)); + } + wt.close(); + return { success: false, message: "expected writer to error on stop" }; + } catch (e) { + const isWTE = e instanceof WebTransportError; + const code = isWTE ? e.streamErrorCode : null; + wt.close(); + return { + success: isWTE && e.source === "stream" && code === 88, + message: "writer errored: isWebTransportError=" + isWTE + " code=" + code + " " + e, + details: { streamErrorCode: code } + }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Connection close interrupts stream I/O +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn client_close_interrupts_server_read() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let mut buf = [0u8; 1024]; + loop { + match recv.read(&mut buf).await { + Ok(Some(_)) => continue, + Ok(None) => panic!("expected connection error, got clean finish"), + Err(ReadError::SessionError(ref e)) if is_session_closed(e) => break, + Err(e) => panic!("expected session closed, got {e}"), + } + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new TextEncoder().encode("some data")); + wt.close(); + await wt.closed; + return { success: true, message: "client closed while server was reading" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn client_close_interrupts_server_write() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + // Read to confirm the client has opened the stream + let mut buf = [0u8; 1024]; + recv.read(&mut buf).await.expect("read failed"); + let chunk = vec![0u8; 1024]; + loop { + match send.write_all(&chunk).await { + Ok(()) => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(WriteError::SessionError(ref e)) if is_session_closed(e) => break, + Err(e) => panic!("expected session closed, got {e}"), + } + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + // Write to trigger server accept + await writer.write(new Uint8Array([1])); + // Small delay so the server starts writing + await new Promise(r => setTimeout(r, 100)); + wt.close(); + await wt.closed; + return { success: true, message: "client closed while server was writing" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_close_interrupts_client_read() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + // Read to confirm the client has started + let mut buf = [0u8; 1024]; + recv.read(&mut buf).await.expect("read failed"); + session.close(0, b""); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + // Write to trigger server accept + await writer.write(new Uint8Array([1])); + try { + while (true) { + const { done } = await reader.read(); + if (done) { + return { success: false, message: "expected error, got clean finish" }; + } + } + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + return { success: true, message: "read interrupted by server close: " + e }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_close_interrupts_client_write() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + // Read to confirm the client has started + let mut buf = [0u8; 1024]; + recv.read(&mut buf).await.expect("read failed"); + session.close(0, b""); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + // Write to trigger server accept + await writer.write(new Uint8Array([1])); + try { + for (let i = 0; i < 100; i++) { + await writer.write(new Uint8Array(1024)); + await new Promise(r => setTimeout(r, 10)); + } + return { success: false, message: "expected error on write" }; + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + return { success: true, message: "write interrupted by server close: " + e }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Connection close interrupts accept +// --------------------------------------------------------------------------- + +#[tokio::test] +#[ignore = "flaky"] +async fn client_close_interrupts_server_accept_bi() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // First accept succeeds — client opened one stream before closing + session.accept_bi().await.expect("first accept_bi failed"); + // Second accept should fail with a session close error + let err = session.accept_bi().await.unwrap_err(); + assert!( + is_session_closed(&err), + "expected session closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1])); + wt.close(); + await wt.closed; + return { success: true, message: "client closed while server was accepting bidi" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +#[ignore = "flaky"] +async fn client_close_interrupts_server_accept_uni() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // First accept succeeds — client opened one stream before closing + session.accept_uni().await.expect("first accept_uni failed"); + // Second accept should fail with a session close error + let err = session.accept_uni().await.unwrap_err(); + assert!( + is_session_closed(&err), + "expected session closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + await writer.write(new Uint8Array([1])); + await writer.close(); + wt.close(); + await wt.closed; + return { success: true, message: "client closed while server was accepting uni" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_close_interrupts_client_accept_bi() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // Small delay so the client starts waiting on incomingBidirectionalStreams + tokio::time::sleep(Duration::from_millis(100)).await; + session.close(0, b""); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingBidirectionalStreams.getReader(); + try { + const { done } = await reader.read(); + if (done) { + return { success: true, message: "incomingBidirectionalStreams closed" }; + } + return { success: false, message: "expected stream to end, got a value" }; + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + return { success: true, message: "accept bidi interrupted by server close: " + e }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_close_interrupts_client_accept_uni() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // Small delay so the client starts waiting on incomingUnidirectionalStreams + tokio::time::sleep(Duration::from_millis(100)).await; + session.close(0, b""); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingUnidirectionalStreams.getReader(); + try { + const { done } = await reader.read(); + if (done) { + return { success: true, message: "incomingUnidirectionalStreams closed" }; + } + return { success: false, message: "expected stream to end, got a value" }; + } catch (e) { + if (!(e instanceof WebTransportError) || e.source !== "session") throw e; + return { success: true, message: "accept uni interrupted by server close: " + e }; + } + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Stream isolation +// --------------------------------------------------------------------------- + +/// Opens 3 bidi streams on one session. Stream 0 is echoed normally, stream 1 +/// is reset by the client, stream 2 is reset by the server. Verifies that the +/// resets on streams 1 and 2 do not disturb stream 0. +#[tokio::test] +async fn stream_reset_does_not_affect_other_streams() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut tasks = tokio::task::JoinSet::new(); + loop { + match session.accept_bi().await { + Ok((mut send, mut recv)) => { + tasks.spawn(async move { + match recv.read_to_end(1024 * 1024).await { + Ok(data) if data == b"reset-this" => { + send.reset(33).expect("reset failed"); + } + Ok(data) => { + send.write_all(&data) + .await + .expect("echo: write_all failed"); + send.finish().expect("echo: finish failed"); + } + Err(web_transport_quinn::ReadToEndError::ReadError( + ReadError::Reset(_), + )) => { + // Client reset this stream — expected + } + Err(e) => panic!("unexpected read error: {e}"), + } + }); + } + Err(e) if is_session_closed(&e) => break, + Err(e) => panic!("accept_bi failed: {e}"), + } + } + while let Some(result) = tasks.join_next().await { + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + + const [echoResult, clientResetResult, serverResetResult] = await Promise.allSettled([ + // Stream 0: normal echo + (async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + await writer.write(new TextEncoder().encode("stream0")); + await writer.close(); + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + return received; + })(), + // Stream 1: client resets writer + (async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new TextEncoder().encode("data")); + const err = new WebTransportError({ message: "abort", streamErrorCode: 42 }); + await writer.abort(err); + })(), + // Stream 2: server resets after reading + (async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + await writer.write(new TextEncoder().encode("reset-this")); + await writer.close(); + while (true) { + const { done } = await reader.read(); + if (done) throw new Error("expected reset, got clean finish"); + } + })() + ]); + + wt.close(); + + const echoOk = echoResult.status === "fulfilled" && echoResult.value === "stream0"; + const clientResetOk = clientResetResult.status === "fulfilled"; + const srErr = serverResetResult.reason; + const serverResetOk = serverResetResult.status === "rejected" + && srErr instanceof WebTransportError + && srErr.source === "stream" + && srErr.streamErrorCode === 33; + + return { + success: echoOk && clientResetOk && serverResetOk, + message: "echo=" + echoOk + " clientReset=" + clientResetOk + + " serverReset=" + serverResetOk + + " echoVal=" + JSON.stringify(echoResult.value) + + " srSource=" + (srErr instanceof WebTransportError ? srErr.source : "N/A") + + " srCode=" + (srErr instanceof WebTransportError ? srErr.streamErrorCode : "N/A") + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/uni_stream.rs b/web-transport-browser-tests/tests/uni_stream.rs new file mode 100644 index 00000000..75cd2c9d --- /dev/null +++ b/web-transport-browser-tests/tests/uni_stream.rs @@ -0,0 +1,295 @@ +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; + +mod common; +use common::{init_tracing, LONG_TIMEOUT, TIMEOUT}; + +// --------------------------------------------------------------------------- +// Client-Initiated +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn uni_stream_client_to_server() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut recv = session.accept_uni().await.expect("accept_uni failed"); + let data = recv + .read_to_end(1024 * 1024) + .await + .expect("read_to_end failed"); + assert_eq!( + String::from_utf8_lossy(&data), + "uni data", + "server should receive 'uni data'" + ); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + await writer.write(new TextEncoder().encode("uni data")); + await writer.close(); + wt.close(); + return { success: true, message: "uni stream sent" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn uni_stream_client_to_server_multiple() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut collected = Vec::new(); + for _ in 0..3 { + let mut recv = session.accept_uni().await.expect("accept_uni failed"); + let data = recv.read_to_end(1024).await.expect("read_to_end failed"); + collected.push(String::from_utf8_lossy(&data).into_owned()); + } + assert_eq!(collected.len(), 3, "server should receive 3 messages"); + collected.sort(); + assert_eq!(collected, vec!["msg0", "msg1", "msg2"]); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + for (let i = 0; i < 3; i++) { + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + await writer.write(new TextEncoder().encode("msg" + i)); + await writer.close(); + } + wt.close(); + return { success: true, message: "3 uni streams sent" }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn uni_stream_client_large_payload() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut recv = session.accept_uni().await.expect("accept_uni failed"); + let data = recv + .read_to_end(2 * 1024 * 1024) + .await + .expect("read_to_end failed"); + assert_eq!(data.len(), 1024 * 1024, "server should receive 1MB"); + // Spot-check pattern every 4KB: each byte = (index % 251) as u8 + for i in (0..data.len()).step_by(4096) { + assert_eq!(data[i], (i % 251) as u8, "data mismatch at byte {i}"); + } + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + + const SIZE = 1024 * 1024; + const data = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) data[i] = i % 251; + + const CHUNK = 64 * 1024; + for (let off = 0; off < SIZE; off += CHUNK) { + await writer.write(data.subarray(off, Math.min(off + CHUNK, SIZE))); + } + await writer.close(); + wt.close(); + return { success: true, message: "sent 1MB via uni stream" }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Server-Initiated +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn uni_stream_server_to_client() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut send = session.open_uni().await.expect("open_uni failed"); + send.write_all(b"server uni") + .await + .expect("write_all failed"); + send.finish().expect("finish failed"); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingUnidirectionalStreams.getReader(); + const { value: stream } = await reader.read(); + + const sr = stream.getReader(); + let received = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + wt.close(); + return { + success: received === "server uni", + message: "received: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn uni_stream_server_to_client_multiple() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + for i in 0u8..3 { + let mut send = session.open_uni().await.expect("open_uni failed"); + send.write_all(&[i]).await.expect("write_all failed"); + send.finish().expect("finish failed"); + } + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingUnidirectionalStreams.getReader(); + const values = []; + for (let i = 0; i < 3; i++) { + const { value: stream, done } = await reader.read(); + if (done) break; + const sr = stream.getReader(); + const { value } = await sr.read(); + values.push(value[0]); + } + wt.close(); + values.sort((a, b) => a - b); + const ok = values.length === 3 && values[0] === 0 && values[1] === 1 && values[2] === 2; + return { success: ok, message: "values: " + JSON.stringify(values) }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn uni_stream_server_large_payload() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut send = session.open_uni().await.expect("open_uni failed"); + // 64KB of pattern data: each byte = (index % 251) as u8 + let data: Vec = (0..65536).map(|i| (i % 251) as u8).collect(); + send.write_all(&data).await.expect("write_all failed"); + send.finish().expect("finish failed"); + let err = session.closed().await; + assert!(is_session_closed(&err), "unexpected session error: {err}"); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingUnidirectionalStreams.getReader(); + const { value: stream } = await reader.read(); + const sr = stream.getReader(); + + const chunks = []; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + chunks.push(value); + } + const received = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let off = 0; + for (const c of chunks) { received.set(c, off); off += c.length; } + + let ok = received.length === 65536; + for (let i = 0; ok && i < received.length; i++) { + if (received[i] !== (i % 251)) { ok = false; } + } + wt.close(); + return { success: ok, message: "len=" + received.length }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} From 451f37fca18a22565ccae8cf408d72bf3d7c5721 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:08:26 -0800 Subject: [PATCH 03/18] Update browser tests to use W3C-compliant session close Now that CloseWebTransportSession capsule is implemented, update the browser test suite to reflect proper close behavior: remove #[ignore] annotations from passing tests, await session.closed() to ensure capsule delivery, keep stream references to prevent early cancellation, and improve JS error reporting with stack traces. Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/js.rs | 3 ++- web-transport-browser-tests/src/server.rs | 8 ++++++++ .../tests/concurrent.rs | 3 +-- .../tests/connection.rs | 20 ++++--------------- .../tests/stream_error.rs | 12 +++++------ 5 files changed, 20 insertions(+), 26 deletions(-) diff --git a/web-transport-browser-tests/src/js.rs b/web-transport-browser-tests/src/js.rs index 94dd3265..100f4e84 100644 --- a/web-transport-browser-tests/src/js.rs +++ b/web-transport-browser-tests/src/js.rs @@ -34,9 +34,10 @@ pub fn wrap_test_js(server_url: &str, certificate_hash: &[u8], user_code: &str) }} return JSON.stringify(result); }} catch (e) {{ + const msg = "JS exception: " + (e.stack ? e.stack : e.toString()); return JSON.stringify({{ success: false, - message: e.toString(), + message: msg, details: {{ name: e.name, stack: e.stack }} }}); }} diff --git a/web-transport-browser-tests/src/server.rs b/web-transport-browser-tests/src/server.rs index 97057d6a..a48f0237 100644 --- a/web-transport-browser-tests/src/server.rs +++ b/web-transport-browser-tests/src/server.rs @@ -180,6 +180,7 @@ pub fn is_session_closed(e: &web_transport_quinn::SessionError) -> bool { e, ConnectionError(web_transport_quinn::quinn::ConnectionError::ApplicationClosed(_)) | ConnectionError(web_transport_quinn::quinn::ConnectionError::LocallyClosed) + | WebTransportError(web_transport_quinn::WebTransportError::Closed(_, _)) ) } @@ -187,7 +188,14 @@ pub fn is_session_closed(e: &web_transport_quinn::SessionError) -> bool { pub fn immediate_close_handler(code: u32, reason: &'static str) -> ServerHandler { Box::new(move |session| { Box::pin(async move { + // Give the browser a bit of time to finish establishing the session. + // Without this, browser sometimes throws WebTransportError instead of + // cleanly closing the session. + tokio::time::sleep(Duration::from_millis(500)).await; session.close(code, reason.as_bytes()); + // Wait for the connection to actually close. + // This ensures the CloseWebTransportSession capsule is delivered. + session.closed().await; }) }) } diff --git a/web-transport-browser-tests/tests/concurrent.rs b/web-transport-browser-tests/tests/concurrent.rs index f759bea6..990221eb 100644 --- a/web-transport-browser-tests/tests/concurrent.rs +++ b/web-transport-browser-tests/tests/concurrent.rs @@ -498,8 +498,7 @@ async fn bidirectional_open() { match session.accept_bi().await { Ok((mut send, mut recv)) => { tasks.spawn(async move { - let data = - recv.read_to_end(1024).await.expect("read_to_end failed"); + let data = recv.read_to_end(1024).await.expect("read_to_end failed"); send.write_all(&data).await.expect("write_all failed"); send.finish().expect("finish failed"); }); diff --git a/web-transport-browser-tests/tests/connection.rs b/web-transport-browser-tests/tests/connection.rs index 04bfc3ab..755befbf 100644 --- a/web-transport-browser-tests/tests/connection.rs +++ b/web-transport-browser-tests/tests/connection.rs @@ -41,7 +41,6 @@ async fn close_client_server_sees_closed() { } #[tokio::test] -#[ignore = "close code/reason don't round-trip: library uses QUIC CONNECTION_CLOSE instead of CLOSE_WEBTRANSPORT_SESSION capsule"] async fn close_client_with_code_and_reason() { init_tracing(); @@ -92,13 +91,7 @@ async fn close_server_client_sees_closed() { .run_js( r#" const wt = await connectWebTransport(); - try { - await wt.closed; - // FIXME: this should not throw once proper close capsule is implemented - throw new Error("wt.closed should have rejected"); - } catch (e) { - if (!(e instanceof WebTransportError) || e.source !== "session") throw e; - } + await wt.closed; return { success: true, message: "server closed session" }; "#, TIMEOUT, @@ -111,7 +104,6 @@ async fn close_server_client_sees_closed() { } #[tokio::test] -#[ignore = "close code/reason don't round-trip: library uses QUIC CONNECTION_CLOSE instead of CLOSE_WEBTRANSPORT_SESSION capsule"] async fn connection_closed_resolves_on_server_close() { init_tracing(); let harness = harness::setup(harness::immediate_close_handler(7, "server goodbye")) @@ -148,6 +140,7 @@ async fn close_server_while_streaming() { session.accept_bi().await.expect("accept_bi failed"); tokio::time::sleep(Duration::from_millis(200)).await; session.close(55, b"mid-stream"); + session.closed().await; }) }); @@ -164,13 +157,8 @@ async fn close_server_while_streaming() { // Start writing but don't close — server will close the session await writer.write(new TextEncoder().encode("streaming...")); - // Wait for the session to close - try { - await wt.closed; - return { success: false, message: "wt.closed should have rejected" }; - } catch (e) { - if (!(e instanceof WebTransportError) || e.source !== "session") throw e; - } + // Wait for the session to close. + await wt.closed; // Verify we can't write anymore try { diff --git a/web-transport-browser-tests/tests/stream_error.rs b/web-transport-browser-tests/tests/stream_error.rs index bb5464ce..f446038d 100644 --- a/web-transport-browser-tests/tests/stream_error.rs +++ b/web-transport-browser-tests/tests/stream_error.rs @@ -502,14 +502,14 @@ async fn server_close_interrupts_client_write() { // --------------------------------------------------------------------------- #[tokio::test] -#[ignore = "flaky"] async fn client_close_interrupts_server_accept_bi() { init_tracing(); let handler: ServerHandler = Box::new(|session| { Box::pin(async move { // First accept succeeds — client opened one stream before closing - session.accept_bi().await.expect("first accept_bi failed"); + // Keep references to streams to avoid early cancellation + let _s1 = session.accept_bi().await.expect("first accept_bi failed"); // Second accept should fail with a session close error let err = session.accept_bi().await.unwrap_err(); assert!( @@ -542,14 +542,14 @@ async fn client_close_interrupts_server_accept_bi() { } #[tokio::test] -#[ignore = "flaky"] async fn client_close_interrupts_server_accept_uni() { init_tracing(); let handler: ServerHandler = Box::new(|session| { Box::pin(async move { // First accept succeeds — client opened one stream before closing - session.accept_uni().await.expect("first accept_uni failed"); + // Keep references to streams to avoid early cancellation + let _s1 = session.accept_uni().await.expect("first accept_uni failed"); // Second accept should fail with a session close error let err = session.accept_uni().await.unwrap_err(); assert!( @@ -683,9 +683,7 @@ async fn stream_reset_does_not_affect_other_streams() { send.reset(33).expect("reset failed"); } Ok(data) => { - send.write_all(&data) - .await - .expect("echo: write_all failed"); + send.write_all(&data).await.expect("echo: write_all failed"); send.finish().expect("echo: finish failed"); } Err(web_transport_quinn::ReadToEndError::ReadError( From a5e1efe76e517343156563a558333f319c3df38b Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:17:03 -0800 Subject: [PATCH 04/18] Validate close code and reason propagation in browser tests Assert WebTransportError::Closed(_, _) directly in all browser tests instead of using the is_session_closed() helper, ensuring the close capsule info is accurately surfaced through every error path. Co-Authored-By: Claude Opus 4.6 --- .../tests/bidi_stream.rs | 18 ++++-- .../tests/concurrent.rs | 13 +++- .../tests/connection.rs | 22 +++++-- web-transport-browser-tests/tests/datagram.rs | 8 ++- .../tests/stream_error.rs | 60 +++++++++++++++---- .../tests/uni_stream.rs | 33 +++++++--- 6 files changed, 121 insertions(+), 33 deletions(-) diff --git a/web-transport-browser-tests/tests/bidi_stream.rs b/web-transport-browser-tests/tests/bidi_stream.rs index 863e18d6..687df698 100644 --- a/web-transport-browser-tests/tests/bidi_stream.rs +++ b/web-transport-browser-tests/tests/bidi_stream.rs @@ -1,5 +1,6 @@ use web_transport_browser_tests::harness; -use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; +use web_transport_browser_tests::server::ServerHandler; +use web_transport_quinn::{SessionError, WebTransportError}; mod common; use common::{init_tracing, TIMEOUT}; @@ -177,7 +178,10 @@ async fn bidi_stream_server_initiated() { .expect("write_all failed"); send.finish().expect("finish failed"); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -225,7 +229,10 @@ async fn bidi_stream_server_initiated_multiple() { send.finish().expect("finish failed"); } let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -274,7 +281,10 @@ async fn bidi_stream_server_initiated_bidirectional_exchange() { "server should receive pong" ); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); diff --git a/web-transport-browser-tests/tests/concurrent.rs b/web-transport-browser-tests/tests/concurrent.rs index 990221eb..b00598f1 100644 --- a/web-transport-browser-tests/tests/concurrent.rs +++ b/web-transport-browser-tests/tests/concurrent.rs @@ -1,5 +1,6 @@ use web_transport_browser_tests::harness; -use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; +use web_transport_browser_tests::server::ServerHandler; +use web_transport_quinn::{SessionError, WebTransportError}; mod common; use common::{init_tracing, LONG_TIMEOUT, TIMEOUT}; @@ -331,7 +332,10 @@ async fn large_uni_stream_client_to_server_1mb() { assert_eq!(data[i], (i % 251) as u8, "data mismatch at byte {i}"); } let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -517,7 +521,10 @@ async fn bidirectional_open() { } let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); diff --git a/web-transport-browser-tests/tests/connection.rs b/web-transport-browser-tests/tests/connection.rs index 755befbf..02a6b980 100644 --- a/web-transport-browser-tests/tests/connection.rs +++ b/web-transport-browser-tests/tests/connection.rs @@ -1,7 +1,8 @@ use std::time::Duration; use web_transport_browser_tests::harness; -use web_transport_browser_tests::server::{is_session_closed, RequestHandler, ServerHandler}; +use web_transport_browser_tests::server::{RequestHandler, ServerHandler}; +use web_transport_quinn::{SessionError, WebTransportError}; mod common; use common::{init_tracing, TIMEOUT}; @@ -17,7 +18,13 @@ async fn close_client_server_sees_closed() { let handler: ServerHandler = Box::new(|session| { Box::pin(async move { let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -137,10 +144,17 @@ async fn close_server_while_streaming() { let handler: ServerHandler = Box::new(|session| { Box::pin(async move { // Accept a bidi stream to confirm the client started streaming - session.accept_bi().await.expect("accept_bi failed"); + let _s1 = session.accept_bi().await.expect("accept_bi failed"); tokio::time::sleep(Duration::from_millis(200)).await; session.close(55, b"mid-stream"); - session.closed().await; + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); }) }); diff --git a/web-transport-browser-tests/tests/datagram.rs b/web-transport-browser-tests/tests/datagram.rs index 0e8b76c5..ccfec477 100644 --- a/web-transport-browser-tests/tests/datagram.rs +++ b/web-transport-browser-tests/tests/datagram.rs @@ -2,7 +2,8 @@ use std::time::Duration; use bytes::Bytes; use web_transport_browser_tests::harness; -use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; +use web_transport_browser_tests::server::ServerHandler; +use web_transport_quinn::{SessionError, WebTransportError}; mod common; use common::{init_tracing, TIMEOUT}; @@ -141,7 +142,10 @@ async fn datagram_server_initiated() { .send_datagram(Bytes::from_static(b"server dgram")) .expect("send_datagram failed"); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); diff --git a/web-transport-browser-tests/tests/stream_error.rs b/web-transport-browser-tests/tests/stream_error.rs index f446038d..9d235ab2 100644 --- a/web-transport-browser-tests/tests/stream_error.rs +++ b/web-transport-browser-tests/tests/stream_error.rs @@ -1,8 +1,8 @@ use std::time::Duration; use web_transport_browser_tests::harness; -use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; -use web_transport_quinn::{ReadError, WriteError}; +use web_transport_browser_tests::server::ServerHandler; +use web_transport_quinn::{ReadError, SessionError, WebTransportError, WriteError}; mod common; use common::{init_tracing, TIMEOUT}; @@ -21,7 +21,13 @@ async fn stream_client_abort_sends_reset() { "server should receive RESET_STREAM with code 42" ); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -63,7 +69,13 @@ async fn stream_client_cancel_sends_stop_sending() { "server should receive STOP_SENDING with code 77" ); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -216,7 +228,13 @@ async fn stream_server_reset_client_reader_errors() { recv.read(&mut buf).await.expect("read failed"); send.reset(33).expect("reset failed"); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -272,7 +290,13 @@ async fn stream_server_stop_client_writer_errors() { recv.stop(88).expect("stop failed"); // small delay to make sure stop propagates let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -330,7 +354,9 @@ async fn client_close_interrupts_server_read() { match recv.read(&mut buf).await { Ok(Some(_)) => continue, Ok(None) => panic!("expected connection error, got clean finish"), - Err(ReadError::SessionError(ref e)) if is_session_closed(e) => break, + Err(ReadError::SessionError(SessionError::WebTransportError( + WebTransportError::Closed(_, _), + ))) => break, Err(e) => panic!("expected session closed, got {e}"), } } @@ -375,7 +401,9 @@ async fn client_close_interrupts_server_write() { Ok(()) => { tokio::time::sleep(Duration::from_millis(10)).await; } - Err(WriteError::SessionError(ref e)) if is_session_closed(e) => break, + Err(WriteError::SessionError(SessionError::WebTransportError( + WebTransportError::Closed(_, _), + ))) => break, Err(e) => panic!("expected session closed, got {e}"), } } @@ -513,8 +541,11 @@ async fn client_close_interrupts_server_accept_bi() { // Second accept should fail with a session close error let err = session.accept_bi().await.unwrap_err(); assert!( - is_session_closed(&err), - "expected session closed, got {err}" + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" ); }) }); @@ -553,8 +584,11 @@ async fn client_close_interrupts_server_accept_uni() { // Second accept should fail with a session close error let err = session.accept_uni().await.unwrap_err(); assert!( - is_session_closed(&err), - "expected session closed, got {err}" + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" ); }) }); @@ -695,7 +729,7 @@ async fn stream_reset_does_not_affect_other_streams() { } }); } - Err(e) if is_session_closed(&e) => break, + Err(SessionError::WebTransportError(WebTransportError::Closed(_, _))) => break, Err(e) => panic!("accept_bi failed: {e}"), } } diff --git a/web-transport-browser-tests/tests/uni_stream.rs b/web-transport-browser-tests/tests/uni_stream.rs index 75cd2c9d..79f15b73 100644 --- a/web-transport-browser-tests/tests/uni_stream.rs +++ b/web-transport-browser-tests/tests/uni_stream.rs @@ -1,5 +1,6 @@ use web_transport_browser_tests::harness; -use web_transport_browser_tests::server::{is_session_closed, ServerHandler}; +use web_transport_browser_tests::server::ServerHandler; +use web_transport_quinn::{SessionError, WebTransportError}; mod common; use common::{init_tracing, LONG_TIMEOUT, TIMEOUT}; @@ -25,7 +26,10 @@ async fn uni_stream_client_to_server() { "server should receive 'uni data'" ); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -67,7 +71,10 @@ async fn uni_stream_client_to_server_multiple() { collected.sort(); assert_eq!(collected, vec!["msg0", "msg1", "msg2"]); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -112,7 +119,10 @@ async fn uni_stream_client_large_payload() { assert_eq!(data[i], (i % 251) as u8, "data mismatch at byte {i}"); } let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -162,7 +172,10 @@ async fn uni_stream_server_to_client() { .expect("write_all failed"); send.finish().expect("finish failed"); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -209,7 +222,10 @@ async fn uni_stream_server_to_client_multiple() { send.finish().expect("finish failed"); } let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); @@ -254,7 +270,10 @@ async fn uni_stream_server_large_payload() { send.write_all(&data).await.expect("write_all failed"); send.finish().expect("finish failed"); let err = session.closed().await; - assert!(is_session_closed(&err), "unexpected session error: {err}"); + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); }) }); From 7b621fbe2bc7a1dfbcefa3776ed91c84c2988557 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:37:00 -0800 Subject: [PATCH 05/18] Add extended browser test coverage for streams, datagrams, and error handling Tests cover half-close semantics, stream priorities, rapid stream creation, concurrent bidi/uni streams, session stats, datagram edge cases (max size, oversized, empty, high water marks), and post-close/reset/stop error behavior. Co-Authored-By: Claude Opus 4.6 --- .../tests/bidi_stream.rs | 129 ++++++ .../tests/concurrent.rs | 181 +++++++++ .../tests/connection.rs | 311 ++++++++++++++- web-transport-browser-tests/tests/datagram.rs | 143 +++++++ .../tests/stream_error.rs | 370 +++++++++++++++++- 5 files changed, 1130 insertions(+), 4 deletions(-) diff --git a/web-transport-browser-tests/tests/bidi_stream.rs b/web-transport-browser-tests/tests/bidi_stream.rs index 687df698..2839df3f 100644 --- a/web-transport-browser-tests/tests/bidi_stream.rs +++ b/web-transport-browser-tests/tests/bidi_stream.rs @@ -386,3 +386,132 @@ async fn read_from_cancelled_stream() { let result = result.unwrap(); assert!(result.success, "{}", result.message); } + +// --------------------------------------------------------------------------- +// Half-Close & Priority +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn bidi_stream_half_close_write_then_read() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let data = recv + .read_to_end(64 * 1024) + .await + .expect("read_to_end failed"); + // Double the data to make the half-close assertion unambiguous + let mut doubled = data.clone(); + doubled.extend_from_slice(&data); + send.write_all(&doubled).await.expect("write_all failed"); + send.finish().expect("finish failed"); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + await writer.write(new TextEncoder().encode("abc")); + await writer.close(); + + let received = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + wt.close(); + return { + success: received === "abcabc", + message: "received: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn bidi_stream_server_priority() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + for i in 0..3i32 { + let (mut send, _recv) = session.open_bi().await.expect("open_bi failed"); + send.set_priority(i).expect("set_priority failed"); + let msg = format!("prio{i}"); + send.write_all(msg.as_bytes()) + .await + .expect("write_all failed"); + send.finish().expect("finish failed"); + } + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingBidirectionalStreams.getReader(); + const messages = []; + for (let i = 0; i < 3; i++) { + const { value: stream, done } = await reader.read(); + if (done) break; + const sr = stream.readable.getReader(); + let msg = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + msg += new TextDecoder().decode(value); + } + messages.push(msg); + } + messages.sort(); + const expected = ["prio0", "prio1", "prio2"]; + const ok = JSON.stringify(messages) === JSON.stringify(expected); + wt.close(); + return { + success: ok, + message: "messages: " + JSON.stringify(messages) + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/concurrent.rs b/web-transport-browser-tests/tests/concurrent.rs index b00598f1..6503367f 100644 --- a/web-transport-browser-tests/tests/concurrent.rs +++ b/web-transport-browser-tests/tests/concurrent.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use web_transport_browser_tests::harness; use web_transport_browser_tests::server::ServerHandler; use web_transport_quinn::{SessionError, WebTransportError}; @@ -62,6 +64,185 @@ async fn multiple_bidi_streams_concurrent() { assert!(result.success, "{}", result.message); } +// --------------------------------------------------------------------------- +// Race conditions, rapid creation, mixed server streams +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn rapid_stream_creation() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const N = 50; + const promises = []; + for (let i = 0; i < N; i++) { + promises.push((async () => { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + await writer.write(new Uint8Array([i % 256])); + await writer.close(); + const { value, done } = await reader.read(); + if (done) return false; + return value.length === 1 && value[0] === (i % 256); + })()); + } + const results = await Promise.all(promises); + const allOk = results.every(r => r === true); + wt.close(); + return { + success: allOk, + message: results.filter(r => !r).length + " of " + N + " failed" + }; + "#, + LONG_TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_close_while_client_creating_streams() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // Accept the first stream + let _s1 = session.accept_bi().await.expect("accept_bi failed"); + tokio::time::sleep(Duration::from_millis(100)).await; + session.close(99, b"closing"); + session.closed().await; + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const N = 20; + const promises = []; + for (let i = 0; i < N; i++) { + promises.push((async () => { + try { + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([i])); + await writer.close(); + return true; + } catch (e) { + return false; + } + })()); + } + const results = await Promise.all(promises); + const succeeded = results.filter(r => r).length; + const failed = results.filter(r => !r).length; + try { await wt.closed; } catch (e) {} + return { + success: succeeded >= 1, + message: "succeeded=" + succeeded + " failed=" + failed + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_opens_bidi_and_uni_simultaneously() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut bi_send, _bi_recv) = session.open_bi().await.expect("open_bi failed"); + bi_send + .write_all(b"bidi-data") + .await + .expect("write_all failed"); + bi_send.finish().expect("finish failed"); + + let mut uni_send = session.open_uni().await.expect("open_uni failed"); + uni_send + .write_all(b"uni-data") + .await + .expect("write_all failed"); + uni_send.finish().expect("finish failed"); + + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const [bidiResult, uniResult] = await Promise.all([ + (async () => { + const reader = wt.incomingBidirectionalStreams.getReader(); + const { value: stream, done } = await reader.read(); + if (done) return ""; + const sr = stream.readable.getReader(); + let msg = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + msg += new TextDecoder().decode(value); + } + return msg; + })(), + (async () => { + const reader = wt.incomingUnidirectionalStreams.getReader(); + const { value: stream, done } = await reader.read(); + if (done) return ""; + const sr = stream.getReader(); + let msg = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + msg += new TextDecoder().decode(value); + } + return msg; + })() + ]); + + wt.close(); + const ok = bidiResult === "bidi-data" && uniResult === "uni-data"; + return { + success: ok, + message: "bidi=" + bidiResult + " uni=" + uniResult + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + #[tokio::test] async fn multiple_streams_mixed_types() { init_tracing(); diff --git a/web-transport-browser-tests/tests/connection.rs b/web-transport-browser-tests/tests/connection.rs index 02a6b980..721498e4 100644 --- a/web-transport-browser-tests/tests/connection.rs +++ b/web-transport-browser-tests/tests/connection.rs @@ -1,12 +1,93 @@ use std::time::Duration; +use bytes::Bytes; use web_transport_browser_tests::harness; use web_transport_browser_tests::server::{RequestHandler, ServerHandler}; -use web_transport_quinn::{SessionError, WebTransportError}; +use web_transport_quinn::generic::Stats; +use web_transport_quinn::{quinn, SessionError, WebTransportError}; mod common; use common::{init_tracing, TIMEOUT}; +// --------------------------------------------------------------------------- +// Session Stats +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn session_stats_after_data_transfer() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let data = recv + .read_to_end(64 * 1024) + .await + .expect("read_to_end failed"); + send.write_all(&data).await.expect("write_all failed"); + send.finish().expect("finish failed"); + + let stats = session.stats(); + assert!( + stats.bytes_sent().unwrap_or(0) > 0, + "bytes_sent should be > 0" + ); + assert!( + stats.bytes_received().unwrap_or(0) > 0, + "bytes_received should be > 0" + ); + assert!( + stats.packets_sent().unwrap_or(0) > 0, + "packets_sent should be > 0" + ); + assert!( + stats.rtt().unwrap_or(Duration::ZERO) > Duration::ZERO, + "rtt should be > 0" + ); + + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const data = new Uint8Array(10 * 1024).fill(42); + await writer.write(data); + await writer.close(); + + let total = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + total += value.length; + } + wt.close(); + return { success: total === 10240, message: "echoed " + total + " bytes" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + // --------------------------------------------------------------------------- // Session Close // --------------------------------------------------------------------------- @@ -151,9 +232,9 @@ async fn close_server_while_streaming() { assert!( matches!( err, - SessionError::WebTransportError(WebTransportError::Closed(_, _)) + SessionError::ConnectionError(quinn::ConnectionError::LocallyClosed) ), - "expected WebTransportError::Closed, got {err}" + "expected ConnectionError::LocallyClosed, got {err}" ); }) }); @@ -196,6 +277,66 @@ async fn close_server_while_streaming() { assert!(result.success, "{}", result.message); } +#[tokio::test] +async fn close_client_while_streaming() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + // Accept a bidi stream to confirm the client started streaming + let (mut send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + + // Start writing from the server side — the client will close mid-stream + let chunk = vec![0xABu8; 4096]; + loop { + match send.write_all(&chunk).await { + Ok(()) => {} + Err(_) => break, + } + } + + let err = session.closed().await; + match err { + SessionError::WebTransportError(WebTransportError::Closed(code, reason)) => { + assert_eq!(code, 77, "close code mismatch"); + assert_eq!(reason, "client mid-stream", "close reason mismatch"); + } + other => panic!( + "expected WebTransportError::Closed(77, \"client mid-stream\"), got {other:?}" + ), + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + + // Send some data so the server accepts the stream + await writer.write(new TextEncoder().encode("hello")); + + // Give the server a moment to start writing back + await new Promise(r => setTimeout(r, 200)); + + // Close the session from the client while the server is writing + wt.close({ closeCode: 77, reason: "client mid-stream" }); + await wt.closed; + return { success: true, message: "client closed while server was streaming" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + // --------------------------------------------------------------------------- // Edge Cases // --------------------------------------------------------------------------- @@ -240,3 +381,167 @@ async fn session_rejection_by_server() { let result = result.unwrap(); assert!(result.success, "{}", result.message); } + +// --------------------------------------------------------------------------- +// Close boundary values (parameterized) +// --------------------------------------------------------------------------- + +macro_rules! close_code_reason_test { + ($name:ident, $code:expr, $reason_js:expr, $reason_rust:expr) => { + #[tokio::test] + async fn $name() { + init_tracing(); + + let expected_code: u32 = $code; + let expected_reason: String = ($reason_rust).to_string(); + + let handler: ServerHandler = Box::new({ + let reason = expected_reason.clone(); + move |session| { + let reason = reason.clone(); + Box::pin(async move { + let err = session.closed().await; + match err { + SessionError::WebTransportError(WebTransportError::Closed(c, r)) => { + assert_eq!(c, expected_code, "close code mismatch"); + assert_eq!(r, reason, "close reason mismatch"); + } + other => panic!("expected WebTransportError::Closed, got {other:?}"), + } + }) + } + }); + + let harness = harness::setup(handler).await.unwrap(); + + let js_code = format!( + r#" + const wt = await connectWebTransport(); + wt.close({{ closeCode: {}, reason: {} }}); + await wt.closed; + return {{ success: true, message: "closed" }}; + "#, + expected_code, $reason_js + ); + + let result = harness.run_js(&js_code, TIMEOUT).await; + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); + } + }; +} + +close_code_reason_test!(close_code_zero, 0, r#""""#, ""); +close_code_reason_test!(close_max_code, 4294967295u32, r#""max""#, "max"); +close_code_reason_test!(close_empty_reason, 42, r#""""#, ""); +close_code_reason_test!(close_unicode_reason, 1, r#""goodbye 👋🌍""#, "goodbye 👋🌍"); +close_code_reason_test!( + close_long_reason, + 1, + r#""x".repeat(1024)"#, + "x".repeat(1024) +); + +// --------------------------------------------------------------------------- +// Server use-after-close (parameterized) +// --------------------------------------------------------------------------- + +macro_rules! server_use_after_close_test { + ($name:ident, |$session:ident| $op:expr) => { + #[tokio::test] + async fn $name() { + init_tracing(); + + let handler: ServerHandler = Box::new(|$session| { + Box::pin(async move { + $session.close(7, b"done"); + $session.closed().await; + let err = { $op }.unwrap_err(); + assert!( + matches!( + err, + SessionError::ConnectionError(quinn::ConnectionError::LocallyClosed) + ), + "expected ConnectionError::LocallyClosed, got {err:?}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + try { await wt.closed; } catch (e) { + if (!(e instanceof WebTransportError)) throw e; + } + return { success: true, message: "session closed" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); + } + }; +} + +server_use_after_close_test!(server_open_bi_after_close, |session| session + .open_bi() + .await); +server_use_after_close_test!(server_open_uni_after_close, |session| session + .open_uni() + .await); +server_use_after_close_test!(server_accept_bi_after_close, |session| session + .accept_bi() + .await); +server_use_after_close_test!(server_accept_uni_after_close, |session| session + .accept_uni() + .await); + +#[tokio::test] +async fn server_send_datagram_after_close() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + session.close(7, b"done"); + session.closed().await; + let err = session + .send_datagram(Bytes::from_static(b"test")) + .unwrap_err(); + assert!( + matches!( + err, + SessionError::SendDatagramError(quinn::SendDatagramError::ConnectionLost( + quinn::ConnectionError::LocallyClosed + )) + ), + "expected SendDatagramError(ConnectionLost(LocallyClosed)), got {err:?}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + try { await wt.closed; } catch (e) { + if (!(e instanceof WebTransportError)) throw e; + } + return { success: true, message: "session closed" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/datagram.rs b/web-transport-browser-tests/tests/datagram.rs index ccfec477..8fbd49e9 100644 --- a/web-transport-browser-tests/tests/datagram.rs +++ b/web-transport-browser-tests/tests/datagram.rs @@ -172,3 +172,146 @@ async fn datagram_server_initiated() { let result = result.unwrap(); assert!(result.success, "{}", result.message); } + +// --------------------------------------------------------------------------- +// Size boundaries & properties +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn datagram_at_max_size() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const maxSize = wt.datagrams.maxDatagramSize; + const writer = wt.datagrams.writable.getWriter(); + const reader = wt.datagrams.readable.getReader(); + + const data = new Uint8Array(maxSize).fill(0xAB); + await writer.write(data); + const { value } = await reader.read(); + wt.close(); + return { + success: value.length === maxSize, + message: "sent=" + maxSize + " received=" + value.length + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn datagram_oversized_rejected() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let max_size = session.max_datagram_size(); + let oversized = Bytes::from(vec![0xFFu8; max_size + 100]); + let result = session.send_datagram(oversized); + assert!( + result.is_err(), + "oversized send_datagram should fail, but succeeded" + ); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + // Give the server time to attempt the oversized send + await new Promise(r => setTimeout(r, 500)); + wt.close(); + return { success: true, message: "server rejected oversized datagram" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +#[ignore = "empty datagrams appear to be silently dropped, causing a timeout"] +async fn datagram_empty() { + init_tracing(); + let harness = harness::setup(harness::echo_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const writer = wt.datagrams.writable.getWriter(); + const reader = wt.datagrams.readable.getReader(); + + await writer.write(new Uint8Array(0)); + const { value } = await reader.read(); + wt.close(); + return { + success: value.length === 0, + message: "received length=" + value.length + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn datagram_high_water_marks() { + init_tracing(); + let harness = harness::setup(harness::idle_handler()).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const inHWM = wt.datagrams.incomingHighWaterMark; + const outHWM = wt.datagrams.outgoingHighWaterMark; + const initialOk = typeof inHWM === "number" && typeof outHWM === "number"; + + wt.datagrams.incomingHighWaterMark = 10; + wt.datagrams.outgoingHighWaterMark = 20; + const newIn = wt.datagrams.incomingHighWaterMark; + const newOut = wt.datagrams.outgoingHighWaterMark; + + wt.close(); + return { + success: initialOk && newIn === 10 && newOut === 20, + message: "initial: in=" + inHWM + " out=" + outHWM + + " new: in=" + newIn + " out=" + newOut + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} diff --git a/web-transport-browser-tests/tests/stream_error.rs b/web-transport-browser-tests/tests/stream_error.rs index 9d235ab2..09659466 100644 --- a/web-transport-browser-tests/tests/stream_error.rs +++ b/web-transport-browser-tests/tests/stream_error.rs @@ -2,7 +2,7 @@ use std::time::Duration; use web_transport_browser_tests::harness; use web_transport_browser_tests::server::ServerHandler; -use web_transport_quinn::{ReadError, SessionError, WebTransportError, WriteError}; +use web_transport_quinn::{quinn, ReadError, SessionError, WebTransportError, WriteError}; mod common; use common::{init_tracing, TIMEOUT}; @@ -698,6 +698,374 @@ async fn server_close_interrupts_client_accept_uni() { // Stream isolation // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Boundary error codes (parameterized) +// --------------------------------------------------------------------------- + +macro_rules! reset_code_test { + ($name:ident, $code:expr) => { + #[tokio::test] + async fn $name() { + init_tracing(); + + let expected_code: u32 = $code; + + let handler: ServerHandler = Box::new(move |session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let code = recv.received_reset().await.ok().flatten(); + assert_eq!( + code, + Some(expected_code), + "reset code mismatch" + ); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let js_code = format!( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + let err = new WebTransportError({{ message: "abort", streamErrorCode: {} }}); + await writer.abort(err); + wt.close(); + return {{ success: true, message: "writer aborted with code {}" }}; + "#, + expected_code, expected_code + ); + + let result = harness.run_js(&js_code, TIMEOUT).await; + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); + } + }; +} + +reset_code_test!(stream_reset_code_zero, 0); +reset_code_test!(stream_reset_code_255, 255); + +macro_rules! stop_code_test { + ($name:ident, $code:expr) => { + #[tokio::test] + async fn $name() { + init_tracing(); + + let expected_code: u32 = $code; + + let handler: ServerHandler = Box::new(move |session| { + Box::pin(async move { + let (send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let code = send.stopped().await.ok().flatten(); + assert_eq!( + code, + Some(expected_code), + "stop code mismatch" + ); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let js_code = format!( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const reader = stream.readable.getReader(); + let err = new WebTransportError({{ message: "cancel", streamErrorCode: {} }}); + await reader.cancel(err); + wt.close(); + return {{ success: true, message: "reader cancelled with code {}" }}; + "#, + expected_code, expected_code + ); + + let result = harness.run_js(&js_code, TIMEOUT).await; + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); + } + }; +} + +stop_code_test!(stream_stop_code_zero, 0); +stop_code_test!(stream_stop_code_255, 255); + +// --------------------------------------------------------------------------- +// Server stream use-after-finish/reset/stop/session-close +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn server_write_after_finish() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, _recv) = session.open_bi().await.expect("open_bi failed"); + send.write_all(b"hello").await.expect("write_all failed"); + send.finish().expect("finish failed"); + let result = send.write_all(b"more").await; + assert!( + matches!(result, Err(WriteError::ClosedStream)), + "expected ClosedStream after finish, got {result:?}" + ); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const reader = wt.incomingBidirectionalStreams.getReader(); + const { value: stream } = await reader.read(); + const sr = stream.readable.getReader(); + let received = ""; + while (true) { + const { value, done } = await sr.read(); + if (done) break; + received += new TextDecoder().decode(value); + } + wt.close(); + return { + success: received === "hello", + message: "received: " + received + }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_write_after_reset() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, _recv) = session.open_bi().await.expect("open_bi failed"); + send.reset(42).expect("reset failed"); + let result = send.write_all(b"more").await; + assert!( + matches!(result, Err(WriteError::ClosedStream)), + "expected ClosedStream after reset, got {result:?}" + ); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + // Give the server time to open a stream, reset it, and attempt write + await new Promise(r => setTimeout(r, 500)); + wt.close(); + return { success: true, message: "server tested write after reset" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_read_after_stop() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + // Read initial data to ensure stream is established + let mut buf = [0u8; 1024]; + recv.read(&mut buf).await.expect("initial read failed"); + recv.stop(10).expect("stop failed"); + let result = recv.read(&mut buf).await; + // Quinn sets `all_data_read = true` in stop(), so subsequent + // reads return Ok(None) — same as a cleanly finished stream — + // rather than an error. + assert!( + matches!(result, Ok(None)), + "expected Ok(None) after stop, got {result:?}" + ); + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1])); + // Wait for STOP_SENDING to arrive, then handle writer error + try { + for (let i = 0; i < 100; i++) { + await writer.write(new Uint8Array(1024)); + await new Promise(r => setTimeout(r, 10)); + } + } catch (e) { + // Expected — STOP_SENDING causes writer error + } + wt.close(); + return { success: true, message: "client done" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_read_on_stream_after_session_close() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + session.close(7, b"done"); + session.closed().await; + let mut buf = [0u8; 1024]; + let result = recv.read(&mut buf).await; + match result { + Err(ReadError::SessionError(SessionError::ConnectionError( + quinn::ConnectionError::LocallyClosed, + ))) => {} + other => panic!( + "expected ReadError::SessionError(ConnectionError::LocallyClosed), got {other:?}" + ), + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1])); + try { await wt.closed; } catch (e) { + if (!(e instanceof WebTransportError)) throw e; + } + return { success: true, message: "session closed" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +#[tokio::test] +async fn server_write_on_stream_after_session_close() { + init_tracing(); + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let (mut send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + session.close(7, b"done"); + session.closed().await; + let result = send.write_all(b"test").await; + match result { + Err(WriteError::SessionError(SessionError::ConnectionError( + quinn::ConnectionError::LocallyClosed, + ))) => {} + other => panic!( + "expected WriteError::SessionError(ConnectionError::LocallyClosed), got {other:?}" + ), + } + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + r#" + const wt = await connectWebTransport(); + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write(new Uint8Array([1])); + try { await wt.closed; } catch (e) { + if (!(e instanceof WebTransportError)) throw e; + } + return { success: true, message: "session closed" }; + "#, + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +// --------------------------------------------------------------------------- +// Stream isolation +// --------------------------------------------------------------------------- + /// Opens 3 bidi streams on one session. Stream 0 is echoed normally, stream 1 /// is reset by the client, stream 2 is reset by the server. Verifies that the /// resets on streams 1 and 2 do not disturb stream 0. From 95a69e21177df91ba6139a8e94e41b209300aa69 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:52:35 -0800 Subject: [PATCH 06/18] Remove unsafe impl Sync for SharedBrowser Wrap Browser in tokio::sync::Mutex and ChildStdin in std::sync::Mutex so SharedBrowser derives Sync naturally without unsafe. Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/browser.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/web-transport-browser-tests/src/browser.rs b/web-transport-browser-tests/src/browser.rs index 146f8a2e..cfe6bad3 100644 --- a/web-transport-browser-tests/src/browser.rs +++ b/web-transport-browser-tests/src/browser.rs @@ -17,7 +17,7 @@ use serde::Deserialize; use crate::js; struct SharedBrowser { - browser: Browser, + browser: tokio::sync::Mutex, /// URL of the blank page server (http://localhost:{port}). page_url: String, // A dedicated Tokio runtime that owns the browser event handler and HTTP @@ -31,13 +31,9 @@ struct SharedBrowser { // Holds the stdin pipe to the cleanup watchdog process. When our process // exits (for any reason, including SIGKILL), the OS closes this FD, // unblocking the watchdog which then kills Chrome and removes the temp dir. - _watchdog_pipe: Option, + _watchdog_pipe: std::sync::Mutex>, } -// Safety: Browser and Page are Send + Sync; the runtime is only used to keep -// background tasks alive and is never accessed from multiple threads. -unsafe impl Sync for SharedBrowser {} - static BROWSER: OnceLock = OnceLock::new(); /// Try to build a BrowserConfig, auto-downloading Chromium via the fetcher if @@ -190,11 +186,11 @@ fn init_browser() -> SharedBrowser { let watchdog_pipe = spawn_cleanup_watchdog(chrome_pid, &data_dir); SharedBrowser { - browser, + browser: tokio::sync::Mutex::new(browser), page_url, _runtime: rt, _keepalive_page: keepalive_page, - _watchdog_pipe: watchdog_pipe, + _watchdog_pipe: std::sync::Mutex::new(watchdog_pipe), } }) .join() @@ -221,6 +217,8 @@ impl TestContext { let context_id = shared .browser + .lock() + .await .create_browser_context(CreateBrowserContextParams::default()) .await .context("failed to create browser context")?; @@ -229,6 +227,8 @@ impl TestContext { // origin with the WebTransport API available. let page = shared .browser + .lock() + .await .new_page( CreateTargetParams::builder() .url(&shared.page_url) @@ -275,7 +275,7 @@ impl TestContext { pub async fn dispose(mut self) { if let Some(id) = self.context_id.take() { let shared = get_browser(); - let _ = shared.browser.dispose_browser_context(id).await; + let _ = shared.browser.lock().await.dispose_browser_context(id).await; } } } From a79f9aa9c8a21edc45f596964a3517d9a8d98e09 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:03:55 -0800 Subject: [PATCH 07/18] Surface accept-loop panics in TestServer::shutdown Replace `let _ = task.await` with `.expect()` so that panics inside the spawned accept loop are propagated instead of silently swallowed. Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-transport-browser-tests/src/server.rs b/web-transport-browser-tests/src/server.rs index a48f0237..2d42c8c5 100644 --- a/web-transport-browser-tests/src/server.rs +++ b/web-transport-browser-tests/src/server.rs @@ -36,7 +36,7 @@ impl TestServer { tx.send(()).expect("shutdown receiver should be alive"); } if let Some(task) = self.task.take() { - let _ = task.await; + task.await.expect("accept loop task panicked"); } let handles: Vec<_> = self.handler_tasks.lock().unwrap().drain(..).collect(); assert_eq!( From 2743e6288d72287d6824abbed4102fbc256d0880 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:05:18 -0800 Subject: [PATCH 08/18] Remove sort() in bidi_stream_server_priority to assert arrival order The test was sorting messages before comparing, which only verified set membership rather than actual priority/arrival ordering. Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/tests/bidi_stream.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/web-transport-browser-tests/tests/bidi_stream.rs b/web-transport-browser-tests/tests/bidi_stream.rs index 2839df3f..c5743e83 100644 --- a/web-transport-browser-tests/tests/bidi_stream.rs +++ b/web-transport-browser-tests/tests/bidi_stream.rs @@ -498,7 +498,6 @@ async fn bidi_stream_server_priority() { } messages.push(msg); } - messages.sort(); const expected = ["prio0", "prio1", "prio2"]; const ok = JSON.stringify(messages) === JSON.stringify(expected); wt.close(); From 83ef97d288130827b02af54eca2bd76d50a0d731 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:19:16 -0800 Subject: [PATCH 09/18] Fix shell injection in cleanup watchdog by using positional arguments Pass chrome_pid and data_dir as shell positional arguments ($0, $1) instead of interpolating them into the command string with format!(). Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/browser.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web-transport-browser-tests/src/browser.rs b/web-transport-browser-tests/src/browser.rs index cfe6bad3..cf4ff7c0 100644 --- a/web-transport-browser-tests/src/browser.rs +++ b/web-transport-browser-tests/src/browser.rs @@ -99,13 +99,13 @@ fn spawn_cleanup_watchdog( ) -> Option { use std::process::{Command, Stdio}; + // Pass chrome_pid and data_dir as positional arguments ($0, $1) to avoid + // shell injection from interpolating paths into the command string. let mut child = Command::new("sh") .arg("-c") - .arg(format!( - "read _; kill -9 {} 2>/dev/null; rm -rf '{}'", - chrome_pid, - data_dir.display(), - )) + .arg(r#"read _; kill -9 "$0" 2>/dev/null; rm -rf "$1""#) + .arg(chrome_pid.to_string()) + .arg(data_dir.as_os_str()) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) From 8813fd29469ab36d22e143c62bfdc0a763ee4f1f Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:37:43 -0800 Subject: [PATCH 10/18] Fix watchdog kill targeting entire process group when Chrome PID unavailable Remove unwrap_or(0) fallback that caused `kill -9 0` to target the whole process group. Instead, preserve the Option and only spawn the cleanup watchdog when a Chrome PID is available. Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/browser.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/web-transport-browser-tests/src/browser.rs b/web-transport-browser-tests/src/browser.rs index cf4ff7c0..7ffc7bac 100644 --- a/web-transport-browser-tests/src/browser.rs +++ b/web-transport-browser-tests/src/browser.rs @@ -157,10 +157,7 @@ fn init_browser() -> SharedBrowser { // Extract Chrome's PID before we lose mutability, so the watchdog // can kill it on abnormal exit. - let chrome_pid = browser - .get_mut_child() - .and_then(|c| c.inner.id()) - .unwrap_or(0); + let chrome_pid = browser.get_mut_child().and_then(|c| c.inner.id()); // Spawn the CDP event handler on this runtime. tokio::spawn(async move { @@ -183,7 +180,7 @@ fn init_browser() -> SharedBrowser { // our process exits. The data dir path matches build_browser_config(). let data_dir = std::env::temp_dir().join(format!("chromiumoxide-{}", std::process::id())); - let watchdog_pipe = spawn_cleanup_watchdog(chrome_pid, &data_dir); + let watchdog_pipe = chrome_pid.and_then(|pid| spawn_cleanup_watchdog(pid, &data_dir)); SharedBrowser { browser: tokio::sync::Mutex::new(browser), From 2b837fa65e119935f998726b1ac8d74d934b9d3f Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:40:11 -0800 Subject: [PATCH 11/18] Avoid panic on shutdown send when accept loop already exited Replace expect-based tx.send(()) with let _ = tx.send(()) so that a dropped receiver does not mask the real accept-loop error surfaced by task.await.expect(). Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-transport-browser-tests/src/server.rs b/web-transport-browser-tests/src/server.rs index 2d42c8c5..8ad92db7 100644 --- a/web-transport-browser-tests/src/server.rs +++ b/web-transport-browser-tests/src/server.rs @@ -33,7 +33,7 @@ impl TestServer { /// if any handler panicked. pub async fn shutdown(mut self, expected_handlers: usize) { if let Some(tx) = self.shutdown_tx.take() { - tx.send(()).expect("shutdown receiver should be alive"); + let _ = tx.send(()); } if let Some(task) = self.task.take() { task.await.expect("accept loop task panicked"); From a8855d89f215901530755e4ffbd15abf94f8545d Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:55:19 -0800 Subject: [PATCH 12/18] Clean connection close in stream_error tests --- .../tests/stream_error.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/web-transport-browser-tests/tests/stream_error.rs b/web-transport-browser-tests/tests/stream_error.rs index 09659466..aa5a437f 100644 --- a/web-transport-browser-tests/tests/stream_error.rs +++ b/web-transport-browser-tests/tests/stream_error.rs @@ -123,6 +123,7 @@ async fn stream_client_reset_server_reader_errors() { } } session.close(0, b""); + let _ = session.closed().await; }) }); @@ -138,13 +139,7 @@ async fn stream_client_reset_server_reader_errors() { await writer.write(new TextEncoder().encode("some data")); let err = new WebTransportError({ message: "abort", streamErrorCode: 42 }); await writer.abort(err); - // Wait for the server to observe the reset and close the session - try { - await wt.closed; - throw new Error("wt.closed should have rejected"); - } catch (e) { - if (!(e instanceof WebTransportError) || e.source !== "session") throw e; - } + await wt.closed; return { success: true, message: "writer aborted with code 42" }; "#, TIMEOUT, @@ -178,6 +173,7 @@ async fn stream_client_stop_server_writer_errors() { } } session.close(0, b""); + let _ = session.closed().await; }) }); @@ -198,13 +194,7 @@ async fn stream_client_stop_server_writer_errors() { let err = new WebTransportError({ message: "cancel", streamErrorCode: 77 }); await reader.cancel(err); - // Wait for the server to observe the stop and close the session - try { - await wt.closed; - throw new Error("wt.closed should have rejected"); - } catch (e) { - if (!(e instanceof WebTransportError) || e.source !== "session") throw e; - } + await wt.closed; return { success: true, message: "reader cancelled with code 77" }; "#, TIMEOUT, @@ -446,6 +436,7 @@ async fn server_close_interrupts_client_read() { let mut buf = [0u8; 1024]; recv.read(&mut buf).await.expect("read failed"); session.close(0, b""); + let _ = session.closed().await; }) }); @@ -492,6 +483,7 @@ async fn server_close_interrupts_client_write() { let mut buf = [0u8; 1024]; recv.read(&mut buf).await.expect("read failed"); session.close(0, b""); + let _ = session.closed().await; }) }); @@ -625,6 +617,7 @@ async fn server_close_interrupts_client_accept_bi() { // Small delay so the client starts waiting on incomingBidirectionalStreams tokio::time::sleep(Duration::from_millis(100)).await; session.close(0, b""); + let _ = session.closed().await; }) }); @@ -664,6 +657,7 @@ async fn server_close_interrupts_client_accept_uni() { // Small delay so the client starts waiting on incomingUnidirectionalStreams tokio::time::sleep(Duration::from_millis(100)).await; session.close(0, b""); + let _ = session.closed().await; }) }); From 6d26c3c8b0fb72d011bb472f3022449cf810d180 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:24:14 -0800 Subject: [PATCH 13/18] Replace wt.close() synchronization with stream-based handshakes in stream_error tests Tests were unreliable because they used wt.close()/wt.closed for synchronization, which races with the stream error signals. Instead, use server-to-client writes and second-stream reads as synchronization points to ensure the server has observed the error before the connection tears down. Also increase immediate_close_handler delay to 1000ms. Co-Authored-By: Claude Opus 4.6 --- web-transport-browser-tests/src/server.rs | 2 +- .../tests/stream_error.rs | 106 +++++++++--------- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/web-transport-browser-tests/src/server.rs b/web-transport-browser-tests/src/server.rs index 8ad92db7..cbce0e88 100644 --- a/web-transport-browser-tests/src/server.rs +++ b/web-transport-browser-tests/src/server.rs @@ -191,7 +191,7 @@ pub fn immediate_close_handler(code: u32, reason: &'static str) -> ServerHandler // Give the browser a bit of time to finish establishing the session. // Without this, browser sometimes throws WebTransportError instead of // cleanly closing the session. - tokio::time::sleep(Duration::from_millis(500)).await; + tokio::time::sleep(Duration::from_millis(1000)).await; session.close(code, reason.as_bytes()); // Wait for the connection to actually close. // This ensures the CloseWebTransportSession capsule is delivered. diff --git a/web-transport-browser-tests/tests/stream_error.rs b/web-transport-browser-tests/tests/stream_error.rs index aa5a437f..815b00ee 100644 --- a/web-transport-browser-tests/tests/stream_error.rs +++ b/web-transport-browser-tests/tests/stream_error.rs @@ -13,21 +13,16 @@ async fn stream_client_abort_sends_reset() { let handler: ServerHandler = Box::new(|session| { Box::pin(async move { - let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + send.write(&[1, 2, 3]).await.expect("write failed"); let code = recv.received_reset().await.ok().flatten(); assert_eq!( code, Some(42), "server should receive RESET_STREAM with code 42" ); - let err = session.closed().await; - assert!( - matches!( - err, - SessionError::WebTransportError(WebTransportError::Closed(_, _)) - ), - "expected WebTransportError::Closed, got {err}" - ); + send.write(&[1, 2, 3]).await.expect("write failed"); + let _ = session.closed().await; }) }); @@ -38,12 +33,18 @@ async fn stream_client_abort_sends_reset() { r#" const wt = await connectWebTransport(); const stream = await wt.createBidirectionalStream(); + + // Wait for the server to establish the stream + const reader = stream.readable.getReader(); + await reader.read(); + const writer = stream.writable.getWriter(); // Construct WebTransportError — try both (message, init) and (init) forms let err = new WebTransportError({ message: "abort", streamErrorCode: 42 }); await writer.abort(err); - wt.close(); + + await reader.read(); return { success: true, message: "writer aborted with code 42" }; "#, TIMEOUT, @@ -61,21 +62,17 @@ async fn stream_client_cancel_sends_stop_sending() { let handler: ServerHandler = Box::new(|session| { Box::pin(async move { - let (send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send2, _recv2) = session.accept_bi().await.expect("2nd accept_bi failed"); + send.write(&[1]).await.expect("write failed"); let code = send.stopped().await.ok().flatten(); assert_eq!( code, Some(77), "server should receive STOP_SENDING with code 77" ); - let err = session.closed().await; - assert!( - matches!( - err, - SessionError::WebTransportError(WebTransportError::Closed(_, _)) - ), - "expected WebTransportError::Closed, got {err}" - ); + send2.write(&[1]).await.expect("write to 2nd stream failed"); + let _ = session.closed().await; }) }); @@ -86,12 +83,17 @@ async fn stream_client_cancel_sends_stop_sending() { r#" const wt = await connectWebTransport(); const stream = await wt.createBidirectionalStream(); + const stream2 = await wt.createBidirectionalStream(); const reader = stream.readable.getReader(); + const reader2 = stream2.readable.getReader(); + + // Wait for the server to establish the stream + await reader.read(); - // Construct WebTransportError — try both (message, init) and (init) forms let err = new WebTransportError({ message: "cancel", streamErrorCode: 77 }); await reader.cancel(err); - wt.close(); + + await reader2.read(); return { success: true, message: "reader cancelled with code 77" }; "#, TIMEOUT, @@ -109,7 +111,8 @@ async fn stream_client_reset_server_reader_errors() { let handler: ServerHandler = Box::new(|session| { Box::pin(async move { - let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + send.write(&[1]).await.expect("write failed"); let mut buf = [0u8; 1024]; loop { match recv.read(&mut buf).await { @@ -122,7 +125,7 @@ async fn stream_client_reset_server_reader_errors() { Err(e) => panic!("unexpected read error: {e}"), } } - session.close(0, b""); + send.write(&[1]).await.expect("write failed"); let _ = session.closed().await; }) }); @@ -134,12 +137,17 @@ async fn stream_client_reset_server_reader_errors() { r#" const wt = await connectWebTransport(); const stream = await wt.createBidirectionalStream(); - const writer = stream.writable.getWriter(); + // Wait for the server to establish the stream + const reader = stream.readable.getReader(); + await reader.read(); + + const writer = stream.writable.getWriter(); await writer.write(new TextEncoder().encode("some data")); let err = new WebTransportError({ message: "abort", streamErrorCode: 42 }); await writer.abort(err); - await wt.closed; + + await reader.read(); return { success: true, message: "writer aborted with code 42" }; "#, TIMEOUT, @@ -158,6 +166,7 @@ async fn stream_client_stop_server_writer_errors() { let handler: ServerHandler = Box::new(|session| { Box::pin(async move { let (mut send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send2, _recv2) = session.accept_bi().await.expect("2nd accept_bi failed"); // Keep writing until we get a Stopped error from the client's cancel let chunk = vec![0u8; 1024]; loop { @@ -172,7 +181,7 @@ async fn stream_client_stop_server_writer_errors() { Err(e) => panic!("unexpected write error: {e}"), } } - session.close(0, b""); + send2.write(&[1]).await.expect("write to 2nd stream failed"); let _ = session.closed().await; }) }); @@ -184,17 +193,18 @@ async fn stream_client_stop_server_writer_errors() { r#" const wt = await connectWebTransport(); const stream = await wt.createBidirectionalStream(); - const writer = stream.writable.getWriter(); + const stream2 = await wt.createBidirectionalStream(); const reader = stream.readable.getReader(); + const reader2 = stream2.readable.getReader(); - // Write to trigger server accept - await writer.write(new Uint8Array([1])); // Read to confirm the server has started writing await reader.read(); let err = new WebTransportError({ message: "cancel", streamErrorCode: 77 }); await reader.cancel(err); - await wt.closed; + + await reader2.read(); + return { success: true, message: "reader cancelled with code 77" }; "#, TIMEOUT, @@ -706,21 +716,16 @@ macro_rules! reset_code_test { let handler: ServerHandler = Box::new(move |session| { Box::pin(async move { - let (_send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send, mut recv) = session.accept_bi().await.expect("accept_bi failed"); + send.write(&[1]).await.expect("write failed"); let code = recv.received_reset().await.ok().flatten(); assert_eq!( code, Some(expected_code), "reset code mismatch" ); - let err = session.closed().await; - assert!( - matches!( - err, - SessionError::WebTransportError(WebTransportError::Closed(_, _)) - ), - "expected WebTransportError::Closed, got {err}" - ); + send.write(&[1]).await.expect("write failed"); + let _ = session.closed().await; }) }); @@ -730,10 +735,12 @@ macro_rules! reset_code_test { r#" const wt = await connectWebTransport(); const stream = await wt.createBidirectionalStream(); + const reader = stream.readable.getReader(); + await reader.read(); const writer = stream.writable.getWriter(); let err = new WebTransportError({{ message: "abort", streamErrorCode: {} }}); await writer.abort(err); - wt.close(); + await reader.read(); return {{ success: true, message: "writer aborted with code {}" }}; "#, expected_code, expected_code @@ -760,21 +767,17 @@ macro_rules! stop_code_test { let handler: ServerHandler = Box::new(move |session| { Box::pin(async move { - let (send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send, _recv) = session.accept_bi().await.expect("accept_bi failed"); + let (mut send2, _recv2) = session.accept_bi().await.expect("2nd accept_bi failed"); + send.write(&[1]).await.expect("write failed"); let code = send.stopped().await.ok().flatten(); assert_eq!( code, Some(expected_code), "stop code mismatch" ); - let err = session.closed().await; - assert!( - matches!( - err, - SessionError::WebTransportError(WebTransportError::Closed(_, _)) - ), - "expected WebTransportError::Closed, got {err}" - ); + send2.write(&[1]).await.expect("write to 2nd stream failed"); + let _ = session.closed().await; }) }); @@ -784,10 +787,13 @@ macro_rules! stop_code_test { r#" const wt = await connectWebTransport(); const stream = await wt.createBidirectionalStream(); + const stream2 = await wt.createBidirectionalStream(); const reader = stream.readable.getReader(); + const reader2 = stream2.readable.getReader(); + await reader.read(); let err = new WebTransportError({{ message: "cancel", streamErrorCode: {} }}); await reader.cancel(err); - wt.close(); + await reader2.read(); return {{ success: true, message: "reader cancelled with code {}" }}; "#, expected_code, expected_code From ce12526c37367ecd153bf09ee9fc36ec9c241e60 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:58:26 -0800 Subject: [PATCH 14/18] Add tests reproducing concurrent accept_bi/accept_uni lost waker bug Spawning multiple tasks that each call accept_bi() or accept_uni() on the same session causes all but one caller to hang indefinitely. The unfold stream only stores one waker, so concurrent pollers get their wakers overwritten and are never woken again. Co-Authored-By: Claude Opus 4.6 --- .../tests/concurrent_accept.rs | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 web-transport-browser-tests/tests/concurrent_accept.rs diff --git a/web-transport-browser-tests/tests/concurrent_accept.rs b/web-transport-browser-tests/tests/concurrent_accept.rs new file mode 100644 index 00000000..cef980b9 --- /dev/null +++ b/web-transport-browser-tests/tests/concurrent_accept.rs @@ -0,0 +1,390 @@ +use std::time::Duration; + +use web_transport_browser_tests::harness; +use web_transport_browser_tests::server::ServerHandler; +use web_transport_quinn::{SessionError, WebTransportError}; + +mod common; +use common::{init_tracing, TIMEOUT}; + +/// Reproduces the lost-waker bug when multiple tasks call `accept_bi()` +/// concurrently on the same session. +/// +/// The browser opens N bidi streams. On the server side, N independent tasks +/// each call `session.accept_bi()` concurrently. With the unfold-based +/// implementation, only one waker is stored, so all but one task hang forever. +#[tokio::test] +async fn concurrent_accept_bi_from_multiple_tasks() { + init_tracing(); + + const N: usize = 3; + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut tasks = tokio::task::JoinSet::new(); + + // Spawn N independent tasks that each call accept_bi() concurrently. + // This is the pattern that triggers the lost-waker bug: the unfold + // stream only stores one waker, so N-1 tasks never get woken. + for i in 0..N { + let session = session.clone(); + tasks.spawn(async move { + let (mut send, mut recv) = session + .accept_bi() + .await + .unwrap_or_else(|e| panic!("task {i}: accept_bi failed: {e}")); + let data = recv + .read_to_end(1024) + .await + .unwrap_or_else(|e| panic!("task {i}: read_to_end failed: {e}")); + send.write_all(&data) + .await + .unwrap_or_else(|e| panic!("task {i}: write_all failed: {e}")); + send.finish() + .unwrap_or_else(|e| panic!("task {i}: finish failed: {e}")); + }); + } + + // All N tasks must complete. With the bug, this times out because + // N-1 tasks are stuck in accept_bi() with dead wakers. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut completed = 0; + while let Some(result) = tokio::time::timeout_at(deadline, tasks.join_next()).await.ok().flatten() { + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + completed += 1; + } + assert_eq!( + completed, N, + "only {completed}/{N} accept_bi tasks completed (lost waker bug)" + ); + + // Keep the session alive until the client closes. + let err = session.closed().await; + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + &format!( + r#" + const wt = await connectWebTransport(); + const N = {N}; + const promises = []; + + for (let i = 0; i < N; i++) {{ + promises.push((async () => {{ + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const msg = "bi-" + i; + await writer.write(new TextEncoder().encode(msg)); + await writer.close(); + + let received = ""; + while (true) {{ + const {{ value, done }} = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + }} + return received; + }})()); + }} + + const results = await Promise.all(promises); + const expected = Array.from({{ length: N }}, (_, i) => "bi-" + i); + results.sort(); + expected.sort(); + const ok = JSON.stringify(results) === JSON.stringify(expected); + wt.close(); + return {{ + success: ok, + message: "results: " + JSON.stringify(results) + }}; + "# + ), + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +/// Tests that `accept_bi()` and `accept_uni()` work correctly when called +/// concurrently from separate tasks on the same session. They share a single +/// `Mutex` but operate on independent internal state. +#[tokio::test] +async fn concurrent_accept_bi_and_uni_from_multiple_tasks() { + init_tracing(); + + const N: usize = 3; + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut tasks = tokio::task::JoinSet::new(); + + // Spawn N tasks for accept_bi and N tasks for accept_uni concurrently. + for i in 0..N { + let session = session.clone(); + tasks.spawn(async move { + let (mut send, mut recv) = session + .accept_bi() + .await + .unwrap_or_else(|e| panic!("bi task {i}: accept_bi failed: {e}")); + let data = recv + .read_to_end(1024) + .await + .unwrap_or_else(|e| panic!("bi task {i}: read_to_end failed: {e}")); + send.write_all(&data) + .await + .unwrap_or_else(|e| panic!("bi task {i}: write_all failed: {e}")); + send.finish() + .unwrap_or_else(|e| panic!("bi task {i}: finish failed: {e}")); + format!("bi-done-{i}") + }); + } + for i in 0..N { + let session = session.clone(); + tasks.spawn(async move { + let mut recv = session + .accept_uni() + .await + .unwrap_or_else(|e| panic!("uni task {i}: accept_uni failed: {e}")); + let data = recv + .read_to_end(1024) + .await + .unwrap_or_else(|e| panic!("uni task {i}: read_to_end failed: {e}")); + String::from_utf8(data) + .unwrap_or_else(|e| panic!("uni task {i}: invalid utf8: {e}")) + }); + } + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut completed = 0; + while let Some(result) = + tokio::time::timeout_at(deadline, tasks.join_next()) + .await + .ok() + .flatten() + { + if let Err(e) = result { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + completed += 1; + } + assert_eq!( + completed, + N * 2, + "only {completed}/{} accept tasks completed", + N * 2 + ); + + // Signal the browser that all streams were received. + let mut signal = session + .open_uni() + .await + .expect("open_uni for signal failed"); + signal.write_all(b"ok").await.expect("signal write failed"); + signal.finish().expect("signal finish failed"); + + // Keep the session alive until the client closes. + let err = session.closed().await; + assert!( + matches!( + err, + SessionError::WebTransportError(WebTransportError::Closed(_, _)) + ), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + &format!( + r#" + const wt = await connectWebTransport(); + const N = {N}; + const promises = []; + + // Open N bidi streams (server echoes them back) + for (let i = 0; i < N; i++) {{ + promises.push((async () => {{ + const stream = await wt.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + const msg = "bi-" + i; + await writer.write(new TextEncoder().encode(msg)); + await writer.close(); + + let received = ""; + while (true) {{ + const {{ value, done }} = await reader.read(); + if (done) break; + received += new TextDecoder().decode(value); + }} + return received; + }})()); + }} + + // Open N uni streams + for (let i = 0; i < N; i++) {{ + promises.push((async () => {{ + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + await writer.write(new TextEncoder().encode("uni-" + i)); + await writer.close(); + return "uni-sent-" + i; + }})()); + }} + + const results = await Promise.all(promises); + + // Wait for the server signal before closing. + const reader = wt.incomingUnidirectionalStreams.getReader(); + const {{ value: signal }} = await reader.read(); + const sr = signal.getReader(); + await sr.read(); + + wt.close(); + + const biResults = results.slice(0, N).sort(); + const expected = Array.from({{ length: N }}, (_, i) => "bi-" + i).sort(); + const ok = JSON.stringify(biResults) === JSON.stringify(expected); + return {{ + success: ok, + message: "bi results: " + JSON.stringify(biResults) + }}; + "# + ), + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} + +/// Same bug but for `accept_uni()`: multiple tasks calling it concurrently +/// causes lost wakers. +#[tokio::test] +async fn concurrent_accept_uni_from_multiple_tasks() { + init_tracing(); + + const N: usize = 3; + + let handler: ServerHandler = Box::new(|session| { + Box::pin(async move { + let mut tasks = tokio::task::JoinSet::new(); + + for i in 0..N { + let session = session.clone(); + tasks.spawn(async move { + let mut recv = session + .accept_uni() + .await + .unwrap_or_else(|e| panic!("task {i}: accept_uni failed: {e}")); + let data = recv + .read_to_end(1024) + .await + .unwrap_or_else(|e| panic!("task {i}: read_to_end failed: {e}")); + String::from_utf8(data).unwrap_or_else(|e| panic!("task {i}: invalid utf8: {e}")) + }); + } + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut received = Vec::new(); + while let Some(result) = tokio::time::timeout_at(deadline, tasks.join_next()).await.ok().flatten() { + match result { + Ok(s) => received.push(s), + Err(e) => { + if e.is_panic() { + std::panic::resume_unwind(e.into_panic()); + } + } + } + } + received.sort(); + assert_eq!( + received.len(), + N, + "only {}/{N} accept_uni tasks completed (lost waker bug); got: {received:?}", + received.len() + ); + + // Signal the browser that all streams were received. + let mut signal = session + .open_uni() + .await + .expect("open_uni for signal failed"); + signal.write_all(b"ok").await.expect("signal write failed"); + signal.finish().expect("signal finish failed"); + + // Keep the session alive until the client closes. + let err = session.closed().await; + assert!( + matches!(err, SessionError::WebTransportError(WebTransportError::Closed(_, _))), + "expected WebTransportError::Closed, got {err}" + ); + }) + }); + + let harness = harness::setup(handler).await.unwrap(); + + let result = harness + .run_js( + &format!( + r#" + const wt = await connectWebTransport(); + const N = {N}; + const promises = []; + + for (let i = 0; i < N; i++) {{ + promises.push((async () => {{ + const stream = await wt.createUnidirectionalStream(); + const writer = stream.getWriter(); + await writer.write(new TextEncoder().encode("uni-" + i)); + await writer.close(); + return true; + }})()); + }} + + await Promise.all(promises); + + // Wait for the server to signal that all streams were received + // before closing, so we don't abort in-flight stream decodes. + const reader = wt.incomingUnidirectionalStreams.getReader(); + const {{ value: signal }} = await reader.read(); + const sr = signal.getReader(); + await sr.read(); + + wt.close(); + return {{ success: true, message: "sent " + N + " uni streams" }}; + "# + ), + TIMEOUT, + ) + .await; + + harness.teardown().await; + let result = result.unwrap(); + assert!(result.success, "{}", result.message); +} From cacf67bc104e59e928a66cc4c8c10ef9d8af98f0 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:37:54 -0800 Subject: [PATCH 15/18] Implement W3C-compliant CloseWebTransportSession capsule for browser compatibility Write a CloseWebTransportSession capsule inside an HTTP/3 DATA frame on the CONNECT stream (RFC 9297), enabling browsers to receive close codes and reasons via the W3C WebTransport API's `WebTransport.closed` promise. Adds Http3CapsuleReader to handle capsules split across multiple DATA frames, and reworks Session::close() to deliver the capsule before closing the QUIC connection. Co-Authored-By: Claude Opus 4.6 --- web-transport-proto/src/capsule.rs | 185 ++++++++++++++++++++++++++++- web-transport-quinn/Cargo.toml | 1 + web-transport-quinn/src/session.rs | 165 +++++++++++++++++++++---- 3 files changed, 325 insertions(+), 26 deletions(-) diff --git a/web-transport-proto/src/capsule.rs b/web-transport-proto/src/capsule.rs index a018b3cd..ee7d76b0 100644 --- a/web-transport-proto/src/capsule.rs +++ b/web-transport-proto/src/capsule.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use bytes::{Buf, BufMut, Bytes, BytesMut}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use crate::{VarInt, VarIntUnexpectedEnd, MAX_FRAME_SIZE}; +use crate::{Frame, VarInt, VarIntUnexpectedEnd, MAX_FRAME_SIZE}; // The spec (draft-ietf-webtrans-http3-06) says the type is 0x2843, which would // varint-encode to 0x68 0x43. However, actual wire data shows 0x43 0x28 which @@ -232,6 +232,84 @@ impl From for CapsuleError { } } +/// Reads capsules from an HTTP/3 CONNECT stream where capsule protocol +/// data is carried inside DATA frames (RFC 9297 Section 3.2). +/// +/// Handles capsules split across multiple DATA frames and multiple +/// capsules within a single DATA frame. +pub struct Http3CapsuleReader { + stream: S, + buf: BytesMut, +} + +impl Http3CapsuleReader { + pub fn new(stream: S) -> Self { + Self { + stream, + buf: BytesMut::new(), + } + } + + /// Read the next capsule. Returns `Ok(None)` on clean EOF. + pub async fn read(&mut self) -> Result, CapsuleError> { + loop { + if !self.buf.is_empty() { + let mut slice = &self.buf[..]; + match Capsule::decode(&mut slice) { + Ok(capsule) => { + self.buf.advance(self.buf.len() - slice.len()); + return Ok(Some(capsule)); + } + Err(CapsuleError::UnexpectedEnd | CapsuleError::VarInt(_)) => {} + Err(e) => return Err(e), + } + } + + if !self.read_data_frame().await? { + return if self.buf.is_empty() { + Ok(None) + } else { + Err(CapsuleError::UnexpectedEnd) + }; + } + } + } + + /// Read the next HTTP/3 DATA frame into the buffer, skipping non-DATA frames. + /// Returns `false` on EOF. + async fn read_data_frame(&mut self) -> Result { + loop { + let frame_type = match VarInt::read(&mut self.stream).await { + Ok(v) => Frame(v), + Err(_) => return Ok(false), + }; + let len = VarInt::read(&mut self.stream) + .await + .map_err(|_| CapsuleError::UnexpectedEnd)? + .into_inner() as usize; + + if len > MAX_FRAME_SIZE as usize { + return Err(CapsuleError::MessageTooLong); + } + + if frame_type != Frame::DATA { + if len > 0 { + let mut skip = vec![0u8; len]; + self.stream.read_exact(&mut skip).await?; + } + continue; + } + + if len > 0 { + let start = self.buf.len(); + self.buf.resize(start + len, 0); + self.stream.read_exact(&mut self.buf[start..]).await?; + } + return Ok(true); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -493,4 +571,109 @@ mod tests { let err = Capsule::read(&mut cursor).await.unwrap_err(); assert!(matches!(err, CapsuleError::UnexpectedEnd)); } + + fn encode_capsule(c: &Capsule) -> Vec { + let mut buf = Vec::new(); + c.encode(&mut buf); + buf + } + + fn wrap_in_data_frame(payload: &[u8]) -> Vec { + let mut frame = Vec::new(); + Frame::DATA.encode(&mut frame); + VarInt::from_u32(payload.len() as u32).encode(&mut frame); + frame.extend_from_slice(payload); + frame + } + + /// Concatenate multiple DATA-framed chunks into a single wire buffer. + fn split_into_data_frames(capsule_bytes: &[u8], splits: &[usize]) -> Vec { + let mut wire = Vec::new(); + let mut prev = 0; + for &s in splits { + wire.extend_from_slice(&wrap_in_data_frame(&capsule_bytes[prev..s])); + prev = s; + } + wire.extend_from_slice(&wrap_in_data_frame(&capsule_bytes[prev..])); + wire + } + + fn reader_from(wire: Vec) -> Http3CapsuleReader>> { + Http3CapsuleReader::new(std::io::Cursor::new(wire)) + } + + #[tokio::test] + async fn test_http3_reader_single_capsule_single_frame() { + let capsule = Capsule::CloseWebTransportSession { code: 42, reason: "bye".into() }; + let mut reader = reader_from(wrap_in_data_frame(&encode_capsule(&capsule))); + assert_eq!(reader.read().await.unwrap().unwrap(), capsule); + assert!(reader.read().await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_http3_reader_capsule_split_across_frames() { + let capsule = Capsule::CloseWebTransportSession { code: 100, reason: "split".into() }; + let bytes = encode_capsule(&capsule); + let mut reader = reader_from(split_into_data_frames(&bytes, &[bytes.len() / 2])); + assert_eq!(reader.read().await.unwrap().unwrap(), capsule); + } + + #[tokio::test] + async fn test_http3_reader_capsule_split_across_three_frames() { + let capsule = Capsule::CloseWebTransportSession { code: 200, reason: "three".into() }; + let bytes = encode_capsule(&capsule); + let mut reader = reader_from(split_into_data_frames(&bytes, &[2, 5])); + assert_eq!(reader.read().await.unwrap().unwrap(), capsule); + } + + #[tokio::test] + async fn test_http3_reader_multiple_capsules_one_frame() { + let c1 = Capsule::Grease { num: 1 }; + let c2 = Capsule::CloseWebTransportSession { code: 7, reason: "done".into() }; + let mut bytes = encode_capsule(&c1); + bytes.extend_from_slice(&encode_capsule(&c2)); + + let mut reader = reader_from(wrap_in_data_frame(&bytes)); + assert_eq!(reader.read().await.unwrap().unwrap(), c1); + assert_eq!(reader.read().await.unwrap().unwrap(), c2); + } + + #[tokio::test] + async fn test_http3_reader_skips_non_data_frames() { + let capsule = Capsule::CloseWebTransportSession { code: 0, reason: String::new() }; + let mut wire = Vec::new(); + // HEADERS frame before the DATA frame. + Frame::HEADERS.encode(&mut wire); + VarInt::from_u32(5).encode(&mut wire); + wire.extend_from_slice(b"hello"); + wire.extend_from_slice(&wrap_in_data_frame(&encode_capsule(&capsule))); + + let mut reader = reader_from(wire); + assert_eq!(reader.read().await.unwrap().unwrap(), capsule); + } + + #[tokio::test] + async fn test_http3_reader_eof_returns_none() { + assert!(reader_from(vec![]).read().await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_http3_reader_partial_capsule_at_eof() { + let mut partial = Vec::new(); + VarInt::from_u64(0x2843).unwrap().encode(&mut partial); + // Missing: length + payload + + let mut reader = reader_from(wrap_in_data_frame(&partial)); + assert!(matches!(reader.read().await, Err(CapsuleError::UnexpectedEnd))); + } + + #[tokio::test] + async fn test_http3_reader_empty_data_frame() { + let capsule = Capsule::CloseWebTransportSession { code: 1, reason: "ok".into() }; + let mut wire = wrap_in_data_frame(&[]); + wire.extend_from_slice(&wrap_in_data_frame(&encode_capsule(&capsule))); + + let mut reader = reader_from(wire); + assert_eq!(reader.read().await.unwrap().unwrap(), capsule); + } } diff --git a/web-transport-quinn/Cargo.toml b/web-transport-quinn/Cargo.toml index 8da01cc3..c3f0decd 100644 --- a/web-transport-quinn/Cargo.toml +++ b/web-transport-quinn/Cargo.toml @@ -40,6 +40,7 @@ thiserror = "2" tokio = { version = "1", default-features = false, features = [ "io-util", "macros", + "time", ] } tracing = "0.1" url = "2" diff --git a/web-transport-quinn/src/session.rs b/web-transport-quinn/src/session.rs index 5ab7b952..1fc93951 100644 --- a/web-transport-quinn/src/session.rs +++ b/web-transport-quinn/src/session.rs @@ -44,6 +44,14 @@ pub struct Session { #[allow(dead_code)] settings: Option>, + // The send side of the CONNECT stream, used to write the CloseWebTransportSession capsule. + // Wrapped in Arc>> so close() can take it exactly once. + connect_send: Arc>>, + + // Close info received from the peer via a CloseWebTransportSession capsule. + // Set by the background task when a capsule is received. + close_capsule: Arc>>, + // The request sent by the client. request: ConnectRequest, @@ -71,6 +79,8 @@ impl Session { // Accept logic is stateful, so use an Arc to share it. let accept = SessionAccept::new(conn.clone(), session_id); + let close_capsule = Arc::new(Mutex::new(None)); + let this = Self { conn, accept: Some(Arc::new(Mutex::new(accept))), @@ -79,40 +89,60 @@ impl Session { header_bi, header_datagram, settings: Some(Arc::new(settings)), + connect_send: Arc::new(Mutex::new(Some(connect.send))), + close_capsule: close_capsule.clone(), request: connect.request.clone(), response: connect.response.clone(), }; - // Run a background task to check if the connect stream is closed. - let mut this2 = this.clone(); + // Run a background task to read capsules from the CONNECT recv stream. + let conn2 = this.conn.clone(); + let connect_send2 = this.connect_send.clone(); tokio::spawn(async move { - let (code, reason) = this2.run_closed(connect).await; - // TODO We shouldn't be closing the QUIC connection with the same error. - this2.close(code, reason.as_bytes()); + let close_info = Self::run_closed(connect.recv).await; + + // Send capsule close info (if any) for Session::closed() to consume. + // This MUST happen before conn.close() so the info is available + // when conn.closed() resolves on other tasks. + if let Some(ref info) = close_info { + *close_capsule.lock().unwrap() = Some(info.clone()); + } + + // Take the send stream so that any concurrent/future close() call + // sees None and doesn't try to write a capsule that can't be delivered + // (the peer already closed). + let _send = connect_send2.lock().unwrap().take(); + + // Close the QUIC connection. + let code = close_info.as_ref().map_or(0, |(c, _)| *c); + let http3_code: quinn::VarInt = web_transport_proto::error_to_http3(code) + .try_into() + .unwrap(); + conn2.close(http3_code, &[]); }); this } - // Keep reading from the control stream until it's closed. - async fn run_closed(&mut self, mut connect: Connected) -> (u32, String) { + // Keep reading capsules from the CONNECT recv stream until it's closed. + // Returns Some((code, reason)) if a CloseWebTransportSession capsule was received, + // or None if the stream closed without a capsule. + async fn run_closed(recv: quinn::RecvStream) -> Option<(u32, String)> { + let mut reader = web_transport_proto::Http3CapsuleReader::new(recv); loop { - match web_transport_proto::Capsule::read(&mut connect.recv).await { + match reader.read().await { Ok(Some(web_transport_proto::Capsule::CloseWebTransportSession { code, reason, - })) => { - return (code, reason); - } + })) => return Some((code, reason)), Ok(Some(web_transport_proto::Capsule::Grease { .. })) => {} Ok(Some(web_transport_proto::Capsule::Unknown { typ, payload })) => { tracing::warn!(%typ, size = payload.len(), "unknown capsule"); } - Ok(None) => { - return (0, "stream closed".to_string()); - } - Err(_) => { - return (1, "capsule error".to_string()); + Ok(None) => return None, + Err(e) => { + tracing::warn!(?e, "failed to read capsule"); + return None; } } } @@ -256,26 +286,109 @@ impl Session { mtu.saturating_sub(self.header_datagram.len()) } - /// Immediately close the connection with an error code and reason. See [`quinn::Connection::close`]. + /// Close the session with an error code and reason. + /// + /// When there is a session ID (WebTransport over HTTP/3), a `CloseWebTransportSession` + /// capsule is written on the CONNECT stream before the QUIC connection is closed. + /// This allows browser clients to receive the close code and reason via `WebTransport.closed`. + /// + /// The capsule write and connection close happen asynchronously in a spawned task. + /// Callers should `await` [`Session::closed()`] if they need to ensure the capsule + /// has been delivered before proceeding. pub fn close(&self, code: u32, reason: &[u8]) { - let code = if self.session_id.is_some() { - web_transport_proto::error_to_http3(code) - .try_into() - .unwrap() + if self.session_id.is_some() { + // Take the send stream. Only the first caller gets it. + let send = self.connect_send.lock().unwrap().take(); + + if let Some(send) = send { + let conn = self.conn.clone(); + let capsule = web_transport_proto::Capsule::CloseWebTransportSession { + code, + reason: String::from_utf8_lossy(reason).into_owned(), + }; + let timeout = self.rtt() * 3; + + tokio::spawn(async move { + Self::close_with_capsule(conn, send, capsule, code, timeout).await; + }); + } else { + // Send stream already taken — close() was already called. + // The first call is handling the capsule + connection close. + tracing::debug!("close() called more than once; ignoring duplicate"); + } } else { - code.into() - }; + // Raw QUIC mode: no capsule needed. + self.conn.close(code.into(), reason); + } + } - self.conn.close(code, reason) + /// Write the CloseWebTransportSession capsule, finish the stream, wait for + /// the peer to close the connection (or timeout), then force-close. + async fn close_with_capsule( + conn: quinn::Connection, + mut send: quinn::SendStream, + capsule: web_transport_proto::Capsule, + code: u32, + timeout: std::time::Duration, + ) { + let http3_code: quinn::VarInt = web_transport_proto::error_to_http3(code) + .try_into() + .unwrap(); + + // Encode the capsule, then wrap it in an HTTP/3 DATA frame. + // In HTTP/3, capsule data is carried inside DATA frames on the CONNECT + // stream (RFC 9297 Section 3.2). + let mut capsule_bytes = Vec::new(); + capsule.encode(&mut capsule_bytes); + + let mut frame = Vec::new(); + Frame::DATA.encode(&mut frame); + VarInt::from_u32(capsule_bytes.len() as u32).encode(&mut frame); + frame.extend_from_slice(&capsule_bytes); + + // Write the DATA frame to the CONNECT send stream. + if let Err(e) = send.write_all(&frame).await { + tracing::warn!(?e, "failed to write CloseWebTransportSession capsule"); + conn.close(http3_code, b""); + return; + } + + // FIN the send stream so the peer knows no more capsules are coming. + if let Err(e) = send.finish() { + tracing::warn!(?e, "failed to finish CONNECT send stream"); + conn.close(http3_code, b""); + return; + } + + // Wait for the peer to close the CONNECT stream after receiving the capsule. + if tokio::time::timeout(timeout, conn.closed()).await.is_err() { + tracing::debug!("timeout waiting for peer to close; force-closing connection"); + conn.close(http3_code, b""); + } } /// Wait until the session is closed, returning the error. See [`quinn::Connection::closed`]. + /// + /// If the peer sent a `CloseWebTransportSession` capsule, the returned error will be + /// [`WebTransportError::Closed`] with the code and reason from the capsule. pub async fn closed(&self) -> SessionError { - self.conn.closed().await.into() + let err = self.conn.closed().await; + + // Check if the background task received a CloseWebTransportSession capsule. + // The background task always sends the capsule info before calling conn.close(), + // so the value is guaranteed to be visible here when conn.closed() resolves. + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return WebTransportError::Closed(code, reason).into(); + } + + err.into() } /// Return why the session was closed, or None if it's not closed. See [`quinn::Connection::close_reason`]. pub fn close_reason(&self) -> Option { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return Some(WebTransportError::Closed(code, reason).into()); + } self.conn.close_reason().map(Into::into) } @@ -304,6 +417,8 @@ impl Session { header_datagram: Default::default(), accept: None, settings: None, + connect_send: Arc::new(Mutex::new(None)), + close_capsule: Arc::new(Mutex::new(None)), request: request.into(), response: response.into(), } From b13cd306b7fca5c88fcbb1d53303032a37c5e77d Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:14:06 -0800 Subject: [PATCH 16/18] Propagate close capsule info to stream, accept, and datagram errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a peer sends a CloseWebTransportSession capsule, the background task stores the (code, reason) and calls conn.close(), causing all active Quinn operations to fail with LocallyClosed. Previously only Session::closed() checked the capsule—now every error path maps LocallyClosed to WebTransportError::Closed(code, reason). - Add CloseCapsule type alias and map_conn_error/map_session_error/ map_send_datagram_error helpers on Session and SessionAccept - Thread close_capsule into RecvStream and SendStream; add map_read_error/map_write_error helpers - Store local close info in close() so locally-initiated closes also surface WebTransportError::Closed consistently Co-Authored-By: Claude Opus 4.6 --- web-transport-quinn/src/recv.rs | 69 +++++++++++-- web-transport-quinn/src/send.rs | 57 ++++++++-- web-transport-quinn/src/session.rs | 160 +++++++++++++++++++++-------- 3 files changed, 228 insertions(+), 58 deletions(-) diff --git a/web-transport-quinn/src/recv.rs b/web-transport-quinn/src/recv.rs index 6734c202..796983b5 100644 --- a/web-transport-quinn/src/recv.rs +++ b/web-transport-quinn/src/recv.rs @@ -6,17 +6,49 @@ use std::{ use bytes::Bytes; -use crate::{ReadError, ReadExactError, ReadToEndError, SessionError}; +use crate::{ + session::CloseCapsule, ReadError, ReadExactError, ReadToEndError, SessionError, + WebTransportError, +}; /// A stream that can be used to recieve bytes. See [`quinn::RecvStream`]. #[derive(Debug)] pub struct RecvStream { inner: quinn::RecvStream, + close_capsule: CloseCapsule, } impl RecvStream { - pub(crate) fn new(stream: quinn::RecvStream) -> Self { - Self { inner: stream } + pub(crate) fn new(stream: quinn::RecvStream, close_capsule: CloseCapsule) -> Self { + Self { + inner: stream, + close_capsule, + } + } + + fn map_read_error(&self, e: quinn::ReadError) -> ReadError { + if let quinn::ReadError::ConnectionLost(ref conn_err) = e { + if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return ReadError::SessionError(WebTransportError::Closed(code, reason).into()); + } + } + } + e.into() + } + + fn map_read_exact_error(&self, e: quinn::ReadExactError) -> ReadExactError { + if let quinn::ReadExactError::ReadError(read_err) = e { + return self.map_read_error(read_err).into(); + } + e.into() + } + + fn map_read_to_end_error(&self, e: quinn::ReadToEndError) -> ReadToEndError { + if let quinn::ReadToEndError::Read(read_err) = e { + return self.map_read_error(read_err).into(); + } + e.into() } /// Tell the other end to stop sending data with the given error code. See [`quinn::RecvStream::stop`]. @@ -31,12 +63,18 @@ impl RecvStream { /// Read some data into the buffer and return the amount read. See [`quinn::RecvStream::read`]. pub async fn read(&mut self, buf: &mut [u8]) -> Result, ReadError> { - self.inner.read(buf).await.map_err(Into::into) + self.inner + .read(buf) + .await + .map_err(|e| self.map_read_error(e)) } /// Fill the entire buffer with data. See [`quinn::RecvStream::read_exact`]. pub async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), ReadExactError> { - self.inner.read_exact(buf).await.map_err(Into::into) + self.inner + .read_exact(buf) + .await + .map_err(|e| self.map_read_exact_error(e)) } /// Read a chunk of data from the stream. See [`quinn::RecvStream::read_chunk`]. @@ -48,17 +86,23 @@ impl RecvStream { self.inner .read_chunk(max_length, ordered) .await - .map_err(Into::into) + .map_err(|e| self.map_read_error(e)) } /// Read chunks of data from the stream. See [`quinn::RecvStream::read_chunks`]. pub async fn read_chunks(&mut self, bufs: &mut [Bytes]) -> Result, ReadError> { - self.inner.read_chunks(bufs).await.map_err(Into::into) + self.inner + .read_chunks(bufs) + .await + .map_err(|e| self.map_read_error(e)) } /// Read until the end of the stream or the limit is hit. See [`quinn::RecvStream::read_to_end`]. pub async fn read_to_end(&mut self, size_limit: usize) -> Result, ReadToEndError> { - self.inner.read_to_end(size_limit).await.map_err(Into::into) + self.inner + .read_to_end(size_limit) + .await + .map_err(|e| self.map_read_to_end_error(e)) } /// Block until the stream has been reset and return the error code. See [`quinn::RecvStream::received_reset`]. @@ -70,7 +114,14 @@ impl RecvStream { Ok(Some(code)) => Ok(Some( web_transport_proto::error_from_http3(code.into_inner()).unwrap(), )), - Err(quinn::ResetError::ConnectionLost(e)) => Err(e.into()), + Err(quinn::ResetError::ConnectionLost(conn_err)) => { + if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return Err(WebTransportError::Closed(code, reason).into()); + } + } + Err(conn_err.into()) + } Err(quinn::ResetError::ZeroRttRejected) => unreachable!("0-RTT not supported"), } } diff --git a/web-transport-quinn/src/send.rs b/web-transport-quinn/src/send.rs index 836490f0..92754cd6 100644 --- a/web-transport-quinn/src/send.rs +++ b/web-transport-quinn/src/send.rs @@ -6,7 +6,7 @@ use std::{ use bytes::{Buf, Bytes}; -use crate::{ClosedStream, SessionError, WriteError}; +use crate::{session::CloseCapsule, ClosedStream, SessionError, WebTransportError, WriteError}; /// A stream that can be used to send bytes. See [`quinn::SendStream`]. /// @@ -15,11 +15,28 @@ use crate::{ClosedStream, SessionError, WriteError}; #[derive(Debug)] pub struct SendStream { stream: quinn::SendStream, + close_capsule: CloseCapsule, } impl SendStream { - pub(crate) fn new(stream: quinn::SendStream) -> Self { - Self { stream } + pub(crate) fn new(stream: quinn::SendStream, close_capsule: CloseCapsule) -> Self { + Self { + stream, + close_capsule, + } + } + + fn map_write_error(&self, e: quinn::WriteError) -> WriteError { + if let quinn::WriteError::ConnectionLost(ref conn_err) = e { + if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return WriteError::SessionError( + WebTransportError::Closed(code, reason).into(), + ); + } + } + } + e.into() } /// Abruptly reset the stream with the provided error code. See [`quinn::SendStream::reset`]. @@ -38,7 +55,14 @@ impl SendStream { match self.stream.stopped().await { Ok(Some(code)) => Ok(web_transport_proto::error_from_http3(code.into_inner())), Ok(None) => Ok(None), - Err(quinn::StoppedError::ConnectionLost(e)) => Err(e.into()), + Err(quinn::StoppedError::ConnectionLost(conn_err)) => { + if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return Err(WebTransportError::Closed(code, reason).into()); + } + } + Err(conn_err.into()) + } Err(quinn::StoppedError::ZeroRttRejected) => unreachable!("0-RTT not supported"), } } @@ -47,27 +71,42 @@ impl SendStream { /// Write some data to the stream, returning the size written. See [`quinn::SendStream::write`]. pub async fn write(&mut self, buf: &[u8]) -> Result { - self.stream.write(buf).await.map_err(Into::into) + self.stream + .write(buf) + .await + .map_err(|e| self.map_write_error(e)) } /// Write all of the data to the stream. See [`quinn::SendStream::write_all`]. pub async fn write_all(&mut self, buf: &[u8]) -> Result<(), WriteError> { - self.stream.write_all(buf).await.map_err(Into::into) + self.stream + .write_all(buf) + .await + .map_err(|e| self.map_write_error(e)) } /// Write chunks of data to the stream. See [`quinn::SendStream::write_chunks`]. pub async fn write_chunks(&mut self, bufs: &mut [Bytes]) -> Result { - self.stream.write_chunks(bufs).await.map_err(Into::into) + self.stream + .write_chunks(bufs) + .await + .map_err(|e| self.map_write_error(e)) } /// Write a chunk of data to the stream. See [`quinn::SendStream::write_chunk`]. pub async fn write_chunk(&mut self, buf: Bytes) -> Result<(), WriteError> { - self.stream.write_chunk(buf).await.map_err(Into::into) + self.stream + .write_chunk(buf) + .await + .map_err(|e| self.map_write_error(e)) } /// Write all of the chunks of data to the stream. See [`quinn::SendStream::write_all_chunks`]. pub async fn write_all_chunks(&mut self, bufs: &mut [Bytes]) -> Result<(), WriteError> { - self.stream.write_all_chunks(bufs).await.map_err(Into::into) + self.stream + .write_all_chunks(bufs) + .await + .map_err(|e| self.map_write_error(e)) } /// Mark the stream as finished, such that no more data can be written. See [`quinn::SendStream::finish`]. diff --git a/web-transport-quinn/src/session.rs b/web-transport-quinn/src/session.rs index 1fc93951..a5d4d6ef 100644 --- a/web-transport-quinn/src/session.rs +++ b/web-transport-quinn/src/session.rs @@ -16,6 +16,10 @@ use crate::{ ClientError, Connected, RecvStream, SendStream, SessionError, Settings, WebTransportError, }; +/// Shared close capsule info set by the background task when a +/// `CloseWebTransportSession` capsule is received from the peer. +pub(crate) type CloseCapsule = Arc>>; + /// An established WebTransport session, acting like a full QUIC connection. See [`quinn::Connection`]. /// /// It is important to remember that WebTransport is layered on top of QUIC: @@ -76,11 +80,11 @@ impl Session { let mut header_datagram = Vec::new(); session_id.encode(&mut header_datagram); - // Accept logic is stateful, so use an Arc to share it. - let accept = SessionAccept::new(conn.clone(), session_id); - let close_capsule = Arc::new(Mutex::new(None)); + // Accept logic is stateful, so use an Arc to share it. + let accept = SessionAccept::new(conn.clone(), session_id, close_capsule.clone()); + let this = Self { conn, accept: Some(Arc::new(Mutex::new(accept))), @@ -172,57 +176,81 @@ impl Session { /// Accept a new unidirectional stream. See [`quinn::Connection::accept_uni`]. pub async fn accept_uni(&self) -> Result { if let Some(accept) = &self.accept { - poll_fn(|cx| accept.lock().unwrap().poll_accept_uni(cx)).await + poll_fn(|cx| accept.lock().unwrap().poll_accept_uni(cx)) + .await + .map_err(|e| self.map_session_error(e)) } else { self.conn .accept_uni() .await - .map(RecvStream::new) - .map_err(Into::into) + .map(|recv| RecvStream::new(recv, self.close_capsule.clone())) + .map_err(|e| self.map_conn_error(e)) } } /// Accept a new bidirectional stream. See [`quinn::Connection::accept_bi`]. pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), SessionError> { if let Some(accept) = &self.accept { - poll_fn(|cx| accept.lock().unwrap().poll_accept_bi(cx)).await + poll_fn(|cx| accept.lock().unwrap().poll_accept_bi(cx)) + .await + .map_err(|e| self.map_session_error(e)) } else { self.conn .accept_bi() .await - .map(|(send, recv)| (SendStream::new(send), RecvStream::new(recv))) - .map_err(Into::into) + .map(|(send, recv)| { + ( + SendStream::new(send, self.close_capsule.clone()), + RecvStream::new(recv, self.close_capsule.clone()), + ) + }) + .map_err(|e| self.map_conn_error(e)) } } /// Open a new unidirectional stream. See [`quinn::Connection::open_uni`]. pub async fn open_uni(&self) -> Result { - let mut send = self.conn.open_uni().await?; + let mut send = self + .conn + .open_uni() + .await + .map_err(|e| self.map_conn_error(e))?; // Set the stream priority to max and then write the stream header. // Otherwise the application could write data with lower priority than the header, resulting in queuing. // Also the header is very important for determining the session ID without reliable reset. send.set_priority(i32::MAX).ok(); - Self::write_full(&mut send, &self.header_uni).await?; + Self::write_full(&mut send, &self.header_uni) + .await + .map_err(|e| self.map_session_error(e))?; // Reset the stream priority back to the default of 0. send.set_priority(0).ok(); - Ok(SendStream::new(send)) + Ok(SendStream::new(send, self.close_capsule.clone())) } /// Open a new bidirectional stream. See [`quinn::Connection::open_bi`]. pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), SessionError> { - let (mut send, recv) = self.conn.open_bi().await?; + let (mut send, recv) = self + .conn + .open_bi() + .await + .map_err(|e| self.map_conn_error(e))?; // Set the stream priority to max and then write the stream header. // Otherwise the application could write data with lower priority than the header, resulting in queuing. // Also the header is very important for determining the session ID without reliable reset. send.set_priority(i32::MAX).ok(); - Self::write_full(&mut send, &self.header_bi).await?; + Self::write_full(&mut send, &self.header_bi) + .await + .map_err(|e| self.map_session_error(e))?; // Reset the stream priority back to the default of 0. send.set_priority(0).ok(); - Ok((SendStream::new(send), RecvStream::new(recv))) + Ok(( + SendStream::new(send, self.close_capsule.clone()), + RecvStream::new(recv, self.close_capsule.clone()), + )) } /// Asynchronously receives an application datagram from the remote peer. @@ -235,7 +263,7 @@ impl Session { .conn .read_datagram() .await - .map_err(SessionError::from)?; + .map_err(|e| self.map_session_error(SessionError::from(e)))?; let mut cursor = Cursor::new(&datagram); @@ -259,7 +287,7 @@ impl Session { /// Datagrams are unreliable and may be dropped or delivered out of order. /// The data must be smaller than [`max_datagram_size`](Self::max_datagram_size). pub fn send_datagram(&self, data: Bytes) -> Result<(), SessionError> { - if !self.header_datagram.is_empty() { + let result = if !self.header_datagram.is_empty() { // Unfortunately, we need to allocate/copy each datagram because of the Quinn API. // Pls go +1 if you care: https://github.com/quinn-rs/quinn/issues/1724 let mut buf = BytesMut::with_capacity(self.header_datagram.len() + data.len()); @@ -268,12 +296,12 @@ impl Session { buf.extend_from_slice(&self.header_datagram); buf.extend_from_slice(&data); - self.conn.send_datagram(buf.into())?; + self.conn.send_datagram(buf.into()) } else { - self.conn.send_datagram(data)?; - } + self.conn.send_datagram(data) + }; - Ok(()) + result.map_err(|e| self.map_send_datagram_error(e)) } /// Computes the maximum size of datagrams that may be passed to @@ -301,10 +329,20 @@ impl Session { let send = self.connect_send.lock().unwrap().take(); if let Some(send) = send { + let reason = String::from_utf8_lossy(reason).into_owned(); + + // Store the local close info so closed()/map_conn_error() can + // return WebTransportError::Closed even for locally-initiated closes. + // Use get_or_insert to avoid overwriting a peer capsule that arrived first. + self.close_capsule + .lock() + .unwrap() + .get_or_insert_with(|| (code, reason.clone())); + let conn = self.conn.clone(); let capsule = web_transport_proto::Capsule::CloseWebTransportSession { code, - reason: String::from_utf8_lossy(reason).into_owned(), + reason, }; let timeout = self.rtt() * 3; @@ -372,24 +410,48 @@ impl Session { /// If the peer sent a `CloseWebTransportSession` capsule, the returned error will be /// [`WebTransportError::Closed`] with the code and reason from the capsule. pub async fn closed(&self) -> SessionError { - let err = self.conn.closed().await; + self.map_conn_error(self.conn.closed().await) + } + + /// Return why the session was closed, or None if it's not closed. See [`quinn::Connection::close_reason`]. + pub fn close_reason(&self) -> Option { + self.conn.close_reason().map(|e| self.map_conn_error(e)) + } - // Check if the background task received a CloseWebTransportSession capsule. - // The background task always sends the capsule info before calling conn.close(), - // so the value is guaranteed to be visible here when conn.closed() resolves. - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return WebTransportError::Closed(code, reason).into(); + /// Convert a `quinn::ConnectionError` to `SessionError`, checking the + /// close_capsule so that `LocallyClosed` becomes `WebTransportError::Closed` + /// when a close capsule was received from the peer. + fn map_conn_error(&self, e: quinn::ConnectionError) -> SessionError { + if matches!(e, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return WebTransportError::Closed(code, reason).into(); + } } + e.into() + } - err.into() + /// Convert a `SessionError` that wraps `LocallyClosed` into + /// `WebTransportError::Closed` when a close capsule was received. + fn map_session_error(&self, e: SessionError) -> SessionError { + if let SessionError::ConnectionError(ref conn_err) = e { + if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return WebTransportError::Closed(code, reason).into(); + } + } + } + e } - /// Return why the session was closed, or None if it's not closed. See [`quinn::Connection::close_reason`]. - pub fn close_reason(&self) -> Option { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return Some(WebTransportError::Closed(code, reason).into()); + fn map_send_datagram_error(&self, e: quinn::SendDatagramError) -> SessionError { + if let quinn::SendDatagramError::ConnectionLost(ref conn_err) = e { + if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return WebTransportError::Closed(code, reason).into(); + } + } } - self.conn.close_reason().map(Into::into) + e.into() } async fn write_full(send: &mut quinn::SendStream, buf: &[u8]) -> Result<(), SessionError> { @@ -486,10 +548,17 @@ pub struct SessionAccept { // Keep track of work being done to read/write the WebTransport stream header. pending_uni: FuturesUnordered>>, pending_bi: FuturesUnordered>>, + + // Close capsule info from the peer, shared with Session. + close_capsule: CloseCapsule, } impl SessionAccept { - pub(crate) fn new(conn: quinn::Connection, session_id: VarInt) -> Self { + pub(crate) fn new( + conn: quinn::Connection, + session_id: VarInt, + close_capsule: CloseCapsule, + ) -> Self { // Create a stream that just outputs new streams, so it's easy to call from poll. let accept_uni = Box::pin(futures::stream::unfold(conn.clone(), |conn| async { Some((conn.accept_uni().await, conn)) @@ -510,7 +579,18 @@ impl SessionAccept { pending_uni: FuturesUnordered::new(), pending_bi: FuturesUnordered::new(), + + close_capsule, + } + } + + fn map_conn_error(&self, e: quinn::ConnectionError) -> SessionError { + if matches!(e, quinn::ConnectionError::LocallyClosed) { + if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { + return WebTransportError::Closed(code, reason).into(); + } } + e.into() } // This is poll-based because we accept and decode streams in parallel. @@ -524,7 +604,7 @@ impl SessionAccept { // Accept any new streams. if let Poll::Ready(Some(res)) = self.accept_uni.poll_next_unpin(cx) { // Start decoding the header and add the future to the list of pending streams. - let recv = res?; + let recv = res.map_err(|e| self.map_conn_error(e))?; let pending = Self::decode_uni(recv, self.session_id); self.pending_uni.push(Box::pin(pending)); @@ -545,7 +625,7 @@ impl SessionAccept { // Decide if we keep looping based on the type. match typ { StreamUni::WEBTRANSPORT => { - let recv = RecvStream::new(recv); + let recv = RecvStream::new(recv, self.close_capsule.clone()); return Poll::Ready(Ok(recv)); } StreamUni::QPACK_DECODER => { @@ -595,7 +675,7 @@ impl SessionAccept { // Accept any new streams. if let Poll::Ready(Some(res)) = self.accept_bi.poll_next_unpin(cx) { // Start decoding the header and add the future to the list of pending streams. - let (send, recv) = res?; + let (send, recv) = res.map_err(|e| self.map_conn_error(e))?; let pending = Self::decode_bi(send, recv, self.session_id); self.pending_bi.push(Box::pin(pending)); @@ -615,8 +695,8 @@ impl SessionAccept { if let Some((send, recv)) = res { // Wrap the streams in our own types for correct error codes. - let send = SendStream::new(send); - let recv = RecvStream::new(recv); + let send = SendStream::new(send, self.close_capsule.clone()); + let recv = RecvStream::new(recv, self.close_capsule.clone()); return Poll::Ready(Ok((send, recv))); } From 1c1eb1c8f83f99a869097ea1d4ca4959df2addb1 Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:41:20 -0800 Subject: [PATCH 17/18] Replace CloseCapsule mutex with Arc> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use OnceLock for set-once, first-writer-wins semantics between close() and the background capsule reader task. This eliminates the Mutex> in favor of lock-free reads on every operation's error path. - close() uses error.set() as the sole guard — if it wins, it owns the capsule write and connection teardown; if the background task already set a remote close error, close() returns early - Background task symmetrically bails out if close() won the race - Consolidated three near-identical error mappers (map_conn_error, map_session_error, map_send_datagram_error) into a single map_error(impl Into) - closed() always awaits conn.closed() to ensure the capsule is delivered before returning, unlike quinn's early-return pattern - Removed connect_send reference from background task since the OnceLock guard makes it unnecessary Co-Authored-By: Claude Opus 4.6 --- web-transport-quinn/src/recv.rs | 50 +++----- web-transport-quinn/src/send.rs | 28 ++--- web-transport-quinn/src/session.rs | 193 ++++++++++++----------------- 3 files changed, 102 insertions(+), 169 deletions(-) diff --git a/web-transport-quinn/src/recv.rs b/web-transport-quinn/src/recv.rs index 796983b5..96208b07 100644 --- a/web-transport-quinn/src/recv.rs +++ b/web-transport-quinn/src/recv.rs @@ -1,52 +1,32 @@ use std::{ io, pin::Pin, + sync::{Arc, OnceLock}, task::{Context, Poll}, }; use bytes::Bytes; -use crate::{ - session::CloseCapsule, ReadError, ReadExactError, ReadToEndError, SessionError, - WebTransportError, -}; +use crate::{ReadError, ReadExactError, ReadToEndError, SessionError}; /// A stream that can be used to recieve bytes. See [`quinn::RecvStream`]. #[derive(Debug)] pub struct RecvStream { inner: quinn::RecvStream, - close_capsule: CloseCapsule, + error: Arc>, } impl RecvStream { - pub(crate) fn new(stream: quinn::RecvStream, close_capsule: CloseCapsule) -> Self { + pub(crate) fn new(stream: quinn::RecvStream, error: Arc>) -> Self { Self { inner: stream, - close_capsule, + error, } } fn map_read_error(&self, e: quinn::ReadError) -> ReadError { - if let quinn::ReadError::ConnectionLost(ref conn_err) = e { - if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return ReadError::SessionError(WebTransportError::Closed(code, reason).into()); - } - } - } - e.into() - } - - fn map_read_exact_error(&self, e: quinn::ReadExactError) -> ReadExactError { - if let quinn::ReadExactError::ReadError(read_err) = e { - return self.map_read_error(read_err).into(); - } - e.into() - } - - fn map_read_to_end_error(&self, e: quinn::ReadToEndError) -> ReadToEndError { - if let quinn::ReadToEndError::Read(read_err) = e { - return self.map_read_error(read_err).into(); + if let Some(err) = self.error.get() { + return ReadError::SessionError(err.clone()); } e.into() } @@ -74,7 +54,10 @@ impl RecvStream { self.inner .read_exact(buf) .await - .map_err(|e| self.map_read_exact_error(e)) + .map_err(|e| match e { + quinn::ReadExactError::ReadError(e) => self.map_read_error(e).into(), + e => e.into(), + }) } /// Read a chunk of data from the stream. See [`quinn::RecvStream::read_chunk`]. @@ -102,7 +85,10 @@ impl RecvStream { self.inner .read_to_end(size_limit) .await - .map_err(|e| self.map_read_to_end_error(e)) + .map_err(|e| match e { + quinn::ReadToEndError::Read(e) => self.map_read_error(e).into(), + e => e.into(), + }) } /// Block until the stream has been reset and return the error code. See [`quinn::RecvStream::received_reset`]. @@ -115,10 +101,8 @@ impl RecvStream { web_transport_proto::error_from_http3(code.into_inner()).unwrap(), )), Err(quinn::ResetError::ConnectionLost(conn_err)) => { - if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return Err(WebTransportError::Closed(code, reason).into()); - } + if let Some(err) = self.error.get() { + return Err(err.clone()); } Err(conn_err.into()) } diff --git a/web-transport-quinn/src/send.rs b/web-transport-quinn/src/send.rs index 92754cd6..504ec8ac 100644 --- a/web-transport-quinn/src/send.rs +++ b/web-transport-quinn/src/send.rs @@ -1,12 +1,13 @@ use std::{ io, pin::Pin, + sync::{Arc, OnceLock}, task::{Context, Poll}, }; use bytes::{Buf, Bytes}; -use crate::{session::CloseCapsule, ClosedStream, SessionError, WebTransportError, WriteError}; +use crate::{ClosedStream, SessionError, WriteError}; /// A stream that can be used to send bytes. See [`quinn::SendStream`]. /// @@ -15,26 +16,17 @@ use crate::{session::CloseCapsule, ClosedStream, SessionError, WebTransportError #[derive(Debug)] pub struct SendStream { stream: quinn::SendStream, - close_capsule: CloseCapsule, + error: Arc>, } impl SendStream { - pub(crate) fn new(stream: quinn::SendStream, close_capsule: CloseCapsule) -> Self { - Self { - stream, - close_capsule, - } + pub(crate) fn new(stream: quinn::SendStream, error: Arc>) -> Self { + Self { stream, error } } fn map_write_error(&self, e: quinn::WriteError) -> WriteError { - if let quinn::WriteError::ConnectionLost(ref conn_err) = e { - if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return WriteError::SessionError( - WebTransportError::Closed(code, reason).into(), - ); - } - } + if let Some(err) = self.error.get() { + return WriteError::SessionError(err.clone()); } e.into() } @@ -56,10 +48,8 @@ impl SendStream { Ok(Some(code)) => Ok(web_transport_proto::error_from_http3(code.into_inner())), Ok(None) => Ok(None), Err(quinn::StoppedError::ConnectionLost(conn_err)) => { - if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return Err(WebTransportError::Closed(code, reason).into()); - } + if let Some(err) = self.error.get() { + return Err(err.clone()); } Err(conn_err.into()) } diff --git a/web-transport-quinn/src/session.rs b/web-transport-quinn/src/session.rs index a5d4d6ef..84df7b93 100644 --- a/web-transport-quinn/src/session.rs +++ b/web-transport-quinn/src/session.rs @@ -4,7 +4,7 @@ use std::{ io::Cursor, ops::Deref, pin::Pin, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, task::{ready, Context, Poll}, }; @@ -16,10 +16,6 @@ use crate::{ ClientError, Connected, RecvStream, SendStream, SessionError, Settings, WebTransportError, }; -/// Shared close capsule info set by the background task when a -/// `CloseWebTransportSession` capsule is received from the peer. -pub(crate) type CloseCapsule = Arc>>; - /// An established WebTransport session, acting like a full QUIC connection. See [`quinn::Connection`]. /// /// It is important to remember that WebTransport is layered on top of QUIC: @@ -52,9 +48,10 @@ pub struct Session { // Wrapped in Arc>> so close() can take it exactly once. connect_send: Arc>>, - // Close info received from the peer via a CloseWebTransportSession capsule. - // Set by the background task when a capsule is received. - close_capsule: Arc>>, + // Session error, set once by either local close() or the background task + // when a remote CloseWebTransportSession capsule is received. + // Uses OnceLock for set-once, first-writer-wins semantics with lock-free reads. + error: Arc>, // The request sent by the client. request: ConnectRequest, @@ -80,10 +77,10 @@ impl Session { let mut header_datagram = Vec::new(); session_id.encode(&mut header_datagram); - let close_capsule = Arc::new(Mutex::new(None)); + let error: Arc> = Arc::new(OnceLock::new()); // Accept logic is stateful, so use an Arc to share it. - let accept = SessionAccept::new(conn.clone(), session_id, close_capsule.clone()); + let accept = SessionAccept::new(conn.clone(), session_id, error.clone()); let this = Self { conn, @@ -94,31 +91,27 @@ impl Session { header_datagram, settings: Some(Arc::new(settings)), connect_send: Arc::new(Mutex::new(Some(connect.send))), - close_capsule: close_capsule.clone(), + error: error.clone(), request: connect.request.clone(), response: connect.response.clone(), }; // Run a background task to read capsules from the CONNECT recv stream. let conn2 = this.conn.clone(); - let connect_send2 = this.connect_send.clone(); tokio::spawn(async move { let close_info = Self::run_closed(connect.recv).await; + let code = close_info.as_ref().map_or(0, |(c, _)| *c); - // Send capsule close info (if any) for Session::closed() to consume. - // This MUST happen before conn.close() so the info is available - // when conn.closed() resolves on other tasks. - if let Some(ref info) = close_info { - *close_capsule.lock().unwrap() = Some(info.clone()); + // Try to record the remote close error. If close() already set + // the error, it owns the connection teardown, so we bail out. + let err: SessionError = match close_info { + Some((code, reason)) => WebTransportError::Closed(code, reason).into(), + None => quinn::ConnectionError::LocallyClosed.into(), + }; + if error.set(err).is_err() { + return; } - // Take the send stream so that any concurrent/future close() call - // sees None and doesn't try to write a capsule that can't be delivered - // (the peer already closed). - let _send = connect_send2.lock().unwrap().take(); - - // Close the QUIC connection. - let code = close_info.as_ref().map_or(0, |(c, _)| *c); let http3_code: quinn::VarInt = web_transport_proto::error_to_http3(code) .try_into() .unwrap(); @@ -178,13 +171,13 @@ impl Session { if let Some(accept) = &self.accept { poll_fn(|cx| accept.lock().unwrap().poll_accept_uni(cx)) .await - .map_err(|e| self.map_session_error(e)) + .map_err(|e| self.map_error(e)) } else { self.conn .accept_uni() .await - .map(|recv| RecvStream::new(recv, self.close_capsule.clone())) - .map_err(|e| self.map_conn_error(e)) + .map(|recv| RecvStream::new(recv, self.error.clone())) + .map_err(|e| self.map_error(e)) } } @@ -193,28 +186,24 @@ impl Session { if let Some(accept) = &self.accept { poll_fn(|cx| accept.lock().unwrap().poll_accept_bi(cx)) .await - .map_err(|e| self.map_session_error(e)) + .map_err(|e| self.map_error(e)) } else { self.conn .accept_bi() .await .map(|(send, recv)| { ( - SendStream::new(send, self.close_capsule.clone()), - RecvStream::new(recv, self.close_capsule.clone()), + SendStream::new(send, self.error.clone()), + RecvStream::new(recv, self.error.clone()), ) }) - .map_err(|e| self.map_conn_error(e)) + .map_err(|e| self.map_error(e)) } } /// Open a new unidirectional stream. See [`quinn::Connection::open_uni`]. pub async fn open_uni(&self) -> Result { - let mut send = self - .conn - .open_uni() - .await - .map_err(|e| self.map_conn_error(e))?; + let mut send = self.conn.open_uni().await.map_err(|e| self.map_error(e))?; // Set the stream priority to max and then write the stream header. // Otherwise the application could write data with lower priority than the header, resulting in queuing. @@ -222,20 +211,16 @@ impl Session { send.set_priority(i32::MAX).ok(); Self::write_full(&mut send, &self.header_uni) .await - .map_err(|e| self.map_session_error(e))?; + .map_err(|e| self.map_error(e))?; // Reset the stream priority back to the default of 0. send.set_priority(0).ok(); - Ok(SendStream::new(send, self.close_capsule.clone())) + Ok(SendStream::new(send, self.error.clone())) } /// Open a new bidirectional stream. See [`quinn::Connection::open_bi`]. pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), SessionError> { - let (mut send, recv) = self - .conn - .open_bi() - .await - .map_err(|e| self.map_conn_error(e))?; + let (mut send, recv) = self.conn.open_bi().await.map_err(|e| self.map_error(e))?; // Set the stream priority to max and then write the stream header. // Otherwise the application could write data with lower priority than the header, resulting in queuing. @@ -243,13 +228,13 @@ impl Session { send.set_priority(i32::MAX).ok(); Self::write_full(&mut send, &self.header_bi) .await - .map_err(|e| self.map_session_error(e))?; + .map_err(|e| self.map_error(e))?; // Reset the stream priority back to the default of 0. send.set_priority(0).ok(); Ok(( - SendStream::new(send, self.close_capsule.clone()), - RecvStream::new(recv, self.close_capsule.clone()), + SendStream::new(send, self.error.clone()), + RecvStream::new(recv, self.error.clone()), )) } @@ -263,7 +248,7 @@ impl Session { .conn .read_datagram() .await - .map_err(|e| self.map_session_error(SessionError::from(e)))?; + .map_err(|e| self.map_error(e))?; let mut cursor = Cursor::new(&datagram); @@ -301,7 +286,13 @@ impl Session { self.conn.send_datagram(data) }; - result.map_err(|e| self.map_send_datagram_error(e)) + result.map_err(|e| match self.error.get() { + Some(SessionError::ConnectionError(conn_err)) => { + quinn::SendDatagramError::ConnectionLost(conn_err.clone()).into() + } + Some(err) => err.clone(), + None => e.into(), + }) } /// Computes the maximum size of datagrams that may be passed to @@ -321,38 +312,31 @@ impl Session { /// This allows browser clients to receive the close code and reason via `WebTransport.closed`. /// /// The capsule write and connection close happen asynchronously in a spawned task. - /// Callers should `await` [`Session::closed()`] if they need to ensure the capsule - /// has been delivered before proceeding. + /// Callers should `await` [`Session::closed()`] to ensure the capsule has been + /// delivered. Session operations will fail once the QUIC connection is closed. pub fn close(&self, code: u32, reason: &[u8]) { + // Record the local close error. First writer wins — if the background + // task already set a remote close error, or close() was already called, + // this is a no-op. + let err = SessionError::ConnectionError(quinn::ConnectionError::LocallyClosed); + if self.error.set(err).is_err() { + return; + } + if self.session_id.is_some() { - // Take the send stream. Only the first caller gets it. + // Take the send stream for the capsule write. let send = self.connect_send.lock().unwrap().take(); if let Some(send) = send { let reason = String::from_utf8_lossy(reason).into_owned(); - - // Store the local close info so closed()/map_conn_error() can - // return WebTransportError::Closed even for locally-initiated closes. - // Use get_or_insert to avoid overwriting a peer capsule that arrived first. - self.close_capsule - .lock() - .unwrap() - .get_or_insert_with(|| (code, reason.clone())); - let conn = self.conn.clone(); - let capsule = web_transport_proto::Capsule::CloseWebTransportSession { - code, - reason, - }; + let capsule = + web_transport_proto::Capsule::CloseWebTransportSession { code, reason }; let timeout = self.rtt() * 3; tokio::spawn(async move { Self::close_with_capsule(conn, send, capsule, code, timeout).await; }); - } else { - // Send stream already taken — close() was already called. - // The first call is handling the capsule + connection close. - tracing::debug!("close() called more than once; ignoring duplicate"); } } else { // Raw QUIC mode: no capsule needed. @@ -409,47 +393,24 @@ impl Session { /// /// If the peer sent a `CloseWebTransportSession` capsule, the returned error will be /// [`WebTransportError::Closed`] with the code and reason from the capsule. + /// + /// Unlike [`quinn::Connection::closed`], this does **not** return early when + /// [`close()`](Self::close) has been called. It waits for the underlying QUIC + /// connection to shut down, ensuring the `CloseWebTransportSession` capsule has + /// been delivered. Use [`close_reason()`](Self::close_reason) for a non-blocking check. pub async fn closed(&self) -> SessionError { - self.map_conn_error(self.conn.closed().await) + self.map_error(self.conn.closed().await) } /// Return why the session was closed, or None if it's not closed. See [`quinn::Connection::close_reason`]. pub fn close_reason(&self) -> Option { - self.conn.close_reason().map(|e| self.map_conn_error(e)) + self.conn.close_reason().map(|e| self.map_error(e)) } - /// Convert a `quinn::ConnectionError` to `SessionError`, checking the - /// close_capsule so that `LocallyClosed` becomes `WebTransportError::Closed` - /// when a close capsule was received from the peer. - fn map_conn_error(&self, e: quinn::ConnectionError) -> SessionError { - if matches!(e, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return WebTransportError::Closed(code, reason).into(); - } - } - e.into() - } - - /// Convert a `SessionError` that wraps `LocallyClosed` into - /// `WebTransportError::Closed` when a close capsule was received. - fn map_session_error(&self, e: SessionError) -> SessionError { - if let SessionError::ConnectionError(ref conn_err) = e { - if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return WebTransportError::Closed(code, reason).into(); - } - } - } - e - } - - fn map_send_datagram_error(&self, e: quinn::SendDatagramError) -> SessionError { - if let quinn::SendDatagramError::ConnectionLost(ref conn_err) = e { - if matches!(conn_err, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return WebTransportError::Closed(code, reason).into(); - } - } + /// Convert an error to `SessionError`, using the stored session error if set. + fn map_error(&self, e: impl Into) -> SessionError { + if let Some(err) = self.error.get() { + return err.clone(); } e.into() } @@ -480,7 +441,7 @@ impl Session { accept: None, settings: None, connect_send: Arc::new(Mutex::new(None)), - close_capsule: Arc::new(Mutex::new(None)), + error: Arc::new(OnceLock::new()), request: request.into(), response: response.into(), } @@ -549,15 +510,15 @@ pub struct SessionAccept { pending_uni: FuturesUnordered>>, pending_bi: FuturesUnordered>>, - // Close capsule info from the peer, shared with Session. - close_capsule: CloseCapsule, + // Session error, shared with Session. + error: Arc>, } impl SessionAccept { pub(crate) fn new( conn: quinn::Connection, session_id: VarInt, - close_capsule: CloseCapsule, + error: Arc>, ) -> Self { // Create a stream that just outputs new streams, so it's easy to call from poll. let accept_uni = Box::pin(futures::stream::unfold(conn.clone(), |conn| async { @@ -580,15 +541,13 @@ impl SessionAccept { pending_uni: FuturesUnordered::new(), pending_bi: FuturesUnordered::new(), - close_capsule, + error, } } - fn map_conn_error(&self, e: quinn::ConnectionError) -> SessionError { - if matches!(e, quinn::ConnectionError::LocallyClosed) { - if let Some((code, reason)) = self.close_capsule.lock().unwrap().clone() { - return WebTransportError::Closed(code, reason).into(); - } + fn map_error(&self, e: impl Into) -> SessionError { + if let Some(err) = self.error.get() { + return err.clone(); } e.into() } @@ -604,7 +563,7 @@ impl SessionAccept { // Accept any new streams. if let Poll::Ready(Some(res)) = self.accept_uni.poll_next_unpin(cx) { // Start decoding the header and add the future to the list of pending streams. - let recv = res.map_err(|e| self.map_conn_error(e))?; + let recv = res.map_err(|e| self.map_error(e))?; let pending = Self::decode_uni(recv, self.session_id); self.pending_uni.push(Box::pin(pending)); @@ -625,7 +584,7 @@ impl SessionAccept { // Decide if we keep looping based on the type. match typ { StreamUni::WEBTRANSPORT => { - let recv = RecvStream::new(recv, self.close_capsule.clone()); + let recv = RecvStream::new(recv, self.error.clone()); return Poll::Ready(Ok(recv)); } StreamUni::QPACK_DECODER => { @@ -675,7 +634,7 @@ impl SessionAccept { // Accept any new streams. if let Poll::Ready(Some(res)) = self.accept_bi.poll_next_unpin(cx) { // Start decoding the header and add the future to the list of pending streams. - let (send, recv) = res.map_err(|e| self.map_conn_error(e))?; + let (send, recv) = res.map_err(|e| self.map_error(e))?; let pending = Self::decode_bi(send, recv, self.session_id); self.pending_bi.push(Box::pin(pending)); @@ -695,8 +654,8 @@ impl SessionAccept { if let Some((send, recv)) = res { // Wrap the streams in our own types for correct error codes. - let send = SendStream::new(send, self.close_capsule.clone()); - let recv = RecvStream::new(recv, self.close_capsule.clone()); + let send = SendStream::new(send, self.error.clone()); + let recv = RecvStream::new(recv, self.error.clone()); return Poll::Ready(Ok((send, recv))); } From 7aee027a5109f6bbcf6456de958e2f7c383d023a Mon Sep 17 00:00:00 2001 From: ai-and-i <138349030+ai-and-i@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:53:18 -0800 Subject: [PATCH 18/18] Fix lost-waker bug in concurrent accept_bi/accept_uni calls SessionAccept used a single unfold stream for accepting connections, which only stores one waker. When multiple tasks call accept_bi() or accept_uni() concurrently, only the last caller's waker is retained and earlier callers hang forever. Add per-method waker vecs (bi_wakers, uni_wakers) to SessionAccept. On Pending, push the caller's waker; on Ready, drain and wake all stored wakers so other callers retry. Replace the ready!() macro with explicit match to ensure the waker push isn't bypassed by its hidden early return on Poll::Pending. Co-Authored-By: Claude Opus 4.6 --- web-transport-quinn/src/session.rs | 59 ++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/web-transport-quinn/src/session.rs b/web-transport-quinn/src/session.rs index 84df7b93..6ee76371 100644 --- a/web-transport-quinn/src/session.rs +++ b/web-transport-quinn/src/session.rs @@ -5,7 +5,7 @@ use std::{ ops::Deref, pin::Pin, sync::{Arc, Mutex, OnceLock}, - task::{ready, Context, Poll}, + task::{Context, Poll, Waker}, }; use bytes::{Bytes, BytesMut}; @@ -512,6 +512,12 @@ pub struct SessionAccept { // Session error, shared with Session. error: Arc>, + + // Wakers from concurrent callers of accept_bi / accept_uni. + // When one caller gets a stream, all others are woken so they can retry. + // This fixes the lost-waker bug where the unfold stream only stores one waker. + bi_wakers: Vec, + uni_wakers: Vec, } impl SessionAccept { @@ -542,6 +548,9 @@ impl SessionAccept { pending_bi: FuturesUnordered::new(), error, + + bi_wakers: Vec::new(), + uni_wakers: Vec::new(), } } @@ -563,7 +572,15 @@ impl SessionAccept { // Accept any new streams. if let Poll::Ready(Some(res)) = self.accept_uni.poll_next_unpin(cx) { // Start decoding the header and add the future to the list of pending streams. - let recv = res.map_err(|e| self.map_error(e))?; + let recv = match res { + Ok(recv) => recv, + Err(e) => { + for waker in self.uni_wakers.drain(..) { + waker.wake(); + } + return Poll::Ready(Err(self.map_error(e))); + } + }; let pending = Self::decode_uni(recv, self.session_id); self.pending_uni.push(Box::pin(pending)); @@ -571,20 +588,26 @@ impl SessionAccept { } // Poll the list of pending streams. - let (typ, recv) = match ready!(self.pending_uni.poll_next_unpin(cx)) { - Some(Ok(res)) => res, - Some(Err(err)) => { + let (typ, recv) = match self.pending_uni.poll_next_unpin(cx) { + Poll::Ready(Some(Ok(res))) => res, + Poll::Ready(Some(Err(err))) => { // Ignore the error, the stream was probably reset early. tracing::warn!(?err, "failed to decode unidirectional stream"); continue; } - None => return Poll::Pending, + Poll::Ready(None) | Poll::Pending => { + self.uni_wakers.push(cx.waker().clone()); + return Poll::Pending; + } }; // Decide if we keep looping based on the type. match typ { StreamUni::WEBTRANSPORT => { let recv = RecvStream::new(recv, self.error.clone()); + for waker in self.uni_wakers.drain(..) { + waker.wake(); + } return Poll::Ready(Ok(recv)); } StreamUni::QPACK_DECODER => { @@ -634,7 +657,15 @@ impl SessionAccept { // Accept any new streams. if let Poll::Ready(Some(res)) = self.accept_bi.poll_next_unpin(cx) { // Start decoding the header and add the future to the list of pending streams. - let (send, recv) = res.map_err(|e| self.map_error(e))?; + let (send, recv) = match res { + Ok(pair) => pair, + Err(e) => { + for waker in self.bi_wakers.drain(..) { + waker.wake(); + } + return Poll::Ready(Err(self.map_error(e))); + } + }; let pending = Self::decode_bi(send, recv, self.session_id); self.pending_bi.push(Box::pin(pending)); @@ -642,20 +673,26 @@ impl SessionAccept { } // Poll the list of pending streams. - let res = match ready!(self.pending_bi.poll_next_unpin(cx)) { - Some(Ok(res)) => res, - Some(Err(err)) => { + let res = match self.pending_bi.poll_next_unpin(cx) { + Poll::Ready(Some(Ok(res))) => res, + Poll::Ready(Some(Err(err))) => { // Ignore the error, the stream was probably reset early. tracing::warn!(?err, "failed to decode bidirectional stream"); continue; } - None => return Poll::Pending, + Poll::Ready(None) | Poll::Pending => { + self.bi_wakers.push(cx.waker().clone()); + return Poll::Pending; + } }; if let Some((send, recv)) = res { // Wrap the streams in our own types for correct error codes. let send = SendStream::new(send, self.error.clone()); let recv = RecvStream::new(recv, self.error.clone()); + for waker in self.bi_wakers.drain(..) { + waker.wake(); + } return Poll::Ready(Ok((send, recv))); }