diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c27ead98c8a..72f38c533f4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -335,7 +335,7 @@ jobs: # `rs audio-devices` plays moq-audio's device tests into a private null sink. # `rs multicast` runs the mDNS tests, which loop multicast back on the runner. # `rs render-sw` renders through wgpu on Mesa's software Vulkan driver. - # `test qmux` runs js/qmux against the Rust qmux release, per draft. + # `test qmux` runs js/qmux against the in-tree Rust qmux, per draft. recipe: ["gate", "rs doctest --workspace", "rs loom", "test drill-sensitivity", "rs uring", "test drain", "gh test", "test json-properties", "rs quic-sim", "test drill impaired", "rs cli-export", "rs quic-stress", "rs audio-devices", "rs multicast", "rs render-sw", "test qmux"] include: - recipe: rs send-depth diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 22e3edbf648..9005351ec46 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -225,6 +225,7 @@ jobs: - "rs/moq-srt/**" - "rs/moq-stats/**" - "rs/moq-tokio/**" + - "rs/qmux/**" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" diff --git a/Cargo.lock b/Cargo.lock index c8691624501..4a94380c3ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5055,6 +5055,7 @@ dependencies = [ "hex-literal", "lru-slab", "moq-clock", + "moq-quic", "proptest", "qlog", "rand 0.10.3", @@ -5111,7 +5112,7 @@ dependencies = [ "moq-stats", "moq-tokio", "moq-uring", - "qmux", + "qmux 0.7.0", "rand 0.10.3", "rcgen 0.14.10", "reqwest", @@ -5333,7 +5334,7 @@ dependencies = [ "noq-proto", "notify", "pin-project-lite", - "qmux", + "qmux 0.7.0", "rand 0.10.3", "rcgen 0.14.10", "reqwest", @@ -7534,13 +7535,34 @@ dependencies = [ "bytes", "futures", "libc", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite 0.30.0", + "tracing", + "web-transport-proto", + "web-transport-trait", +] + +[[package]] +name = "qmux" +version = "0.7.0" +dependencies = [ + "anyhow", + "bytes", + "futures", + "kio 0.6.2", + "libc", + "moq-clock", + "moq-net", + "moq-quic", + "qmux 0.6.5", + "rustc-hash 2.1.3", "rustls", "thiserror 2.0.21", "tokio", "tokio-rustls", "tokio-tungstenite 0.30.0", "tracing", - "web-transport-proto", "web-transport-trait", ] diff --git a/Cargo.toml b/Cargo.toml index ee1b6062a99..dad19aec936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ members = [ "rs/moq-v4l", "rs/moq-video", "rs/moq-wasm", + "rs/qmux", "rs/uniffi-bindgen", ] default-members = [ @@ -89,6 +90,7 @@ default-members = [ "rs/moq-v4l", "rs/moq-video", # "rs/moq-wasm", # wasm32-unknown-unknown target only + "rs/qmux", # Maturin runs the workspace's `uniffi-bindgen` binary from the root to # generate the Python wheel, and that only searches default members. Its # `cli` feature keeps a plain check from compiling the generator. @@ -221,7 +223,7 @@ percent-encoding = "2" pollster = "1.0" # default-features off to skip the fork and timeout machinery; crates opt into what they use. proptest = { version = "1.12.0", default-features = false, features = ["std"] } -qmux = { version = "0.6.5", default-features = false } +qmux = { version = "0.7.0", path = "rs/qmux", default-features = false } rand = "0.10.1" rangemap = "1.8" # default-features off so each consumer picks its crypto provider. diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index e9205196a6f..4b91ad1c3e0 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -16,6 +16,7 @@ links cover crates awaiting their first release. | [moq-net](/lib/rs/moq-net) | The pub/sub layer: sessions, origins, broadcasts, tracks, groups, frames. Transport-agnostic. | | [moq-pattern](https://docs.rs/moq-pattern) | Path patterns such as `room/**`. Re-exported by moq-net and moq-auth. | | [moq-tokio](https://docs.rs/moq-tokio) | Connects and listens over QUIC, WebSocket, or iroh, from config or CLI flags. Its `driver` feature alone is a tokio QUIC stack without MoQ. | +| [qmux](https://docs.rs/qmux) | QUIC streams, datagrams, and flow control over TCP, TLS, WebSocket, and Unix sockets, on moq-quic's state machine. | | [moq-sock](https://docs.rs/moq-sock) | Socket binding and CPU pinning for thread-per-core listeners. | | [moq-ice](https://github.com/moq-dev/moq/tree/main/rs/moq-ice) | The ICE agent behind native WebRTC: drives a str0m connection over UDP, with a STUN client. | | [moq-uring](https://docs.rs/moq-uring) | Experimental Linux io\_uring server for moq-lite. | @@ -37,7 +38,7 @@ links cover crates awaiting their first release. | [moq-hls](https://docs.rs/moq-hls), [moq-rtmp](https://docs.rs/moq-rtmp), [moq-srt](https://docs.rs/moq-srt), [moq-rtsp](https://github.com/moq-dev/moq/tree/main/rs/moq-rtsp), [moq-rtc](https://docs.rs/moq-rtc) | The [gateways](/bin/), as libraries you can embed with your own auth. | | [moq-ffi](https://docs.rs/moq-ffi), [moq-c](/lib/c/) | The UniFFI core behind the language bindings, and the C ABI. | | [moq-relay](/bin/relay/), [moq-cli](/bin/cli) | The binaries, also usable as crates. | -| [web-transport](https://github.com/moq-dev/web-transport) | The QUIC/WebTransport/qmux transports, in a sibling repository. | +| [web-transport](https://github.com/moq-dev/web-transport) | The WebTransport traits and the browser and iroh transports, in a sibling repository. | ## Quick start diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 2cd45e031d6..1a0136a8e3f 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -25,13 +25,16 @@ ignored; its guide says what replaces it. - **Binaries:** [moq-relay](https://github.com/moq-dev/moq/blob/main/rs/moq-relay/MIGRATE.md), [moq-cli (`moq`)](https://github.com/moq-dev/moq/blob/main/rs/moq-cli/MIGRATE.md), [moq-gst](https://github.com/moq-dev/moq/blob/main/rs/moq-gst/MIGRATE.md), [obs-moq](https://github.com/moq-dev/moq/blob/main/cpp/obs/MIGRATE.md) - **Bindings:** [moq-ffi (UniFFI)](https://github.com/moq-dev/moq/blob/main/rs/moq-ffi/MIGRATE.md), [moq-c (C)](https://github.com/moq-dev/moq/blob/main/rs/moq-c/MIGRATE.md), [Python](https://github.com/moq-dev/moq/blob/main/py/moq-rs/MIGRATE.md), [Go](https://github.com/moq-dev/moq/blob/main/go/wrapper/MIGRATE.md), [Swift](https://github.com/moq-dev/moq/blob/main/swift/MIGRATE.md), [Kotlin](https://github.com/moq-dev/moq/blob/main/kt/moq/MIGRATE.md), [Dart](https://github.com/moq-dev/moq/blob/main/dart/moq/MIGRATE.md), [C++](https://github.com/moq-dev/moq/blob/main/cpp/moq/MIGRATE.md) -- **Rust:** [hang](https://github.com/moq-dev/moq/blob/main/rs/hang/MIGRATE.md), [kio](https://github.com/moq-dev/moq/blob/main/rs/kio/MIGRATE.md), [moq-archive](https://github.com/moq-dev/moq/blob/main/rs/moq-archive/MIGRATE.md), [moq-audio](https://github.com/moq-dev/moq/blob/main/rs/moq-audio/MIGRATE.md), [moq-auth](https://github.com/moq-dev/moq/blob/main/rs/moq-auth/MIGRATE.md), [moq-boy](https://github.com/moq-dev/moq/blob/main/rs/moq-boy/MIGRATE.md), [moq-clock](https://github.com/moq-dev/moq/blob/main/rs/moq-clock/MIGRATE.md), [moq-clock-macros](https://github.com/moq-dev/moq/blob/main/rs/moq-clock/macros/MIGRATE.md), [moq-e2ee](https://github.com/moq-dev/moq/blob/main/rs/moq-e2ee/MIGRATE.md), [moq-flate](https://github.com/moq-dev/moq/blob/main/rs/moq-flate/MIGRATE.md), [moq-hls](https://github.com/moq-dev/moq/blob/main/rs/moq-hls/MIGRATE.md), [moq-ice](https://github.com/moq-dev/moq/blob/main/rs/moq-ice/MIGRATE.md), [moq-json](https://github.com/moq-dev/moq/blob/main/rs/moq-json/MIGRATE.md), [moq-loc](https://github.com/moq-dev/moq/blob/main/rs/moq-loc/MIGRATE.md), [moq-msf](https://github.com/moq-dev/moq/blob/main/rs/moq-msf/MIGRATE.md), [moq-mux](https://github.com/moq-dev/moq/blob/main/rs/moq-mux/MIGRATE.md), [moq-net](https://github.com/moq-dev/moq/blob/main/rs/moq-net/MIGRATE.md), [moq-nvenc](https://github.com/moq-dev/moq/blob/main/rs/moq-nvenc/MIGRATE.md), [moq-pattern](https://github.com/moq-dev/moq/blob/main/rs/moq-pattern/MIGRATE.md), [moq-quic](https://github.com/moq-dev/moq/blob/main/rs/moq-quic/MIGRATE.md), [moq-room](https://github.com/moq-dev/moq/blob/main/rs/moq-room/MIGRATE.md), [moq-rtc](https://github.com/moq-dev/moq/blob/main/rs/moq-rtc/MIGRATE.md), [moq-rtmp](https://github.com/moq-dev/moq/blob/main/rs/moq-rtmp/MIGRATE.md), [moq-sock](https://github.com/moq-dev/moq/blob/main/rs/moq-sock/MIGRATE.md), [moq-srt](https://github.com/moq-dev/moq/blob/main/rs/moq-srt/MIGRATE.md), [moq-stats](https://github.com/moq-dev/moq/blob/main/rs/moq-stats/MIGRATE.md), [moq-tokio](https://github.com/moq-dev/moq/blob/main/rs/moq-tokio/MIGRATE.md), [moq-transcode](https://github.com/moq-dev/moq/blob/main/rs/moq-transcode/MIGRATE.md), [moq-uring](https://github.com/moq-dev/moq/blob/main/rs/moq-uring/MIGRATE.md), [moq-v4l](https://github.com/moq-dev/moq/blob/main/rs/moq-v4l/MIGRATE.md), [moq-video](https://github.com/moq-dev/moq/blob/main/rs/moq-video/MIGRATE.md) +- **Rust:** [hang](https://github.com/moq-dev/moq/blob/main/rs/hang/MIGRATE.md), [kio](https://github.com/moq-dev/moq/blob/main/rs/kio/MIGRATE.md), [moq-archive](https://github.com/moq-dev/moq/blob/main/rs/moq-archive/MIGRATE.md), [moq-audio](https://github.com/moq-dev/moq/blob/main/rs/moq-audio/MIGRATE.md), [moq-auth](https://github.com/moq-dev/moq/blob/main/rs/moq-auth/MIGRATE.md), [moq-boy](https://github.com/moq-dev/moq/blob/main/rs/moq-boy/MIGRATE.md), [moq-clock](https://github.com/moq-dev/moq/blob/main/rs/moq-clock/MIGRATE.md), [moq-clock-macros](https://github.com/moq-dev/moq/blob/main/rs/moq-clock/macros/MIGRATE.md), [moq-e2ee](https://github.com/moq-dev/moq/blob/main/rs/moq-e2ee/MIGRATE.md), [moq-flate](https://github.com/moq-dev/moq/blob/main/rs/moq-flate/MIGRATE.md), [moq-hls](https://github.com/moq-dev/moq/blob/main/rs/moq-hls/MIGRATE.md), [moq-ice](https://github.com/moq-dev/moq/blob/main/rs/moq-ice/MIGRATE.md), [moq-json](https://github.com/moq-dev/moq/blob/main/rs/moq-json/MIGRATE.md), [moq-loc](https://github.com/moq-dev/moq/blob/main/rs/moq-loc/MIGRATE.md), [moq-msf](https://github.com/moq-dev/moq/blob/main/rs/moq-msf/MIGRATE.md), [moq-mux](https://github.com/moq-dev/moq/blob/main/rs/moq-mux/MIGRATE.md), [moq-net](https://github.com/moq-dev/moq/blob/main/rs/moq-net/MIGRATE.md), [moq-nvenc](https://github.com/moq-dev/moq/blob/main/rs/moq-nvenc/MIGRATE.md), [moq-pattern](https://github.com/moq-dev/moq/blob/main/rs/moq-pattern/MIGRATE.md), [moq-quic](https://github.com/moq-dev/moq/blob/main/rs/moq-quic/MIGRATE.md), [moq-room](https://github.com/moq-dev/moq/blob/main/rs/moq-room/MIGRATE.md), [moq-rtc](https://github.com/moq-dev/moq/blob/main/rs/moq-rtc/MIGRATE.md), [moq-rtmp](https://github.com/moq-dev/moq/blob/main/rs/moq-rtmp/MIGRATE.md), [moq-sock](https://github.com/moq-dev/moq/blob/main/rs/moq-sock/MIGRATE.md), [moq-srt](https://github.com/moq-dev/moq/blob/main/rs/moq-srt/MIGRATE.md), [moq-stats](https://github.com/moq-dev/moq/blob/main/rs/moq-stats/MIGRATE.md), [moq-tokio](https://github.com/moq-dev/moq/blob/main/rs/moq-tokio/MIGRATE.md), [moq-transcode](https://github.com/moq-dev/moq/blob/main/rs/moq-transcode/MIGRATE.md), [moq-uring](https://github.com/moq-dev/moq/blob/main/rs/moq-uring/MIGRATE.md), [moq-v4l](https://github.com/moq-dev/moq/blob/main/rs/moq-v4l/MIGRATE.md), [moq-video](https://github.com/moq-dev/moq/blob/main/rs/moq-video/MIGRATE.md), [qmux](https://github.com/moq-dev/moq/blob/main/rs/qmux/MIGRATE.md) - **JavaScript:** [`@moq/auth`](https://github.com/moq-dev/moq/blob/main/js/auth/MIGRATE.md), [`@moq/boy`](https://github.com/moq-dev/moq/blob/main/js/moq-boy/MIGRATE.md), [`@moq/clock`](https://github.com/moq-dev/moq/blob/main/js/clock/MIGRATE.md), [`@moq/flate`](https://github.com/moq-dev/moq/blob/main/js/flate/MIGRATE.md), [`@moq/hang`](https://github.com/moq-dev/moq/blob/main/js/hang/MIGRATE.md), [`@moq/json`](https://github.com/moq-dev/moq/blob/main/js/json/MIGRATE.md), [`@moq/loc`](https://github.com/moq-dev/moq/blob/main/js/loc/MIGRATE.md), [`@moq/msf`](https://github.com/moq-dev/moq/blob/main/js/msf/MIGRATE.md), [`@moq/net`](https://github.com/moq-dev/moq/blob/main/js/net/MIGRATE.md), [`@moq/p2p`](https://github.com/moq-dev/moq/blob/main/js/p2p/MIGRATE.md), [`@moq/pattern`](https://github.com/moq-dev/moq/blob/main/js/pattern/MIGRATE.md), [`@moq/publish`](https://github.com/moq-dev/moq/blob/main/js/publish/MIGRATE.md), [`@moq/qmux`](https://github.com/moq-dev/moq/blob/main/js/qmux/MIGRATE.md), [`@moq/room`](https://github.com/moq-dev/moq/blob/main/js/room/MIGRATE.md), [`@moq/signals`](https://github.com/moq-dev/moq/blob/main/js/signals/MIGRATE.md), [`@moq/video`](https://github.com/moq-dev/moq/blob/main/js/video/MIGRATE.md), [`@moq/watch`](https://github.com/moq-dev/moq/blob/main/js/watch/MIGRATE.md) ## Unreleased These land with the next breaking release. +- **WebSocket needs a QMux subprotocol** (#6231). Relays answer a WebSocket + upgrade that offers no `qmux-NN` subprotocol with 400 instead of falling back + to the legacy `webtransport` format, so upgrade such clients before relays. - **Data-track breaks are an object with no bytes** (#5815). A hang data track (TS private PES, sections, `m2ts`, text) marks a discontinuity with a group of one empty object instead of a skipped group. Older consumers fail diff --git a/quest/m0/ffi-shutdown-backport.md b/quest/m0/ffi-shutdown-backport.md index 8d1256fd1d8..1be2190de10 100644 --- a/quest/m0/ffi-shutdown-backport.md +++ b/quest/m0/ffi-shutdown-backport.md @@ -42,8 +42,8 @@ one has none. Wrapper workflows skip versions already published, so bump each changed wrapper's human-owned version (CONTRIBUTING's Versions). Leave `go/wrapper/VERSION` alone: CI derives Go's patch, and `drain()` is additive. Run each binding's tests and `just test interop --all`. The -qmux credit fix is out of scope: it arrives with -[qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md). +qmux credit fix is out of scope: main has it from the in-tree qmux +([moq-dev/moq#6231](https://github.com/moq-dev/moq/pull/6231)). Public API: additive (`MoqSession.drain()` and its binding wrappers). Wire: none. diff --git a/quest/m1/README.md b/quest/m1/README.md index 43519599490..aa4402d648c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -66,7 +66,6 @@ lite-07 stream type here. - [Routing guide](/quest/m1/routing-guide.md) - concepts and practical outbound federation setup - [Controlled time](/quest/m1/time/README.md) - every crate and package reads time through moq-clock or @moq/clock, with regression tests for the changed contracts - [Own the QUIC stack](/quest/m1/quic/README.md) - quinn hard-forked in-tree as `moq-quic`, carrying BBR, reliable reset, hierarchical scheduling, peer limits, and endpoint sharding -- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - qmux runs in-tree over `moq-quic`'s stream state machine, returning credit for every byte, delivering its close frame, and ending an unfinished read after its buffered data once the session closes - [lite-07 padding](/quest/m1/lite07-padding.md) - lite-07 reserves a padding stream type that receivers read and discard, for the browser's padding probes - [Rust request credit](/quest/m1/rs-request-credit.md) - a moq-net request fails at once without stream credit instead of waiting - [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq diff --git a/quest/m1/quic-qmux.md b/quest/m1/quic-qmux.md deleted file mode 100644 index 43dadeffaf1..00000000000 --- a/quest/m1/quic-qmux.md +++ /dev/null @@ -1,113 +0,0 @@ -# [XL] Run qmux on the QUIC stream state machine - -## Goal - -qmux lives in the monorepo and uses `moq-quic`'s stream state machine for -stream lifecycle, flow control, datagram buffering, priority scheduling, and -transport parameters. It owns only record framing, its transport adapters, -and the semantics that differ from QUIC, instead of maintaining a parallel -stream implementation. moq-tokio and moq-relay depend on the in-tree crate. - -## Plan - -Decided in the 2026-09-30 plan: - -- **Scope is the Rust core plus adapters.** Port the prototype core and - moq-dev/web-transport's TCP, TLS, WebSocket, and UDS adapters, ALPN - negotiation, byte-stream framing, and socket stats (about 2.3k lines) onto - it. Its session implements moq-net's `transport::poll` traits instead of - `web-transport-trait` ([Drop web-transport-trait](/quest/m2/drop-web-transport-trait.md)). The TypeScript `@moq/qmux` - keeps its own state machine and is the interop reference; it lives in - `js/qmux`. -- **All four wire versions stay**: draft-02, draft-01, draft-00, and the - legacy `webtransport` format, since published clients (and the TS peer) - negotiate them. They differ only in record framing, so they share the one - state machine. -- **Shape**: the sans-IO part is `moq_quic::mux`, a module behind a feature, - so it reaches the stream state without widening `moq-quic`'s public API. - The tokio session, adapters, and trait impl are the `qmux` crate, published - from the monorepo under the existing crates.io name as its next breaking - version. The web-transport repo's copy stays published, and its - dependents can move to the new release on their own schedule. - -Start from the green -[kixelated/quinn#2](https://github.com/kixelated/quinn/pull/2) prototype, -which is already quinn-based. Rebase it onto `moq-quic` and preserve its -central invariant: drive the -existing stream state machine through the same receive and write entry points -as QUIC, then treat serialization to the reliable underlying transport as the -acknowledgment. Do not copy the stream maps, flow-control accounting, reset -state, or datagram queues into a qmux-specific implementation. - -Keep the shared-core patch narrow. The prototype needs only module wiring, a -receive-offset accessor, frame-iterator access, and limited datagram helper -visibility. Review each exposure as a reusable internal boundary rather than -making the whole QUIC connection public. - -`RESET_STREAM_AT` is a QUIC extension, not a qmux-specific frame, and -`moq-quic` now implements it (#5690). Make qmux drive that shared send and receive state -instead of retaining the prototype's local parsing and transitions. Because -qmux runs over a reliable ordered transport, serialization acknowledges the -committed prefix immediately, but the receiver must still delay the reset -until that prefix is available. Remove the qmux prototype's local -`RESET_STREAM_AT` state once the shared core owns it (moved here from -reliable reset in the 2026-10-05 audit). - -Carry the hierarchical send groups from the scheduler quest into qmux's -record writer. Qmux over TCP, TLS, WebSocket, Unix sockets, and in-memory -duplex transports must produce the same subscription fairness and intra-group -ordering as raw QUIC. This quest owns qmux integration of the scheduler's -reusable acceptance fixtures, including byte fairness, strict priority, -newest/oldest group ordering, cancellation, and blocked-stream behavior. -The native scheduler does not wait for this dependent proof. - -Add the missing wire evidence before release: golden vectors for each of the four versions, -bidirectional interoperability against the last web-transport `qmux` release (0.6.x, a -test-only dependency), and the in-tree TypeScript qmux peer used by `js/net`. -Move `just test qmux` onto the in-tree crate: it runs `test/qmux/interop.test.ts`, -the TS client and TS server against qmux 0.6.3's `interop-server` and -`interop-client` examples per draft, installed with `cargo install` and run -nightly. Bring those examples into the new crate and point the suite at them, -so the TS peer interoperates with the qmux that ships. - -Acceptance carried over from the deleted m0 qmux credit quests (web-transport#412 and #413 -are no longer pursued): a session returns connection-level credit for every byte it -receives, whether the app reads it, drops the stream unread, or it arrives after -STOP_SENDING, so a long-lived session never stalls on MAX_DATA; and `close()` delivers its -APPLICATION_CLOSE frame before the transport drops, whenever the transport stays writable -within the close bound. Test both. - -Also decided 2026-10-20 (maintainer): an unfinished read ends once the session -closes, after its buffered data, so a reader drains what already arrived and -then sees the close instead of hanging. The moq-gst tests -`a_closed_publisher_session_resumes_on_the_next_start` and -`a_moqsink_restart_keeps_pads_when_caps_arrive_at_different_times` -(`rs/moq-gst/src/source/imp.rs`) hang today with the qmux 0.6.3 teardown; -both pass on the in-tree qmux before this lands. - -If -[Rust request credit](/quest/m1/rs-request-credit.md) landed first, the session -also implements its non-waiting open, so a request without stream credit fails -at once on qmux too. Preserve rejection of -prohibited QUIC frames, params-first setup, record-size validation, close and -reset semantics (the first recorded close wins, as close codes #4262 -settled), keep-alive behavior, and bounded flow-control tests. - -There must be one stream state machine in the dependency graph. - -Verify minimal, default, and all-feature builds so enabling iroh, qmux, or -the uring runtime never unifies two copies of the stream state. - -Decided in the 2026-09-30 audit: moved to m2, since no m1 quest consumes it; -the fork plan the same day kept it there. - -Decided 2026-10-20 (maintainer): moved to m1. moq stops depending on the -web-transport repo, so the qmux credit and close fixes ship here instead of -as upstream releases and pin bumps; the bug stays on main until this lands. -The release line gets no backport of the credit and close fixes; the -stream-limit port in the [release shutdown backport](/quest/m0/ffi-shutdown-backport.md) -is the exception. - -## Related - -- [tls:// peer certificates](/quest/m2/tls-listener-mtls.md) - adds a peer-certificate accessor to the in-tree qmux TLS session diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index fd36bfe609c..e0bc59479ae 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -11,8 +11,7 @@ reliable stream resets (#5690), hierarchical send groups (#5711, #5770), one `main`. What remains is bounded incomplete-message memory; per-broadcast fairness gives the crate-private send domains (#5872) their consumer in [Scope track priority](/quest/m1/track-priority-scope.md). -qmux on the shared stream state machine is its own m1 quest, -[quic-qmux](/quest/m1/quic-qmux.md), outside this epic. +qmux already runs on the shared stream state machine, outside this epic. Per-stream acknowledgment progress, per-stream deadlines, and the experiments (the egress profile, ECN measurement, deadline keep-alive) live in [m2](/quest/m2/README.md); media @@ -74,8 +73,6 @@ cache limits remain separate; bitrate pacing composes with this memory cap. ## Related -- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - the m1 - qmux port onto `moq-quic`'s stream state - [Scope track priority](/quest/m1/track-priority-scope.md) - the per-broadcast fairness policy on cluster sessions - [Native probing](/quest/m1/probe-native.md) - moq-quic retransmits in-flight data during ProbeBW_UP to probe up to a session's target diff --git a/quest/m1/rs-request-credit.md b/quest/m1/rs-request-credit.md index b37aeb6e238..2424930b630 100644 --- a/quest/m1/rs-request-credit.md +++ b/quest/m1/rs-request-credit.md @@ -27,13 +27,10 @@ session trait. Also note that `moq-tokio`'s `poll_open_bi` parks an abandoned open in the session's slot rather than dropping it, so the next request inherits it; the non-waiting open should avoid that. `quic::web` is in-tree and can ask `moq-quic` directly, -but the backends `moq-tokio` wraps through `web-transport-trait` (qmux, -iroh) have no credit query there. Decided 2026-10-20 (maintainer): no new -work in moq-dev/web-transport, so qmux gets its query when it moves in-tree -([qmux on moq-quic](/quest/m1/quic-qmux.md)), and iroh, an accepted -exception, keeps waiting. Whichever of this quest and quic-qmux lands second -gives the in-tree qmux session the non-waiting open, so this quest does not -wait on the port. Say in the PR which backends fail fast. +and the in-tree qmux (`rs/qmux`) drives `moq-quic`'s stream state too, so +its session can ask the same way. iroh, wrapped through +`web-transport-trait`, has no credit query and, as an accepted exception, +keeps waiting. This quest gives the qmux session the non-waiting open. Say in the PR which backends fail fast. Propose the error variant's name in the PR. Public API: one new `Error` variant, and possibly a `transport::poll` session method. Wire: none. diff --git a/quest/m2/drop-web-transport-trait.md b/quest/m2/drop-web-transport-trait.md index c07550a2d99..2dfdd635711 100644 --- a/quest/m2/drop-web-transport-trait.md +++ b/quest/m2/drop-web-transport-trait.md @@ -50,9 +50,8 @@ each gets its own impl: and stays public. Notes from related quests: -- [qmux on moq-quic](/quest/m1/quic-qmux.md) planned to port qmux's - `web-transport-trait` impl. If it lands first, its impl targets moq-net's - `transport::poll` instead. +- The in-tree qmux already implements moq-net's `transport::poll` + natively, so only QUIC and iroh still go through the adapter. - [poll_acked](/quest/m2/quic-ack-hook.md) already puts its hook on moq-net's trait because moq-net has no `web-transport-trait` dependency. That holds here. @@ -72,6 +71,5 @@ an upstream patch ([web-transport-proto 0.6.3](/quest/m0/web-transport-proto-0.6 ## Related -- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - the in-tree qmux implements the moq-net traits directly - [poll_acked on moq-net's send stream](/quest/m2/quic-ack-hook.md) - another hook that lives only on moq-net's traits - [Rust requests fail fast without stream credit](/quest/m1/rs-request-credit.md) - its credit query moves to each backend's moq-net impl diff --git a/quest/m2/quic-ack-hook.md b/quest/m2/quic-ack-hook.md index 29ff9a8e4f0..18587e955d8 100644 --- a/quest/m2/quic-ack-hook.md +++ b/quest/m2/quic-ack-hook.md @@ -66,5 +66,3 @@ Decided in the 2026-09-30 audit: moved to m2 with its only consumer, - [Starvation at frame granularity](/quest/m2/starvation-frames.md) - the moq-net consumer -- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - decides - what acknowledgment means over a reliable transport diff --git a/quest/m2/tls-listener-mtls.md b/quest/m2/tls-listener-mtls.md index 796cf5506e4..b7e950ce975 100644 --- a/quest/m2/tls-listener-mtls.md +++ b/quest/m2/tls-listener-mtls.md @@ -18,8 +18,7 @@ without it. Decided by the maintainer (2026-10-06): the peer certificate comes from a new accessor on qmux's TLS session. Rejected: owning the TLS accept outside qmux, or leaving it open. Decided 2026-10-20 (maintainer): no new work in -moq-dev/web-transport, so the accessor goes on the in-tree qmux crate that -[qmux on moq-quic](/quest/m1/quic-qmux.md) brings into this repository. +moq-dev/web-transport, so the accessor goes on the in-tree qmux crate (`rs/qmux`). Keep the token path working on the same listener, and keep pinned peers and CA roots meaning the same as on QUIC. @@ -38,10 +37,6 @@ keeps both working. Public API: relay config may gain a listener option. Wire: none. -## Required - -- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - moves qmux in-tree, where the peer-certificate accessor lands - ## Related - [TCP acceptor](/quest/m2/one-port/tcp-demux.md) - carries `tls://` on the shared TCP port, where the certificate request stays on the moq ALPN diff --git a/quest/m2/wire-stats/qmux.md b/quest/m2/wire-stats/qmux.md index 0948e874199..3dd6ab0f279 100644 --- a/quest/m2/wire-stats/qmux.md +++ b/quest/m2/wire-stats/qmux.md @@ -13,21 +13,18 @@ beside the field. ## Plan -- The in-tree qmux's `TcpStats` (ported from moq-dev/web-transport, which - already reads `TCP_INFO` for rtt and delivery rate): add `tcpi_bytes_sent` - and `tcpi_bytes_received` from the same read. A platform without them +- The in-tree qmux's `TcpStats` (`rs/qmux/src/socket.rs`, which already + reads `TCP_INFO` for rtt and delivery rate): add `tcpi_bytes_sent` and + `tcpi_bytes_received` from the same read. A platform without them (non-Linux, or Linux before 4.19) reports `None`, which moq-net maps to 0. Decided 2026-10-20 (maintainer): moq-dev/web-transport#435 and #436 are not pursued; this lands in-tree after qmux moves. - The transport pushes its counters: the stats tick never polls a session, and there is no per-connection timer. `TCP_INFO` is read on demand, so decide how qmux's I/O path refreshes the `Wire` (for example, after each - socket write batch) without a syscall per write, then return it from - moq-tokio's `Grouped::wire` for `qmux::Session`, including the relay's + socket write batch) without a syscall per write, in place of the + qmux record bytes `qmux::Session::wire` counts today, including the relay's `SocketStats` wiring (`rs/moq-relay/src/websocket.rs`). - Test: a loopback WebSocket session's `sent_bytes` covers at least the payload it wrote. -## Required - -- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - qmux lives in-tree, where its `TcpStats` can grow the byte counters diff --git a/rs/moq-quic/Cargo.toml b/rs/moq-quic/Cargo.toml index c0f70f5feb2..478d337de77 100644 --- a/rs/moq-quic/Cargo.toml +++ b/rs/moq-quic/Cargo.toml @@ -44,6 +44,9 @@ tracing-log = ["tracing/log"] rustls-log = ["rustls?/logging"] # Enable qlog support qlog = ["dep:qlog"] +# The sans-IO QMux connection, which runs the stream state machine over a reliable, ordered +# transport. Driven by the `qmux` crate. +mux = [] # Internal (PRIVATE!) features used to aid testing. # Don't rely on these whatsoever. They may disappear at any time. @@ -90,6 +93,8 @@ criterion = { workspace = true } hex-literal = "1" # The tests and benches read time through the runtime, as the drivers do. moq-clock = { path = "../moq-clock" } +# The mux module's tests run in the default merge gate. +moq-quic = { path = ".", features = ["mux"] } proptest = { workspace = true } rcgen = { workspace = true, features = ["pem", "ring"] } stats_alloc = "0.1.10" diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index 75f7876412a..adfbfc04d18 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -51,6 +51,8 @@ use datagrams::DatagramState; pub use datagrams::{Datagrams, SendDatagramError}; mod mtud; +#[cfg(feature = "mux")] +pub mod mux; mod pacing; mod packet_builder; diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs new file mode 100644 index 00000000000..7462ef35d47 --- /dev/null +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -0,0 +1,880 @@ +//! The sans-IO QMux connection +//! +//! [`Connection`] multiplexes QUIC streams, flow control, and datagrams over one reliable, +//! ordered transport such as TCP, TLS, or a WebSocket (draft-ietf-quic-qmux). It drives the +//! same stream state machine as a QUIC [`crate::Connection`], through the same receive and +//! write entry points. The draft has acknowledgment happen "as soon as data is passed to the +//! underlying transport", so every frame counts as acknowledged the moment it is serialized +//! into a record, and nothing is retransmitted. +//! +//! Like [`crate::Connection`], it performs no I/O. Feed it each record (each frame on +//! draft-00) the transport delimits with [`Connection::handle_record`], write out what +//! [`Connection::poll_transmit`] returns, run [`Connection::handle_timeout`] at +//! [`Connection::poll_timeout`], and drain [`Connection::poll`] for events. + +use std::collections::VecDeque; + +use bytes::Bytes; +use tracing::{debug, trace}; + +use super::{ + ConnectionError, State as ConnectionState, ThinRetransmits, + datagrams::DatagramState, + spaces::Retransmits, + stats::FrameStats, + streams::{ + Credit, RecvStream, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, + Streams, StreamsState, + }, +}; +use crate::{ + ApplicationClose, Dir, Duration, Instant, MAX_STREAM_COUNT, SendDatagramError, Side, + TransportError, VarInt, + frame::{self, Close, Datagram}, + transport_parameters::TransportParameters, +}; + +mod params; +mod timer; +mod wire; + +#[cfg(test)] +mod tests; + +pub use wire::Version; + +use params::{DEFAULT_MAX_RECORD_SIZE, Params}; +use timer::Timer; +use wire::MAX_FRAME_SIZE; + +/// The largest STREAM payload sent: released peers reject a larger one on receive +const MAX_STREAM_PAYLOAD: usize = MAX_FRAME_SIZE - 32; + +/// The frame space of one record, capped so a STREAM frame that fills it, whose header is at +/// least 2 bytes, stays within [`MAX_STREAM_PAYLOAD`] +const MAX_RECORD_BUDGET: usize = MAX_STREAM_PAYLOAD + 2; + +/// How a QMux connection behaves +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Config { + /// The wire format + pub version: Version, + /// How many bidirectional streams the peer may have open at once + pub max_concurrent_bidi_streams: VarInt, + /// How many unidirectional streams the peer may have open at once + pub max_concurrent_uni_streams: VarInt, + /// Bytes the peer may send across all streams before we read them + pub receive_window: VarInt, + /// Bytes the peer may send on one stream before we read them + pub stream_receive_window: VarInt, + /// Bytes we buffer across all streams before a write blocks + pub send_window: u64, + /// Whether streams of equal priority take turns frame by frame, rather than one finishing + /// first + pub send_fairness: bool, + /// How long a silent peer is kept, or `None` for forever; record versions only + pub max_idle_timeout: Option, + /// The largest record Size field the peer may send, at least 16382 + pub max_record_size: VarInt, + /// The largest DATAGRAM frame the peer may send, or `None` to refuse datagrams; record + /// versions only + pub max_datagram_frame_size: Option, + /// Bytes of received datagrams buffered before the oldest is dropped + pub datagram_receive_buffer_size: usize, + /// Bytes of outgoing datagrams queued before the oldest is dropped + pub datagram_send_buffer_size: usize, + /// How long to wait for the peer's transport parameters, or `None` for forever + pub handshake_timeout: Option, + /// How often to probe the round-trip time with QX_PING, or `None` not to; record versions + /// only + pub rtt_interval: Option, + /// Application protocols to negotiate in-band, in preference order, for transports + /// without ALPN; `None` when it was negotiated out of band or not at all, which makes a + /// peer's offer a protocol error; a peer that offers none of them fails the + /// handshake + pub protocols: Option>, +} + +impl Default for Config { + fn default() -> Self { + Self { + version: Version::QMux02, + max_concurrent_bidi_streams: VarInt::from_u32(100), + max_concurrent_uni_streams: VarInt::from_u32(100), + receive_window: VarInt::from_u32(1 << 20), + stream_receive_window: VarInt::from_u32(256 << 10), + send_window: 8 << 20, + send_fairness: true, + max_idle_timeout: Some(Duration::from_secs(30)), + max_record_size: VarInt::from_u64(DEFAULT_MAX_RECORD_SIZE).unwrap(), + max_datagram_frame_size: Some(VarInt::from_u64(DEFAULT_MAX_RECORD_SIZE).unwrap()), + datagram_receive_buffer_size: 1 << 20, + datagram_send_buffer_size: 1 << 20, + handshake_timeout: Some(Duration::from_secs(10)), + rtt_interval: Some(Duration::from_secs(1)), + protocols: None, + } + } +} + +/// Events from [`Connection::poll`] +#[derive(Debug)] +pub enum Event { + /// The peer's transport parameters arrived, so streams can be opened + Connected, + /// Stream activity, as on QUIC + Stream(StreamEvent), + /// One or more datagrams arrived + DatagramReceived, + /// The connection ended + ConnectionLost { + /// Why: [`ConnectionError::Reset`] means the transport ended without a close frame + reason: ConnectionError, + }, +} + +/// A QMux connection over a reliable, ordered transport +pub struct Connection { + side: Side, + config: Config, + streams: StreamsState, + /// Control frames waiting to be written + pending: Retransmits, + conn_state: ConnectionState, + stats: FrameStats, + + params_sent: bool, + peer: Option, + protocol: Option, + timer: Timer, + + next_ping: u64, + greatest_ping_received: Option, + ping_request: bool, + /// The largest request to answer; one response covers every request up to it + ping_response: Option, + + datagrams: DatagramState, + + /// The close frame waiting to be written + close: Option, + close_sent: bool, + error: Option, + events: VecDeque, + /// Draft-00 frames already serialized, one per transport message + queued: VecDeque, +} + +impl Connection { + /// Start a connection; `side` decides which stream IDs are ours + pub fn new(mut config: Config, side: Side, now: Instant) -> Self { + if !config.version.uses_records() { + config.max_idle_timeout = None; + config.max_datagram_frame_size = None; + config.rtt_interval = None; + } + let streams = StreamsState::new( + side, + config.max_concurrent_uni_streams, + config.max_concurrent_bidi_streams, + config.send_window, + config.receive_window, + config.stream_receive_window, + ); + let timer = Timer::new(config.handshake_timeout, now); + Self { + side, + config, + streams, + pending: Retransmits::default(), + conn_state: ConnectionState::Established, + stats: FrameStats::default(), + params_sent: false, + peer: None, + protocol: None, + timer, + next_ping: 0, + greatest_ping_received: None, + ping_request: false, + ping_response: None, + datagrams: DatagramState::default(), + close: None, + close_sent: false, + error: None, + events: VecDeque::new(), + queued: VecDeque::new(), + } + } + + /// Which side of the connection this is + pub fn side(&self) -> Side { + self.side + } + + /// The wire format + pub fn version(&self) -> Version { + self.config.version + } + + /// Whether the peer's transport parameters arrived and the connection is still up + pub fn is_established(&self) -> bool { + self.peer.is_some() && self.error.is_none() + } + + /// Whether the connection ended + pub fn is_closed(&self) -> bool { + self.error.is_some() + } + + /// Whether the connection ended and has nothing left to write, so the transport can be + /// shut down + pub fn is_drained(&self) -> bool { + self.error.is_some() && self.close.is_none() && self.queued.is_empty() + } + + /// Why the connection ended, if it has + pub fn error(&self) -> Option<&ConnectionError> { + self.error.as_ref() + } + + /// The application protocol negotiated in-band, once the peer's parameters arrive + pub fn protocol(&self) -> Option<&str> { + self.protocol.as_deref() + } + + /// The smoothed round-trip time measured with QX_PING, once a probe is answered + pub fn rtt(&self) -> Option { + self.timer.rtt() + } + + /// Process one record's frames, or one frame on draft-00 + /// + /// A protocol violation ends the connection with a CONNECTION_CLOSE from the next + /// [`Self::poll_transmit`]. + pub fn handle_record(&mut self, payload: Bytes, now: Instant) { + if self.error.is_some() { + return; + } + self.timer.on_recv(now); + if let Err(err) = self.process(payload, now) { + self.fail(err); + } + } + + /// The transport refused an incoming record larger than `max_record_size`, or a draft-00 + /// frame larger than `max_frame_size`, rather than read it into memory + pub fn handle_oversized(&mut self) { + if self.error.is_none() { + self.fail(self.oversized()); + } + } + + /// The transport found the peer violating its framing, such as overrunning a buffer it + /// bounds itself, so close with `err` + pub fn handle_violation(&mut self, err: TransportError) { + if self.error.is_none() { + self.fail(err); + } + } + + /// The transport failed, or ended without a close frame + pub fn transport_closed(&mut self) { + self.lost(ConnectionError::Reset); + } + + /// Whether the transport is refusing writes, which defers the idle timeout by at most one + /// more window: the peer is evidently there, just slow to read + pub fn set_backpressured(&mut self, backpressured: bool) { + self.timer.set_backpressured(backpressured); + } + + /// Close the connection with an APPLICATION_CLOSE, unless it already ended + /// + /// The frame comes from the next [`Self::poll_transmit`]; flush it before shutting down + /// the transport. + pub fn close(&mut self, error_code: VarInt, reason: Bytes) { + if self.error.is_some() { + return; + } + self.close = Some(Close::Application(ApplicationClose { error_code, reason })); + self.lost(ConnectionError::LocallyClosed); + } + + /// The next record to write, without its Size field, or the next frame on draft-00 + pub fn poll_transmit(&mut self, now: Instant) -> Option { + if let Some(frame) = self.queued.pop_front() { + return Some(frame); + } + let buf = Bytes::from(self.fill(now)?); + if self.config.version.uses_records() { + return Some(buf); + } + self.split(buf); + self.queued.pop_front() + } + + /// The instant [`Self::handle_timeout`] should run next + pub fn poll_timeout(&self) -> Option { + match self.error { + Some(_) => None, + None => self.timer.next(), + } + } + + /// Run the handshake, idle, keep-alive, and RTT timers + pub fn handle_timeout(&mut self, now: Instant) { + if self.error.is_some() { + return; + } + let due = self.timer.poll(now); + if due.handshake { + debug!("qmux handshake timed out"); + self.lost(ConnectionError::TimedOut); + } else if due.idle { + // The idle timeout closes silently, as on QUIC + debug!("qmux idle timeout"); + self.lost(ConnectionError::TimedOut); + } else if due.ping { + self.ping_request = true; + } + } + + /// The next event + pub fn poll(&mut self) -> Option { + if let Some(event) = self.events.pop_front() { + return Some(event); + } + self.streams.poll().map(Event::Stream) + } + + /// Open and accept streams + pub fn streams(&mut self) -> Streams<'_> { + Streams { + state: &mut self.streams, + conn_state: &self.conn_state, + } + } + + /// Schedule outgoing streams in send groups + pub fn send_groups(&mut self) -> SendGroups<'_> { + SendGroups { + state: &mut self.streams, + } + } + + /// The send half of stream `id` + pub fn send_stream(&mut self, id: crate::StreamId) -> SendStream<'_> { + SendStream { + id, + state: &mut self.streams, + pending: &mut self.pending, + conn_state: &self.conn_state, + } + } + + /// The receive half of stream `id` + pub fn recv_stream(&mut self, id: crate::StreamId) -> RecvStream<'_> { + RecvStream { + id, + state: &mut self.streams, + pending: &mut self.pending, + } + } + + /// Send and receive datagrams + pub fn datagrams(&mut self) -> Datagrams<'_> { + Datagrams { conn: self } + } + + /// Return the connection credit [`Chunks::hold`](crate::Chunks::hold) took + /// + /// # Panics + /// If `credit` came from another connection. + pub fn release(&mut self, credit: Credit) -> ShouldTransmit { + let transmit = self.streams.release(credit); + self.pending.max_data |= transmit.should_transmit(); + transmit + } + + /// The frame space of a record: the peer's `max_record_size`, under the STREAM ceiling + fn budget(&self) -> usize { + let peer = self + .peer + .as_ref() + .map_or(DEFAULT_MAX_RECORD_SIZE, |p| p.max_record_size.into_inner()); + usize::try_from(peer).map_or(MAX_RECORD_BUDGET, |peer| peer.min(MAX_RECORD_BUDGET)) + } + + fn local_params(&self) -> Params { + let config = &self.config; + let mut tp = TransportParameters::default(); + tp.max_idle_timeout = config + .max_idle_timeout + .map_or(VarInt::from_u32(0), |timeout| { + VarInt::from_u64(timeout.as_millis() as u64).unwrap_or(VarInt::MAX) + }); + tp.initial_max_data = config.receive_window; + tp.initial_max_stream_data_bidi_local = config.stream_receive_window; + tp.initial_max_stream_data_bidi_remote = config.stream_receive_window; + tp.initial_max_stream_data_uni = config.stream_receive_window; + tp.initial_max_streams_bidi = config.max_concurrent_bidi_streams; + tp.initial_max_streams_uni = config.max_concurrent_uni_streams; + // A datagram must fit one record + tp.max_datagram_frame_size = config + .max_datagram_frame_size + .map(|size| size.min(config.max_record_size)); + tp.reset_stream_at = config.version == Version::QMux02; + Params { + tp, + max_record_size: config.max_record_size, + protocols: config.protocols.clone(), + } + } + + /// Serialize the next batch of frames, each counted as acknowledged on the spot + fn fill(&mut self, now: Instant) -> Option> { + if self.close_sent { + return None; + } + let budget = self.budget(); + let mut buf = Vec::new(); + + // The transport parameters go first + if !self.params_sent { + let mut blob = Vec::new(); + self.local_params().encode(&mut blob); + wire::encode_transport_parameters(&mut buf, &blob); + self.params_sent = true; + } + + if self.error.is_some() { + // Write the close frame, if any, and go quiet + if let Some(close) = self.close.take() { + let room = budget - buf.len(); + close.encode(&mut buf, room); + } + self.close_sent = true; + if buf.is_empty() { + return None; + } + self.timer.on_send(now); + return Some(buf); + } + + if let Some(seq) = self.ping_response.take() { + wire::encode_ping(&mut buf, true, seq); + } + if std::mem::take(&mut self.ping_request) { + let seq = VarInt::from_u64(self.next_ping).expect("QX_PING sequence overflow"); + wire::encode_ping(&mut buf, false, seq); + self.timer.ping_sent(self.next_ping, now); + self.next_ping += 1; + } + + let mut written = ThinRetransmits::default(); + self.streams.write_control_frames( + &mut buf, + &mut self.pending, + &mut written, + &mut self.stats, + budget, + ); + if let Some(written) = written.get() { + for &(id, reliable_size) in &written.reset_stream { + self.streams.reset_acked(id, reliable_size); + } + } + + // Stream data and datagrams last, so a STREAM frame without a Length field runs to the + // end of the record + let written = self + .streams + .write_frames(&mut buf, 0..budget, self.config.send_fairness); + for meta in written.streams { + let id = meta.id; + // A reset held until the peer has the data below its Reliable Size goes out now + if let Some(reliable_size) = self.streams.received_ack_of(meta) { + self.pending.reset_stream.push((id, reliable_size)); + } + } + // Freed streams may grant the peer more + self.streams.queue_max_stream_id(&mut self.pending); + + if buf.is_empty() { + return None; + } + self.timer.on_send(now); + Some(buf) + } + + /// Queue a draft-00 batch one frame per message, each STREAM frame with its Length field + fn split(&mut self, mut buf: Bytes) { + while !buf.is_empty() { + let before = buf.clone(); + let ty = before[0]; + let frame = wire::next_frame(&mut buf) + .expect("serialized a valid frame") + .expect("not empty"); + let len = before.len() - buf.len(); + let stream = match frame { + wire::Frame::Quic(frame::Frame::Stream(stream)) | wire::Frame::Continue(stream) + if ty & 0x02 == 0 => + { + stream + } + _ => { + self.queued.push_back(before.slice(..len)); + continue; + } + }; + let meta = frame::StreamMeta { + id: stream.id, + offsets: stream.offset..stream.offset + stream.data.len() as u64, + fin: stream.fin, + }; + let mut frame = Vec::with_capacity(len + 2); + meta.encode(true, &mut frame); + frame.extend_from_slice(&stream.data); + self.queued.push_back(frame.into()); + } + } + + fn process(&mut self, mut payload: Bytes, now: Instant) -> Result<(), TransportError> { + let size = payload.len(); + let limit = match self.config.version.uses_records() { + true => self.config.max_record_size.into_inner(), + false => MAX_FRAME_SIZE as u64, + }; + if size as u64 > limit { + return Err(self.oversized()); + } + + let mut frames = 0; + loop { + let before = payload.len(); + let Some(frame) = wire::next_frame(&mut payload)? else { + break; + }; + let frame_len = before - payload.len(); + frames += 1; + if frames > 1 && !self.config.version.uses_records() { + return Err(TransportError::FRAME_ENCODING_ERROR( + "draft-00 message carries more than one frame", + )); + } + self.on_frame(frame, size, frame_len, now)?; + if self.error.is_some() { + // The peer closed; ignore the rest + return Ok(()); + } + } + if frames == 0 { + return Err(TransportError::FRAME_ENCODING_ERROR( + "record carries no frames", + )); + } + self.streams.queue_max_stream_id(&mut self.pending); + Ok(()) + } + + /// The error for a record or draft-00 frame over its size limit + fn oversized(&self) -> TransportError { + match self.config.version.uses_records() { + true => TransportError::PROTOCOL_VIOLATION("record exceeds max_record_size"), + false => TransportError::FRAME_ENCODING_ERROR("frame exceeds max_frame_size"), + } + } + + /// End the connection over a violation, telling the peer with a CONNECTION_CLOSE + fn fail(&mut self, err: TransportError) { + debug!(%err, "qmux protocol error"); + self.close = Some(Close::from(err.clone())); + self.lost(ConnectionError::TransportError(err)); + } + + fn on_frame( + &mut self, + frame: wire::Frame, + payload_len: usize, + frame_len: usize, + now: Instant, + ) -> Result<(), TransportError> { + let params = matches!(frame, wire::Frame::TransportParameters(_)); + let padding = matches!(frame, wire::Frame::Quic(frame::Frame::Padding)); + if params && self.peer.is_some() { + return Err(TransportError::PROTOCOL_VIOLATION( + "duplicate QX_TRANSPORT_PARAMETERS", + )); + } + // Draft-02 requires the parameters first + if self.config.version == Version::QMux02 && !params && !padding && self.peer.is_none() { + return Err(TransportError::PROTOCOL_VIOLATION( + "first frame was not QX_TRANSPORT_PARAMETERS", + )); + } + + let frame = match frame { + wire::Frame::TransportParameters(blob) => return self.on_params(blob, now), + wire::Frame::PingRequest(_) | wire::Frame::PingResponse(_) + if !self.config.version.uses_records() => + { + return Err(TransportError::FRAME_ENCODING_ERROR("QX_PING on draft-00")); + } + wire::Frame::PingRequest(seq) => { + let strict = self.config.version == Version::QMux02; + if strict + && self + .greatest_ping_received + .is_some_and(|greatest| seq.into_inner() <= greatest) + { + return Err(TransportError::PROTOCOL_VIOLATION( + "QX_PING sequence number did not increase", + )); + } + self.greatest_ping_received = Some(seq.into_inner()); + self.ping_response = self.ping_response.max(Some(seq)); + return Ok(()); + } + wire::Frame::PingResponse(seq) => { + if self.config.version == Version::QMux02 && seq.into_inner() >= self.next_ping { + return Err(TransportError::PROTOCOL_VIOLATION( + "QX_PING response to a request never sent", + )); + } + self.timer.pong(seq.into_inner(), now); + return Ok(()); + } + wire::Frame::Continue(mut stream) => { + if let Some(offset) = self.streams.rx_offset(stream.id) { + stream.offset = offset; + } + return self.on_stream(stream, payload_len); + } + wire::Frame::Quic(frame) => frame, + }; + + match frame { + frame::Frame::Padding => {} + frame::Frame::Stream(stream) => { + // The transport is ordered, so a gap can only be a peer bug + if self + .streams + .rx_offset(stream.id) + .is_some_and(|expected| stream.offset != expected) + { + return Err(TransportError::PROTOCOL_VIOLATION( + "stream data out of order", + )); + } + self.on_stream(stream, payload_len)?; + } + frame::Frame::ResetStream(reset) => { + if reset.reliable_size.is_some() && self.config.version != Version::QMux02 { + let mut err = + TransportError::FRAME_ENCODING_ERROR("unsupported RESET_STREAM_AT"); + err.frame = Some(frame::FrameType::RESET_STREAM_AT); + return Err(err); + } + if self.streams.received_reset(reset)?.should_transmit() { + self.pending.max_data = true; + } + } + frame::Frame::StopSending(frame::StopSending { id, error_code }) => { + if let Some(reliable_size) = self.streams.received_stop_sending(id, error_code)? { + self.pending.reset_stream.push((id, reliable_size)); + } + } + frame::Frame::MaxData(max) => self.streams.received_max_data(max), + frame::Frame::MaxStreamData { id, offset } => { + self.streams.received_max_stream_data(id, offset)?; + } + frame::Frame::MaxStreams { dir, count } => { + self.streams.received_max_streams(dir, count)?; + } + frame::Frame::DataBlocked { offset } => { + trace!(offset, "peer is blocked at the connection level"); + } + frame::Frame::StreamDataBlocked { id, offset } => { + if id.initiator() == self.side && id.dir() == Dir::Uni { + return Err(TransportError::STREAM_STATE_ERROR( + "STREAM_DATA_BLOCKED on send-only stream", + )); + } + trace!(stream = %id, offset, "peer is blocked at the stream level"); + } + frame::Frame::StreamsBlocked { dir, limit } => { + if limit > MAX_STREAM_COUNT { + return Err(TransportError::FRAME_ENCODING_ERROR( + "unrepresentable stream limit", + )); + } + trace!(%dir, limit, "peer is blocked opening streams"); + } + frame::Frame::Close(Close::Application(close)) => { + self.lost(ConnectionError::ApplicationClosed(close)); + } + frame::Frame::Close(Close::Connection(close)) => { + self.lost(ConnectionError::ConnectionClosed(close)); + } + frame::Frame::Datagram(mut datagram) => { + let Some(max) = self.config.max_datagram_frame_size else { + return Err(TransportError::PROTOCOL_VIOLATION( + "unexpected DATAGRAM frame", + )); + }; + // The limit is on the frame as encoded, with or without its Length field + if frame_len as u64 > max.into_inner() { + return Err(TransportError::PROTOCOL_VIOLATION("oversized datagram")); + } + // The receive buffer charges only the payload, so a small one must not pin + // its whole record + if datagram.data.len() < payload_len / 2 { + datagram.data = Bytes::copy_from_slice(&datagram.data); + } + if self + .datagrams + .received(datagram, &Some(self.config.datagram_receive_buffer_size))? + { + self.events.push_back(Event::DatagramReceived); + } + } + // ACK, PING, CRYPTO, NEW_TOKEN, connection ID and path management, ACK_FREQUENCY, + // IMMEDIATE_ACK, and HANDSHAKE_DONE have no place on a reliable transport + frame => { + let mut err = TransportError::PROTOCOL_VIOLATION("frame prohibited in QMux"); + err.frame = Some(frame.ty()); + return Err(err); + } + } + Ok(()) + } + + fn on_stream( + &mut self, + stream: frame::Stream, + payload_len: usize, + ) -> Result<(), TransportError> { + if self + .streams + .received(stream, payload_len)? + .should_transmit() + { + self.pending.max_data = true; + } + Ok(()) + } + + fn on_params(&mut self, blob: Bytes, now: Instant) -> Result<(), TransportError> { + let mut peer = Params::decode(!self.side, self.config.version, blob)?; + self.protocol = match (&self.config.protocols, &peer.protocols) { + (Some(ours), theirs) => { + let theirs = theirs.as_deref().unwrap_or_default(); + // The server's preference wins, as with ALPN + let (server, client) = match self.side { + Side::Server => (ours.as_slice(), theirs), + Side::Client => (theirs, ours.as_slice()), + }; + let agreed = server.iter().find(|p| client.contains(p)).cloned(); + // As with ALPN, asking for a protocol and agreeing on none fails the handshake + if agreed.is_none() { + return Err(TransportError::TRANSPORT_PARAMETER_ERROR( + "no common application protocol", + )); + } + agreed + } + (None, Some(_)) => { + return Err(TransportError::TRANSPORT_PARAMETER_ERROR( + "unexpected application_protocols", + )); + } + (None, None) => None, + }; + // RESET_STREAM_AT is a draft-02 extension + peer.tp.reset_stream_at &= self.config.version == Version::QMux02; + self.streams.set_params(&peer.tp); + + let idle = match self.config.version.uses_records() { + true => super::negotiate_max_idle_timeout( + self.config.max_idle_timeout.map(|timeout| { + VarInt::from_u64(timeout.as_millis() as u64).unwrap_or(VarInt::MAX) + }), + Some(peer.tp.max_idle_timeout), + ), + false => None, + }; + self.timer.established(idle, self.config.rtt_interval, now); + self.peer = Some(peer); + self.events.push_back(Event::Connected); + Ok(()) + } + + /// End the connection with `reason`, unless it already ended: the first close wins + fn lost(&mut self, reason: ConnectionError) { + if self.error.is_some() { + return; + } + self.error = Some(reason.clone()); + self.conn_state = ConnectionState::Draining; + self.events.push_back(Event::ConnectionLost { reason }); + } +} + +impl std::fmt::Debug for Connection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Connection") + .field("side", &self.side) + .field("version", &self.config.version) + .field("established", &self.peer.is_some()) + .field("error", &self.error) + .finish_non_exhaustive() + } +} + +/// Datagrams on a QMux connection, which the transport delivers reliably and in order +pub struct Datagrams<'a> { + conn: &'a mut Connection, +} + +impl Datagrams<'_> { + /// Queue a datagram scheduled by `options`, like one of its group's streams + /// + /// Sending never blocks: while queued datagrams overflow + /// [`Config::datagram_send_buffer_size`], the oldest of the lowest-priority group is + /// dropped. + /// + /// # Panics + /// - when `options.group` was opened on another connection + pub fn send(&mut self, data: Bytes, options: SendOptions<'_>) -> Result<(), SendDatagramError> { + if self.conn.config.max_datagram_frame_size.is_none() { + return Err(SendDatagramError::Disabled); + } + let max = self + .max_size() + .ok_or(SendDatagramError::UnsupportedByPeer)?; + let budget = self.conn.config.datagram_send_buffer_size; + let datagram = Datagram { data }; + if datagram.data.len() > max || datagram.size(true) > budget { + return Err(SendDatagramError::TooLarge); + } + if !self.conn.streams.queue_datagram(datagram, options, budget) { + trace!("dropping outgoing datagram"); + } + Ok(()) + } + + /// The largest payload [`Self::send`] takes, or `None` when either side refuses datagrams + /// or the peer's parameters have not arrived + pub fn max_size(&self) -> Option { + let peer = self.conn.peer.as_ref()?.tp.max_datagram_frame_size?; + self.conn.config.max_datagram_frame_size?; + let frame = peer.into_inner().min(self.conn.budget() as u64); + Datagram::max_payload(frame).map(|payload| payload as usize) + } + + /// Receive the next datagram + pub fn recv(&mut self) -> Option { + self.conn.datagrams.recv() + } +} diff --git a/rs/moq-quic/src/connection/mux/params.rs b/rs/moq-quic/src/connection/mux/params.rs new file mode 100644 index 00000000000..5adef1eecd1 --- /dev/null +++ b/rs/moq-quic/src/connection/mux/params.rs @@ -0,0 +1,256 @@ +//! QMux transport parameters, carried in QX_TRANSPORT_PARAMETERS instead of a TLS extension +//! +//! The standard parameters are QUIC v1's and decode through [`TransportParameters`]. QMux adds +//! a pass over every parameter that rejects the QUIC v1 ones it prohibits and any duplicate, +//! and extracts its own `max_record_size` and `application_protocols`, which the shared codec +//! would skip as unknown. + +use std::collections::HashSet; + +use bytes::{Buf, BufMut, Bytes}; + +use super::Version; +use crate::{ + Side, TransportError, VarInt, coding::BufMutExt, transport_parameters::TransportParameters, +}; + +const MAX_IDLE_TIMEOUT: u64 = 0x01; +const INITIAL_MAX_DATA: u64 = 0x04; +const INITIAL_MAX_STREAM_DATA_BIDI_LOCAL: u64 = 0x05; +const INITIAL_MAX_STREAM_DATA_BIDI_REMOTE: u64 = 0x06; +const INITIAL_MAX_STREAM_DATA_UNI: u64 = 0x07; +const INITIAL_MAX_STREAMS_BIDI: u64 = 0x08; +const INITIAL_MAX_STREAMS_UNI: u64 = 0x09; +const RESET_STREAM_AT: u64 = 0x1d; +const MAX_DATAGRAM_FRAME_SIZE: u64 = 0x20; +const MAX_RECORD_SIZE: u64 = 0x0571c59429cd0845; +/// The non-TLS substitute for ALPN: each side lists its protocols and the server's preference +/// wins +const APPLICATION_PROTOCOLS: u64 = 0x3d4f9c2a8b1e6075; + +/// The default and minimum `max_record_size`: 16 KiB less a 2-byte Size field +pub(super) const DEFAULT_MAX_RECORD_SIZE: u64 = 16382; + +/// The QUIC v1 parameters QMux prohibits: original_destination_connection_id, +/// stateless_reset_token, max_udp_payload_size, ack_delay_exponent, max_ack_delay, +/// disable_active_migration, preferred_address, active_connection_id_limit, +/// initial_source_connection_id, and retry_source_connection_id +fn is_forbidden(id: u64) -> bool { + matches!(id, 0x00 | 0x02 | 0x03 | 0x0a..=0x10) +} + +/// The parameters one endpoint advertises +#[derive(Debug, Clone)] +pub(super) struct Params { + /// The standard QUIC parameters: flow control, stream limits, idle timeout, datagrams, and + /// RESET_STREAM_AT + pub(super) tp: TransportParameters, + /// The largest record Size field the sender accepts + pub(super) max_record_size: VarInt, + /// The application protocols offered for in-band negotiation, absent when the parameter is + pub(super) protocols: Option>, +} + +fn err(reason: &'static str) -> TransportError { + TransportError::TRANSPORT_PARAMETER_ERROR(reason) +} + +impl Params { + /// Encode in the order qmux 0.6 did, so both produce the same bytes + pub(super) fn encode(&self, buf: &mut Vec) { + fn varint(buf: &mut Vec, id: u64, value: VarInt) { + if value.into_inner() == 0 { + return; + } + buf.write_var(id); + buf.write_var(value.size() as u64); + buf.write(value); + } + + let tp = &self.tp; + varint(buf, MAX_IDLE_TIMEOUT, tp.max_idle_timeout); + varint(buf, INITIAL_MAX_DATA, tp.initial_max_data); + varint( + buf, + INITIAL_MAX_STREAM_DATA_BIDI_LOCAL, + tp.initial_max_stream_data_bidi_local, + ); + varint( + buf, + INITIAL_MAX_STREAM_DATA_BIDI_REMOTE, + tp.initial_max_stream_data_bidi_remote, + ); + varint( + buf, + INITIAL_MAX_STREAM_DATA_UNI, + tp.initial_max_stream_data_uni, + ); + varint(buf, INITIAL_MAX_STREAMS_BIDI, tp.initial_max_streams_bidi); + varint(buf, INITIAL_MAX_STREAMS_UNI, tp.initial_max_streams_uni); + if let Some(size) = tp.max_datagram_frame_size { + varint(buf, MAX_DATAGRAM_FRAME_SIZE, size); + } + varint(buf, MAX_RECORD_SIZE, self.max_record_size); + if tp.reset_stream_at { + buf.write_var(RESET_STREAM_AT); + buf.write_var(0); + } + if let Some(protocols) = self.protocols.as_ref().filter(|p| !p.is_empty()) { + let mut value = Vec::new(); + for protocol in protocols { + value.write_var(protocol.len() as u64); + value.put_slice(protocol.as_bytes()); + } + buf.write_var(APPLICATION_PROTOCOLS); + buf.write_var(value.len() as u64); + buf.put_slice(&value); + } + } + + /// Decode a peer's parameters; `side` is the peer's + pub(super) fn decode( + side: Side, + version: Version, + blob: Bytes, + ) -> Result { + let mut max_record_size = VarInt::from_u64(DEFAULT_MAX_RECORD_SIZE).unwrap(); + let mut protocols = None; + let mut seen = HashSet::new(); + let mut scan = &blob[..]; + while scan.has_remaining() { + let id = crate::coding::BufExt::get_var(&mut scan) + .map_err(|_| err("truncated parameter id"))?; + let len = crate::coding::BufExt::get_var(&mut scan) + .map_err(|_| err("truncated parameter length"))?; + let len = usize::try_from(len).map_err(|_| err("oversized parameter"))?; + if scan.remaining() < len { + return Err(err("truncated parameter value")); + } + let mut value = &scan[..len]; + scan.advance(len); + + if is_forbidden(id) { + return Err(err("prohibited QUIC v1 transport parameter")); + } + if !seen.insert(id) { + return Err(err("duplicate transport parameter")); + } + match id { + // Draft-00 has no records, so its peer's value would only shrink our frames + MAX_RECORD_SIZE if version.uses_records() => { + max_record_size = crate::coding::BufExt::get(&mut value) + .map_err(|_| err("malformed max_record_size"))?; + if value.has_remaining() { + return Err(err("malformed max_record_size")); + } + } + APPLICATION_PROTOCOLS => { + let mut list = Vec::new(); + while value.has_remaining() { + let len = crate::coding::BufExt::get_var(&mut value) + .map_err(|_| err("malformed application_protocols"))?; + let len = usize::try_from(len) + .ok() + .filter(|&len| len <= value.remaining()) + .ok_or_else(|| err("malformed application_protocols"))?; + let name = std::str::from_utf8(&value[..len]) + .map_err(|_| err("application protocol is not UTF-8"))?; + list.push(name.to_owned()); + value.advance(len); + } + protocols = Some(list); + } + _ => {} + } + } + if version.uses_records() && max_record_size.into_inner() < DEFAULT_MAX_RECORD_SIZE { + return Err(err("max_record_size below the minimum")); + } + + let tp = TransportParameters::read(side, &mut &blob[..])?; + Ok(Self { + tp, + max_record_size, + protocols, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_param(buf: &mut Vec, id: u64, value: u64) { + let value = VarInt::from_u64(value).unwrap(); + buf.write_var(id); + buf.write_var(value.size() as u64); + buf.write(value); + } + + fn decode(buf: Vec) -> Result { + Params::decode(Side::Client, Version::QMux02, buf.into()) + } + + #[test] + fn round_trip() { + let mut tp = TransportParameters::default(); + tp.max_idle_timeout = VarInt::from_u32(30_000); + tp.initial_max_data = VarInt::from_u32(1 << 20); + tp.initial_max_stream_data_uni = VarInt::from_u32(1 << 16); + tp.initial_max_streams_uni = VarInt::from_u32(16); + tp.max_datagram_frame_size = Some(VarInt::from_u32(4096)); + tp.reset_stream_at = true; + let params = Params { + tp, + max_record_size: VarInt::from_u32(32_768), + protocols: Some(vec!["moq-lite-03".into(), "moqt-16".into()]), + }; + let mut buf = Vec::new(); + params.encode(&mut buf); + let decoded = decode(buf).unwrap(); + assert_eq!(decoded.tp, params.tp); + assert_eq!(decoded.max_record_size, params.max_record_size); + assert_eq!(decoded.protocols, params.protocols); + } + + #[test] + fn rejects_prohibited() { + // stateless_reset_token + assert!(decode(vec![0x02, 0x00]).is_err()); + } + + #[test] + fn rejects_duplicates_the_shared_codec_skips() { + let mut buf = Vec::new(); + write_param(&mut buf, 0x1234, 7); + write_param(&mut buf, 0x1234, 7); + assert!(decode(buf).is_err()); + } + + #[test] + fn ignores_unknown() { + let mut buf = Vec::new(); + write_param(&mut buf, 0x1234, 7); + write_param(&mut buf, INITIAL_MAX_DATA, 42); + assert_eq!( + decode(buf).unwrap().tp.initial_max_data, + VarInt::from_u32(42) + ); + } + + #[test] + fn record_size_is_for_records_only() { + let mut buf = Vec::new(); + write_param(&mut buf, MAX_RECORD_SIZE, 1); + assert!(decode(buf.clone()).is_err()); + let params = Params::decode(Side::Client, Version::QMux00, buf.into()).unwrap(); + assert_eq!(params.max_record_size.into_inner(), DEFAULT_MAX_RECORD_SIZE); + } + + #[test] + fn rejects_unrepresentable_stream_limit() { + let mut buf = Vec::new(); + write_param(&mut buf, INITIAL_MAX_STREAMS_UNI, 1 << 61); + assert!(decode(buf).is_err()); + } +} diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs new file mode 100644 index 00000000000..8ab7f0b1ec9 --- /dev/null +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -0,0 +1,874 @@ +use bytes::{BufMut, Bytes}; + +use super::*; +use crate::{ReadError, StreamId, TransportErrorCode, coding::BufMutExt, frame::FrameType}; + +/// Two connections joined by an in-memory transport, on a clock the test advances +struct Pair { + client: Connection, + server: Connection, + now: Instant, +} + +impl Pair { + fn new(config: Config) -> Self { + Self::with(config.clone(), config) + } + + fn with(client: Config, server: Config) -> Self { + let now = Instant::now(); + let mut pair = Self { + client: Connection::new(client, Side::Client, now), + server: Connection::new(server, Side::Server, now), + now, + }; + pair.drive(); + pair + } + + /// Exchange records until neither side has anything to write + fn drive(&mut self) { + loop { + let mut idle = true; + while let Some(record) = self.client.poll_transmit(self.now) { + self.server.handle_record(record, self.now); + idle = false; + } + while let Some(record) = self.server.poll_transmit(self.now) { + self.client.handle_record(record, self.now); + idle = false; + } + if idle { + return; + } + } + } + + fn advance(&mut self, by: Duration) { + self.now += by; + self.client.handle_timeout(self.now); + self.server.handle_timeout(self.now); + } +} + +fn config(version: Version) -> Config { + Config { + version, + ..Config::default() + } +} + +fn events(conn: &mut Connection) -> Vec { + std::iter::from_fn(|| conn.poll()).collect() +} + +/// Read every byte currently available on `id` +fn read_all(conn: &mut Connection, id: StreamId) -> (Vec, bool) { + let mut recv = conn.recv_stream(id); + let mut chunks = recv.read(true).unwrap(); + let mut data = Vec::new(); + let mut fin = false; + loop { + match chunks.next(usize::MAX) { + Ok(Some(chunk)) => data.extend_from_slice(&chunk.bytes), + Ok(None) => { + fin = true; + break; + } + Err(ReadError::Blocked) => break, + Err(err) => panic!("read failed: {err}"), + } + } + let _ = chunks.finalize(); + (data, fin) +} + +fn lost(conn: &mut Connection) -> Option { + events(conn).into_iter().find_map(|event| match event { + Event::ConnectionLost { reason } => Some(reason), + _ => None, + }) +} + +#[test] +fn handshake() { + for &version in Version::ALL { + let mut pair = Pair::new(config(version)); + assert!(pair.client.is_established(), "{version:?}"); + assert!(pair.server.is_established(), "{version:?}"); + assert!(matches!(events(&mut pair.client)[..], [Event::Connected])); + assert!(matches!(events(&mut pair.server)[..], [Event::Connected])); + } +} + +#[test] +fn streams_carry_data_and_fin() { + for &version in Version::ALL { + let mut pair = Pair::new(config(version)); + let id = pair + .client + .streams() + .open(Dir::Bi, SendOptions::default()) + .unwrap(); + let payload = vec![7u8; 100_000]; + let mut sent = 0; + while sent < payload.len() { + sent += pair.client.send_stream(id).write(&payload[sent..]).unwrap(); + pair.drive(); + } + pair.client.send_stream(id).finish().unwrap(); + pair.drive(); + + assert_eq!(pair.server.streams().accept(Dir::Bi), Some(id)); + let mut received = Vec::new(); + loop { + let (data, fin) = read_all(&mut pair.server, id); + received.extend(data); + pair.drive(); + if fin { + break; + } + } + assert_eq!(received, payload, "{version:?}"); + // Serializing the FIN acknowledged it + assert!(events(&mut pair.client).iter().any( + |e| matches!(e, Event::Stream(StreamEvent::Finished { id: done }) if *done == id) + )); + } +} + +/// Every byte received returns its connection credit, whether read, dropped unread, or +/// arriving after STOP_SENDING, so a long-lived session never stalls on MAX_DATA +#[test] +fn credit_returns_for_unread_and_stopped_streams() { + let mut cfg = config(Version::QMux02); + cfg.receive_window = VarInt::from_u32(64 << 10); + cfg.stream_receive_window = VarInt::from_u32(32 << 10); + let mut pair = Pair::new(cfg); + let chunk = vec![1u8; 16 << 10]; + + // Far more than the connection window, a stream at a time + for round in 0..64 { + let id = pair + .client + .streams() + .open(Dir::Uni, SendOptions::default()) + .unwrap(); + assert_eq!( + pair.client.send_stream(id).write(&chunk).unwrap(), + chunk.len(), + "round {round}" + ); + pair.drive(); + + let accepted = pair.server.streams().accept(Dir::Uni).unwrap(); + match round % 2 { + // Stopped before the rest arrives, which then arrives anyway + 0 => { + pair.server + .recv_stream(accepted) + .stop(VarInt::from_u32(1)) + .unwrap(); + let _ = pair.client.send_stream(id).write(&chunk); + } + // Read to the end + _ => { + pair.client.send_stream(id).finish().unwrap(); + pair.drive(); + let (data, fin) = read_all(&mut pair.server, accepted); + assert_eq!(data.len(), chunk.len()); + assert!(fin); + } + } + pair.drive(); + } + assert!(pair.client.is_established()); + assert!(pair.server.is_established()); +} + +#[test] +fn close_delivers_application_close() { + let mut pair = Pair::new(config(Version::QMux02)); + pair.client + .close(VarInt::from_u32(42), Bytes::from_static(b"bye")); + // The first close wins + pair.client.close(VarInt::from_u32(7), Bytes::new()); + assert!(!pair.client.is_drained()); + pair.drive(); + assert!(pair.client.is_drained()); + match lost(&mut pair.server) { + Some(ConnectionError::ApplicationClosed(close)) => { + assert_eq!(close.error_code, VarInt::from_u32(42)); + assert_eq!(&close.reason[..], b"bye"); + } + other => panic!("unexpected {other:?}"), + } + assert!(matches!( + lost(&mut pair.client), + Some(ConnectionError::LocallyClosed) + )); +} + +/// A record carrying the peer's parameters, then `frame` +fn after_params(version: Version, frame: &[u8]) -> Bytes { + let mut peer = Connection::new(config(version), Side::Client, Instant::now()); + let mut record = peer.poll_transmit(Instant::now()).unwrap().to_vec(); + record.extend_from_slice(frame); + record.into() +} + +fn transport_error(conn: &mut Connection) -> TransportError { + match lost(conn) { + Some(ConnectionError::TransportError(err)) => err, + other => panic!("expected a transport error, got {other:?}"), + } +} + +#[test] +fn rejects_prohibited_frames() { + // PING, ACK, CRYPTO, HANDSHAKE_DONE + let frames: &[&[u8]] = &[ + &[0x01], + &[0x02, 0x00, 0x00, 0x00, 0x00], + &[0x06, 0x00, 0x00], + &[0x1e], + ]; + for frame in frames { + let now = Instant::now(); + let mut server = Connection::new(config(Version::QMux02), Side::Server, now); + server.handle_record(after_params(Version::QMux02, frame), now); + let err = transport_error(&mut server); + assert_eq!( + err.code, + TransportErrorCode::PROTOCOL_VIOLATION, + "{frame:?}" + ); + // The violation goes out as a CONNECTION_CLOSE after our parameters + let record = server.poll_transmit(now).unwrap(); + let mut rest = record.clone(); + assert!(matches!( + wire::next_frame(&mut rest).unwrap(), + Some(wire::Frame::TransportParameters(_)) + )); + assert!(matches!( + wire::next_frame(&mut rest).unwrap(), + Some(wire::Frame::Quic(frame::Frame::Close(Close::Connection(_)))) + )); + assert!(server.is_drained()); + } +} + +#[test] +fn draft02_requires_params_first() { + let now = Instant::now(); + let mut server = Connection::new(config(Version::QMux02), Side::Server, now); + // MAX_DATA before the parameters + server.handle_record(Bytes::from_static(&[0x10, 0x01]), now); + assert_eq!( + transport_error(&mut server).code, + TransportErrorCode::PROTOCOL_VIOLATION + ); + + // Draft-01 tolerates it + let mut server = Connection::new(config(Version::QMux01), Side::Server, now); + server.handle_record(Bytes::from_static(&[0x10, 0x01]), now); + assert!(!server.is_closed()); + + // A second QX_TRANSPORT_PARAMETERS is a violation on every version + for &version in Version::ALL { + let mut pair = Pair::new(config(version)); + let mut repeat = Connection::new(config(version), Side::Client, now); + let params = repeat.poll_transmit(now).unwrap(); + pair.server.handle_record(params, pair.now); + assert_eq!( + transport_error(&mut pair.server).code, + TransportErrorCode::PROTOCOL_VIOLATION + ); + } +} + +#[test] +fn rejects_empty_records() { + let mut pair = Pair::new(config(Version::QMux02)); + pair.server.handle_record(Bytes::new(), pair.now); + assert_eq!( + transport_error(&mut pair.server).code, + TransportErrorCode::FRAME_ENCODING_ERROR + ); +} + +#[test] +fn draft00_rejects_qx_ping() { + for ty in [0x348c67529ef8c7bd_u64, 0x348c67529ef8c7be] { + let mut pair = Pair::new(config(Version::QMux00)); + let mut ping = Vec::new(); + ping.write_var(ty); + ping.write_var(0); + pair.server.handle_record(ping.into(), pair.now); + assert_eq!( + transport_error(&mut pair.server).code, + TransportErrorCode::FRAME_ENCODING_ERROR + ); + } +} + +#[test] +fn rejects_oversized_records() { + let now = Instant::now(); + let mut server = Connection::new(config(Version::QMux01), Side::Server, now); + server.handle_record(vec![0; DEFAULT_MAX_RECORD_SIZE as usize + 1].into(), now); + assert_eq!( + transport_error(&mut server).code, + TransportErrorCode::PROTOCOL_VIOLATION + ); + + let mut server = Connection::new(config(Version::QMux00), Side::Server, now); + server.handle_record(vec![0; MAX_FRAME_SIZE + 1].into(), now); + assert_eq!( + transport_error(&mut server).code, + TransportErrorCode::FRAME_ENCODING_ERROR + ); + + // The same, refused by the transport before it was read + for (version, code) in [ + (Version::QMux01, TransportErrorCode::PROTOCOL_VIOLATION), + (Version::QMux00, TransportErrorCode::FRAME_ENCODING_ERROR), + ] { + let mut server = Connection::new(config(version), Side::Server, now); + server.handle_oversized(); + assert_eq!(transport_error(&mut server).code, code); + assert!(server.poll_transmit(now).is_some(), "CONNECTION_CLOSE"); + } +} + +#[test] +fn records_stay_under_the_stream_ceiling() { + let mut cfg = config(Version::QMux02); + cfg.max_record_size = VarInt::from_u32(1 << 20); + let mut pair = Pair::new(cfg); + let id = pair + .client + .streams() + .open(Dir::Uni, SendOptions::default()) + .unwrap(); + pair.client.send_stream(id).write(&[0; 100_000]).unwrap(); + while let Some(record) = pair.client.poll_transmit(pair.now) { + assert!(record.len() <= MAX_RECORD_BUDGET); + pair.server.handle_record(record, pair.now); + } +} + +#[test] +fn draft00_sends_one_length_prefixed_frame_per_message() { + let now = Instant::now(); + let mut client = Connection::new(config(Version::QMux00), Side::Client, now); + let mut server = Connection::new(config(Version::QMux00), Side::Server, now); + while let Some(frame) = server.poll_transmit(now) { + client.handle_record(frame, now); + } + let id = client + .streams() + .open(Dir::Uni, SendOptions::default()) + .unwrap(); + client.send_stream(id).write(&[3; 40_000]).unwrap(); + client.send_stream(id).finish().unwrap(); + let mut messages = 0; + while let Some(message) = client.poll_transmit(now) { + assert!(message.len() <= MAX_FRAME_SIZE); + let ty = message[0]; + if (0x08..=0x0f).contains(&ty) { + assert_ne!(ty & 0x02, 0, "STREAM frame without a Length field"); + } + let mut rest = message.clone(); + wire::next_frame(&mut rest).unwrap().unwrap(); + assert!(rest.is_empty(), "more than one frame in a message"); + server.handle_record(message, now); + messages += 1; + } + assert!(messages > 3); + assert!(!server.is_closed()); + assert_eq!(server.streams().accept(Dir::Uni), Some(id)); + let (data, fin) = read_all(&mut server, id); + assert_eq!(data.len(), 40_000); + assert!(fin); +} + +#[test] +fn stream_offsets_follow_the_transport_order() { + let now = Instant::now(); + // A STREAM frame on stream 2 (client uni) at offset 5, before any data + let mut gap = Vec::new(); + gap.write_var(0x0e); // OFF | LEN + gap.write_var(2); + gap.write_var(5); + gap.write_var(1); + gap.put_u8(0); + let mut first = Vec::new(); + frame::StreamMeta { + id: StreamId(2), + offsets: 0..3, + fin: false, + } + .encode(true, &mut first); + first.extend_from_slice(b"abc"); + + let mut server = Connection::new(config(Version::QMux01), Side::Server, now); + server.handle_record(after_params(Version::QMux01, &first), now); + server.handle_record(gap.clone().into(), now); + assert_eq!( + transport_error(&mut server).code, + TransportErrorCode::PROTOCOL_VIOLATION + ); + + // Including as the stream's first frame + let mut server = Connection::new(config(Version::QMux01), Side::Server, now); + server.handle_record(after_params(Version::QMux01, &gap), now); + assert_eq!( + transport_error(&mut server).code, + TransportErrorCode::PROTOCOL_VIOLATION + ); + + // Released senders omit the offset on every frame, which continues the stream + let mut server = Connection::new(config(Version::QMux01), Side::Server, now); + server.handle_record(after_params(Version::QMux01, &first), now); + server.handle_record(Bytes::from(first.clone()), now); + assert!(!server.is_closed()); + let id = server.streams().accept(Dir::Uni).unwrap(); + assert_eq!(read_all(&mut server, id).0, b"abcabc"); +} + +#[test] +fn reset_stream_at_delivers_the_prefix() { + let mut pair = Pair::new(config(Version::QMux02)); + let id = pair + .client + .streams() + .open(Dir::Uni, SendOptions::default()) + .unwrap(); + pair.client.send_stream(id).write(b"header").unwrap(); + pair.client.send_stream(id).write(b"payload").unwrap(); + pair.client + .send_stream(id) + .reset_at(6, VarInt::from_u32(9)) + .unwrap(); + pair.drive(); + + let accepted = pair.server.streams().accept(Dir::Uni).unwrap(); + let mut recv = pair.server.recv_stream(accepted); + let mut chunks = recv.read(true).unwrap(); + let mut data = Vec::new(); + let err = loop { + match chunks.next(usize::MAX) { + Ok(Some(chunk)) => data.extend_from_slice(&chunk.bytes), + Ok(None) => panic!("finished instead of reset"), + Err(err) => break err, + } + }; + let _ = chunks.finalize(); + assert!(data.starts_with(b"header"), "{data:?} {err:?}"); + assert_eq!(err, ReadError::Reset(VarInt::from_u32(9))); +} + +#[test] +fn reset_stream_at_needs_draft02() { + let mut reset = Vec::new(); + reset.write(FrameType::RESET_STREAM_AT); + reset.write_var(2); + reset.write_var(0); + reset.write_var(4); + reset.write_var(2); + let now = Instant::now(); + let mut server = Connection::new(config(Version::QMux01), Side::Server, now); + server.handle_record(after_params(Version::QMux01, &reset), now); + assert_eq!( + transport_error(&mut server).code, + TransportErrorCode::FRAME_ENCODING_ERROR + ); +} + +#[test] +fn datagrams() { + let mut pair = Pair::new(config(Version::QMux02)); + let max = pair.client.datagrams().max_size().unwrap(); + assert!(max > 16_000); + pair.client + .datagrams() + .send(Bytes::from_static(b"hello"), SendOptions::default()) + .unwrap(); + pair.drive(); + assert!( + events(&mut pair.server) + .iter() + .any(|e| matches!(e, Event::DatagramReceived)) + ); + assert_eq!(pair.server.datagrams().recv().unwrap(), &b"hello"[..]); + + // Draft-00 has none + let mut pair = Pair::new(config(Version::QMux00)); + assert_eq!( + pair.client + .datagrams() + .send(Bytes::from_static(b"hello"), SendOptions::default()), + Err(SendDatagramError::Disabled) + ); +} + +#[test] +fn datagram_limits_count_the_encoded_frame() { + let mut limited = config(Version::QMux02); + limited.max_datagram_frame_size = Some(VarInt::from_u32(1200)); + + // A frame without its Length field may fill the limit exactly + let mut pair = Pair::with(config(Version::QMux02), limited.clone()); + let mut fits = vec![0x30]; + fits.resize(1200, 7); + pair.server.handle_record(fits.into(), pair.now); + assert_eq!(pair.server.datagrams().recv().unwrap().len(), 1199); + let mut over = vec![0x30]; + over.resize(1201, 7); + pair.server.handle_record(over.into(), pair.now); + assert_eq!( + transport_error(&mut pair.server).code, + TransportErrorCode::PROTOCOL_VIOLATION + ); + + // Sending sizes the Length field exactly: 1200 bytes carry 1197 + let mut pair = Pair::with(config(Version::QMux02), limited); + assert_eq!(pair.client.datagrams().max_size(), Some(1197)); + pair.client + .datagrams() + .send(vec![7; 1197].into(), SendOptions::default()) + .unwrap(); + pair.drive(); + assert_eq!(pair.server.datagrams().recv().unwrap().len(), 1197); + + // A limit too small for an empty frame takes none + let mut tiny = config(Version::QMux02); + tiny.max_datagram_frame_size = Some(VarInt::from_u32(1)); + let mut pair = Pair::with(config(Version::QMux02), tiny); + assert_eq!(pair.client.datagrams().max_size(), None); + assert!( + pair.client + .datagrams() + .send(Bytes::new(), SendOptions::default()) + .is_err() + ); +} + +#[test] +fn a_small_datagram_does_not_pin_its_record() { + let mut pair = Pair::new(config(Version::QMux02)); + let mut record = Vec::new(); + record.write_var(0x31); // DATAGRAM with a length + record.write_var(1); + record.put_u8(7); + record.resize(DEFAULT_MAX_RECORD_SIZE as usize, 0); // PADDING + let record = Bytes::from(record); + let span = record.as_ptr_range(); + pair.server.handle_record(record.clone(), pair.now); + + let datagram = pair.server.datagrams().recv().unwrap(); + assert_eq!(datagram, &[7][..]); + assert!( + !span.contains(&datagram.as_ptr()), + "the datagram still points into its record" + ); +} + +#[test] +fn negotiates_the_servers_preference() { + let mut client = config(Version::QMux01); + client.protocols = Some(vec!["a".into(), "b".into(), "c".into()]); + let mut server = config(Version::QMux01); + server.protocols = Some(vec!["c".into(), "b".into()]); + let pair = Pair::with(client.clone(), server); + assert_eq!(pair.client.protocol(), Some("c")); + assert_eq!(pair.server.protocol(), Some("c")); + + // An offer the other side did not ask for is an error + let mut pair = Pair::with(client.clone(), config(Version::QMux01)); + assert_eq!( + transport_error(&mut pair.server).code, + TransportErrorCode::TRANSPORT_PARAMETER_ERROR + ); + + // So is agreeing on nothing + let mut server = config(Version::QMux01); + server.protocols = Some(vec!["d".into()]); + let mut pair = Pair::with(client, server); + assert_eq!( + transport_error(&mut pair.server).code, + TransportErrorCode::TRANSPORT_PARAMETER_ERROR + ); +} + +#[test] +fn keepalive_holds_an_idle_session_open() { + let mut pair = Pair::new(config(Version::QMux02)); + for _ in 0..30 { + pair.advance(Duration::from_secs(5)); + pair.drive(); + } + assert!(pair.client.is_established()); + assert!(pair.client.rtt().is_some()); +} + +#[test] +fn idle_timeout_reclaims_a_silent_peer() { + let mut pair = Pair::new(config(Version::QMux02)); + // The server never hears back: its keep-alives go out, nothing answers + for _ in 0..10 { + pair.advance(Duration::from_secs(5)); + while pair.server.poll_transmit(pair.now).is_some() {} + while pair.client.poll_transmit(pair.now).is_some() {} + } + assert!(matches!( + lost(&mut pair.server), + Some(ConnectionError::TimedOut) + )); +} + +#[test] +fn backpressure_defers_the_idle_timeout_once() { + let mut pair = Pair::new(config(Version::QMux02)); + pair.server.set_backpressured(true); + pair.advance(Duration::from_secs(31)); + assert!(!pair.server.is_closed()); + pair.advance(Duration::from_secs(31)); + assert!(matches!( + lost(&mut pair.server), + Some(ConnectionError::TimedOut) + )); +} + +#[test] +fn handshake_timeout() { + let now = Instant::now(); + let mut server = Connection::new(config(Version::QMux02), Side::Server, now); + let deadline = server.poll_timeout().unwrap(); + server.handle_timeout(deadline); + assert!(matches!(lost(&mut server), Some(ConnectionError::TimedOut))); +} + +#[test] +fn transport_end_without_close() { + let mut pair = Pair::new(config(Version::QMux02)); + pair.server.transport_closed(); + assert!(matches!( + lost(&mut pair.server), + Some(ConnectionError::Reset) + )); + assert!(pair.server.is_drained()); +} + +/// The first record of a default client, byte for byte, in the order qmux 0.6 encodes, so a +/// codec change shows up as a wire change +#[test] +fn golden_transport_parameters() { + let expected = [ + ( + Version::QMux02, + concat!( + "ff5153300d0a0d0a370104800075300404801000000504800400000604800400", + "00070480040000080240640902406420027ffec571c59429cd0845027ffe1d00", + ), + ), + ( + Version::QMux01, + concat!( + "ff5153300d0a0d0a350104800075300404801000000504800400000604800400", + "00070480040000080240640902406420027ffec571c59429cd0845027ffe", + ), + ), + ( + Version::QMux00, + concat!( + "ff5153300d0a0d0a2b0404801000000504800400000604800400000704800400", + "000802406409024064c571c59429cd0845027ffe", + ), + ), + ]; + for (version, hex) in expected { + let mut client = Connection::new(config(version), Side::Client, Instant::now()); + let record = client.poll_transmit(Instant::now()).unwrap(); + let actual: String = record.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual, hex, "{version:?}"); + } +} + +/// An established client's records for a finished stream, a reset, and a close, byte for +/// byte, so a framing change shows up as a wire change. Every version frames them alike: +/// the record header is the transport's. +#[test] +fn golden_records() { + for &version in Version::ALL { + let mut pair = Pair::new(config(version)); + let record = |conn: &mut Connection, now| { + let record = conn.poll_transmit(now).unwrap(); + assert!(conn.poll_transmit(now).is_none()); + record + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + }; + + let id = pair + .client + .streams() + .open(Dir::Uni, SendOptions::default()) + .unwrap(); + pair.client.send_stream(id).write(b"hi").unwrap(); + pair.client.send_stream(id).finish().unwrap(); + let stream = record(&mut pair.client, pair.now); + + let id = pair + .client + .streams() + .open(Dir::Uni, SendOptions::default()) + .unwrap(); + pair.client.send_stream(id).write(b"x").unwrap(); + pair.client + .send_stream(id) + .reset(VarInt::from_u32(5)) + .unwrap(); + let reset = record(&mut pair.client, pair.now); + + pair.client + .close(VarInt::from_u32(42), Bytes::from_static(b"bye")); + let close = record(&mut pair.client, pair.now); + + // STREAM(OFF=0, LEN, FIN) id 2 "hi"; RESET_STREAM id 6 code 5 final size 1; + // CONNECTION_CLOSE(application) code 42 "bye" + assert_eq!( + [stream, reset, close], + ["0b02026869", "04060501", "1d2a03627965"], + "{version:?}" + ); + } +} + +/// The client's STREAM bytes in the order its records carry them, as `(stream, bytes)` runs +fn stream_order(pair: &mut Pair) -> Vec<(u64, usize)> { + let mut order: Vec<(u64, usize)> = Vec::new(); + while let Some(record) = pair.client.poll_transmit(pair.now) { + let mut rest = record.clone(); + while let Some(frame) = wire::next_frame(&mut rest).unwrap() { + let (wire::Frame::Quic(frame::Frame::Stream(stream)) | wire::Frame::Continue(stream)) = + frame + else { + continue; + }; + let id = u64::from(stream.id); + match order.last_mut() { + Some((last, len)) if *last == id => *len += stream.data.len(), + _ => order.push((id, stream.data.len())), + } + } + pair.server.handle_record(record, pair.now); + } + order +} + +/// Open a client uni stream with `options` and queue `len` bytes and a FIN on it +fn queue(pair: &mut Pair, options: SendOptions<'_>, len: usize) -> u64 { + let id = pair.client.streams().open(Dir::Uni, options).unwrap(); + assert_eq!( + pair.client.send_stream(id).write(&vec![0; len]).unwrap(), + len + ); + pair.client.send_stream(id).finish().unwrap(); + u64::from(id) +} + +/// The record writer is QUIC's scheduler: a higher priority preempts a lower one. +#[test] +fn records_follow_strict_priority() { + let mut pair = Pair::new(config(Version::QMux02)); + let low = queue( + &mut pair, + SendOptions { + group: None, + priority: 0, + }, + 30_000, + ); + let high = queue( + &mut pair, + SendOptions { + group: None, + priority: 1, + }, + 30_000, + ); + assert_eq!(stream_order(&mut pair), [(high, 30_000), (low, 30_000)]); +} + +/// Streams of equal priority share the records instead of one draining first. +#[test] +fn records_share_equal_priority() { + let mut pair = Pair::new(config(Version::QMux02)); + let a = queue(&mut pair, SendOptions::default(), 30_000); + let b = queue(&mut pair, SendOptions::default(), 30_000); + let order = stream_order(&mut pair); + assert!(order.len() >= 4, "{order:?}"); + for (id, _) in &order { + assert!([a, b].contains(id)); + } + for stream in [a, b] { + let sent: usize = order + .iter() + .filter(|(id, _)| *id == stream) + .map(|(_, len)| len) + .sum(); + assert_eq!(sent, 30_000); + } +} + +/// Ungrouped streams go before every send group, and groups order by their own priority. +#[test] +fn records_order_send_groups() { + let mut pair = Pair::new(config(Version::QMux02)); + let low = pair.client.send_groups().open(0); + let high = pair.client.send_groups().open(1); + let in_low = queue( + &mut pair, + SendOptions { + group: Some(&low), + priority: 0, + }, + 20_000, + ); + let in_high = queue( + &mut pair, + SendOptions { + group: Some(&high), + priority: 0, + }, + 20_000, + ); + let ungrouped = queue(&mut pair, SendOptions::default(), 20_000); + assert_eq!( + stream_order(&mut pair), + [(ungrouped, 20_000), (in_high, 20_000), (in_low, 20_000)] + ); + + // Raising a group's priority reorders what it has left to send. + let first = queue( + &mut pair, + SendOptions { + group: Some(&high), + priority: 0, + }, + 20_000, + ); + let second = queue( + &mut pair, + SendOptions { + group: Some(&low), + priority: 0, + }, + 20_000, + ); + pair.client.send_groups().set_priority(&low, 2); + assert_eq!(stream_order(&mut pair), [(second, 20_000), (first, 20_000)]); +} diff --git a/rs/moq-quic/src/connection/mux/timer.rs b/rs/moq-quic/src/connection/mux/timer.rs new file mode 100644 index 00000000000..32c16f289ca --- /dev/null +++ b/rs/moq-quic/src/connection/mux/timer.rs @@ -0,0 +1,199 @@ +//! Idle timeout, keep-alive, and RTT probing +//! +//! A reliable transport has no acknowledgments to show the peer is alive, so the idle +//! deadline follows records. Receiving one always restarts it. Sending restarts it at most +//! once per receive (RFC 9000 §10.1): counting every send would let our own keep-alives +//! restart the deadline they exist to test, and a peer that went silent while its socket +//! still accepts writes would never be reclaimed. +//! +//! Keep-alive QX_PINGs go out after a third of the idle window without sending. RTT probes +//! go out on their own interval regardless, since an application that is busy sending is +//! when latency matters and the keep-alive never fires. While the transport refuses writes, +//! the peer is evidently there but slow, so the idle close is deferred by at most one more +//! window. + +use std::collections::VecDeque; + +use crate::{Duration, Instant}; + +/// The most unanswered probes tracked. A peer that answers retires them at once, so this +/// only fills against one that keeps the session busy while ignoring QX_PING. +const MAX_OUTSTANDING: usize = 64; + +#[derive(Debug)] +pub(super) struct Timer { + /// Fails the handshake if the peer's parameters have not arrived by then + handshake: Option, + /// The negotiated idle timeout, once the peer's parameters arrive + idle: Option, + rtt_interval: Option, + + last_recv: Instant, + /// The send that last restarted the idle deadline + send_reset: Instant, + /// Whether a send may restart the deadline: a record arrived since the last one did + send_credit: bool, + last_send: Instant, + last_ping: Instant, + /// When the idle close began deferring for transport backpressure + deferred: Option, + backpressured: bool, + + /// Probes sent and not yet answered, oldest first, with the time each was written + outstanding: VecDeque<(u64, Instant)>, + rtt: Option, +} + +/// What [`Timer::poll`] found due +#[derive(Debug, Default)] +pub(super) struct Due { + pub(super) handshake: bool, + pub(super) idle: bool, + pub(super) ping: bool, +} + +impl Timer { + pub(super) fn new(handshake: Option, now: Instant) -> Self { + Self { + handshake: handshake.map(|timeout| now + timeout), + idle: None, + rtt_interval: None, + last_recv: now, + send_reset: now, + send_credit: true, + last_send: now, + last_ping: now, + deferred: None, + backpressured: false, + outstanding: VecDeque::new(), + rtt: None, + } + } + + /// Arm the idle timeout and RTT probing once the peer's parameters arrive + pub(super) fn established( + &mut self, + idle: Option, + rtt_interval: Option, + now: Instant, + ) { + self.handshake = None; + self.idle = idle; + self.rtt_interval = rtt_interval; + self.last_recv = now; + self.send_reset = now; + self.send_credit = true; + self.last_ping = self.last_send.max(now); + } + + pub(super) fn on_recv(&mut self, now: Instant) { + self.last_recv = now; + self.send_credit = true; + self.deferred = None; + } + + pub(super) fn on_send(&mut self, now: Instant) { + self.last_send = now; + if self.send_credit { + self.send_reset = now; + self.send_credit = false; + } + } + + pub(super) fn set_backpressured(&mut self, backpressured: bool) { + self.backpressured = backpressured; + } + + fn last_activity(&self) -> Instant { + self.last_recv.max(self.send_reset) + } + + fn keepalive(&self) -> Option { + self.idle + .map(|idle| (idle / 3).max(Duration::from_millis(1))) + } + + /// The next instant [`Self::poll`] has something to do + pub(super) fn next(&self) -> Option { + let idle = self.idle.map(|idle| match self.deferred { + Some(since) => since + idle, + None => self.last_activity() + idle, + }); + let keepalive = self + .keepalive() + .map(|every| self.last_send.max(self.last_ping) + every); + let rtt = self.rtt_interval.map(|every| self.last_ping + every); + [self.handshake, idle, keepalive, rtt] + .into_iter() + .flatten() + .min() + } + + pub(super) fn poll(&mut self, now: Instant) -> Due { + let mut due = Due { + handshake: self.handshake.is_some_and(|at| now >= at), + ..Due::default() + }; + + let keepalive = self + .keepalive() + .is_some_and(|every| now >= self.last_send.max(self.last_ping) + every); + let rtt = self + .rtt_interval + .is_some_and(|every| now >= self.last_ping + every); + if keepalive || rtt { + // A ping cannot get past a stalled transport, and must not pile up behind it + due.ping = !self.backpressured; + self.last_ping = now; + } + + if let Some(idle) = self.idle + && now >= self.last_activity() + idle + { + match self.deferred { + // A peer that died with our buffers full must not hang here forever + Some(since) => due.idle = now >= since + idle, + None if self.backpressured => self.deferred = Some(now), + None => due.idle = true, + } + } + due + } + + /// Record a probe written at `now` + pub(super) fn ping_sent(&mut self, seq: u64, now: Instant) { + if self.outstanding.len() >= MAX_OUTSTANDING { + self.outstanding.pop_front(); + } + self.outstanding.push_back((seq, now)); + } + + /// Fold in the response to `seq` received at `now` + /// + /// A responder may answer several requests with the largest sequence number, so this + /// retires every request up to `seq` and measures from the newest of them: the one the + /// peer had to receive before answering. + pub(super) fn pong(&mut self, seq: u64, now: Instant) { + let mut newest = None; + while let Some(&(sent, at)) = self.outstanding.front() { + if sent > seq { + break; + } + newest = Some(at); + self.outstanding.pop_front(); + } + let Some(at) = newest else { + return; + }; + let sample = now.saturating_duration_since(at); + self.rtt = Some(match self.rtt { + // RFC 6298's smoothing + Some(rtt) => (rtt * 7 + sample) / 8, + None => sample, + }); + } + + pub(super) fn rtt(&self) -> Option { + self.rtt + } +} diff --git a/rs/moq-quic/src/connection/mux/wire.rs b/rs/moq-quic/src/connection/mux/wire.rs new file mode 100644 index 00000000000..6439ae4d5db --- /dev/null +++ b/rs/moq-quic/src/connection/mux/wire.rs @@ -0,0 +1,139 @@ +//! What QMux adds to the QUIC v1 wire format +//! +//! QMux reuses the QUIC frame formats verbatim, so records are parsed with the crate's own +//! [`frame::Iter`]. This module supplies only what that parser does not know, the +//! `QX_TRANSPORT_PARAMETERS` and `QX_PING` frames, intercepted by peeking each frame's type +//! before the shared parser sees it. + +use bytes::{Buf, BufMut, Bytes}; + +use crate::{ + TransportError, VarInt, + coding::{BufExt, BufMutExt}, + frame, +}; + +/// Encodes as `\xffQMX\r\n\r\n`, which no HTTP/1.1 or HTTP/2 preface starts with +const QX_TRANSPORT_PARAMETERS: u64 = 0x3f5153300d0a0d0a; +const QX_PING_REQUEST: u64 = 0x348c67529ef8c7bd; +const QX_PING_RESPONSE: u64 = 0x348c67529ef8c7be; + +/// The largest frame draft-00 accepts, header included, and the send-side ceiling for every +/// version: released peers reject a larger STREAM frame on receive. +pub(super) const MAX_FRAME_SIZE: usize = 16384; + +/// The QMux wire format, chosen out of band by ALPN or the WebSocket subprotocol +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Version { + /// draft-ietf-quic-qmux-00: bare frames, without records, QX_PING, or datagrams + QMux00, + /// draft-ietf-quic-qmux-01: frames in size-prefixed records, with QX_PING keep-alives, + /// datagrams, and the `max_idle_timeout` and `max_record_size` parameters + QMux01, + /// draft-ietf-quic-qmux-02: draft-01 plus RESET_STREAM_AT, and stricter rules for + /// QX_TRANSPORT_PARAMETERS and QX_PING + QMux02, +} + +impl Version { + /// Every supported version, newest first + pub const ALL: &[Self] = &[Self::QMux02, Self::QMux01, Self::QMux00]; + + /// Whether frames travel in records, which brings QX_PING, datagrams, the idle timeout, and + /// `max_record_size` along + pub fn uses_records(self) -> bool { + matches!(self, Self::QMux01 | Self::QMux02) + } + + /// The bare ALPN for this version, like `qmux-01` + pub fn alpn(self) -> &'static str { + match self { + Self::QMux00 => "qmux-00", + Self::QMux01 => "qmux-01", + Self::QMux02 => "qmux-02", + } + } + + /// The prefix pairing this version with an application protocol, like `qmux-01.` + pub fn prefix(self) -> &'static str { + match self { + Self::QMux00 => "qmux-00.", + Self::QMux01 => "qmux-01.", + Self::QMux02 => "qmux-02.", + } + } +} + +/// One frame of a record +#[derive(Debug)] +pub(super) enum Frame { + /// A QUIC frame, including the ones QMux prohibits, which the connection rejects + Quic(frame::Frame), + /// A STREAM frame without the OFF bit, so it continues the stream: senders released + /// before offsets were encoded omit it on every frame + Continue(frame::Stream), + TransportParameters(Bytes), + PingRequest(VarInt), + PingResponse(VarInt), +} + +fn truncated() -> TransportError { + TransportError::FRAME_ENCODING_ERROR("truncated frame") +} + +/// Decode the next frame of a record, or `None` once it is exhausted +pub(super) fn next_frame(bytes: &mut Bytes) -> Result, TransportError> { + if bytes.is_empty() { + return Ok(None); + } + + let mut peek = &bytes[..]; + let ty = peek.get_var().map_err(|_| truncated())?; + let frame = match ty { + QX_TRANSPORT_PARAMETERS | QX_PING_REQUEST | QX_PING_RESPONSE => { + bytes.advance(bytes.len() - peek.len()); + match ty { + QX_TRANSPORT_PARAMETERS => { + let len = bytes.get_var().map_err(|_| truncated())?; + let len = usize::try_from(len).map_err(|_| truncated())?; + if bytes.remaining() < len { + return Err(truncated()); + } + Frame::TransportParameters(bytes.split_to(len)) + } + QX_PING_REQUEST => Frame::PingRequest(bytes.get().map_err(|_| truncated())?), + _ => Frame::PingResponse(bytes.get().map_err(|_| truncated())?), + } + } + _ => { + let mut iter = frame::Iter::new(std::mem::take(bytes))?; + let frame = iter + .next() + .expect("payload is not empty") + .map_err(TransportError::from)?; + *bytes = iter.into_rest(); + match frame { + frame::Frame::Stream(stream) if (0x08..=0x0f).contains(&ty) && ty & 0x04 == 0 => { + Frame::Continue(stream) + } + frame => Frame::Quic(frame), + } + } + }; + Ok(Some(frame)) +} + +pub(super) fn encode_transport_parameters(buf: &mut Vec, params: &[u8]) { + buf.write_var(QX_TRANSPORT_PARAMETERS); + buf.write_var(params.len() as u64); + buf.put_slice(params); +} + +pub(super) fn encode_ping(buf: &mut Vec, response: bool, seq: VarInt) { + buf.write_var(match response { + true => QX_PING_RESPONSE, + false => QX_PING_REQUEST, + }); + buf.write(seq); +} diff --git a/rs/moq-quic/src/connection/streams/recv.rs b/rs/moq-quic/src/connection/streams/recv.rs index 027fabe6dbf..e9b3222a9a4 100644 --- a/rs/moq-quic/src/connection/streams/recv.rs +++ b/rs/moq-quic/src/connection/streams/recv.rs @@ -18,6 +18,10 @@ pub(super) struct Recv { pub(super) assembler: Assembler, sent_max_stream_data: u64, pub(super) end: u64, + /// The end of the data STREAM frames carried, which on an ordered transport is where the + /// next one starts. Unlike `end`, a reset's final size does not raise it. + #[cfg(feature = "mux")] + pub(super) data_end: u64, pub(super) stopped: bool, } @@ -28,6 +32,8 @@ impl Recv { assembler: Assembler::new(), sent_max_stream_data: initial_max_data, end: 0, + #[cfg(feature = "mux")] + data_end: 0, stopped: false, }) } @@ -38,6 +44,10 @@ impl Recv { self.assembler.reinit(); self.sent_max_stream_data = initial_max_data; self.end = 0; + #[cfg(feature = "mux")] + { + self.data_end = 0; + } self.stopped = false; } @@ -83,6 +93,10 @@ impl Recv { } self.end = self.end.max(end); + #[cfg(feature = "mux")] + { + self.data_end = self.data_end.max(end); + } // A reset stream only still delivers the data below its Reliable Size if let RecvState::ResetRecvd { reliable_size, .. } = self.state { let len = reliable_size diff --git a/rs/moq-quic/src/connection/streams/state.rs b/rs/moq-quic/src/connection/streams/state.rs index f2696d89cc4..52e0f9a3da7 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -480,6 +480,26 @@ impl StreamsState { self.pending.can_send(space) } + /// The offset just past the data received so far on `id` + /// + /// `None` if `id` cannot receive data now, such as a closed stream or our own + /// unidirectional one. On an ordered transport, data at any other offset is a protocol + /// violation. + #[cfg(feature = "mux")] + pub(crate) fn rx_offset(&self, id: StreamId) -> Option { + // A peer stream not opened yet starts at offset 0 too + if id.initiator() != self.side && id.index() >= self.next_remote[id.dir() as usize] { + return Some(0); + } + let recv = self.recv.get(&id)?; + // Present but unopened: the peer may send on it, starting at offset 0 + Some( + recv.as_ref() + .and_then(|r| r.as_open_recv()) + .map_or(0, |r| r.data_end), + ) + } + /// Whether MAX_STREAM_DATA frames could be sent for stream `id` pub(crate) fn can_send_flow_control(&self, id: StreamId) -> bool { self.recv diff --git a/rs/moq-quic/src/frame.rs b/rs/moq-quic/src/frame.rs index b45aa525e8d..0c2df18bfce 100644 --- a/rs/moq-quic/src/frame.rs +++ b/rs/moq-quic/src/frame.rs @@ -741,6 +741,12 @@ impl Iter { fn take_remaining(&mut self) -> Bytes { mem::take(&mut self.bytes) } + + /// The payload past the frames parsed so far, so the mux can interleave its own frame types + #[cfg(feature = "mux")] + pub(crate) fn into_rest(self) -> Bytes { + self.bytes + } } impl Iterator for Iter { diff --git a/rs/moq-quic/src/lib.rs b/rs/moq-quic/src/lib.rs index 2fd368abe13..4714081a45b 100644 --- a/rs/moq-quic/src/lib.rs +++ b/rs/moq-quic/src/lib.rs @@ -59,6 +59,8 @@ pub use crate::connection::{ RttEstimator, SendDatagramError, SendGroup, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, UdpStats, WriteError, Written, }; +#[cfg(feature = "mux")] +pub use connection::mux; #[cfg(feature = "qlog")] pub use connection::qlog::QlogStream; diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 607497a1d01..380bbe6257b 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -5,6 +5,11 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ## Unreleased +### WebSocket clients must offer a QMux subprotocol (#6231) + +The relay answers a WebSocket upgrade with 400 when its `Sec-WebSocket-Protocol` offers none of the relay's QMux subprotocols, instead of falling back to the legacy `webtransport` format. +Upgrade any client that still speaks only `webtransport` over WebSocket before the relay. + ### io\_uring workers start serving separately (#6245) `uring::Workers::serve(cluster, auth, shutdown, sessions)` is now `spawn` with the same arguments, which sets every worker up without accepting, followed by `serve()`, which starts them accepting. diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 00ab7a5406e..009d0acd4b3 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -524,10 +524,7 @@ mod tests { let (session, driver) = moq_net::Client::new() .with_versions("moq-lite-07-wip".parse::().unwrap().into()) .with_publisher(origin.consume()) - .connect( - now, - moq_tokio::transport::Session::new(client, moq_net::transport::Binding::InBand), - ) + .connect(now, client) .await .unwrap(); tokio::spawn(moq_net::time::run(driver)); @@ -538,10 +535,7 @@ mod tests { let request = moq_net::Server::new() .with_versions("moq-lite-07-wip".parse::().unwrap().into()) .with_subscriber(origin) - .accept_request( - now, - moq_tokio::transport::Session::new(server, moq_net::transport::Binding::InBand), - ) + .accept_request(now, server) .await .unwrap(); let lease = lease.preparing(request.auth(), request.role()).unwrap(); @@ -627,23 +621,14 @@ mod tests { .with_versions("moq-lite-07-wip".parse::().unwrap().into()) .with_publisher(origin.consume()); let connecting = async { - let (session, driver) = client - .connect( - now, - moq_tokio::transport::Session::new(client_transport, moq_net::transport::Binding::InBand), - ) - .await - .unwrap(); + let (session, driver) = client.connect(now, client_transport).await.unwrap(); (session, tokio::spawn(moq_net::time::run(driver))) }; let refusing = async { let request = moq_net::Server::new() .with_versions("moq-lite-07-wip".parse::().unwrap().into()) .with_subscriber(origin) - .accept_request( - now, - moq_tokio::transport::Session::new(server, moq_net::transport::Binding::InBand), - ) + .accept_request(now, server) .await .unwrap(); let mut changed = grant("a/**", 3000); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 36bde2e77ca..de58da2f33e 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -2167,17 +2167,14 @@ mod tests { None, ) .await; - let transport = qmux::ws::Upgraded::new(socket) - .with_alpn("qmux-01.moq-lite-06") - .connect(); + let transport = qmux::ws::Upgraded::new(socket, "qmux-01.moq-lite-06") + .connect() + .unwrap(); let origin = moq_tokio::origin::spawn(); let (session, driver) = moq_net::Client::new() .with_versions("moq-lite-06".parse::().unwrap().into()) .with_publisher(origin.consume()) - .connect( - moq_net::time::now(), - moq_tokio::transport::Session::new(transport, moq_net::transport::Binding::Uri), - ) + .connect(moq_net::time::now(), transport) .await .unwrap(); tokio::spawn(moq_net::time::run(driver)); diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 8d131f4a23d..0d02b941653 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -93,17 +93,14 @@ pub(crate) async fn serve_ws( /// /// We advertise the configured qmux × moq-net subprotocol matrix, with bare /// qmux fallbacks last. axum picks the first entry that the client also offered, so -/// a modern client lands on `qmux-01.moq-lite-06`; old clients still match -/// `webtransport` or `qmux-00.moql` and negotiate via SETUP. +/// a modern client lands on `qmux-01.moq-lite-06`; one that only offers a bare +/// `qmux-0X` negotiates the moq version via SETUP. /// -/// When the client offered subprotocols and none of them are ours, the -/// qmux-over-WebSocket draft requires us to fail the handshake rather than -/// upgrade with no selection: the client MUST treat a missing -/// `Sec-WebSocket-Protocol` response header as a failure anyway, so upgrading -/// only wastes a connection that has already agreed on nothing. -/// -/// A client that offers no subprotocol at all is left alone: it upgrades and -/// negotiates the moq version over moq-lite SETUP instead. +/// When none of the offered subprotocols are ours, the qmux-over-WebSocket draft +/// requires us to fail the handshake rather than upgrade with no selection: the +/// client MUST treat a missing `Sec-WebSocket-Protocol` response header as a +/// failure anyway, so upgrading only wastes a connection that has already agreed +/// on nothing. A client that offers none at all names no QMux version either. fn negotiate_subprotocol(ws: WebSocketUpgrade, alpns: &[&str]) -> Result { let supported = supported_subprotocols(alpns); @@ -115,27 +112,11 @@ fn negotiate_subprotocol(ws: WebSocketUpgrade, alpns: &[&str]) -> Result(requested: impl IntoIterator, supported: &[String]) -> bool { - let mut offered = false; - - for protocol in requested { - let protocol = protocol.trim_ascii(); - if protocol.is_empty() { - continue; - } - offered = true; - - if supported.iter().any(|s| s.as_bytes() == protocol) { - return true; - } - } - - !offered + requested + .into_iter() + .any(|protocol| supported.iter().any(|s| s.as_bytes() == protocol.trim_ascii())) } /// QMux wire-format versions that can ride under a `{prefix}.{alpn}` pair. @@ -149,11 +130,11 @@ const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19", "moqt-20", "moqt-21", /// Subprotocols to advertise on the WebSocket upgrade. /// /// Generates the cross product of `alpns` × [`QMUX_VERSIONS`], with -/// the bare qmux fallbacks (`qmux-01`, `qmux-00`, `webtransport`) appended -/// last so versioned subprotocols always win the exact-string match axum -/// performs. Without the versioned entries, axum picks bare `webtransport`, -/// qmux can't resolve a moq version from it, and the relay silently -/// downgrades clients to Lite02 via SETUP-based negotiation. +/// the bare qmux fallbacks (`qmux-02`, `qmux-01`, `qmux-00`) appended last so +/// versioned subprotocols always win the exact-string match axum performs. +/// Without the versioned entries, axum picks a bare fallback, qmux can't +/// resolve a moq version from it, and the relay silently downgrades clients to +/// Lite02 via SETUP-based negotiation. /// /// `qmux-00.moqt-{18,19,20,21,22}` is excluded: moq-transport-18 and newer require /// qmux-01, so those pairs are illegal. @@ -284,7 +265,7 @@ mod tests { }; use tokio::sync::mpsc; // Brings `qmux::Session::protocol` and `::closed` into scope. - use web_transport_trait::Session as _; + use moq_net::transport::poll::Session as _; struct DoubleErrorSocket; @@ -422,9 +403,8 @@ mod tests { } // Bare qmux fallbacks must come after every versioned entry so they - // only win when the client offers nothing better. The buggy - // `["webtransport"]` advertise list would put a bare entry first and - // silently downgrade modern clients to Lite02. + // only win when the client offers nothing better. A bare entry first + // would silently downgrade modern clients to Lite02. let last_versioned_idx = list .iter() .rposition(|s| s.contains('.')) @@ -474,10 +454,11 @@ mod tests { let supported = supported_subprotocols(moq_net::ALPNS); let known = supported.first().expect("no supported subprotocols").clone(); - // Offering nothing is the legacy route: upgrade and negotiate via SETUP. - assert!(subprotocols_acceptable([], &supported)); - // A blank header carries no identifier, so it reads the same way. - assert!(subprotocols_acceptable([b"" as &[u8], b" "], &supported)); + // Offering nothing names no QMux version, and neither does a blank header. + assert!(!subprotocols_acceptable([], &supported)); + assert!(!subprotocols_acceptable([b"" as &[u8], b" "], &supported)); + // The retired legacy wire format is no longer ours. + assert!(!subprotocols_acceptable([b"webtransport" as &[u8]], &supported)); // Something we know, alone or among identifiers we don't. assert!(subprotocols_acceptable([known.as_bytes()], &supported)); @@ -557,11 +538,11 @@ mod tests { "a supported subprotocol must upgrade, got {status:?}", ); - // No header at all is the legacy route: upgrade, negotiate via SETUP. + // No header at all names no QMux version, so there is nothing to speak. let status = handshake_status(addr, None).await; assert!( - status.starts_with("HTTP/1.1 101"), - "offering no subprotocol must still upgrade, got {status:?}", + status.starts_with("HTTP/1.1 400"), + "offering no subprotocol must fail the handshake, got {status:?}", ); } @@ -589,12 +570,9 @@ mod tests { let wire = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned); let socket = WebSocketAdapter::new(socket); - let upgraded = qmux::ws::Upgraded::new(socket); - let upgraded = match wire.as_deref() { - Some(alpn) => upgraded.with_alpn(alpn), - None => upgraded, - }; - let session = upgraded.accept(); + let mut session = qmux::ws::Upgraded::new(socket, wire.as_deref().unwrap_or_default()) + .accept() + .expect("the upgrade selected a QMux subprotocol"); let _ = tx.send(Observed { wire, app: session.protocol().map(str::to_owned), @@ -625,7 +603,7 @@ mod tests { } /// Split an advertised `{qmux-XX.}{moq-alpn}` pair into its parts, or `None` - /// for a bare fallback (`qmux-01`, `qmux-00`, `webtransport`). + /// for a bare fallback (`qmux-02`, `qmux-01`, `qmux-00`). fn split_pair(entry: &str) -> Option<(qmux::Version, &str)> { QMUX_VERSIONS .iter() @@ -634,8 +612,8 @@ mod tests { /// End-to-end regression: a qmux client offering the full moq ALPN list must /// land on the newest moq ALPN (`moq_net::ALPNS[0]`) on both sides. A bug in - /// `supported_subprotocols` or the `with_alpn` plumbing collapses this to - /// `None` / bare `webtransport`, and moq-net then downgrades to Lite02. + /// `supported_subprotocols` or the subprotocol plumbing collapses this to + /// `None` / a bare fallback, and moq-net then downgrades to Lite02. #[tokio::test] async fn axum_ws_negotiates_newest_moq_alpn() { let (addr, mut rx) = spawn_test_server().await; @@ -657,7 +635,7 @@ mod tests { assert_eq!( observed.app.as_deref(), Some(newest_moq_alpn()), - "server side should see the newest moq ALPN after with_alpn", + "server side should see the newest moq ALPN from the subprotocol", ); drop(session); @@ -697,10 +675,8 @@ mod tests { // The illegal pair is advertised by nobody (moqt-18 requires qmux-01). A client // offering only `qmux-00.moqt-18` still carries qmux's default bare fallbacks - // (`webtransport`, etc.), so the server gracefully downgrades to a bare ALPN - // rather than failing: we always accept a bare web-transport connection, and - // there's no other qmux version worth negotiating for moqt-18. It just never - // lands on `moqt-18`. + // (`qmux-01`, etc.), so the server gracefully downgrades to a bare ALPN + // rather than failing. It just never lands on `moqt-18`. let session = qmux::ws::Client::new() .with_protocol("moqt-18", &[qmux::Version::QMux00]) .connect(&url) @@ -813,13 +789,12 @@ mod tests { // timeout is out of the picture before we go silent. It never speaks moq, // so the server is parked awaiting SETUP -- exactly where an idle // publisher's session sits between groups. - let client = qmux::ws::Upgraded::new(Pipe::new( - client_incoming, - client_to_server, - Arc::new(AtomicBool::new(false)), - )) - .with_alpn(&alpn) - .connect(); + let client = qmux::ws::Upgraded::new( + Pipe::new(client_incoming, client_to_server, Arc::new(AtomicBool::new(false))), + &alpn, + ) + .connect() + .unwrap(); // Paused time only advances once every task is idle, so this resolves // exactly when both ends have settled. @@ -841,7 +816,7 @@ mod tests { /// well before the keep-alive would notice, since its pongs keep it alive. #[tokio::test(start_paused = true)] async fn stalled_setup_is_closed_at_the_deadline() { - use web_transport_trait::Error as _; + use moq_net::transport::Error as _; let alpn = format!("{}{}", preferred_qmux_prefix(), newest_moq_alpn()); let (client_to_server, server_incoming) = mpsc::unbounded_channel(); @@ -863,13 +838,12 @@ mod tests { )); // A live qmux peer that answers keep-alives but never speaks moq. - let client = qmux::ws::Upgraded::new(Pipe::new( - client_incoming, - client_to_server, - Arc::new(AtomicBool::new(false)), - )) - .with_alpn(&alpn) - .connect(); + let mut client = qmux::ws::Upgraded::new( + Pipe::new(client_incoming, client_to_server, Arc::new(AtomicBool::new(false))), + &alpn, + ) + .connect() + .unwrap(); let err = server .await diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index f5d89e8075e..04f0a8b61d7 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -403,10 +403,19 @@ async fn admits_and_reports_the_session() { relay.abort(); } +/// Write all of `data` to a raw qmux stream. +async fn write_all(send: &mut qmux::SendStream, mut data: &[u8]) { + use moq_net::transport::poll::SendStream as _; + while !data.is_empty() { + let n = send.write(data).await.expect("write"); + data = &data[n..]; + } +} + /// A moq-transport client's SETUP token reaches the auth server byte for byte. #[tokio::test] async fn forwards_the_setup_token() { - use web_transport_trait::{RecvStream as _, SendStream as _, Session as _}; + use moq_net::transport::poll::{RecvStream as _, Session as _}; for websocket in [false, true] { let value = [0x00, 0xff, 0x80, b'x']; @@ -428,7 +437,7 @@ async fn forwards_the_setup_token() { let encoded = [&[0x03, 0x00][..], &value].concat(); let params = [&[0x01, 0x03, encoded.len() as u8][..], &encoded].concat(); let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); - let session = if websocket { + let mut session = if websocket { qmux::ws::Client::new() .with_protocol("moqt-16", &[qmux::Version::QMux01]) .connect(&format!("ws://127.0.0.1:{port}/room?source=setup")) @@ -441,8 +450,11 @@ async fn forwards_the_setup_token() { .await .expect("connect TCP") }; - let (mut send, mut recv) = session.open_bi().await.expect("open the control stream"); - send.write_all(&setup).await.expect("send CLIENT_SETUP"); + let (mut send, mut recv) = session + .open_bi(Default::default()) + .await + .expect("open the control stream"); + write_all(&mut send, &setup).await; let mut reply = [0u8; 1]; assert_eq!( tokio::time::timeout(TIMEOUT, recv.read(&mut reply)) @@ -1786,7 +1798,10 @@ async fn relay_request_limits_distinguish_clients_and_peers() { /// Browser-visible policy refusals happen after the WebSocket upgrade, in MoQ's close registry. #[tokio::test] async fn websocket_refusal_is_in_band() { - use web_transport_trait::{Error as _, SendStream as _, Session as _}; + use moq_net::transport::{ + Error as _, + poll::{SendStream as _, Session as _}, + }; for (status, code) in [ (401, moq_net::SessionError::Unauthorized), (403, moq_net::SessionError::Unauthorized), @@ -1795,13 +1810,13 @@ async fn websocket_refusal_is_in_band() { let script = Script::new(grant(Duration::from_secs(3600))); script.on_connect(Answer::Status(status)); let (port, relay) = spawn_ws_relay(build_auth(script.spawn().await)).await; - let session = qmux::ws::Client::new() + let mut session = qmux::ws::Client::new() .with_protocol("moq-lite-06", &[qmux::Version::QMux01]) .connect(&format!("ws://127.0.0.1:{port}/room")) .await .expect("policy refusal must not reject the browser-opaque HTTP upgrade"); - let mut send = session.open_uni().await.expect("open SETUP"); - send.write_all(&[1, 1, 0]).await.expect("send empty SETUP"); + let mut send = session.open_uni(Default::default()).await.expect("open SETUP"); + write_all(&mut send, &[1, 1, 0]).await; send.finish().expect("finish SETUP"); let closed = tokio::time::timeout(TIMEOUT, session.closed()) .await diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index 27b42b1ce42..b697543b30a 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### qmux sessions are qmux 0.7 (#6231) + +The WebSocket, TCP, TLS, and Unix socket transports run on [qmux 0.7](https://github.com/moq-dev/moq/blob/main/rs/qmux/MIGRATE.md), which implements moq-net's poll traits directly instead of going through `web_transport_trait`. +Code that holds a `qmux::Session` from moq-tokio, such as one from the WebSocket listener's `accept`, follows qmux's migration guide. +A WebSocket client must offer a QMux subprotocol: `websocket::accept` refuses an upgrade without one, and the legacy `webtransport` format is gone. + ### MoQ sessions need the `net` feature (#6132) moq-net is optional, behind the default `net` feature that every transport feature (`quic`, `websocket`, `tcp`, `uds`, `iroh`, `webrtc`) turns on. diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 9ca98acc398..6b7074796a8 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -322,9 +322,7 @@ impl Client { let session = qmux::Session::connect(opened.channel, config) .await .map_err(crate::webrtc::Error::from)?; - // A data channel has no request URI, so a path would ride the SETUP; a peer link has none. - let transport = crate::transport::Session::new(session, moq_net::transport::Binding::InBand); - Ok::<_, Error>(connect_session(&self.moq, transport).await?) + Ok::<_, Error>(connect_session(&self.moq, session).await?) }; let session = match self.timeout.is_zero() { @@ -427,11 +425,7 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - let session = connect_session( - &moq, - crate::transport::Session::new(session, moq_net::transport::Binding::InBand), - ) - .await?; + let session = connect_session(&moq, session).await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); } @@ -446,11 +440,7 @@ impl Client { self.resolution_delay, ) .await?; - let session = connect_session( - &moq, - crate::transport::Session::new(session, moq_net::transport::Binding::InBand), - ) - .await?; + let session = connect_session(&moq, session).await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); } @@ -459,11 +449,7 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - let session = connect_session( - &moq, - crate::transport::Session::new(session, moq_net::transport::Binding::InBand), - ) - .await?; + let session = connect_session(&moq, session).await?; return Ok(Dialed::new(session, crate::Transport::Unix)); } @@ -539,11 +525,7 @@ impl Client { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - let session = connect_session( - &self.moq, - crate::transport::Session::new(session, moq_net::transport::Binding::Uri), - ) - .await?; + let session = connect_session(&self.moq, session).await?; Ok(Dialed::new(session, crate::Transport::WebSocket)) } @@ -582,11 +564,7 @@ impl Client { match race_transport_connect(&url, quic, websocket).await? { TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic).await?, transport)), TransportRace::WebSocket(session) => { - let session = connect_session( - &self.moq, - crate::transport::Session::new(session, moq_net::transport::Binding::Uri), - ) - .await?; + let session = connect_session(&self.moq, session).await?; Ok(Dialed::new(session, crate::Transport::WebSocket)) } } diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 76b8b1b97cd..761a4bd23aa 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -430,10 +430,7 @@ mod tests { let client = async { let (session, driver) = moq_net::Client::new() .with_versions(versions.clone()) - .connect( - now, - crate::transport::Session::new(client, moq_net::transport::Binding::InBand), - ) + .connect(now, client) .await .unwrap(); (session, AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver)))) @@ -441,10 +438,7 @@ mod tests { let server = async { let request = moq_net::Server::new() .with_versions(versions.clone()) - .accept_request( - now, - crate::transport::Session::new(server, moq_net::transport::Binding::InBand), - ) + .accept_request(now, server) .await .unwrap(); let mut requests = request.auth().requests().unwrap(); diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 92a9b01a817..0afebcfd54c 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -737,7 +737,8 @@ impl Server { link.local = local; let binding = match url { Some(_) => moq_net::transport::Binding::Uri, None => moq_net::transport::Binding::InBand }; let quic = Some(crate::quic::Connection::new((*session).clone())); - let (request, deadline) = accept_request(&setup.server, session, binding, deadline).await?; + let session = crate::transport::Session::new(session, binding); + let (request, deadline) = accept_request(&setup.server, session, deadline).await?; // Only WebTransport carries a request URL; raw QUIC puts the path in the SETUP. let transport = match url { Some(_) => Transport::WebTransport, None => Transport::Quic }; Ok(Request { transport, url, identity, authority, link, kind: RequestKind::Quic(Box::new(request)), quic, deadline, active }) @@ -756,7 +757,8 @@ impl Server { self.accept.push(async move { let Accepted { session, url, identity, authority, link } = within(deadline, super::iroh::accept(_conn, alpns)).await?; let binding = match url { Some(_) => moq_net::transport::Binding::Uri, None => moq_net::transport::Binding::InBand }; - let (request, deadline) = accept_request(&setup.server, session, binding, deadline).await?; + let session = crate::transport::Session::new(session, binding); + let (request, deadline) = accept_request(&setup.server, session, deadline).await?; Ok(Request { transport: Transport::Iroh, url, @@ -788,7 +790,7 @@ impl Server { self.accept.push(async move { let (session, url, accepted) = within(deadline, _pending).await?; let mut options = crate::websocket::Options::new(url, setup.server, active); - options.link = Link { remote: Some(accepted.remote), local, alpn: accepted.protocol, ..Default::default() }; + options.link = Link { remote: Some(accepted.remote), local, alpn: Some(accepted.protocol), ..Default::default() }; accept_websocket(session, options, deadline).await }.boxed()); } @@ -923,8 +925,7 @@ impl Listener { }; let (session, protocol) = within(deadline, handshake).await?; - let (request, deadline) = - accept_request(&server.moq, session, moq_net::transport::Binding::InBand, deadline).await?; + let (request, deadline) = accept_request(&server.moq, session, deadline).await?; Ok(Request { transport: Transport::WebRtc, url: None, @@ -1051,16 +1052,13 @@ impl Setup { async fn accept_request( server: &moq_net::Server, session: S, - binding: moq_net::transport::Binding, deadline: Option, -) -> crate::Result<(PendingRequest>, Option)> +) -> crate::Result<(PendingRequest, Option)> where - S: crate::transport::Grouped, - crate::transport::Session: moq_net::transport::poll::Boxable, - as moq_net::transport::poll::Session>::SendStream: moq_net::transport::MaybeSync, - as moq_net::transport::poll::Session>::RecvStream: moq_net::transport::MaybeSync, + S: moq_net::transport::poll::Boxable + Send + Sync, + S::SendStream: moq_net::transport::MaybeSync, + S::RecvStream: moq_net::transport::MaybeSync, { - let session = crate::transport::Session::new(session, binding); let deadline = deadline.map(|at| Deadline::new(at, session.clone())); let accept = async { Ok(server.accept_request(moq_net::time::now(), session).await?) }; let request = Deadline::bound(deadline.as_ref(), accept).await?; @@ -1073,8 +1071,7 @@ pub(crate) async fn accept_websocket( options: crate::websocket::Options, deadline: Option, ) -> crate::Result { - let (request, deadline) = - accept_request(&options.server, session, moq_net::transport::Binding::Uri, deadline).await?; + let (request, deadline) = accept_request(&options.server, session, deadline).await?; let authority = options.url.host_str().filter(|h| !h.is_empty()).map(str::to_owned); Ok(Request { transport: Transport::WebSocket, @@ -1109,13 +1106,15 @@ impl Deadline { ))] fn new(at: moq_clock::Instant, session: S) -> Self where - S: web_transport_trait::poll::Session + Clone + Send + Sync + 'static, + S: moq_net::transport::poll::Session + Send + Sync, { let close = move || { let err = moq_net::Error::Timeout; - session - .clone() - .close(moq_net::SessionError::from(&err).to_code(), &err.to_string()); + moq_net::transport::poll::Session::close( + &mut session.clone(), + moq_net::SessionError::from(&err).to_code(), + &err.to_string(), + ); }; Self { at, @@ -1565,7 +1564,7 @@ async fn stream_request( deadline, active, } = incoming; - match accept_request(&setup.server, session, moq_net::transport::Binding::InBand, deadline).await { + match accept_request(&setup.server, session, deadline).await { Ok((request, deadline)) => { let request = Request { transport, @@ -1605,7 +1604,7 @@ pub(crate) enum RequestKind { feature = "websocket", feature = "webrtc" ))] - Qmux(Box>>), + Qmux(Box>), } /// The transport-level facts a backend captures while accepting a connection, before the @@ -2206,8 +2205,8 @@ mod tests { let client = WebSocketStream::from_raw_socket(client, Role::Client, None).await; let server = WebSocketStream::from_raw_socket(server, Role::Server, None).await; ( - qmux::ws::Upgraded::new(client).with_alpn(ALPN).connect(), - qmux::ws::Upgraded::new(server).with_alpn(ALPN).accept(), + qmux::ws::Upgraded::new(client, ALPN).connect().unwrap(), + qmux::ws::Upgraded::new(server, ALPN).accept().unwrap(), ) } @@ -2215,8 +2214,8 @@ mod tests { #[cfg(feature = "websocket")] #[tokio::test(start_paused = true)] async fn upgraded_websocket_preserves_request_metadata() { + use moq_net::transport::poll::{SendOptions, SendStream as _, Session as _}; use qmux::ws::tokio_tungstenite::{WebSocketStream, tungstenite::protocol::Role}; - use web_transport_trait::{SendStream as _, Session as _}; let (client, server) = tokio::io::duplex(64 * 1024); let client = WebSocketStream::from_raw_socket(client, Role::Client, None).await; @@ -2232,11 +2231,11 @@ mod tests { }; options.identity = Some(crate::tls::PeerIdentity::from_chain(Vec::new())); let accept = tokio::spawn(crate::websocket::accept(server, options)); - let client = qmux::ws::Upgraded::new(client) - .with_alpn("qmux-01.moq-lite-06") - .connect(); - let mut setup = client.open_uni().await.unwrap(); - setup.write_all(&[1, 1, 0]).await.unwrap(); + let mut client = qmux::ws::Upgraded::new(client, "qmux-01.moq-lite-06") + .connect() + .unwrap(); + let mut setup = client.open_uni(SendOptions::default()).await.unwrap(); + assert_eq!(setup.write(&[1, 1, 0]).await.unwrap(), 3); setup.finish().unwrap(); let request = accept.await.unwrap().unwrap(); assert_eq!(request.transport(), Transport::WebSocket); @@ -2279,6 +2278,19 @@ mod tests { } } + /// The scripted peer never fails, only parks, so its error is never built. + #[cfg(feature = "websocket")] + #[derive(Debug, Clone, thiserror::Error)] + #[error("scripted peer error")] + struct ScriptedError; + + #[cfg(feature = "websocket")] + impl web_transport_trait::Error for ScriptedError { + fn session_error(&self) -> Option<(u32, String)> { + None + } + } + /// Records every write, or accepts none at all when `stalled`. #[cfg(feature = "websocket")] struct ScriptedSend { @@ -2288,7 +2300,7 @@ mod tests { #[cfg(feature = "websocket")] impl web_transport_trait::SendStream for ScriptedSend { - type Error = qmux::Error; + type Error = ScriptedError; async fn write(&mut self, buf: &[u8]) -> Result { if self.stalled { @@ -2331,7 +2343,7 @@ mod tests { #[cfg(feature = "websocket")] impl web_transport_trait::RecvStream for ScriptedRecv { - type Error = qmux::Error; + type Error = ScriptedError; async fn read(&mut self, dst: &mut [u8]) -> Result, Self::Error> { let Some(mut data) = self.data.take() else { @@ -2354,7 +2366,7 @@ mod tests { impl web_transport_trait::Session for Scripted { type SendStream = ScriptedSend; type RecvStream = ScriptedRecv; - type Error = qmux::Error; + type Error = ScriptedError; async fn accept_uni(&self) -> Result { std::future::pending().await @@ -2434,8 +2446,7 @@ mod tests { let start = tokio::time::Instant::now(); let Err(err) = accept_request( &setup.server, - peer.clone(), - moq_net::transport::Binding::Uri, + crate::transport::Session::new(peer.clone(), moq_net::transport::Binding::Uri), setup.deadline(), ) .await @@ -2481,8 +2492,7 @@ mod tests { let start = tokio::time::Instant::now(); let Ok((request, deadline)) = accept_request( &setup.server, - peer.clone(), - moq_net::transport::Binding::Uri, + crate::transport::Session::new(peer.clone(), moq_net::transport::Binding::Uri), setup.deadline(), ) .await @@ -2508,9 +2518,9 @@ mod tests { #[cfg(feature = "websocket")] #[tokio::test(start_paused = true)] async fn completed_setup_outlives_the_deadline() { - use web_transport_trait::Session as _; + use moq_net::transport::poll::Session as _; - let (client, server) = session_pair().await; + let (mut client, server) = session_pair().await; let timeout = std::time::Duration::from_secs(10); let setup = Setup { activity: Default::default(), @@ -2519,7 +2529,7 @@ mod tests { }; let deadline = setup.deadline(); - let transport = crate::transport::Session::new(client.clone(), moq_net::transport::Binding::Uri); + let transport = client.clone(); let connect = tokio::spawn(async move { let (session, driver) = moq_net::Client::new() .connect(moq_net::time::now(), transport) @@ -2529,8 +2539,7 @@ mod tests { session }); - let Ok((request, _)) = accept_request(&setup.server, server, moq_net::transport::Binding::Uri, deadline).await - else { + let Ok((request, _)) = accept_request(&setup.server, server, deadline).await else { panic!("a prompt SETUP was refused"); }; let (_session, driver) = request.ok().await.expect("server handshake"); @@ -2567,10 +2576,7 @@ mod tests { .await .expect("upgrade"); let (_session, driver) = moq_net::Client::new() - .connect( - moq_net::time::now(), - crate::transport::Session::new(session, moq_net::transport::Binding::Uri), - ) + .connect(moq_net::time::now(), session) .await .expect("client handshake"); moq_net::time::run(driver).await diff --git a/rs/moq-tokio/src/tcp.rs b/rs/moq-tokio/src/tcp.rs index 444ac051841..0f700f629fa 100644 --- a/rs/moq-tokio/src/tcp.rs +++ b/rs/moq-tokio/src/tcp.rs @@ -348,8 +348,8 @@ impl Listener { #[allow(clippy::disallowed_methods, clippy::disallowed_types)] mod tests { use super::*; + use moq_net::transport::poll::Session as _; use std::time::Duration; - use web_transport_trait::Session as _; /// TLS needs a TCP listener to serve on; asking for it alone is refused. #[test] @@ -484,11 +484,11 @@ mod tests { // Both handshakes are done, so each end knows its peer's grant, and an open // with credit left completes without waiting. - for (end, session) in [("client", &client), ("server", &server)] { + for (end, mut session) in [("client", client), ("server", server)] { let mut held = Vec::new(); for opened in 0..1_000 { - let bi = session.open_bi().now_or_never(); - let uni = session.open_uni().now_or_never(); + let bi = session.open_bi(Default::default()).now_or_never(); + let uni = session.open_uni(Default::default()).now_or_never(); let (Some(bi), Some(uni)) = (bi, uni) else { panic!("{end} stalled on stream credit after opening {opened} stream pairs"); }; diff --git a/rs/moq-tokio/src/transport.rs b/rs/moq-tokio/src/transport.rs index 575173ec928..1b36454d3f9 100644 --- a/rs/moq-tokio/src/transport.rs +++ b/rs/moq-tokio/src/transport.rs @@ -1,7 +1,7 @@ //! Adapts the async transport interface to the poll one moq-net requires. //! //! moq-net accepts its own `moq_net::transport::poll` traits. -//! A backend that offers only the async interface (qmux, iroh, quic) is wrapped +//! A backend that offers only the async interface (iroh, quic) is wrapped //! in [`Session`] here, at the edge where async already lives. The wrapping //! costs one allocation per operation and one copy per write, and its //! closed-watch emulation is weaker than a native implementation (see @@ -928,18 +928,6 @@ impl GroupedSend for web_transport_iroh::SendStream { } } -#[cfg(any(feature = "websocket", feature = "tcp", feature = "webrtc"))] -impl Grouped for qmux::Session {} - -#[cfg(any(feature = "websocket", feature = "tcp", feature = "webrtc"))] -impl GroupedSend for qmux::SendStream { - type Group = moq_net::transport::poll::Ungrouped; - - fn set_group(&mut self, group: &Self::Group) { - match *group {} - } -} - #[cfg(test)] #[allow(clippy::disallowed_methods, clippy::disallowed_types)] mod tests { diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index b8af49bef77..acafec04438 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -154,16 +154,16 @@ where .and_then(|timeout| moq_clock::now().checked_add(timeout)); // A vanished peer sends no FIN. Ping and timeout bound the same stale-session // lifetime that QUIC's idle timeout bounds on the native path. - let mut upgraded = qmux::ws::Upgraded::new(socket) + // The subprotocol names the QMux version; an upgrade without one has no wire format. + let alpn = options.link.alpn.as_deref().unwrap_or_default(); + let mut upgraded = qmux::ws::Upgraded::new(socket, alpn) .with_keep_alive(qmux::ws::KeepAlive::default()) .with_session_config(crate::qmux_config()); if let Some(stats) = &options.socket_stats { upgraded = upgraded.with_socket_stats(stats.clone()); } - if let Some(alpn) = &options.link.alpn { - upgraded = upgraded.with_alpn(alpn); - } - crate::server::accept_websocket(upgraded.accept(), options, deadline).await + let session = upgraded.accept().map_err(Error::accept)?; + crate::server::accept_websocket(session, options, deadline).await } /// Servers (host, port) where WebSocket won the race, so later dials skip QUIC's head start. @@ -450,8 +450,8 @@ pub(crate) async fn connect( // Most moq ALPNs can ride on any QMux draft (`&[]` lets the polyfill expand // to every version it knows). `qmux_versions_for` pins the few that the spec - // restricts. qmux also offers the bare ALPNs (`qmux-01`, `qmux-00`, - // `webtransport`) by default so we still interop with relays that only know a + // restricts. qmux also offers the bare ALPNs (`qmux-02`, `qmux-01`, + // `qmux-00`) by default so we still interop with relays that only know a // wire-format version. qmux::ws::Client::new() .with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a)))) @@ -516,13 +516,11 @@ async fn connect_tls_override( .headers() .get(http::header::SEC_WEBSOCKET_PROTOCOL) .and_then(|value| value.to_str().ok()); - let upgraded = qmux::ws::Upgraded::new(websocket) + qmux::ws::Upgraded::new(websocket, negotiated.unwrap_or_default()) .with_keep_alive(qmux::ws::KeepAlive::default()) - .with_session_config(crate::qmux_config()); - Ok(match negotiated { - Some(protocol) => upgraded.with_alpn(protocol).connect(), - None => upgraded.connect(), - }) + .with_session_config(crate::qmux_config()) + .connect() + .map_err(Error::connect) } /// The QMux drafts a moq ALPN is allowed to ride on, for `qmux::ws::Client::with_protocols`. @@ -675,7 +673,7 @@ impl Listener { let (stream, addr) = self.accept_socket().await; tracing::debug!(%addr, "accepted WebSocket TCP connection"); - let accepted = Arc::new(Mutex::new(None::<(Option, Url)>)); + let accepted = Arc::new(Mutex::new(None::<(String, Url)>)); let accepted_callback = accepted.clone(); let protocols = self.protocols.clone(); #[allow(clippy::result_large_err)] @@ -691,7 +689,7 @@ impl Listener { .map(str::trim) .filter(|value| !value.is_empty()) .collect(); - let Ok(protocol) = select_subprotocol(&offered, &protocols) else { + let Some(protocol) = select_subprotocol(&offered, &protocols) else { return Err(http::Response::builder() .status(http::StatusCode::BAD_REQUEST) .body(Some("no supported protocol".to_string())) @@ -704,13 +702,11 @@ impl Listener { .expect("valid rejection response")); }; - if let Some(protocol) = protocol { - response.headers_mut().insert( - http::header::SEC_WEBSOCKET_PROTOCOL, - http::HeaderValue::from_str(protocol).expect("protocol validated at bind"), - ); - } - *accepted_callback.lock().unwrap() = Some((protocol.map(str::to_string), url)); + response.headers_mut().insert( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_str(protocol).expect("protocol validated at bind"), + ); + *accepted_callback.lock().unwrap() = Some((protocol.to_string(), url)); Ok(response) }; @@ -724,13 +720,11 @@ impl Listener { .unwrap() .take() .expect("successful upgrade selected a protocol"); - let upgraded = qmux::ws::Upgraded::new(websocket) + let session = qmux::ws::Upgraded::new(websocket, &protocol) .with_keep_alive(qmux::ws::KeepAlive::default()) - .with_session_config(crate::qmux_config()); - let session = match &protocol { - Some(protocol) => upgraded.with_alpn(protocol).accept(), - None => upgraded.accept(), - }; + .with_session_config(crate::qmux_config()) + .accept() + .map_err(Error::accept)?; Ok((session, url, Accepted { remote: addr, protocol })) } } @@ -757,21 +751,17 @@ impl Listener { pub(crate) struct Accepted { /// The peer's socket address. pub remote: net::SocketAddr, - /// The sub-protocol the upgrade selected, if the client offered one we serve. - pub protocol: Option, + /// The sub-protocol the upgrade selected. + pub protocol: String, } -/// Select a supported subprotocol, while preserving legacy clients that offer none. -fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> std::result::Result, ()> { - if offered.is_empty() { - return Ok(None); - } - +/// The first supported subprotocol the client offered. One that offers none names no QMux +/// version, so there is no wire format to speak. +fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> Option<&'a str> { supported .iter() .find(|protocol| offered.contains(&protocol.as_str())) - .map(|protocol| Some(protocol.as_str())) - .ok_or(()) + .map(String::as_str) } /// Reconstruct the client request URL from an absolute URI or the HTTP Host header. @@ -799,7 +789,6 @@ fn supported_subprotocols(alpns: &[&str]) -> Vec { versions .iter() .copied() - .filter(|version| version.is_qmux()) .map(|version| format!("{}{alpn}", version.prefix())), ); } @@ -813,32 +802,33 @@ mod tests { use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; #[test] - fn subprotocol_selection_preserves_legacy_clients() { + fn subprotocol_selection_requires_an_offer() { let supported = vec!["qmux-01.moq-lite-05".to_string()]; - assert_eq!(select_subprotocol(&[], &supported), Ok(None)); + assert_eq!(select_subprotocol(&[], &supported), None); assert_eq!( select_subprotocol(&["qmux-01.moq-lite-05"], &supported), - Ok(Some("qmux-01.moq-lite-05")) + Some("qmux-01.moq-lite-05") ); - assert_eq!(select_subprotocol(&["unsupported"], &supported), Err(())); + assert_eq!(select_subprotocol(&["webtransport"], &supported), None); } + /// A client offering no subprotocol names no QMux version, so the upgrade is refused + /// rather than falling back to the retired `webtransport` wire format. #[tokio::test] - async fn listener_accepts_legacy_client_without_subprotocol() { + async fn listener_refuses_a_client_without_subprotocol() { let listener = Listener::bind("127.0.0.1:0".parse().unwrap()).await.unwrap(); let addr = listener.local_addr().unwrap(); - let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap().unwrap() }); + let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap() }); let stream = tokio::net::TcpStream::connect(addr).await.unwrap(); - let request_url = format!("ws://{addr}/room?jwt=test"); - let (websocket, response) = tokio_tungstenite::client_async(request_url, stream).await.unwrap(); - assert!(!response.headers().contains_key(http::header::SEC_WEBSOCKET_PROTOCOL)); - - let (session, url, _) = accepted.await.unwrap(); - assert_eq!(url.path(), "/room"); - assert_eq!(url.query(), Some("jwt=test")); - drop(session); - drop(websocket); + let err = tokio_tungstenite::client_async(format!("ws://{addr}/room"), stream) + .await + .unwrap_err(); + assert!( + matches!(&err, tungstenite::Error::Http(response) if response.status() == http::StatusCode::BAD_REQUEST), + "got {err:?}" + ); + assert!(accepted.await.unwrap().is_err()); } #[tokio::test] diff --git a/rs/qmux/Cargo.toml b/rs/qmux/Cargo.toml new file mode 100644 index 00000000000..a9d58ff3ac3 --- /dev/null +++ b/rs/qmux/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "qmux" +description = "QMux: QUIC streams over TCP, TLS, WebSocket, and Unix sockets" +authors = ["Luke Curley"] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.7.0" +edition = "2024" +rust-version.workspace = true + +keywords = ["quic", "qmux", "websocket", "tcp", "multiplexing"] +categories = ["network-programming", "asynchronous"] + +[features] +default = ["tls", "wss"] +tcp = ["tokio/net", "tokio/io-util"] +tls = ["tcp", "dep:tokio-rustls", "dep:rustls"] +uds = ["tokio/net", "tokio/io-util"] +ws = ["dep:tokio-tungstenite", "dep:futures"] +wss = ["ws", "dep:rustls", "tokio-tungstenite/rustls-tls-native-roots"] + +[dependencies] +bytes = { workspace = true } +futures = { workspace = true, optional = true } +kio = { workspace = true } +moq-clock = { workspace = true } +moq-net = { workspace = true } +# The stream state machine, shared with QUIC. `mux` drives it over a reliable transport. +moq-quic = { workspace = true, features = ["mux"] } +rustc-hash = { workspace = true } +rustls = { version = "0.23", default-features = false, optional = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] } +tokio-rustls = { workspace = true, optional = true } +tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "handshake"], optional = true } +tracing = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +# The last release before this crate moved here, for wire interop tests only. +qmux06 = { package = "qmux", version = "0.6.3", default-features = false, features = ["tcp", "ws"] } +tokio = { workspace = true, features = ["full", "test-util"] } +# The 0.6 release's stream API, for driving it in the interop tests. +web-transport-trait = { workspace = true } + +[[example]] +name = "interop-client" +required-features = ["tcp", "ws"] + +[[example]] +name = "interop-server" +required-features = ["tcp", "ws"] diff --git a/rs/qmux/MIGRATE.md b/rs/qmux/MIGRATE.md new file mode 100644 index 00000000000..8df46e68a3c --- /dev/null +++ b/rs/qmux/MIGRATE.md @@ -0,0 +1,44 @@ +# Migrating qmux + +How to update your code for each breaking change, newest first. + +## Unreleased + +### Sessions implement moq-net's poll traits (#6231) + +qmux moved into the moq repository and runs on moq-quic's stream state machine. +`Session`, `SendStream`, and `RecvStream` implement `moq_net::transport::poll` in place of `web_transport_trait`. +Import `moq_net::transport::poll::{Session as _, SendStream as _, RecvStream as _}` and use its `open_uni`, `accept_uni`, `write_buf`, `read_chunk`, `close`, and `closed`. +Streams opened with the same `SendGroup` (from `Session::send_group(priority)`) share a scheduling group. +`SendStream::reset_at(offset, code)` is new and sends draft-02's `RESET_STREAM_AT`. + +### The legacy `webtransport` wire format is gone (#6231) + +`Version::WebTransport`, `Version::is_qmux`, and the `webtransport` entry in `ALPNS` are removed, as is the WebSocket server's fallback to it when a client offers no subprotocol. +Every peer must speak QMux draft-00, 01, or 02; a WebSocket client must offer one of `ALPNS` (or a `qmux-NN.` subprotocol). + +### `ws::Upgraded` takes the negotiated subprotocol, and fails loud (#6231) + +`Upgraded::new(ws)` and `with_alpn(alpn)` merge into `Upgraded::new(ws, alpn)`. +`Upgraded::connect` and `accept` return `Result`, refusing a subprotocol that names no QMux version with `Error::InvalidProtocol`. + +### `Config` collapses the stream windows and makes timers optional (#6231) + +`max_stream_data_bidi_local`, `max_stream_data_bidi_remote`, and `max_stream_data_uni` merge into `max_stream_data`. +`max_idle_timeout` (milliseconds), `handshake_timeout`, and `rtt_interval` become `Option`, with `None` disabling each. +`tcp::Config::handshake_timeout` and `uds::Config::handshake_timeout` take an `Option` to match. + +### `Error` carries plain codes (#6231) + +`ConnectionClosed`, `ConnectionReset`, `StreamReset`, and `StreamStop` carry `u64` codes in place of `VarInt`. +`InvalidFrameType`, `InvalidStreamId`, `DuplicateParam`, `Short`, and `UnexpectedProtocols` are removed: malformed input surfaces as `FrameEncoding`, `ProtocolViolation`, or `TransportParameter`. +`DatagramTooLarge` is new, and `InvalidConfig` reports a configuration the session cannot run with, such as a zero timer or a `ws::KeepAlive` whose `timeout` is not above its `interval` (0.6 clamped it). + +### In-band negotiation without a common protocol fails (#6231) + +With `Protocol::Negotiate`, a peer that offers none of your protocols now fails the handshake with `Error::TransportParameter` instead of connecting with `protocol() == None`. +Use `Protocol::None` on a session that must connect without an application protocol. + +### `StreamId` and `StreamDir` are no longer exported (#6231) + +Stream identity is internal to moq-quic's state machine; nothing in the public API takes one. diff --git a/rs/qmux/examples/common/mod.rs b/rs/qmux/examples/common/mod.rs new file mode 100644 index 00000000000..d3ff1828f5b --- /dev/null +++ b/rs/qmux/examples/common/mod.rs @@ -0,0 +1,59 @@ +//! The scenario both interop examples share; only who opens which stream differs. + +use std::time::Duration; + +use anyhow::{Context as _, bail}; +use bytes::{Bytes, BytesMut}; +use moq_net::transport::poll::{RecvStream as _, SendStream as _}; +use qmux::{RecvStream, SendStream, Version}; + +pub const PROTOCOL: &str = "qmux-interop"; +pub const CLIENT_SEED: usize = 17; +pub const SERVER_SEED: usize = 29; +pub const TIMEOUT: Duration = Duration::from_secs(15); +const PAYLOAD_LEN: usize = 1_200_000; + +pub fn parse_version(value: &str) -> anyhow::Result { + match value { + "qmux-00" => Ok(Version::QMux00), + "qmux-01" => Ok(Version::QMux01), + "qmux-02" => Ok(Version::QMux02), + _ => bail!("unsupported QMux version: {value}"), + } +} + +/// Large enough to need both stream- and connection-level flow-control updates. +pub fn payload(seed: usize) -> Vec { + (0..PAYLOAD_LEN) + .map(|index| ((index * 31 + seed) % 251) as u8) + .collect() +} + +/// Write all of `data` then the FIN, bounded by [`TIMEOUT`]. +pub async fn send_all(send: &mut SendStream, data: &[u8], what: &str) -> anyhow::Result<()> { + let mut data = Bytes::copy_from_slice(data); + tokio::time::timeout(TIMEOUT, async { + while !data.is_empty() { + send.write_buf(&mut data).await?; + } + anyhow::Ok(()) + }) + .await + .with_context(|| format!("timed out writing {what}"))??; + send.finish()?; + Ok(()) +} + +/// Read until the FIN, bounded by [`TIMEOUT`]. +pub async fn recv_all(recv: &mut RecvStream, what: &str) -> anyhow::Result { + let mut buf = BytesMut::new(); + tokio::time::timeout(TIMEOUT, async { + while let Some(chunk) = recv.read_chunk(usize::MAX).await? { + buf.extend_from_slice(&chunk); + } + anyhow::Ok(()) + }) + .await + .with_context(|| format!("timed out reading {what}"))??; + Ok(buf.freeze()) +} diff --git a/rs/qmux/examples/interop-client.rs b/rs/qmux/examples/interop-client.rs new file mode 100644 index 00000000000..7b46a8af452 --- /dev/null +++ b/rs/qmux/examples/interop-client.rs @@ -0,0 +1,91 @@ +//! WebSocket client used by the cross-language QMux smoke test. +//! +//! The mirror of `interop-server`: here the TypeScript side accepts the socket +//! and this example dials it, so the roles that decide the stream-id space are +//! swapped. Completing the scenario proves a `Session.accept` server agrees with +//! an independent implementation about role assignment and the wire format. + +mod common; + +use anyhow::{Context as _, bail}; +use bytes::Bytes; +use common::*; +use moq_net::transport::poll::{SendOptions, Session as _}; +use qmux::{Session, Version}; + +async fn run(mut session: Session, version: Version) -> anyhow::Result<()> { + if session.protocol() != Some(PROTOCOL) { + bail!("unexpected negotiated protocol: {:?}", session.protocol()); + } + + // Rust -> TypeScript. + let mut send = tokio::time::timeout(TIMEOUT, session.open_uni(SendOptions::default())) + .await + .context("timed out opening the client unidirectional stream")??; + send_all(&mut send, &payload(CLIENT_SEED), "the client flow-control payload").await?; + + // TypeScript -> Rust. Server-initiated streams are the ids only a + // `Session::accept` session may mint; a client-role server would be rejected + // here rather than accepted. + let mut recv = tokio::time::timeout(TIMEOUT, session.accept_uni()) + .await + .context("timed out accepting the server unidirectional stream")??; + let received = recv_all(&mut recv, "the server flow-control payload").await?; + if received.as_ref() != payload(SERVER_SEED) { + bail!("server flow-control payload did not match"); + } + + // Exercise both halves of one bidirectional stream and FIN propagation. + let (mut send, mut recv) = tokio::time::timeout(TIMEOUT, session.open_bi(SendOptions::default())) + .await + .context("timed out opening the bidirectional stream")??; + let request = format!("ping:{}", version.alpn()); + send_all(&mut send, request.as_bytes(), "the bidirectional request").await?; + let response = recv_all(&mut recv, "the bidirectional response").await?; + if response.as_ref() != format!("pong:{}", version.alpn()).as_bytes() { + bail!("unexpected bidirectional response"); + } + + // DATAGRAM is available only on record-framed drafts (QMux01+). + if version.uses_records() { + session.send_datagram(Bytes::from_static(b"rust-datagram"), SendOptions::default())?; + let datagram = tokio::time::timeout(TIMEOUT, session.recv_datagram()) + .await + .context("timed out receiving the TypeScript datagram")??; + if datagram.as_ref() != b"typescript-datagram" { + bail!("unexpected TypeScript datagram"); + } + } else if session.max_datagram_size() != 0 { + bail!("QMux00 unexpectedly negotiated datagram support"); + } + + let closed = tokio::time::timeout(TIMEOUT, session.closed()) + .await + .context("timed out waiting for the TypeScript server to close")?; + match closed { + qmux::Error::ConnectionClosed { code: 42, reason } if reason == "interop complete" => {} + other => bail!("unexpected session close: {other}"), + } + + Ok(()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let url = args + .next() + .context("usage: interop-client ")?; + let raw_version = args + .next() + .context("usage: interop-client ")?; + let version = parse_version(&raw_version)?; + + let session = qmux::ws::Client::new() + .with_protocol(PROTOCOL, &[version]) + .require_protocol() + .connect(&url) + .await?; + + run(session, version).await +} diff --git a/rs/qmux/examples/interop-server.rs b/rs/qmux/examples/interop-server.rs new file mode 100644 index 00000000000..c5530a30d9b --- /dev/null +++ b/rs/qmux/examples/interop-server.rs @@ -0,0 +1,95 @@ +//! WebSocket server used by the cross-language QMux smoke test. +//! +//! The TypeScript client starts this example once per supported QMux draft. The +//! server accepts only the requested draft, so completing the scenario proves +//! the WebSocket subprotocol negotiation and both implementations' wire formats +//! agree. + +mod common; + +use std::io::Write as _; + +use anyhow::{Context as _, bail}; +use bytes::Bytes; +use common::*; +use moq_net::transport::poll::{SendOptions, Session as _}; +use qmux::{Session, Version}; +use tokio::net::TcpListener; + +async fn run(mut session: Session, version: Version) -> anyhow::Result<()> { + if session.protocol() != Some(PROTOCOL) { + bail!("unexpected negotiated protocol: {:?}", session.protocol()); + } + + // TypeScript -> Rust, with the default Rust windows. + let mut recv = tokio::time::timeout(TIMEOUT, session.accept_uni()) + .await + .context("timed out accepting the client unidirectional stream")??; + let received = recv_all(&mut recv, "the client flow-control payload").await?; + if received.as_ref() != payload(CLIENT_SEED) { + bail!("client flow-control payload did not match"); + } + + // Rust -> TypeScript. The client advertises deliberately small receive + // windows, so this cannot finish unless it consumes and replenishes credit. + let mut send = tokio::time::timeout(TIMEOUT, session.open_uni(SendOptions::default())) + .await + .context("timed out opening the server unidirectional stream")??; + send_all(&mut send, &payload(SERVER_SEED), "the server flow-control payload").await?; + + // Exercise both halves of one bidirectional stream and FIN propagation. + let (mut send, mut recv) = tokio::time::timeout(TIMEOUT, session.accept_bi()) + .await + .context("timed out accepting the bidirectional stream")??; + let request = recv_all(&mut recv, "the bidirectional request").await?; + if request.as_ref() != format!("ping:{}", version.alpn()).as_bytes() { + bail!("unexpected bidirectional request"); + } + let response = format!("pong:{}", version.alpn()); + send_all(&mut send, response.as_bytes(), "the bidirectional response").await?; + + // DATAGRAM is available only on record-framed drafts (QMux01+). + if version.uses_records() { + session.send_datagram(Bytes::from_static(b"rust-datagram"), SendOptions::default())?; + let datagram = tokio::time::timeout(TIMEOUT, session.recv_datagram()) + .await + .context("timed out receiving the TypeScript datagram")??; + if datagram.as_ref() != b"typescript-datagram" { + bail!("unexpected TypeScript datagram"); + } + } else if session.max_datagram_size() != 0 { + bail!("QMux00 unexpectedly negotiated datagram support"); + } + + let closed = tokio::time::timeout(TIMEOUT, session.closed()) + .await + .context("timed out waiting for the TypeScript client to close")?; + match closed { + qmux::Error::ConnectionClosed { code: 42, reason } if reason == "interop complete" => {} + other => bail!("unexpected session close: {other}"), + } + + Ok(()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let raw_version = std::env::args() + .nth(1) + .context("usage: interop-server ")?; + let version = parse_version(&raw_version)?; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + println!("ws://{addr}/interop"); + std::io::stdout().flush()?; + + let (socket, _) = listener.accept().await?; + let session = qmux::ws::Server::new() + .with_protocol(PROTOCOL, &[version]) + .require_protocol() + .accept(socket) + .await?; + + run(session, version).await +} diff --git a/rs/qmux/src/alpn.rs b/rs/qmux/src/alpn.rs new file mode 100644 index 00000000000..87fe2807fa1 --- /dev/null +++ b/rs/qmux/src/alpn.rs @@ -0,0 +1,156 @@ +//! ALPN / subprotocol negotiation helpers shared by TLS and WebSocket transports. +//! +//! Each entry is `(alpn, versions)`: a single ALPN paired with the QMux +//! wire-format versions it can ride on. The wire form `{version.prefix()}{alpn}` +//! is emitted once per version, in order. An empty `versions` slice means +//! every QMux draft this crate knows about ([`Version::ALL`]). +//! +//! Bare version ALPNs (`qmux-02`, `qmux-01`, `qmux-00`, no app protocol +//! attached) are advertised/accepted by default, so a peer that only knows a +//! wire-format version can still connect. Set the `require_protocol` flag to +//! suppress them and advertise/accept only the configured prefixed pairs. +//! +//! [`parse`] recovers `(Version, Option)` from a negotiated wire-format +//! ALPN, refusing anything that names no QMux version. + +use crate::{Error, Version}; + +/// Resolve an entry's `versions` slice: empty falls back to every supported +/// QMux draft, mirroring JS's `null` value. +pub(crate) fn expand_versions(versions: &[Version]) -> &[Version] { + if versions.is_empty() { Version::ALL } else { versions } +} + +/// Build the ALPN list from `(alpn, versions)` entries. +/// +/// Each entry emits `{v.prefix()}{alpn}` per version in `expand_versions(versions)`. +/// Unless `require_protocol` is set, the bare version ALPNs (`qmux-02`, +/// `qmux-01`, `qmux-00`) are appended after the prefixed pairs as a fallback for +/// peers that don't want to commit to an app protocol. +/// +/// Suitable for TLS ALPN or WebSocket `Sec-WebSocket-Protocol`. +pub(crate) fn build<'a>( + entries: impl IntoIterator, + require_protocol: bool, +) -> Vec { + let mut out: Vec = Vec::new(); + for (alpn, versions) in entries { + for &version in expand_versions(versions) { + out.push(format!("{}{}", version.prefix(), alpn)); + } + } + if !require_protocol { + out.extend(Version::ALL.iter().map(|v| v.alpn().to_string())); + } + out +} + +/// Parse a negotiated wire-format ALPN into `(version, app_protocol)`. +/// +/// Recognises `{qmux-VV.}{alpn}` as `(QMuxVV, Some(alpn))` and the bare version ALPN +/// `qmux-VV` as `(QMuxVV, None)`. Anything else, an absent ALPN included, names no wire +/// format we speak. +pub(crate) fn parse(alpn: Option<&str>) -> Result<(Version, Option), Error> { + let alpn = alpn.unwrap_or_default(); + for &v in Version::ALL { + if alpn == v.alpn() { + return Ok((v, None)); + } + if let Some(rest) = alpn.strip_prefix(v.prefix()) { + let app = (!rest.is_empty()).then(|| rest.to_string()); + return Ok((v, app)); + } + } + Err(Error::InvalidProtocol(alpn.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_appends_bare_alpns_by_default() { + let out = build([("moq-lite-04", &[Version::QMux01][..])], false); + assert_eq!(out, vec!["qmux-01.moq-lite-04", "qmux-02", "qmux-01", "qmux-00"]); + } + + #[test] + fn build_emits_only_prefixed_pairs_when_require_protocol() { + let out = build( + [ + ("moq-lite-04", &[Version::QMux01][..]), + ("moq-transport-17", &[Version::QMux00][..]), + ], + true, + ); + assert_eq!(out, vec!["qmux-01.moq-lite-04", "qmux-00.moq-transport-17"]); + } + + #[test] + fn build_expands_empty_versions_to_all_qmux_drafts() { + let out = build([("moq-lite-04", &[][..])], true); + assert_eq!( + out, + vec!["qmux-02.moq-lite-04", "qmux-01.moq-lite-04", "qmux-00.moq-lite-04"] + ); + } + + #[test] + fn build_emits_one_pair_per_listed_version() { + let out = build([("moq-lite-04", &[Version::QMux00, Version::QMux01][..])], true); + assert_eq!(out, vec!["qmux-00.moq-lite-04", "qmux-01.moq-lite-04"]); + } + + #[test] + fn build_empty_by_default_emits_only_bare_alpns() { + let entries: [(&str, &[Version]); 0] = []; + let out = build(entries, false); + assert_eq!(out, vec!["qmux-02", "qmux-01", "qmux-00"]); + } + + #[test] + fn build_empty_with_require_protocol_emits_nothing() { + let entries: [(&str, &[Version]); 0] = []; + let out = build(entries, true); + assert!(out.is_empty()); + } + + #[test] + fn parse_recognises_prefixed_pairs() { + assert_eq!( + parse(Some("qmux-02.moq-lite-04")).unwrap(), + (Version::QMux02, Some("moq-lite-04".to_string())) + ); + assert_eq!( + parse(Some("qmux-01.moq-lite-04")).unwrap(), + (Version::QMux01, Some("moq-lite-04".to_string())) + ); + assert_eq!( + parse(Some("qmux-00.moq-transport-17")).unwrap(), + (Version::QMux00, Some("moq-transport-17".to_string())) + ); + } + + #[test] + fn parse_recognises_bare_versions() { + assert_eq!(parse(Some("qmux-02")).unwrap(), (Version::QMux02, None)); + assert_eq!(parse(Some("qmux-01")).unwrap(), (Version::QMux01, None)); + assert_eq!(parse(Some("qmux-00")).unwrap(), (Version::QMux00, None)); + } + + #[test] + fn parse_refuses_anything_else() { + for alpn in [ + None, + Some(""), + Some("h2"), + Some("webtransport"), + Some("webtransport.moq-lite-04"), + ] { + assert!( + matches!(parse(alpn), Err(Error::InvalidProtocol(_))), + "{alpn:?} names no QMux version" + ); + } + } +} diff --git a/rs/qmux/src/config.rs b/rs/qmux/src/config.rs new file mode 100644 index 00000000000..0d68363cccd --- /dev/null +++ b/rs/qmux/src/config.rs @@ -0,0 +1,237 @@ +use std::time::Duration; + +use moq_quic::VarInt; +use moq_quic::mux; + +use crate::Version; + +/// The default and minimum `max_record_size`: 16 KiB less a 2-byte Size field +const DEFAULT_MAX_RECORD_SIZE: u64 = 16382; + +/// How the application-level protocol is determined for a session. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub enum Protocol { + /// No application protocol. + #[default] + None, + + /// Advertise these protocols and negotiate one in-band, via the QMux + /// `application_protocols` transport parameter (preference order). + /// + /// For transports without ALPN (TCP, Unix sockets). Both peers must opt in: + /// receiving the parameter while *not* in this mode is a protocol error, and so is + /// agreeing on none ([`Error::TransportParameter`](crate::Error::TransportParameter)). + /// The agreed protocol is resolved by the time + /// [`Session::connect`](crate::Session::connect) / [`accept`](crate::Session::accept) + /// returns. + Negotiate(Vec), + + /// Already negotiated out of band (TLS / WebSocket ALPN). Reported as-is; + /// the `application_protocols` parameter is never sent, and receiving it is + /// a protocol error. + Negotiated(String), +} + +/// Configuration for a QMux session. +/// +/// Construct with [`Config::new`] (or [`Config::negotiated`]) and set the public fields you +/// need. For the common transports, prefer the higher-level `tcp` / `uds` builders, which +/// wrap this. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Config { + /// Wire format version. + pub version: Version, + /// How the application protocol is determined. See [`Protocol`]. + pub protocol: Protocol, + + /// Max concurrent bidirectional streams the peer can open. + pub max_streams_bidi: u64, + /// Max concurrent unidirectional streams the peer can open. + pub max_streams_uni: u64, + /// Connection-level receive window in bytes. + pub max_data: u64, + /// Per-stream receive window in bytes, for every stream. + pub max_stream_data: u64, + + /// Close the session after this long without hearing from the peer, keeping it alive with + /// `QX_PING` meanwhile, or `None` to never time out. Zero is refused. The smaller of the two peers' values + /// applies. Only the record-framed drafts (QMux01+) negotiate it. + pub max_idle_timeout: Option, + /// Maximum QMux Record size in bytes (draft-01+), at least the default. Default: 16382. + pub max_record_size: u64, + + /// Largest DATAGRAM *frame* (RFC 9221: frame type + length + payload) we + /// advertise willingness to receive, in bytes; `0` disables datagrams. + /// + /// This is a frame size, not a payload size: the usable payload is this value less the + /// per-frame overhead. A datagram must fit within a single record, so this is capped at + /// [`max_record_size`](Config::max_record_size). Datagrams are a record-framed-draft + /// feature (QMux01+), so this is ignored on QMux00. Default: 16382 bytes. + pub max_datagram_frame_size: u64, + + /// How long [`Session::connect`](crate::Session::connect) / + /// [`accept`](crate::Session::accept) waits for the peer's transport parameters, or + /// `None` to wait indefinitely. Bounds the handshake so a peer that completes the + /// transport connection but never sends its parameters can't hang establishment + /// forever. Default: 10s. + pub handshake_timeout: Option, + + /// How often to send a `QX_PING` request purely to measure round-trip time, or `None` + /// to not measure it. Zero is refused. + /// + /// Separate from the keep-alive ping, which is activity-gated and so never fires while + /// an application is actively sending. Only the record-framed drafts (QMux01+) carry + /// `QX_PING`. Default: 1s. + pub rtt_interval: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + version: Version::QMux02, + protocol: Protocol::None, + max_streams_bidi: 100, + max_streams_uni: 100, + max_data: 1 << 20, + max_stream_data: 256 << 10, + max_idle_timeout: Some(Duration::from_secs(30)), + max_record_size: DEFAULT_MAX_RECORD_SIZE, + // Fill a full record by default; the record layer bounds the size. + max_datagram_frame_size: DEFAULT_MAX_RECORD_SIZE, + handshake_timeout: Some(Duration::from_secs(10)), + rtt_interval: Some(Duration::from_secs(1)), + } + } +} + +impl Config { + /// Create a config with default flow control values and no application + /// protocol. Set [`Config::protocol`] to negotiate one. + pub fn new(version: Version) -> Self { + Self { + version, + ..Default::default() + } + } + + /// Create a config whose protocol was already negotiated out of band + /// (TLS / WebSocket ALPN). `protocol` is the chosen name, or `None`. + pub fn negotiated(version: Version, protocol: Option) -> Self { + Self::default().with_negotiated(version, protocol) + } + + /// This config with the out-of-band negotiation's outcome applied. + pub(crate) fn with_negotiated(self, version: Version, protocol: Option) -> Self { + Self { + version, + protocol: match protocol { + Some(name) => Protocol::Negotiated(name), + None => Protocol::None, + }, + ..self + } + } + + /// The protocol reported before any in-band negotiation. + pub(crate) fn negotiated_protocol(&self) -> Option { + match &self.protocol { + Protocol::Negotiated(name) => Some(name.clone()), + Protocol::None | Protocol::Negotiate(_) => None, + } + } + + /// The connection's settings, refusing a value the wire cannot carry, the peer would + /// refuse, or that never lets a timer rest + pub(crate) fn to_mux(&self) -> Result { + fn varint(value: u64) -> Result { + VarInt::from_u64(value).map_err(|_| crate::Error::InvalidConfig("value exceeds a QUIC varint")) + } + // `None` is how to disable these; zero would fire on every poll + if self.max_idle_timeout == Some(Duration::ZERO) { + return Err(crate::Error::InvalidConfig("zero max_idle_timeout")); + } + if self.rtt_interval == Some(Duration::ZERO) { + return Err(crate::Error::InvalidConfig("zero rtt_interval")); + } + + // QUIC's stream count ceiling (RFC 9000 §4.6), which peers enforce on the parameter + const MAX_STREAM_COUNT: u64 = 1 << 60; + if self.max_streams_bidi > MAX_STREAM_COUNT || self.max_streams_uni > MAX_STREAM_COUNT { + return Err(crate::Error::InvalidConfig("stream count above 2^60")); + } + + let mut config = mux::Config::default(); + config.version = self.version; + config.max_concurrent_bidi_streams = varint(self.max_streams_bidi)?; + config.max_concurrent_uni_streams = varint(self.max_streams_uni)?; + config.receive_window = varint(self.max_data)?; + config.stream_receive_window = varint(self.max_stream_data)?; + config.max_idle_timeout = self.max_idle_timeout; + if self.version.uses_records() && self.max_record_size < DEFAULT_MAX_RECORD_SIZE { + return Err(crate::Error::InvalidConfig("max_record_size below 16382")); + } + config.max_record_size = varint(self.max_record_size)?; + config.max_datagram_frame_size = (self.max_datagram_frame_size != 0) + .then(|| varint(self.max_datagram_frame_size)) + .transpose()?; + config.handshake_timeout = self.handshake_timeout; + config.rtt_interval = self.rtt_interval; + // Only advertise protocols when negotiating in-band; TLS/WS already chose one via + // ALPN and must not send this parameter. + config.protocols = match &self.protocol { + Protocol::Negotiate(list) => Some(list.clone()), + Protocol::None | Protocol::Negotiated(_) => None, + }; + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_size_below_the_minimum_is_refused() { + for &version in Version::ALL { + let config = Config { + max_record_size: DEFAULT_MAX_RECORD_SIZE - 1, + ..Config::new(version) + }; + assert_eq!(config.to_mux().is_err(), version.uses_records(), "{version:?}"); + } + } + + #[test] + fn zero_timers_are_refused() { + let idle = Config { + max_idle_timeout: Some(Duration::ZERO), + ..Config::default() + }; + assert!(matches!(idle.to_mux(), Err(crate::Error::InvalidConfig(_)))); + let rtt = Config { + rtt_interval: Some(Duration::ZERO), + ..Config::default() + }; + assert!(matches!(rtt.to_mux(), Err(crate::Error::InvalidConfig(_)))); + } + + #[test] + fn stream_counts_above_quics_ceiling_are_refused() { + let at = Config { + max_streams_bidi: 1 << 60, + max_streams_uni: 1 << 60, + ..Config::default() + }; + assert!(at.to_mux().is_ok()); + for (bidi, uni) in [((1 << 60) + 1, 0), (0, (1 << 60) + 1)] { + let over = Config { + max_streams_bidi: bidi, + max_streams_uni: uni, + ..Config::default() + }; + assert!(matches!(over.to_mux(), Err(crate::Error::InvalidConfig(_)))); + } + } +} diff --git a/rs/qmux/src/error.rs b/rs/qmux/src/error.rs new file mode 100644 index 00000000000..2dfc2b6cfed --- /dev/null +++ b/rs/qmux/src/error.rs @@ -0,0 +1,203 @@ +use std::sync::Arc; + +use moq_net::transport::CloseKind; +use moq_quic::{ConnectionError, TransportErrorCode}; + +/// Errors that can occur during QMux session and stream operations. +#[derive(Debug, thiserror::Error, Clone)] +#[non_exhaustive] +pub enum Error { + /// The stream already finished or was reset. + #[error("stream closed")] + StreamClosed, + + /// The session ended with an application close: the peer's APPLICATION_CLOSE, or our + /// own [`close`](moq_net::transport::poll::Session::close). + #[error("connection closed: {code}: {reason}")] + ConnectionClosed { code: u64, reason: String }, + + /// The peer sent a CONNECTION_CLOSE: it detected a protocol violation or transport + /// error. Unlike [`ConnectionClosed`](Self::ConnectionClosed), it does *not* surface as + /// an application close. + #[error("connection reset: {code}: {reason}")] + ConnectionReset { code: u64, reason: String }, + + /// The peer reset the stream with this code. + #[error("stream reset: {0}")] + StreamReset(u64), + + /// The peer asked us to stop sending on the stream with this code. + #[error("stream stop: {0}")] + StreamStop(u64), + + /// A record or frame exceeded its size limit. + #[error("frame too large")] + FrameTooLarge, + + /// The peer sent more than its flow control credit allowed. + #[error("flow control error")] + FlowControlError, + + /// A frame was malformed on the wire, QUIC's FRAME_ENCODING_ERROR. + #[error("frame encoding error")] + FrameEncoding, + + /// The peer broke a protocol rule, QUIC's PROTOCOL_VIOLATION. + #[error("protocol violation")] + ProtocolViolation, + + /// A transport parameter carried an illegal value, QUIC's TRANSPORT_PARAMETER_ERROR. + #[error("transport parameter error")] + TransportParameter, + + /// A [`Config`](crate::Config) value the session cannot run with. + #[error("invalid config: {0}")] + InvalidConfig(&'static str), + + /// The peer opened more streams than it was allowed. + #[error("stream limit exceeded")] + StreamLimitExceeded, + + /// The transport ended without a close frame, or the session was dropped. + #[error("connection closed")] + Closed, + + /// The peer went silent for the idle timeout. + #[error("idle timeout")] + IdleTimeout, + + /// The peer never sent its transport parameters. + #[error("handshake timeout: peer never sent transport parameters")] + HandshakeTimeout, + + /// An application protocol name that is not an HTTP token, or an ALPN that names no + /// QMux version. + #[error("invalid protocol token: {0:?}")] + InvalidProtocol(String), + + /// The TLS server name is not a valid DNS name or IP address. + #[error("invalid server name")] + InvalidServerName, + + /// The server rejected the handshake with a non-success HTTP status. + /// + /// Surfaced from the WebSocket upgrade response so callers can distinguish + /// terminal failures (e.g. 401/403 auth rejections) from transient I/O + /// errors worth retrying. + #[error("http error status: {0}")] + Http(u16), + + // Foreign error sources aren't `Clone`, but `Error` must be (the session + // fans one terminal error out to every waiter). `Arc` bridges the two while + // keeping the original error, source chain and all, instead of a string. + /// The transport's I/O failed. + #[error(transparent)] + Io(Arc), + + /// The WebSocket failed. + #[cfg(feature = "ws")] + #[error(transparent)] + WebSocket(Arc), + + /// Datagrams are disabled on this session, or the peer does not accept them. + #[error("datagrams not supported")] + DatagramsUnsupported, + + /// The datagram exceeds [`max_datagram_size`](moq_net::transport::poll::Session::max_datagram_size). + #[error("datagram too large")] + DatagramTooLarge, +} + +impl Error { + /// The terminal error for a connection that ended for `reason`, `established` or not + pub(crate) fn lost(reason: ConnectionError, established: bool) -> Self { + match reason { + ConnectionError::ApplicationClosed(close) => Self::ConnectionClosed { + code: close.error_code.into_inner(), + reason: String::from_utf8_lossy(&close.reason).into_owned(), + }, + ConnectionError::ConnectionClosed(close) => Self::ConnectionReset { + code: close.error_code.into(), + reason: String::from_utf8_lossy(&close.reason).into_owned(), + }, + ConnectionError::TransportError(err) => match err.code { + TransportErrorCode::FLOW_CONTROL_ERROR | TransportErrorCode::FINAL_SIZE_ERROR => Self::FlowControlError, + TransportErrorCode::FRAME_ENCODING_ERROR => Self::FrameEncoding, + TransportErrorCode::TRANSPORT_PARAMETER_ERROR => Self::TransportParameter, + TransportErrorCode::STREAM_LIMIT_ERROR => Self::StreamLimitExceeded, + _ => Self::ProtocolViolation, + }, + ConnectionError::TimedOut if established => Self::IdleTimeout, + ConnectionError::TimedOut => Self::HandshakeTimeout, + // A local close publishes its own code, so this is a close without one. + _ => Self::Closed, + } + } +} + +// Hand-written rather than `#[from]`: thiserror's `#[from]` would generate +// `From>`, but `?` call sites yield a bare `std::io::Error`. +impl From for Error { + fn from(err: std::io::Error) -> Self { + Self::Io(Arc::new(err)) + } +} + +#[cfg(feature = "ws")] +impl From for Error { + fn from(err: tokio_tungstenite::tungstenite::Error) -> Self { + // A non-101 upgrade response carries the HTTP status; surface it as a + // distinct, transport-agnostic variant so callers can act on terminal + // rejections (e.g. auth) without downcasting tungstenite. + if let tokio_tungstenite::tungstenite::Error::Http(response) = &err { + return Self::Http(response.status().as_u16()); + } + Self::WebSocket(Arc::new(err)) + } +} + +impl moq_net::transport::Error for Error { + fn close_kind(&self) -> CloseKind { + match self { + Self::IdleTimeout => CloseKind::IdleTimeout, + Self::Closed => CloseKind::Closed, + _ => CloseKind::Transport, + } + } + + fn session_error(&self) -> Option<(u32, String)> { + match self { + Self::ConnectionClosed { code, reason } => Some(((*code).try_into().ok()?, reason.clone())), + _ => None, + } + } + + fn stream_error(&self) -> Option { + match self { + Self::StreamReset(code) | Self::StreamStop(code) => (*code).try_into().ok(), + _ => None, + } + } +} + +#[cfg(all(test, feature = "ws"))] +mod tests { + use super::*; + use tokio_tungstenite::tungstenite::{self, http}; + + #[test] + fn preserves_http_status() { + let response = http::Response::builder() + .status(http::StatusCode::UNAUTHORIZED) + .body(None::>) + .unwrap(); + let err: Error = tungstenite::Error::Http(Box::new(response)).into(); + assert!(matches!(err, Error::Http(401))); + } + + #[test] + fn non_http_preserves_source() { + let err: Error = tungstenite::Error::ConnectionClosed.into(); + assert!(matches!(err, Error::WebSocket(_))); + } +} diff --git a/rs/qmux/src/lib.rs b/rs/qmux/src/lib.rs new file mode 100644 index 00000000000..9fe27830814 --- /dev/null +++ b/rs/qmux/src/lib.rs @@ -0,0 +1,73 @@ +//! QMux (draft-ietf-quic-qmux) over reliable transports. +//! +//! Runs QUIC streams, datagrams, and flow control over TCP, TLS, WebSocket, and Unix +//! sockets. Speaks draft-02 by default, negotiating down to draft-01 or draft-00. The +//! streams are QUIC's: [`moq_quic`]'s state machine drives them, with each record handed to +//! the transport counting as acknowledged. +//! +//! # Layout +//! +//! The crate root holds the transport-independent session API: [`Session`] and its +//! [`SendStream`] / [`RecvStream`] halves, which implement +//! [`moq_net::transport::poll`], plus the [`Config`], [`Version`], and [`Error`] types they +//! share. +//! +//! Everything tied to a specific transport lives in that transport's module: +//! +//! | Module | Entry point | Feature | +//! | --- | --- | --- | +//! | `tcp` | `tcp::Config` | `tcp` | +//! | `uds` | `uds::Config` | `uds` (Unix only) | +//! | `tls` | `tls::Client`, `tls::Server` | `tls` | +//! | `ws` | `ws::Client`, `ws::Server`, `ws::Upgraded` | `ws` | +//! | [`transport`] | [`transport::Transport`] | | +//! +//! For anything the built-in modules don't cover, wrap an +//! [`AsyncRead`](tokio::io::AsyncRead) + [`AsyncWrite`](tokio::io::AsyncWrite) byte stream +//! in `transport::Stream`, or implement [`transport::Transport`] yourself, and pass it to +//! [`Session::connect`] / [`Session::accept`]. + +// ALPN/subprotocol negotiation is only used by the TLS and WebSocket transports. +#[cfg(any(feature = "tls", feature = "ws"))] +mod alpn; +mod config; +mod error; +mod protocol; +mod session; +mod socket; + +/// The transport abstraction, and the byte-stream implementation. +pub mod transport; + +/// Plain TCP transport. +#[cfg(feature = "tcp")] +pub mod tcp; + +/// Unix domain socket transport. +#[cfg(all(unix, feature = "uds"))] +pub mod uds; + +/// TLS over TCP transport. +#[cfg(feature = "tls")] +pub mod tls; + +/// WebSocket transport. +#[cfg(feature = "ws")] +pub mod ws; + +pub use config::{Config, Protocol}; +pub use error::Error; +pub use moq_quic::mux::Version; +pub use session::{RecvStream, SendGroup, SendStream, Session}; +#[cfg(unix)] +pub use socket::TcpStats; +pub use socket::{SharedSocketStats, SocketStats}; + +/// Every supported version's bare ALPN, newest first +/// +/// Use this when configuring TLS to advertise QMux support. For one version's ALPN, use +/// [`Version::alpn()`]. +pub const ALPNS: &[&str] = &["qmux-02", "qmux-01", "qmux-00"]; + +/// The largest frame draft-00 accepts, header included +const MAX_FRAME_SIZE: usize = 16384; diff --git a/rs/qmux/src/protocol.rs b/rs/qmux/src/protocol.rs new file mode 100644 index 00000000000..48fa1733c2c --- /dev/null +++ b/rs/qmux/src/protocol.rs @@ -0,0 +1,25 @@ +use crate::Error; + +/// Validate that a protocol name contains only valid HTTP token characters (tchar). +#[allow(dead_code)] +pub(crate) fn validate_protocol(protocol: &str) -> Result<(), Error> { + if protocol.is_empty() { + return Err(Error::InvalidProtocol(protocol.to_string())); + } + + for c in protocol.chars() { + if !is_tchar(c) { + return Err(Error::InvalidProtocol(protocol.to_string())); + } + } + + Ok(()) +} + +/// Check if a character is a valid HTTP token character per RFC 7230. +fn is_tchar(c: char) -> bool { + matches!(c, + 'a'..='z' | 'A'..='Z' | '0'..='9' + | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~' + ) +} diff --git a/rs/qmux/src/session.rs b/rs/qmux/src/session.rs new file mode 100644 index 00000000000..e56cf9e202e --- /dev/null +++ b/rs/qmux/src/session.rs @@ -0,0 +1,1209 @@ +//! The session: shared state, the reader and writer tasks, and the handles. +//! +//! [`mux::Connection`] owns the streams, flow control, and timers; this module moves its +//! records over the transport. A writer task drains [`mux::Connection::poll_transmit`] +//! into the transport and runs the timers, and a reader task feeds each received record +//! back in. The handles call straight into the connection under its lock and park on the +//! per-stream waiter lists the tasks wake, the way the QUIC drivers do. + +use std::pin::Pin; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::{Buf, Bytes, BytesMut}; +use moq_clock::Instant; +use moq_net::transport::{self as net, Binding, poll}; +use moq_quic::{Dir, Side, StreamId, TransportError, TransportErrorCode, VarInt, mux}; +use rustc_hash::FxHashMap; + +use crate::transport::{Reader, Transport, Writer}; +use crate::{Config, Error, SharedSocketStats}; + +/// How long a closing session keeps writing to deliver its close frame before it drops the +/// transport anyway, like QUIC's draining period. A peer that stopped reading cannot hold the +/// transport open past it. +const CLOSE_TIMEOUT: Duration = Duration::from_secs(3); + +/// The most bytes gathered before the writer flushes the transport. +const BATCH_BYTES: usize = 64 * 1024; + +/// How far [`RecvStream::poll_closed`] reads ahead of the application before it waits for +/// the backlog to drain. +const READ_AHEAD: usize = 64 * 1024; +/// How much to ask for per read-ahead chunk. +const READ_CHUNK: usize = 8 * 1024; + +/// Everything the handles and the tasks share. +struct Inner { + state: Mutex, + /// Updated by the reader and writer, so stats never take the lock. + wire: net::Wire, + socket_stats: Option, + binding: Binding, +} + +struct State { + conn: mux::Connection, + /// Wakes the writer; handles kick it after mutating the connection so fresh egress + /// reaches the transport. + driver: kio::WaiterList, + /// Wakes the reader once the connection ends, so it stops reading. + reader: kio::WaiterList, + + established: bool, + establish_waiters: kio::WaiterList, + accept_bi_waiters: kio::WaiterList, + accept_uni_waiters: kio::WaiterList, + /// Whoever is blocked on the peer's MAX_STREAMS credit. + open_waiters: kio::WaiterList, + + /// Per-stream read/write parking, keyed by stream id. + readable: FxHashMap, + writable: FxHashMap, + /// Send streams waiting for their end: a FIN serialized, or a `STOP_SENDING`. + finishing: FxHashMap, + /// Every send stream a handle still holds, and how it ended once that happened. + sends: FxHashMap>, + + datagram_waiters: kio::WaiterList, + + /// The terminal error, set exactly once; everything fails with it after. + closed: Option, + closed_waiters: kio::WaiterList, + /// The error for a close this side asked for, published once the writer has delivered + /// the close frame, so a caller that exits the moment `poll_closed` resolves already + /// handed it to the transport. + local_close: Option, + + sent_bytes: u64, + received_bytes: u64, +} + +/// How a send stream ended, once the session has seen it happen. +#[derive(Clone, Copy, Debug)] +enum End { + /// The FIN went to the transport, which on a reliable transport is delivery. + Delivered, + /// The peer sent `STOP_SENDING` with this code. + Stopped(u64), +} + +impl State { + /// Terminate with `err` (the first one wins) and wake absolutely everyone. + fn fail(&mut self, err: Error) { + if self.closed.is_some() { + return; + } + self.closed = Some(err); + self.driver.wake(); + self.reader.wake(); + self.closed_waiters.wake(); + self.establish_waiters.wake(); + self.accept_bi_waiters.wake(); + self.accept_uni_waiters.wake(); + self.open_waiters.wake(); + self.datagram_waiters.wake(); + for waiters in self.readable.values_mut() { + waiters.wake(); + } + for waiters in self.writable.values_mut() { + waiters.wake(); + } + for waiters in self.finishing.values_mut() { + waiters.wake(); + } + } + + /// Apply every pending connection event to the parking tables. + fn sweep(&mut self) { + while let Some(event) = self.conn.poll() { + match event { + mux::Event::Connected => { + self.established = true; + self.establish_waiters.wake(); + } + mux::Event::Stream(event) => self.sweep_stream(event), + mux::Event::DatagramReceived => self.datagram_waiters.wake(), + mux::Event::ConnectionLost { reason } => { + self.reader.wake(); + // The writer publishes a local close once its frame is out. + if !matches!(reason, moq_quic::ConnectionError::LocallyClosed) { + let err = Error::lost(reason, self.established); + self.fail(err); + } + } + } + } + } + + fn sweep_stream(&mut self, event: moq_quic::StreamEvent) { + use moq_quic::StreamEvent; + + // Waking removes the entry: a still-interested poller re-registers on its next poll, + // so the maps only hold streams somebody is parked on. + match event { + StreamEvent::Opened { dir: Dir::Bi } => self.accept_bi_waiters.wake(), + StreamEvent::Opened { dir: Dir::Uni } => self.accept_uni_waiters.wake(), + StreamEvent::Available { .. } => self.open_waiters.wake(), + StreamEvent::Readable { id } => wake(&mut self.readable, id), + StreamEvent::Writable { id } => wake(&mut self.writable, id), + StreamEvent::Finished { id } => self.end(id, End::Delivered), + StreamEvent::Stopped { id, error_code } => { + self.end(id, End::Stopped(error_code.into_inner())); + // A writer blocked on capacity has to learn it will never come. + wake(&mut self.writable, id); + } + // Nothing waits on a send stream's acknowledgment yet. + StreamEvent::Acked { .. } => {} + } + } + + /// Record how a send stream ended, and wake whoever is watching it. + /// + /// A stream whose handle already dropped records nothing: nobody is left to read the + /// verdict, and the entry would outlive every use for it. + fn end(&mut self, id: StreamId, end: End) { + if let Some(slot) = self.sends.get_mut(&id) { + *slot = Some(end); + } + wake(&mut self.finishing, id); + } + + /// Drop send stream `id`'s bookkeeping, parking included. + fn forget_send(&mut self, id: StreamId) { + self.sends.remove(&id); + self.finishing.remove(&id); + self.writable.remove(&id); + } +} + +fn wake(lists: &mut FxHashMap, id: StreamId) { + if let Some(mut waiters) = lists.remove(&id) { + waiters.wake(); + } +} + +impl Inner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap() + } + + /// Close with an APPLICATION_CLOSE, reporting `err` once it is out. + fn close(&self, code: u64, reason: &str, err: Error) { + let mut state = self.lock(); + if state.closed.is_some() || state.conn.is_closed() { + return; + } + state.conn.close( + VarInt::from_u64(code).unwrap_or(VarInt::MAX), + Bytes::copy_from_slice(reason.as_bytes()), + ); + state.local_close = Some(err); + state.sweep(); + state.driver.wake(); + // A closing connection ignores whatever else the peer sends. + state.reader.wake(); + } + + /// Gather the next records to write, running the timers while there are none, or `None` + /// once the connection has nothing left to say. + fn poll_batch(&self, waiter: &kio::Waiter, timer: &mut Timer) -> Poll>> { + let mut state = self.lock(); + // Register first: a handle mutating the connection after this sweep re-polls us. + waiter.register(&mut state.driver); + loop { + let now = moq_clock::now(); + if state.conn.poll_timeout().is_some_and(|at| now >= at) { + state.conn.handle_timeout(now); + } + + let mut batch = Vec::new(); + let mut size = 0; + while size < BATCH_BYTES { + let Some(record) = state.conn.poll_transmit(now) else { + break; + }; + size += record.len(); + batch.push(record); + } + state.sweep(); + if !batch.is_empty() { + return Poll::Ready(Some(batch)); + } + if state.conn.is_drained() { + return Poll::Ready(None); + } + if !timer.poll(waiter, state.conn.poll_timeout()) { + return Poll::Pending; + } + } + } + + /// Run the timers while a write is stalled, resolving once a closing session has spent + /// its [`CLOSE_TIMEOUT`] and should drop the transport. + fn poll_stalled(&self, waiter: &kio::Waiter, timer: &mut Timer) -> Poll<()> { + let mut state = self.lock(); + waiter.register(&mut state.driver); + if !timer.stalled { + // The peer is evidently there but slow, which defers the idle timeout. + timer.stalled = true; + state.conn.set_backpressured(true); + } + loop { + let now = moq_clock::now(); + if state.conn.is_closed() && timer.closing.is_none() { + timer.closing = Some(now + CLOSE_TIMEOUT); + } + if timer.closing.is_some_and(|at| now >= at) { + return Poll::Ready(()); + } + if state.conn.poll_timeout().is_some_and(|at| now >= at) { + state.conn.handle_timeout(now); + state.sweep(); + } + let deadline = match (state.conn.poll_timeout(), timer.closing) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + if !timer.poll(waiter, deadline) { + return Poll::Pending; + } + } + } + + /// Resolve once the connection ended, which is when the reader stops. + fn poll_ended(&self, waiter: &kio::Waiter) -> Poll<()> { + let mut state = self.lock(); + if state.conn.is_closed() || state.closed.is_some() { + return Poll::Ready(()); + } + waiter.register(&mut state.reader); + Poll::Pending + } + + /// The writer stopped: publish a local close, or the failure that stopped it. + fn writer_done(&self, result: Result<(), Error>) { + let mut state = self.lock(); + if let Some(err) = state.local_close.take() { + state.fail(err); + } + if let Err(err) = result { + state.fail(err); + } + state.conn.transport_closed(); + state.sweep(); + // A drained connection ended with a close from either side, already published; this + // only lands when the transport died under a live one. + state.fail(Error::Closed); + } +} + +/// The writer's timers. +#[derive(Default)] +struct Timer { + sleep: Option, + /// When a closing session gives up on delivering its close frame. + closing: Option, + /// The current write is stalled on the transport. + stalled: bool, +} + +impl Timer { + /// Wait for `deadline`, returning whether it already passed. + fn poll(&mut self, waiter: &kio::Waiter, deadline: Option) -> bool { + let Some(deadline) = deadline else { + return false; + }; + if moq_clock::now() >= deadline { + return true; + } + let sleep = match &mut self.sleep { + Some(sleep) => { + if sleep.deadline() != deadline { + sleep.reset(deadline); + } + sleep + } + None => self.sleep.insert(moq_clock::sleep_until(deadline)), + }; + waiter.poll_future(Pin::new(sleep)).is_ready() + } +} + +/// Drain the connection into `writer` until it has nothing left to say. +async fn run_writer(inner: Arc, mut writer: W) { + let mut timer = Timer::default(); + let result = async { + loop { + let batch = tokio::select! { + biased; + // Transport-level maintenance, like a WebSocket keep-alive Ping. First, since a + // writer that always has a batch ready would otherwise never send one; it is + // ready only once per keep-alive interval. + result = writer.maintain() => { + result?; + continue; + } + batch = kio::wait(|waiter| inner.poll_batch(waiter, &mut timer)) => batch, + }; + let Some(batch) = batch else { + return Ok(()); + }; + + let size: usize = batch.iter().map(Bytes::len).sum(); + let write = async { + for record in batch { + writer.feed(record).await?; + } + writer.flush().await + }; + tokio::pin!(write); + let result = tokio::select! { + biased; + result = &mut write => result, + // Pending while the write runs; resolves once a close has waited long enough. + () = kio::wait(|waiter| inner.poll_stalled(waiter, &mut timer)) => Err(Error::Closed), + }; + if std::mem::take(&mut timer.stalled) { + inner.lock().conn.set_backpressured(false); + } + result?; + + let mut state = inner.lock(); + state.sent_bytes += size as u64; + inner.wire.set_sent_bytes(state.sent_bytes); + } + } + .await; + + // Shut the transport down gracefully, unless it already failed, within what is left of + // the close bound. + if result.is_ok() { + let bound = timer + .closing + .map_or(CLOSE_TIMEOUT, |at| at.saturating_duration_since(moq_clock::now())); + let _ = moq_clock::timeout(bound, writer.close()).await; + } + inner.writer_done(result); +} + +/// Feed every record from `reader` into the connection until it ends. +async fn run_reader(inner: Arc, mut reader: R) { + loop { + let record = tokio::select! { + biased; + () = kio::wait(|waiter| inner.poll_ended(waiter)) => return, + record = reader.recv() => record, + }; + + let mut state = inner.lock(); + let violation = |code, reason: &str| TransportError::new(code, reason.to_string()); + match record { + Ok(record) => { + state.received_bytes += record.len() as u64; + inner.wire.set_received_bytes(state.received_bytes); + state.conn.handle_record(record, moq_clock::now()); + } + Err(Error::FrameTooLarge) => state.conn.handle_oversized(), + Err(Error::FlowControlError) => state.conn.handle_violation(violation( + TransportErrorCode::FLOW_CONTROL_ERROR, + "transport buffer overrun", + )), + Err(Error::FrameEncoding) => state + .conn + .handle_violation(violation(TransportErrorCode::FRAME_ENCODING_ERROR, "transport framing")), + Err(Error::ProtocolViolation) => state + .conn + .handle_violation(violation(TransportErrorCode::PROTOCOL_VIOLATION, "transport framing")), + Err(err) => { + // The transport's own error says more than the connection's reset, unless + // this side is closing, when the peer hanging up is the expected answer and + // the writer publishes the close it asked for. + if state.local_close.is_none() { + state.fail(err); + } + state.conn.transport_closed(); + } + } + state.sweep(); + state.driver.wake(); + } +} + +/// Closes the session once the last [`Session`] handle drops. +struct Guard(Arc); + +impl Drop for Guard { + fn drop(&mut self) { + self.0.close(0, "", Error::Closed); + } +} + +/// A multiplexed session over a reliable transport. +/// +/// Clones share the session; each carries its own parking so concurrent pending operations +/// don't trample each other's wakeups. Dropping the last clone closes the session with an +/// APPLICATION_CLOSE of code 0; streams outliving it fail. +pub struct Session { + inner: Arc, + guard: Arc, + park: kio::Park, + /// The application protocol, cached at establishment so `protocol()` can borrow it. + protocol: Option, +} + +impl Session { + /// Open a client-side session over the given transport, waiting until it is established + /// before returning. + /// + /// "Established" means the peer's transport parameters have been received and applied, + /// so [`protocol`](Self::protocol) returns its final value. Bounded by + /// [`Config::handshake_timeout`]: if the peer completes the transport handshake but + /// never sends its parameters, this returns [`Error::HandshakeTimeout`] rather than + /// hanging. + pub async fn connect(transport: T, config: Config) -> Result { + Self::new(transport, Side::Client, config)?.established().await + } + + /// Open a server-side session over the given transport, waiting until it is + /// established before returning. See [`Session::connect`] for the semantics. + pub async fn accept(transport: T, config: Config) -> Result { + Self::new(transport, Side::Server, config)?.established().await + } + + /// Start a session over the transport without waiting for the handshake, for callers + /// that resolved the protocol out of band (the WebSocket subprotocol, say). + pub(crate) fn new(transport: T, side: Side, config: Config) -> Result { + let conn = mux::Connection::new(config.to_mux()?, side, moq_clock::now()); + let inner = Arc::new(Inner { + state: Mutex::new(State { + conn, + driver: kio::WaiterList::new(), + reader: kio::WaiterList::new(), + established: false, + establish_waiters: kio::WaiterList::new(), + accept_bi_waiters: kio::WaiterList::new(), + accept_uni_waiters: kio::WaiterList::new(), + open_waiters: kio::WaiterList::new(), + readable: FxHashMap::default(), + writable: FxHashMap::default(), + finishing: FxHashMap::default(), + sends: FxHashMap::default(), + datagram_waiters: kio::WaiterList::new(), + closed: None, + closed_waiters: kio::WaiterList::new(), + local_close: None, + sent_bytes: 0, + received_bytes: 0, + }), + wire: net::Wire::new(), + socket_stats: transport.socket_stats(), + binding: transport.binding(), + }); + + let (writer, reader) = transport.split(); + tokio::spawn(run_writer(inner.clone(), writer)); + tokio::spawn(run_reader(inner.clone(), reader)); + + Ok(Self { + guard: Arc::new(Guard(inner.clone())), + inner, + park: kio::Park::default(), + protocol: config.negotiated_protocol(), + }) + } + + /// Wait for the peer's transport parameters, then report the negotiated protocol. + async fn established(mut self) -> Result { + let inner = self.inner.clone(); + kio::wait(|waiter| { + let mut state = inner.lock(); + if state.established { + return Poll::Ready(Ok(())); + } + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + waiter.register(&mut state.establish_waiters); + Poll::Pending + }) + .await?; + if self.protocol.is_none() { + self.protocol = inner.lock().conn.protocol().map(str::to_owned); + } + Ok(self) + } + + /// The application protocol: negotiated in-band, or out of band before the session + /// started. + pub fn protocol(&self) -> Option<&str> { + self.protocol.as_deref() + } + + /// The wire format this session speaks. + pub fn version(&self) -> crate::Version { + self.inner.lock().conn.version() + } + + /// Open a send group at `priority`, for streams to join with + /// [`SendStream::set_group`](poll::SendStream::set_group) and datagrams with + /// [`SendOptions`](poll::SendOptions). + pub fn send_group(&self, priority: i64) -> SendGroup { + let id = self.inner.lock().conn.send_groups().open(priority); + SendGroup(Arc::new(GroupInner { + inner: self.inner.clone(), + id: Some(id), + })) + } + + fn poll_accept_dir(&mut self, cx: &Context<'_>, dir: Dir) -> Poll> { + let waiter = self.park.hold(cx); + let mut state = self.inner.lock(); + if let Some(id) = state.conn.streams().accept(dir) { + return Poll::Ready(Ok(id)); + } + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + match dir { + Dir::Bi => waiter.register(&mut state.accept_bi_waiters), + Dir::Uni => waiter.register(&mut state.accept_uni_waiters), + } + Poll::Pending + } + + fn poll_open_dir( + &mut self, + cx: &Context<'_>, + dir: Dir, + options: poll::SendOptions<'_, SendGroup>, + ) -> Poll> { + let waiter = self.park.hold(cx); + let mut state = self.inner.lock(); + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + // The peer's MAX_STREAMS credit is what gates us: `open` hands back nothing while it + // is spent. + if let Some(id) = state.conn.streams().open(dir, to_quic(options)) { + state.driver.wake(); + return Poll::Ready(Ok(id)); + } + waiter.register(&mut state.open_waiters); + Poll::Pending + } +} + +impl Clone for Session { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + guard: self.guard.clone(), + park: kio::Park::default(), + protocol: self.protocol.clone(), + } + } +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session") + .field("protocol", &self.protocol) + .finish_non_exhaustive() + } +} + +/// The options as the connection knows them. +fn to_quic<'a>(options: poll::SendOptions<'a, SendGroup>) -> moq_quic::SendOptions<'a> { + moq_quic::SendOptions { + group: options.group.map(SendGroup::id), + priority: options.priority, + } +} + +impl poll::Session for Session { + type SendStream = SendStream; + type RecvStream = RecvStream; + type Error = Error; + + fn poll_accept_uni(&mut self, cx: &mut Context<'_>) -> Poll> { + self.poll_accept_dir(cx, Dir::Uni) + .map_ok(|id| RecvStream::new(self.inner.clone(), id)) + } + + fn poll_accept_bi(&mut self, cx: &mut Context<'_>) -> Poll> { + self.poll_accept_dir(cx, Dir::Bi).map_ok(|id| { + ( + SendStream::new(self.inner.clone(), id), + RecvStream::new(self.inner.clone(), id), + ) + }) + } + + fn poll_open_uni( + &mut self, + cx: &mut Context<'_>, + options: poll::SendOptions<'_, SendGroup>, + ) -> Poll> { + self.poll_open_dir(cx, Dir::Uni, options) + .map_ok(|id| SendStream::new(self.inner.clone(), id)) + } + + fn poll_open_bi( + &mut self, + cx: &mut Context<'_>, + options: poll::SendOptions<'_, SendGroup>, + ) -> Poll> { + self.poll_open_dir(cx, Dir::Bi, options).map_ok(|id| { + ( + SendStream::new(self.inner.clone(), id), + RecvStream::new(self.inner.clone(), id), + ) + }) + } + + fn send_group(&mut self, priority: i64) -> Option { + Some(Session::send_group(self, priority)) + } + + fn send_datagram(&mut self, payload: Bytes, options: poll::SendOptions<'_, SendGroup>) -> Result<(), Error> { + let mut state = self.inner.lock(); + if let Some(err) = &state.closed { + return Err(err.clone()); + } + match state.conn.datagrams().send(payload, to_quic(options)) { + Ok(()) => {} + Err(moq_quic::SendDatagramError::TooLarge) => return Err(Error::DatagramTooLarge), + Err(_) => return Err(Error::DatagramsUnsupported), + } + state.driver.wake(); + Ok(()) + } + + fn poll_recv_datagram(&mut self, cx: &mut Context<'_>) -> Poll> { + let waiter = self.park.hold(cx); + let mut state = self.inner.lock(); + if let Some(datagram) = state.conn.datagrams().recv() { + return Poll::Ready(Ok(datagram)); + } + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + waiter.register(&mut state.datagram_waiters); + Poll::Pending + } + + fn max_datagram_size(&self) -> usize { + self.inner.lock().conn.datagrams().max_size().unwrap_or(0) + } + + fn protocol(&self) -> Option<&str> { + Session::protocol(self) + } + + fn binding(&self) -> Binding { + self.inner.binding + } + + fn close(&mut self, code: u32, reason: &str) { + let err = Error::ConnectionClosed { + code: code.into(), + reason: reason.to_string(), + }; + self.inner.close(code.into(), reason, err); + } + + fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { + let waiter = self.park.hold(cx); + let mut state = self.inner.lock(); + if let Some(err) = &state.closed { + return Poll::Ready(err.clone()); + } + waiter.register(&mut state.closed_waiters); + Poll::Pending + } + + fn stats(&self) -> impl net::Stats { + let state = self.inner.lock(); + // The kernel's view of the socket underneath beats a QX_PING sample: it is there + // from the first byte and costs no traffic. + let socket = self.inner.socket_stats.as_ref(); + Stats { + sent: state.sent_bytes, + received: state.received_bytes, + rtt: socket.and_then(|stats| stats.rtt()).or(state.conn.rtt()), + rate: socket.and_then(|stats| stats.estimated_send_rate()), + } + } + + fn wire(&self) -> Option { + Some(self.inner.wire.clone()) + } +} + +/// A snapshot of the session's counters. +struct Stats { + sent: u64, + received: u64, + rtt: Option, + rate: Option, +} + +impl net::Stats for Stats { + fn bytes_sent(&self) -> Option { + Some(self.sent) + } + + fn bytes_received(&self) -> Option { + Some(self.received) + } + + fn rtt(&self) -> Option { + self.rtt + } + + fn estimated_send_rate(&self) -> Option { + self.rate + } +} + +/// A send group on a [`Session`]. +/// +/// Streams that join a group share bytes with the session's other groups: a higher +/// priority group preempts every lower one, and groups of equal priority get an equal share +/// of bytes however many streams each has queued. Clones share one group, released once the +/// last clone drops and its streams finish. +#[derive(Clone)] +pub struct SendGroup(Arc); + +struct GroupInner { + inner: Arc, + /// Taken only on drop. + id: Option, +} + +impl SendGroup { + fn id(&self) -> &moq_quic::SendGroup { + self.0.id.as_ref().expect("only taken on drop") + } +} + +impl poll::SendGroup for SendGroup { + fn set_priority(&mut self, priority: i64) { + let mut state = self.0.inner.lock(); + state.conn.send_groups().set_priority(self.id(), priority); + state.driver.wake(); + } +} + +impl Drop for GroupInner { + fn drop(&mut self) { + if let Some(id) = self.id.take() { + self.inner.lock().conn.send_groups().close(id); + } + } +} + +impl std::fmt::Debug for SendGroup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SendGroup").finish_non_exhaustive() + } +} + +/// An outgoing stream. Dropping it unfinished resets it with code 0. +pub struct SendStream { + inner: Arc, + id: StreamId, + park: kio::Park, + /// The FIN was queued; further writes are refused and `poll_closed` waits for it to go + /// out. + fin: bool, + /// We reset the stream; it is as closed as it will ever be. + reset: bool, +} + +impl SendStream { + fn new(inner: Arc, id: StreamId) -> Self { + { + // Seeded from the connection rather than empty: a peer can stop a stream before + // the application accepts it, and that event had no handle to land on. + let mut state = inner.lock(); + let stopped = state.conn.send_stream(id).stopped().ok().flatten(); + state + .sends + .insert(id, stopped.map(|code| End::Stopped(code.into_inner()))); + } + Self { + inner, + id, + park: kio::Park::default(), + fin: false, + reset: false, + } + } + + /// Reset the stream with `code`, still delivering the first `offset` bytes written, as + /// RESET_STREAM_AT does on draft-02. Elsewhere the prefix is not guaranteed. + pub fn reset_at(&mut self, offset: u64, code: u32) { + self.reset_with(code, Some(offset)); + } + + fn reset_with(&mut self, code: u32, offset: Option) { + if self.reset { + return; + } + let code = VarInt::from_u32(code); + let mut state = self.inner.lock(); + // Err means the stream is already gone, which is what we wanted. + let _ = match offset { + Some(offset) => state.conn.send_stream(self.id).reset_at(offset, code), + None => state.conn.send_stream(self.id).reset(code), + }; + state.driver.wake(); + self.reset = true; + } +} + +impl poll::SendStream for SendStream { + type Error = Error; + type Group = SendGroup; + + fn poll_write(&mut self, cx: &mut Context<'_>, mut buf: &[u8]) -> Poll> { + self.poll_write_buf(cx, &mut buf) + } + + fn poll_write_buf(&mut self, cx: &mut Context<'_>, buf: &mut B) -> Poll> { + let waiter = self.park.hold(cx); + if self.fin || self.reset { + return Poll::Ready(Err(Error::StreamClosed)); + } + let mut state = self.inner.lock(); + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + let mut stream = state.conn.send_stream(self.id); + // Bytes taken from `buf` cannot go back, so take only what the connection accepts. + // It keeps the chunk by reference, which for a `Bytes` source means no copy at all. + let result = match stream.write_limit().min(buf.chunk().len()) { + // Blocked, failing, or nothing to write; the empty write says which. + 0 => stream.write(&[]), + take => stream + .write_chunks(&mut [buf.copy_to_bytes(take)]) + .map(|written| written.bytes), + }; + match result { + Ok(n) => { + state.driver.wake(); + Poll::Ready(Ok(n)) + } + Err(moq_quic::WriteError::Blocked) => { + waiter.register(state.writable.entry(self.id).or_default()); + Poll::Pending + } + Err(moq_quic::WriteError::Stopped(code)) => Poll::Ready(Err(Error::StreamStop(code.into_inner()))), + Err(moq_quic::WriteError::ClosedStream) => Poll::Ready(Err(Error::StreamClosed)), + } + } + + fn set_priority(&mut self, priority: i64) { + let mut state = self.inner.lock(); + let _ = state.conn.send_stream(self.id).set_priority(priority); + } + + fn set_group(&mut self, group: &SendGroup) { + let mut state = self.inner.lock(); + let _ = state.conn.send_stream(self.id).set_group(Some(group.id())); + } + + fn finish(&mut self) -> Result<(), Error> { + if self.fin || self.reset { + return Ok(()); + } + let mut state = self.inner.lock(); + match state.conn.send_stream(self.id).finish() { + Ok(()) => {} + // A STOP_SENDING beat us here; carry its code like `poll_write` does. The stream is + // over without our reset, so `poll_closed` reports the stop the sweep recorded. + Err(moq_quic::FinishError::Stopped(code)) => { + self.fin = true; + return Err(Error::StreamStop(code.into_inner())); + } + // Already finished or reset, so the FIN it wanted is out. + Err(moq_quic::FinishError::ClosedStream) => {} + } + state.driver.wake(); + self.fin = true; + Ok(()) + } + + fn reset(&mut self, code: u32) { + self.reset_with(code, None); + } + + fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { + let waiter = self.park.hold(cx); + if self.reset { + return Poll::Ready(Ok(())); + } + let mut state = self.inner.lock(); + match state.sends.get(&self.id).copied().flatten() { + Some(End::Stopped(code)) => Poll::Ready(Err(Error::StreamStop(code))), + Some(End::Delivered) => Poll::Ready(Ok(())), + // The end never came. If the session died first, the caller cannot read success + // as "every byte arrived". + None => match &state.closed { + Some(err) => Poll::Ready(Err(err.clone())), + None => { + waiter.register(state.finishing.entry(self.id).or_default()); + Poll::Pending + } + }, + } + } +} + +impl Drop for SendStream { + fn drop(&mut self) { + let mut state = self.inner.lock(); + state.forget_send(self.id); + if !self.fin && !self.reset { + let _ = state.conn.send_stream(self.id).reset(VarInt::from_u32(0)); + state.driver.wake(); + } + } +} + +impl std::fmt::Debug for SendStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SendStream").field("id", &self.id).finish() + } +} + +/// One read out of the connection's reassembly buffer. +enum Read { + /// Bytes, at most as many as were asked for. + Chunk(Bytes), + /// Every byte up to the FIN has been handed over. + Finished, + /// Nothing buffered; the reader wakes us when that changes. + Blocked, + /// The peer reset the stream with this code. + Reset(u64), +} + +/// An incoming stream. Dropping it unfinished sends STOP_SENDING with code 0. +pub struct RecvStream { + inner: Arc, + id: StreamId, + park: kio::Park, + /// Every byte up to the FIN was read out of the connection; reads report the end once + /// `backlog` is drained too. + finished: bool, + /// The peer reset the stream with this code. Reading it frees the connection's stream + /// state, so later reads and `poll_closed` report it from here. + reset: Option, + /// We stopped the stream; no more reads matter. + stopped: bool, + /// Bytes `poll_closed` read ahead, handed to `poll_read` before the connection's. + backlog: BytesMut, + /// The connection credit for `backlog`, kept until the application reads it, as if it + /// were still in the reassembly buffer. + backlog_credit: moq_quic::Credit, +} + +impl RecvStream { + fn new(inner: Arc, id: StreamId) -> Self { + Self { + inner, + id, + park: kio::Park::default(), + finished: false, + reset: None, + stopped: false, + backlog: BytesMut::new(), + backlog_credit: moq_quic::Credit::default(), + } + } + + fn poll_chunk(&mut self, cx: &mut Context<'_>, max: usize) -> Poll, Error>> { + let waiter = self.park.hold(cx); + if max == 0 { + return Poll::Ready(Ok(Some(Bytes::new()))); + } + if !self.backlog.is_empty() { + return Poll::Ready(Ok(Some(self.drain(max)))); + } + if let Some(code) = self.reset { + return Poll::Ready(Err(Error::StreamReset(code))); + } + if self.finished { + return Poll::Ready(Ok(None)); + } + let mut state = self.inner.lock(); + match read(&mut state, self.id, max, None) { + Read::Chunk(bytes) => Poll::Ready(Ok(Some(bytes))), + Read::Finished => { + self.finished = true; + Poll::Ready(Ok(None)) + } + Read::Blocked => { + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + waiter.register(state.readable.entry(self.id).or_default()); + Poll::Pending + } + Read::Reset(code) => { + self.reset = Some(code); + Poll::Ready(Err(Error::StreamReset(code))) + } + } + } + + /// Take up to `max` read-ahead bytes out of the backlog, returning their credit. + /// + /// Wakes a `poll_closed` parked at the read-ahead cap: the room it was waiting for is + /// what this just made. + fn drain(&mut self, max: usize) -> Bytes { + let chunk = self.backlog.split_to(max.min(self.backlog.len())).freeze(); + let credit = self.backlog_credit.split(chunk.len() as u64); + let mut state = self.inner.lock(); + release(&mut state, credit); + if !chunk.is_empty() { + wake(&mut state.readable, self.id); + } + chunk + } + + fn stop_with(&mut self, code: u32) { + let mut state = self.inner.lock(); + release(&mut state, std::mem::take(&mut self.backlog_credit)); + self.backlog.clear(); + if self.stopped || self.finished || self.reset.is_some() { + return; + } + // Err means the stream is already gone, which is what we wanted. + let _ = state.conn.recv_stream(self.id).stop(VarInt::from_u32(code)); + state.driver.wake(); + self.stopped = true; + } +} + +/// Take up to `max` bytes out of the reassembly buffer. +/// +/// Reading is what returns the peer's flow control credit, so a read that owes it a frame +/// kicks the writer. The connection credit for the bytes read moves into `held` instead, +/// when given. +fn read(state: &mut State, id: StreamId, max: usize, held: Option<&mut moq_quic::Credit>) -> Read { + let mut recv = state.conn.recv_stream(id); + let mut chunks = match recv.read(true) { + Ok(chunks) => chunks, + // The stream is gone, so everything it held is already ours. + Err(_) => return Read::Finished, + }; + let read = match chunks.next(max) { + Ok(Some(chunk)) => Read::Chunk(chunk.bytes), + Ok(None) => Read::Finished, + Err(moq_quic::ReadError::Blocked) => Read::Blocked, + Err(moq_quic::ReadError::Reset(code)) => Read::Reset(code.into_inner()), + }; + if let Some(held) = held { + held.merge(chunks.hold()); + } + if chunks.finalize().should_transmit() { + state.driver.wake(); + } + read +} + +/// Return `credit` to the peer, kicking the writer if the peer should hear. +fn release(state: &mut State, credit: moq_quic::Credit) { + if !credit.is_empty() && state.conn.release(credit).should_transmit() { + state.driver.wake(); + } +} + +impl poll::RecvStream for RecvStream { + type Error = Error; + + fn poll_read(&mut self, cx: &mut Context<'_>, dst: &mut [u8]) -> Poll, Error>> { + let Some(chunk) = std::task::ready!(self.poll_chunk(cx, dst.len()))? else { + return Poll::Ready(Ok(None)); + }; + dst[..chunk.len()].copy_from_slice(&chunk); + Poll::Ready(Ok(Some(chunk.len()))) + } + + fn poll_read_chunk(&mut self, cx: &mut Context<'_>, max: usize) -> Poll, Error>> { + self.poll_chunk(cx, max) + } + + fn stop(&mut self, code: u32) { + // Giving up on the read side abandons whatever was read ahead, even when the FIN is + // already in and only the backlog is left. + self.stop_with(code); + } + + fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { + let waiter = self.park.hold(cx); + if let Some(code) = self.reset { + return Poll::Ready(Err(Error::StreamReset(code))); + } + if self.finished || self.stopped { + return Poll::Ready(Ok(())); + } + // The FIN sits behind whatever the peer sent before it, and the connection only + // reports the stream finished once that is read out. Read ahead into the backlog + // `poll_read` serves first, so this watch resolves without the application draining + // the stream, and without losing what it might still want. + let mut state = self.inner.lock(); + loop { + if self.backlog.len() >= READ_AHEAD { + // Enough held: the memory bound wins over watch liveness here. `drain` wakes + // this once the application takes some. + waiter.register(state.readable.entry(self.id).or_default()); + return Poll::Pending; + } + match read(&mut state, self.id, READ_CHUNK, Some(&mut self.backlog_credit)) { + Read::Chunk(bytes) => self.backlog.extend_from_slice(&bytes), + Read::Finished => { + self.finished = true; + return Poll::Ready(Ok(())); + } + Read::Blocked => { + if let Some(err) = &state.closed { + return Poll::Ready(Err(err.clone())); + } + waiter.register(state.readable.entry(self.id).or_default()); + return Poll::Pending; + } + Read::Reset(code) => { + self.reset = Some(code); + return Poll::Ready(Err(Error::StreamReset(code))); + } + } + } + } +} + +impl Drop for RecvStream { + fn drop(&mut self) { + let mut state = self.inner.lock(); + release(&mut state, std::mem::take(&mut self.backlog_credit)); + state.readable.remove(&self.id); + if !self.finished && !self.stopped && self.reset.is_none() { + let _ = state.conn.recv_stream(self.id).stop(VarInt::from_u32(0)); + state.driver.wake(); + } + } +} + +impl std::fmt::Debug for RecvStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecvStream").field("id", &self.id).finish() + } +} + +#[cfg(all(test, any(feature = "tcp", all(unix, feature = "uds"))))] +mod tests; diff --git a/rs/qmux/src/session/tests.rs b/rs/qmux/src/session/tests.rs new file mode 100644 index 00000000000..6dd85d83165 --- /dev/null +++ b/rs/qmux/src/session/tests.rs @@ -0,0 +1,505 @@ +//! Session tests over an in-memory byte stream, on paused tokio time. + +use std::time::Duration; + +use bytes::{Bytes, BytesMut}; +use moq_net::transport::poll::{RecvStream as _, SendOptions, SendStream as _, Session as _}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; +use tokio::sync::watch; + +use super::*; +use crate::transport::Stream; +use crate::{Protocol, Version}; + +/// Room in each direction of the in-memory pipe. +const PIPE: usize = 64 * 1024; + +fn config(version: Version) -> Config { + Config::new(version) +} + +/// Start a client and a server over `client`/`server`, waiting for both handshakes. +async fn establish( + client: DuplexStream, + server: DuplexStream, + client_config: Config, + server_config: Config, +) -> (Session, Session) { + let client = Session::connect( + Stream::new(client, client_config.version, client_config.max_record_size), + client_config, + ); + let server = Session::accept( + Stream::new(server, server_config.version, server_config.max_record_size), + server_config, + ); + let (client, server) = tokio::join!(client, server); + (client.expect("client handshake"), server.expect("server handshake")) +} + +async fn pair(client_config: Config, server_config: Config) -> (Session, Session) { + let (client, server) = tokio::io::duplex(PIPE); + establish(client, server, client_config, server_config).await +} + +/// What a [`link`] does with the bytes in one direction. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Flow { + /// Pass them on. + Open, + /// Stop reading, so the sender's writes back up. + Stall, + /// Read and discard them, like a dead peer. + Drop, +} + +/// Two pipes joined by a relay whose flow each direction a test can change. +fn link() -> (DuplexStream, DuplexStream, watch::Sender, watch::Sender) { + let (client, client_relay) = tokio::io::duplex(PIPE); + let (server_relay, server) = tokio::io::duplex(PIPE); + let (up, up_rx) = watch::channel(Flow::Open); + let (down, down_rx) = watch::channel(Flow::Open); + let (client_read, client_write) = tokio::io::split(client_relay); + let (server_read, server_write) = tokio::io::split(server_relay); + tokio::spawn(relay(client_read, server_write, up_rx)); + tokio::spawn(relay(server_read, client_write, down_rx)); + (client, server, up, down) +} + +async fn relay( + mut from: tokio::io::ReadHalf, + mut to: tokio::io::WriteHalf, + mut flow: watch::Receiver, +) { + let mut buf = vec![0; 4096]; + loop { + let mode = *flow.borrow_and_update(); + if mode == Flow::Stall { + if flow.changed().await.is_err() { + return; + } + continue; + } + let n = tokio::select! { + n = from.read(&mut buf) => n, + _ = flow.changed() => continue, + }; + let Ok(n @ 1..) = n else { + let _ = to.shutdown().await; + return; + }; + if mode == Flow::Open && to.write_all(&buf[..n]).await.is_err() { + return; + } + } +} + +fn payload(len: usize, seed: usize) -> Bytes { + (0..len).map(|i| ((i * 31 + seed) % 251) as u8).collect() +} + +async fn write_all(send: &mut SendStream, mut data: Bytes) { + while !data.is_empty() { + send.write_buf(&mut data).await.expect("write"); + } +} + +async fn read_all(recv: &mut RecvStream) -> Result { + let mut buf = BytesMut::new(); + while let Some(chunk) = recv.read_chunk(usize::MAX).await? { + buf.extend_from_slice(&chunk); + } + Ok(buf.freeze()) +} + +#[tokio::test(start_paused = true)] +async fn every_version_establishes_and_negotiates_in_band() { + for &version in Version::ALL { + let mut client = config(version); + client.protocol = Protocol::Negotiate(vec!["moq-lite-04".into(), "moq-lite-03".into()]); + let mut server = config(version); + server.protocol = Protocol::Negotiate(vec!["moq-lite-03".into()]); + + let (client, server) = pair(client, server).await; + assert_eq!(client.protocol(), Some("moq-lite-03"), "{version:?}"); + assert_eq!(server.protocol(), Some("moq-lite-03"), "{version:?}"); + assert_eq!(client.version(), version); + } +} + +/// A transfer many times the receive windows completes only if reading returns credit: +/// MAX_STREAM_DATA for the stream and MAX_DATA for the connection. +#[tokio::test(start_paused = true)] +async fn reading_returns_flow_control_credit() { + for &version in Version::ALL { + let mut small = config(version); + small.max_data = 64 * 1024; + small.max_stream_data = 16 * 1024; + let (mut client, mut server) = pair(config(version), small).await; + + let data = payload(1 << 20, 3); + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + let writer = { + let data = data.clone(); + tokio::spawn(async move { + write_all(&mut send, data).await; + send.finish().unwrap(); + send.closed().await + }) + }; + + let mut recv = server.accept_uni().await.unwrap(); + assert_eq!(read_all(&mut recv).await.unwrap(), data, "{version:?}"); + writer.await.unwrap().expect("the FIN was delivered"); + } +} + +#[tokio::test(start_paused = true)] +async fn bidirectional_streams_carry_both_halves_and_fins() { + for &version in Version::ALL { + let (mut client, mut server) = pair(config(version), config(version)).await; + + let (mut send, mut recv) = client.open_bi(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"ping")).await; + send.finish().unwrap(); + + let (mut reply, mut request) = server.accept_bi().await.unwrap(); + assert_eq!(read_all(&mut request).await.unwrap(), "ping"); + write_all(&mut reply, Bytes::from_static(b"pong")).await; + reply.finish().unwrap(); + + assert_eq!(read_all(&mut recv).await.unwrap(), "pong"); + send.closed().await.unwrap(); + reply.closed().await.unwrap(); + } +} + +#[tokio::test(start_paused = true)] +async fn resets_and_stops_carry_their_codes() { + let (mut client, mut server) = pair(config(Version::QMux02), config(Version::QMux02)).await; + + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"partial")).await; + send.reset(7); + let mut recv = server.accept_uni().await.unwrap(); + assert!(matches!(read_all(&mut recv).await, Err(Error::StreamReset(7)))); + // The reset sticks: it never turns into a clean end + assert!(matches!(recv.read_chunk(usize::MAX).await, Err(Error::StreamReset(7)))); + assert!(matches!(recv.closed().await, Err(Error::StreamReset(7)))); + + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"unwanted")).await; + let mut recv = server.accept_uni().await.unwrap(); + recv.stop(9); + assert!(matches!(send.closed().await, Err(Error::StreamStop(9)))); + assert!(matches!(send.write(b"more").await, Err(Error::StreamStop(9)))); + + // A stop that fails `finish` still reads as a stop, not a clean end + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"refused")).await; + let mut recv = server.accept_uni().await.unwrap(); + recv.stop(11); + tokio::time::sleep(Duration::from_millis(1)).await; + assert!(matches!(send.finish(), Err(Error::StreamStop(11)))); + assert!(matches!(send.closed().await, Err(Error::StreamStop(11)))); +} + +/// RESET_STREAM_AT still delivers the prefix it covers. +#[tokio::test(start_paused = true)] +async fn reset_at_delivers_its_prefix() { + let (mut client, mut server) = pair(config(Version::QMux02), config(Version::QMux02)).await; + + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"headerbody")).await; + send.reset_at(6, 3); + + let mut recv = server.accept_uni().await.unwrap(); + let mut prefix = BytesMut::new(); + let err = loop { + match recv.read_chunk(usize::MAX).await { + Ok(Some(chunk)) => prefix.extend_from_slice(&chunk), + Ok(None) => panic!("a reset stream never finishes"), + Err(err) => break err, + } + }; + assert!(prefix.starts_with(b"header"), "got {prefix:?}"); + assert!(matches!(err, Error::StreamReset(3)), "got {err:?}"); +} + +#[tokio::test(start_paused = true)] +async fn the_peer_limits_concurrent_streams() { + let mut server = config(Version::QMux02); + server.max_streams_uni = 1; + let (mut client, mut server) = pair(config(Version::QMux02), server).await; + + let mut first = client.open_uni(SendOptions::default()).await.unwrap(); + let mut opening = std::pin::pin!(client.open_uni(SendOptions::default())); + assert!( + futures_poll_once(&mut opening).await.is_none(), + "no credit for a second stream" + ); + + first.finish().unwrap(); + let mut recv = server.accept_uni().await.unwrap(); + read_all(&mut recv).await.unwrap(); + drop(recv); + opening.await.expect("credit returned once the first stream is done"); +} + +/// Poll `future` once, after letting every other task run. +async fn futures_poll_once(future: &mut F) -> Option { + tokio::time::sleep(Duration::from_millis(10)).await; + std::future::poll_fn(|cx| { + Poll::Ready(match Pin::new(&mut *future).poll(cx) { + Poll::Ready(output) => Some(output), + Poll::Pending => None, + }) + }) + .await +} + +#[tokio::test(start_paused = true)] +async fn datagrams_need_records() { + let (mut client, mut server) = pair(config(Version::QMux02), config(Version::QMux02)).await; + assert!(client.max_datagram_size() > 0); + client + .send_datagram(Bytes::from_static(b"hello"), SendOptions::default()) + .unwrap(); + assert_eq!(server.recv_datagram().await.unwrap(), "hello"); + + let (mut client, _server) = pair(config(Version::QMux00), config(Version::QMux00)).await; + assert_eq!(client.max_datagram_size(), 0); + assert!(matches!( + client.send_datagram(Bytes::from_static(b"hello"), SendOptions::default()), + Err(Error::DatagramsUnsupported) + )); +} + +/// A keep-alive that would expire before the first Pong could return is refused. +#[cfg(feature = "ws")] +#[tokio::test] +async fn websocket_keep_alive_needs_room_for_a_pong() { + use tokio_tungstenite::{WebSocketStream, tungstenite::protocol::Role}; + + use crate::transport::WsTransport; + use crate::ws::KeepAlive; + + for (interval, timeout) in [(0, 30), (5, 5), (5, 1)] { + let (socket, _peer) = tokio::io::duplex(PIPE); + let ws = WebSocketStream::from_raw_socket(socket, Role::Client, None).await; + let keep_alive = KeepAlive::new(Duration::from_secs(interval), Duration::from_secs(timeout)); + let result = WsTransport::new(ws, Version::QMux02, 16382, Binding::Uri).with_keep_alive(keep_alive); + assert!( + matches!(result, Err(Error::InvalidConfig(_))), + "{interval}s / {timeout}s" + ); + } +} + +/// A sender that never runs out of output still sends its WebSocket keep-alive Pings, so +/// a peer that has no keep-alive and sends nothing back does not let it time out. +#[cfg(feature = "ws")] +#[tokio::test(start_paused = true)] +async fn websocket_keep_alive_runs_during_continuous_output() { + use tokio_tungstenite::{WebSocketStream, tungstenite::protocol::Role}; + + use crate::transport::WsTransport; + use crate::ws::KeepAlive; + + // The sender's direction drains at 4 KiB per millisecond, far below what it queues + let (a, a_relay) = tokio::io::duplex(PIPE); + let (b_relay, b) = tokio::io::duplex(PIPE); + let (mut a_read, mut a_write) = tokio::io::split(a_relay); + let (mut b_read, mut b_write) = tokio::io::split(b_relay); + tokio::spawn(async move { + let mut buf = vec![0; 4096]; + while let Ok(n @ 1..) = a_read.read(&mut buf).await { + if b_write.write_all(&buf[..n]).await.is_err() { + return; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }); + tokio::spawn(async move { tokio::io::copy(&mut b_read, &mut a_write).await }); + + let a = WebSocketStream::from_raw_socket(a, Role::Client, None).await; + let b = WebSocketStream::from_raw_socket(b, Role::Server, None).await; + // No QX_PING from either side, so only the WebSocket Pings keep the sender alive + let mut config = config(Version::QMux02); + config.rtt_interval = None; + config.max_idle_timeout = None; + let a = WsTransport::new(a, config.version, config.max_record_size, Binding::Uri) + .with_keep_alive(KeepAlive::new(Duration::from_millis(10), Duration::from_millis(500))) + .unwrap(); + let b = WsTransport::new(b, config.version, config.max_record_size, Binding::Uri); + let mut sender = Session::new(a, Side::Client, config.clone()).unwrap(); + let _receiver = Session::new(b, Side::Server, config).unwrap(); + while sender.max_datagram_size() == 0 { + tokio::time::sleep(Duration::from_millis(1)).await; + } + + // Far past the 500ms deadline, queueing more each tick than one batch carries + for _ in 0..2000 { + for seed in 0..100 { + sender + .send_datagram(payload(1000, seed), SendOptions::default()) + .expect("the sender's keep-alive kept it open"); + } + tokio::time::advance(Duration::from_millis(1)).await; + } +} + +/// An application close reaches the peer with its code and reason, and the closing side +/// reports it once the close is out. +#[tokio::test(start_paused = true)] +async fn an_application_close_is_delivered() { + for &version in Version::ALL { + let (mut client, mut server) = pair(config(version), config(version)).await; + client.close(42, "bye"); + // Dropping the handle straight after must not lose the close. + let mut watcher = client.clone(); + drop(client); + + for err in [server.closed().await, watcher.closed().await] { + assert!( + matches!(&err, Error::ConnectionClosed { code: 42, reason } if reason == "bye"), + "{version:?}: got {err:?}" + ); + } + } +} + +/// An unfinished read hands over what already arrived, then ends with the close instead of +/// hanging. +#[tokio::test(start_paused = true)] +async fn a_close_ends_an_unfinished_read_after_its_data() { + let (mut client, mut server) = pair(config(Version::QMux02), config(Version::QMux02)).await; + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"tail")).await; + let mut recv = server.accept_uni().await.unwrap(); + + client.close(42, "bye"); + assert!(matches!( + server.closed().await, + Error::ConnectionClosed { code: 42, .. } + )); + + let mut data = BytesMut::new(); + let err = loop { + match recv.read_chunk(usize::MAX).await { + Ok(Some(chunk)) => data.extend_from_slice(&chunk), + Ok(None) => panic!("an unfinished stream never finishes"), + Err(err) => break err, + } + }; + assert_eq!(&data[..], b"tail"); + assert!(matches!(err, Error::ConnectionClosed { code: 42, .. }), "got {err:?}"); + drop(send); +} + +#[tokio::test(start_paused = true)] +async fn dropping_the_last_handle_closes_the_session() { + let (client, mut server) = pair(config(Version::QMux01), config(Version::QMux01)).await; + drop(client); + let err = server.closed().await; + assert!(matches!(err, Error::ConnectionClosed { code: 0, .. }), "got {err:?}"); +} + +/// A close stuck behind a peer that stopped reading gives up after [`CLOSE_TIMEOUT`] +/// rather than holding the transport open. +#[tokio::test(start_paused = true)] +async fn a_close_behind_a_stalled_peer_is_bounded() { + let (client, server, up, _down) = link(); + let (mut client, _server) = establish(client, server, config(Version::QMux02), config(Version::QMux02)).await; + + up.send(Flow::Stall).unwrap(); + // Fill the pipe so the writer is mid-write when the close comes. + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + let _ = tokio::time::timeout(Duration::from_millis(100), send.write(&payload(1 << 20, 0))).await; + tokio::time::sleep(Duration::from_millis(100)).await; + + let start = tokio::time::Instant::now(); + client.close(5, "stalled"); + let err = client.closed().await; + assert!(matches!(err, Error::ConnectionClosed { code: 5, .. }), "got {err:?}"); + assert!( + start.elapsed() <= CLOSE_TIMEOUT + Duration::from_millis(10), + "took {:?}", + start.elapsed() + ); +} + +#[tokio::test(start_paused = true)] +async fn a_silent_peer_times_out() { + let (client, server, up, down) = link(); + let (mut client, mut server) = establish(client, server, config(Version::QMux02), config(Version::QMux02)).await; + + up.send(Flow::Drop).unwrap(); + down.send(Flow::Drop).unwrap(); + let start = tokio::time::Instant::now(); + assert!(matches!(client.closed().await, Error::IdleTimeout)); + assert!(matches!(server.closed().await, Error::IdleTimeout)); + assert!(start.elapsed() >= Duration::from_secs(30) - Duration::from_secs(1)); +} + +/// Pings keep a quiet but present peer alive past the idle timeout. +#[tokio::test(start_paused = true)] +async fn an_idle_session_stays_up() { + let (mut client, _server) = pair(config(Version::QMux01), config(Version::QMux01)).await; + let closed = tokio::time::timeout(Duration::from_secs(120), client.closed()).await; + assert!(closed.is_err(), "closed with {closed:?}"); +} + +#[tokio::test(start_paused = true)] +async fn a_peer_that_never_sends_parameters_times_out_the_handshake() { + let (client, _silent) = tokio::io::duplex(PIPE); + let config = config(Version::QMux02); + let start = tokio::time::Instant::now(); + let err = Session::connect(Stream::new(client, config.version, config.max_record_size), config) + .await + .unwrap_err(); + assert!(matches!(err, Error::HandshakeTimeout), "got {err:?}"); + assert!(start.elapsed() >= Duration::from_secs(10)); +} + +/// A record over `max_record_size` closes the connection with a CONNECTION_CLOSE the +/// peer can read, rather than dropping the transport. +#[tokio::test(start_paused = true)] +async fn an_oversized_record_is_a_protocol_violation() { + let (client, mut raw) = tokio::io::duplex(PIPE); + let config = config(Version::QMux02); + let session = Session::new( + Stream::new(client, config.version, config.max_record_size), + Side::Client, + config, + ) + .unwrap(); + + // A record Size of 4 MiB, as a 4-byte varint. + raw.write_all(&[0x80, 0x40, 0, 0]).await.unwrap(); + let mut session = session; + let err = session.closed().await; + assert!(matches!(err, Error::ProtocolViolation), "got {err:?}"); + + let mut sent = Vec::new(); + raw.read_to_end(&mut sent).await.unwrap(); + // Our parameters, then a CONNECTION_CLOSE (0x1c) carrying PROTOCOL_VIOLATION (0x0a). + let close = sent.windows(2).position(|w| w == [0x1c, 0x0a]); + assert!(close.is_some(), "no CONNECTION_CLOSE in {sent:02x?}"); +} + +#[tokio::test(start_paused = true)] +async fn the_wire_counters_track_both_directions() { + let (mut client, mut server) = pair(config(Version::QMux02), config(Version::QMux02)).await; + let mut send = client.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, payload(100_000, 1)).await; + send.finish().unwrap(); + let mut recv = server.accept_uni().await.unwrap(); + read_all(&mut recv).await.unwrap(); + send.closed().await.unwrap(); + + let sent = client.wire().unwrap().sent_bytes(); + let received = server.wire().unwrap().received_bytes(); + assert!(sent >= 100_000, "sent {sent}"); + assert!(received >= 100_000, "received {received}"); +} diff --git a/rs/qmux/src/socket.rs b/rs/qmux/src/socket.rs new file mode 100644 index 00000000000..ce52959892c --- /dev/null +++ b/rs/qmux/src/socket.rs @@ -0,0 +1,298 @@ +//! Statistics read from the connection underneath QMux. +//! +//! QMux runs over a reliable byte stream and has no congestion controller of its +//! own, but the transport it sits on usually does. Where that transport is TCP the +//! kernel already tracks a smoothed RTT and (on Linux) a delivery-rate estimate, +//! so the figures are available immediately and cost no extra traffic. This is +//! strictly better than QMux's own [`QX_PING`](crate::Config::rtt_interval) +//! measurement, which needs a round trip before it can report anything. + +use std::sync::Arc; +use std::time::Duration; + +/// Live statistics for the connection underneath a QMux session. +/// +/// Implementations read through to the socket on each call rather than caching, so +/// a caller polling this sees the transport's current view. Every metric is +/// optional: `None` means this transport doesn't track it. +pub trait SocketStats: Send + Sync + 'static { + /// Smoothed round-trip time, if the transport tracks one. + fn rtt(&self) -> Option { + None + } + + /// Estimated delivery rate in **bits per second**, if the transport tracks one. + fn estimated_send_rate(&self) -> Option { + None + } +} + +/// A shared handle to a transport's statistics. +pub type SharedSocketStats = Arc; + +#[cfg(unix)] +pub use unix::TcpStats; + +#[cfg(unix)] +mod unix { + use super::*; + use std::io; + use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; + + /// Kernel TCP statistics for a socket, via `TCP_INFO` (Linux) or + /// `TCP_CONNECTION_INFO` (macOS). + /// + /// Holds its own duplicate of the file descriptor, so it stays readable for as + /// long as the session needs it no matter what the transport does with the + /// original. Construct it from the accepted socket and hand it to the transport + /// with `with_socket_stats`. + /// + /// Reports nothing for a non-TCP socket (a Unix socket, say): the `getsockopt` + /// simply fails and every metric reads `None`. + #[derive(Debug)] + pub struct TcpStats { + fd: OwnedFd, + } + + impl TcpStats { + /// Duplicate `socket`'s descriptor and read statistics from the copy. + pub fn new(socket: &S) -> io::Result { + Ok(Self { + fd: socket.as_fd().try_clone_to_owned()?, + }) + } + + /// Take ownership of an already-duplicated descriptor. + /// + /// Use this when the socket itself has been moved somewhere unreachable (a + /// type-erased HTTP upgrade, for instance) and only the descriptor was kept. + pub fn from_fd(fd: OwnedFd) -> Self { + Self { fd } + } + + fn borrow(&self) -> BorrowedFd<'_> { + self.fd.as_fd() + } + } + + #[cfg(target_os = "linux")] + mod sys { + use super::*; + + /// `struct tcp_info` from `linux/tcp.h`. + /// + /// Mirrored field-for-field rather than hand-computing offsets, so `repr(C)` + /// alignment places each member where the kernel put it. The kernel reports + /// how much it actually filled in, which is less than this on older + /// releases, so every read checks its field against that length. + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct TcpInfo { + pub state: u8, + pub ca_state: u8, + pub retransmits: u8, + pub probes: u8, + pub backoff: u8, + pub options: u8, + pub wscale: u8, + pub app_limited: u8, + + pub rto: u32, + pub ato: u32, + pub snd_mss: u32, + pub rcv_mss: u32, + + pub unacked: u32, + pub sacked: u32, + pub lost: u32, + pub retrans: u32, + pub fackets: u32, + + pub last_data_sent: u32, + pub last_ack_sent: u32, + pub last_data_recv: u32, + pub last_ack_recv: u32, + + pub pmtu: u32, + pub rcv_ssthresh: u32, + /// Smoothed RTT in microseconds. + pub rtt: u32, + pub rttvar: u32, + pub snd_ssthresh: u32, + pub snd_cwnd: u32, + pub advmss: u32, + pub reordering: u32, + + pub rcv_rtt: u32, + pub rcv_space: u32, + pub total_retrans: u32, + + pub pacing_rate: u64, + pub max_pacing_rate: u64, + pub bytes_acked: u64, + pub bytes_received: u64, + pub segs_out: u32, + pub segs_in: u32, + pub notsent_bytes: u32, + pub min_rtt: u32, + pub data_segs_out: u32, + pub data_segs_in: u32, + /// Delivery-rate estimate in bytes per second (Linux 4.9+). + pub delivery_rate: u64, + } + + const IPPROTO_TCP: libc::c_int = 6; + const TCP_INFO: libc::c_int = 11; + + /// Read `tcp_info`, returning it alongside the number of bytes the kernel + /// filled in. `None` if the socket isn't TCP. + pub fn tcp_info(fd: BorrowedFd<'_>) -> Option<(TcpInfo, usize)> { + let mut info = TcpInfo::default(); + let mut len = std::mem::size_of::() as libc::socklen_t; + + // SAFETY: `info` is a live, correctly sized allocation and `len` says how + // large it is; the kernel writes at most that much and updates `len` to + // what it wrote. + let rc = unsafe { + libc::getsockopt( + fd.as_raw_fd(), + IPPROTO_TCP, + TCP_INFO, + (&mut info as *mut TcpInfo).cast(), + &mut len, + ) + }; + + (rc == 0).then_some((info, len as usize)) + } + + /// Whether the kernel filled in the field at `offset` of size `size`. + pub fn has_field(len: usize, offset: usize, size: usize) -> bool { + len >= offset + size + } + } + + #[cfg(target_os = "macos")] + mod sys { + use super::*; + + /// `struct tcp_connection_info` from macOS `netinet/tcp.h`. + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct TcpInfo { + pub state: u8, + pub snd_wscale: u8, + pub rcv_wscale: u8, + _pad1: u8, + pub options: u32, + pub flags: u32, + pub rto: u32, + pub maxseg: u32, + pub snd_ssthresh: u32, + pub snd_cwnd: u32, + pub snd_wnd: u32, + pub snd_sbbytes: u32, + pub rcv_wnd: u32, + /// Most recent RTT sample, in milliseconds. + pub rttcur: u32, + /// Smoothed RTT in milliseconds. + pub srtt: u32, + pub rttvar: u32, + } + + const IPPROTO_TCP: libc::c_int = 6; + const TCP_CONNECTION_INFO: libc::c_int = 0x106; + + pub fn tcp_info(fd: BorrowedFd<'_>) -> Option<(TcpInfo, usize)> { + let mut info = TcpInfo::default(); + let mut len = std::mem::size_of::() as libc::socklen_t; + + // SAFETY: as for the Linux arm above. + let rc = unsafe { + libc::getsockopt( + fd.as_raw_fd(), + IPPROTO_TCP, + TCP_CONNECTION_INFO, + (&mut info as *mut TcpInfo).cast(), + &mut len, + ) + }; + + (rc == 0).then_some((info, len as usize)) + } + + pub fn has_field(len: usize, offset: usize, size: usize) -> bool { + len >= offset + size + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + impl SocketStats for TcpStats { + fn rtt(&self) -> Option { + let (info, len) = sys::tcp_info(self.borrow())?; + + // A successful `getsockopt` on a connected socket is a measurement, so + // report it even at zero. macOS counts in whole milliseconds, and a + // loopback round trip genuinely rounds to zero there. + #[cfg(target_os = "linux")] + { + let offset = std::mem::offset_of!(sys::TcpInfo, rtt); + sys::has_field(len, offset, size_of::()).then(|| Duration::from_micros(info.rtt as u64)) + } + + #[cfg(target_os = "macos")] + { + let offset = std::mem::offset_of!(sys::TcpInfo, srtt); + sys::has_field(len, offset, size_of::()).then(|| Duration::from_millis(info.srtt as u64)) + } + } + + fn estimated_send_rate(&self) -> Option { + // Only Linux measures a delivery rate. macOS exposes a congestion window + // but no rate, and cwnd/rtt is a poor stand-in, so report nothing there + // rather than a figure callers would mistake for a measurement. + #[cfg(target_os = "linux")] + { + let (info, len) = sys::tcp_info(self.borrow())?; + let offset = std::mem::offset_of!(sys::TcpInfo, delivery_rate); + if !sys::has_field(len, offset, size_of::()) || info.delivery_rate == 0 { + return None; + } + // The kernel reports bytes per second; the trait wants bits. + Some(info.delivery_rate.saturating_mul(8)) + } + + #[cfg(not(target_os = "linux"))] + { + None + } + } + } + + /// Platforms without a readable TCP info struct report nothing. + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + impl SocketStats for TcpStats {} + + /// Pin the field offsets against the kernel's struct. + /// + /// The struct is mirrored field-for-field so `repr(C)` computes the offsets, + /// which means a typo in the middle silently shifts everything after it and we + /// would read a plausible-looking wrong number rather than fail. + #[cfg(test)] + mod tests { + /// Offsets from `linux/tcp.h`. + #[cfg(target_os = "linux")] + #[test] + fn linux_tcp_info_layout() { + assert_eq!(std::mem::offset_of!(super::sys::TcpInfo, rtt), 68); + assert_eq!(std::mem::offset_of!(super::sys::TcpInfo, delivery_rate), 160); + } + + /// Offsets from macOS `netinet/tcp.h`. + #[cfg(target_os = "macos")] + #[test] + fn macos_tcp_connection_info_layout() { + assert_eq!(std::mem::offset_of!(super::sys::TcpInfo, srtt), 44); + } + } +} diff --git a/rs/qmux/src/tcp.rs b/rs/qmux/src/tcp.rs new file mode 100644 index 00000000000..1c57adf2f95 --- /dev/null +++ b/rs/qmux/src/tcp.rs @@ -0,0 +1,128 @@ +use tokio::net::{TcpStream, ToSocketAddrs}; + +use crate::transport::build_stream_session; +use crate::{Error, Protocol, Session, Version}; + +/// Builder for a QMux session over plain TCP. +/// +/// TCP has no ALPN, so the wire-format `version` is given explicitly and any +/// application protocol is negotiated in-band via [`Config::protocols`]. +/// +/// ```no_run +/// # async fn f(addr: std::net::SocketAddr) -> Result<(), qmux::Error> { +/// use qmux::{tcp, Version}; +/// +/// // Negotiate one of these protocols (server preference wins): +/// let session = tcp::Config::new(Version::QMux01) +/// .protocols(["moq-lite-04", "moq-lite-03"]) +/// .connect(addr) +/// .await?; +/// let agreed = session.protocol(); // e.g. Some("moq-lite-04") +/// # let _ = agreed; Ok(()) +/// # } +/// ``` +/// +/// For flow control and other session settings, pass a [`crate::Config`] to +/// [`Config::session_config`]. +#[derive(Debug, Clone)] +pub struct Config { + inner: crate::Config, +} + +impl Config { + /// Start building a TCP session speaking QMux `version`. + pub fn new(version: Version) -> Self { + Self { + inner: crate::Config::new(version), + } + } + + /// Advertise these application protocols for in-band negotiation, in + /// preference order. Omit to run without an application protocol. + pub fn protocols(mut self, protocols: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.inner.protocol = Protocol::Negotiate(protocols.into_iter().map(Into::into).collect()); + self + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which this builder keeps. Call it before + /// [`handshake_timeout`](Self::handshake_timeout), which it would replace. + pub fn session_config(mut self, config: crate::Config) -> Self { + self.inner = crate::Config { + version: self.inner.version, + protocol: self.inner.protocol, + ..config + }; + self + } + + /// Override how long establishment waits for the peer's transport + /// parameters before failing with [`Error::HandshakeTimeout`], or `None` to wait + /// indefinitely. Defaults to 10s. See + /// [`Config::handshake_timeout`](crate::Config::handshake_timeout). + pub fn handshake_timeout(mut self, timeout: Option) -> Self { + self.inner.handshake_timeout = timeout; + self + } + + /// Connect to `addr` and start a client session. + /// + /// Awaits the peer's transport parameters before returning, so + /// [`Session::protocol`] is populated + /// on the returned session (bounded by + /// [`handshake_timeout`](Self::handshake_timeout)). + pub async fn connect(self, addr: impl ToSocketAddrs) -> Result { + let stream = TcpStream::connect(addr).await?; + finish(stream, self.inner, false).await + } + + /// Start a server session over an accepted TCP stream. + /// + /// This awaits the QMux handshake (the peer's transport parameters), bounded + /// by [`handshake_timeout`](Self::handshake_timeout). Drive each connection + /// with `tokio::spawn` so a slow or non-cooperative peer can't stall your + /// `listener.accept()` loop. + pub async fn accept(self, stream: TcpStream) -> Result { + finish(stream, self.inner, true).await + } +} + +async fn finish(stream: TcpStream, config: crate::Config, is_server: bool) -> Result { + // The kernel already tracks this socket's RTT (and, on Linux, its delivery + // rate) from the TCP handshake, so the session can report both immediately + // rather than waiting on its own QX_PING round trip. A socket we can't read + // stats from just leaves them unreported. + #[cfg(unix)] + let socket_stats = crate::TcpStats::new(&stream) + .ok() + .map(|s| std::sync::Arc::new(s) as crate::SharedSocketStats); + #[cfg(not(unix))] + let socket_stats = None; + + // `build_stream_session` awaits the peer's transport parameters before + // returning, so `protocol()` is resolved on the session we hand back + // (bounded by the config's handshake timeout). + build_stream_session(stream, config, is_server, socket_stats).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_config_keeps_version_and_protocols() { + let mut session = crate::Config::new(Version::QMux00); + session.max_streams_bidi = 1_000; + let config = Config::new(Version::QMux02) + .protocols(["moq-lite-04"]) + .session_config(session); + + assert_eq!(config.inner.version, Version::QMux02); + assert!(matches!(&config.inner.protocol, Protocol::Negotiate(list) if list == &["moq-lite-04"])); + assert_eq!(config.inner.max_streams_bidi, 1_000); + } +} diff --git a/rs/qmux/src/tls.rs b/rs/qmux/src/tls.rs new file mode 100644 index 00000000000..dc9a587eb55 --- /dev/null +++ b/rs/qmux/src/tls.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; +use tokio::net::{TcpStream, ToSocketAddrs}; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::TlsConnector; + +use crate::transport::Stream; +use crate::{Config, Error, Session, Version, alpn}; + +/// A QMux client over TLS that advertises `(alpn, versions)` entries. +/// +/// Each entry's `versions` slice is expanded into the TLS ALPN list as +/// `{v.prefix()}{alpn}` per version (empty = every QMux draft this crate knows +/// about). The bare version ALPNs (`qmux-02`, `qmux-01`, `qmux-00`) are +/// appended as well unless [`require_protocol`](Client::require_protocol) is +/// set, in which case only the prefixed pairs are offered. The peer's chosen +/// ALPN determines the QMux wire-format version; the `alpn_protocols` field on +/// the supplied `rustls::ClientConfig` is ignored and rebuilt from the entries. +#[derive(Clone)] +pub struct Client { + config: Arc, + protocols: Vec<(String, Vec)>, + require_protocol: bool, + session: Config, +} + +impl Client { + /// Start building a TLS client with the given `rustls::ClientConfig`. + pub fn new(config: Arc) -> Self { + Self { + config, + protocols: Vec::new(), + require_protocol: false, + session: Config::default(), + } + } + + /// Advertise `alpn` under the listed QMux wire-format versions. + /// + /// Pass `&[v]` to pin to one version, `&[v1, v2]` to enumerate, or `&[]` + /// to expand to every QMux draft this crate knows about. + pub fn with_protocol(mut self, alpn: &str, versions: &[Version]) -> Self { + self.protocols.push((alpn.to_string(), versions.to_vec())); + self + } + + /// Advertise multiple `(alpn, versions)` entries in preference order. + pub fn with_protocols<'a>(mut self, entries: impl IntoIterator) -> Self { + self.protocols + .extend(entries.into_iter().map(|(a, vs)| (a.to_string(), vs.to_vec()))); + self + } + + /// Offer only the prefixed `(alpn, version)` pairs, suppressing the bare + /// version ALPNs (`qmux-02`, `qmux-01`, `qmux-00`) offered by default. + pub fn require_protocol(mut self) -> Self { + self.require_protocol = true; + self + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which the handshake negotiates. + pub fn with_session_config(mut self, config: Config) -> Self { + self.session = config; + self + } + + /// Connect to `addr` and start a client session. `server_name` is used for + /// SNI and certificate verification. + pub async fn connect(&self, addr: impl ToSocketAddrs, server_name: &str) -> Result { + let stream = TcpStream::connect(&addr).await?; + // Read the socket's stats handle before TLS takes ownership; the kernel's + // view of the TCP connection is the same either side of the encryption. + let socket_stats = socket_stats(&stream); + + let server_name = rustls::pki_types::ServerName::try_from(server_name) + .map_err(|_| Error::InvalidServerName)? + .to_owned(); + + let entries = self.protocols.iter().map(|(a, vs)| (a.as_str(), vs.as_slice())); + let prefixed = alpn::build(entries, self.require_protocol); + + let mut config = (*self.config).clone(); + config.alpn_protocols = prefixed.iter().map(|s| s.as_bytes().to_vec()).collect(); + + tracing::debug!(?prefixed, "TLS connecting"); + + let connector = TlsConnector::from(Arc::new(config)); + let tls_stream = connector.connect(server_name, stream).await?; + + let negotiated = tls_stream.get_ref().1.alpn_protocol(); + let negotiated_str = negotiated.and_then(|a| std::str::from_utf8(a).ok()); + tracing::debug!(?negotiated_str, "TLS negotiated ALPN"); + + let (version, protocol) = alpn::parse(negotiated_str)?; + tracing::debug!(?version, ?protocol, "parsed ALPN"); + + // In strict mode a bare version ALPN would silently drop the application + // protocol we promised to enforce. + if self.require_protocol && protocol.is_none() { + return Err(Error::InvalidProtocol(negotiated_str.unwrap_or("").to_string())); + } + + let session_config = self.session.clone().with_negotiated(version, protocol); + let mut transport = Stream::new(tls_stream, version, session_config.max_record_size); + if let Some(stats) = socket_stats { + transport = transport.with_socket_stats(stats); + } + // `connect` awaits the peer's transport parameters so `protocol()` is resolved. + Session::connect(transport, session_config).await + } +} + +/// A QMux server that accepts TLS connections. +/// +/// Selection of an offered ALPN happens inside rustls based on the +/// `alpn_protocols` already set on the supplied `rustls::ServerConfig`; the QMux +/// wire-format version is then derived from the negotiated ALPN. Callers +/// building that ALPN list should use `{version.prefix()}{alpn}` for each +/// supported pair (e.g. `"qmux-01.moq-lite-04"`). +#[derive(Clone)] +pub struct Server { + config: Arc, + session: Config, +} + +impl Server { + /// Create a TLS server from the given `rustls::ServerConfig`. + pub fn new(config: Arc) -> Self { + Self { + config, + session: Config::default(), + } + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which the handshake negotiates. + pub fn with_session_config(mut self, config: Config) -> Self { + self.session = config; + self + } + + /// Accept a TLS connection over an established TCP stream. + /// + /// This awaits both the TLS handshake and the QMux handshake (the peer's + /// transport parameters, bounded by the session's + /// [`handshake_timeout`](crate::Config::handshake_timeout)). Drive each + /// connection with `tokio::spawn` so a slow or non-cooperative peer can't + /// stall your `listener.accept()` loop. + pub async fn accept(&self, stream: TcpStream) -> Result { + let socket_stats = socket_stats(&stream); + let acceptor = TlsAcceptor::from(self.config.clone()); + let tls_stream = acceptor.accept(stream).await?; + + let negotiated = tls_stream.get_ref().1.alpn_protocol(); + let negotiated_str = negotiated.and_then(|a| std::str::from_utf8(a).ok()); + tracing::debug!(?negotiated_str, "TLS accepted, negotiated ALPN"); + + let (version, protocol) = alpn::parse(negotiated_str)?; + tracing::debug!(?version, ?protocol, "parsed ALPN"); + + let session_config = self.session.clone().with_negotiated(version, protocol); + let mut transport = Stream::new(tls_stream, version, session_config.max_record_size); + if let Some(stats) = socket_stats { + transport = transport.with_socket_stats(stats); + } + // `accept` awaits the peer's transport parameters so `protocol()` is resolved. + Session::accept(transport, session_config).await + } +} + +/// The kernel's view of `stream`, when this platform exposes one. +fn socket_stats(stream: &TcpStream) -> Option { + #[cfg(unix)] + { + crate::TcpStats::new(stream) + .ok() + .map(|s| std::sync::Arc::new(s) as crate::SharedSocketStats) + } + #[cfg(not(unix))] + { + let _ = stream; + None + } +} diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs new file mode 100644 index 00000000000..3c82f8bde4f --- /dev/null +++ b/rs/qmux/src/transport.rs @@ -0,0 +1,1140 @@ +use bytes::Bytes; + +use crate::Error; + +/// Abstracts message I/O over a reliable transport. +/// +/// Each `send`/`recv` operates on a single complete message (frame). +/// For WebSocket, this maps to individual WS binary messages. +/// For TCP/TLS byte streams, the transport handles frame delimiting. +/// +/// Use [`Stream`] for arbitrary [`AsyncRead`](tokio::io::AsyncRead) + +/// [`AsyncWrite`](tokio::io::AsyncWrite) byte streams. Implement this trait when +/// integrating a transport with its own message boundaries, framing, or +/// lifecycle that the built-in byte-stream and WebSocket adapters do not cover. +/// +/// A transport splits into independently-owned send and receive halves so the +/// session can drive them on separate tasks: a write blocked on transport +/// backpressure must never stall reads (and vice versa). This decoupling is what +/// lets the session observe backpressure and shed unreliable datagrams instead +/// of buffering them behind a stalled socket. +pub trait Transport: Send + 'static { + /// The independently-owned send half. + type Writer: Writer; + /// The independently-owned receive half. + type Reader: Reader; + + /// Split into send and receive halves. + fn split(self) -> (Self::Writer, Self::Reader); + + /// Statistics for the connection underneath, if it exposes any. + /// + /// Read before the split, since the halves are handed to separate tasks. A + /// transport that returns `None` leaves the session reporting only what it can + /// measure itself, i.e. the `QX_PING` round trip. + fn socket_stats(&self) -> Option { + None + } + + /// How the session's endpoint was bound. Most transports know the peer only by its + /// socket, so the default is [`Binding::InBand`](moq_net::transport::Binding::InBand). + fn binding(&self) -> moq_net::transport::Binding { + moq_net::transport::Binding::InBand + } +} + +/// The send half of a [`Transport`]. +pub trait Writer: Send + 'static { + /// Send a single complete message. + fn send(&mut self, data: Bytes) -> impl std::future::Future> + Send; + + /// Queue a single complete message without flushing it. The session's writer + /// feeds every frame that is already queued and then calls [`Writer::flush`] + /// once, so a burst of small frames reaches the socket in one write instead of + /// one write (and one TCP segment) each. The default sends immediately. + fn feed(&mut self, data: Bytes) -> impl std::future::Future> + Send { + self.send(data) + } + + /// Write out everything queued by [`Writer::feed`]. The default has nothing to + /// flush because the default `feed` already sent. + fn flush(&mut self) -> impl std::future::Future> + Send { + std::future::ready(Ok(())) + } + + /// Gracefully close the transport. + fn close(&mut self) -> impl std::future::Future> + Send; + + /// Perform any timer-driven background work and resolve once it's done. The + /// session's writer loop selects on this alongside outbound frames, so a + /// transport can piggy-back periodic maintenance (e.g. a WebSocket keep-alive + /// Ping) on the same task that owns the send half. The default never + /// resolves — transports with nothing to do (TCP, Unix sockets) use it as-is. + fn maintain(&mut self) -> impl std::future::Future> + Send { + std::future::pending() + } +} + +/// The receive half of a [`Transport`]. +pub trait Reader: Send + 'static { + /// Receive the next complete message. + fn recv(&mut self) -> impl std::future::Future> + Send; +} + +// Stream: message I/O over a byte stream (TCP/TLS/Unix). +// Handles QMux frame delimiting to return complete frames as Bytes. +// +// Cancel safety: a dedicated reader task owns the read half and pushes complete +// frames into an `mpsc` channel. `recv()` is just `rx.recv().await`, which is +// cancel safe — if the future is dropped (e.g. a sibling `tokio::select!` branch +// wins), the buffered frame stays in the channel for the next call. The reader +// task itself never gets cancelled mid-parse, so the multi-step async reads in +// `recv_record`/`recv_qmux00_frame` are safe to keep as-is. +#[cfg(any(feature = "tcp", all(unix, feature = "uds")))] +mod stream_transport { + use bytes::{BufMut, Bytes, BytesMut}; + use moq_quic::VarInt; + use moq_quic::coding::Codec; + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, BufWriter}; + use tokio::sync::mpsc; + use tokio::task::JoinHandle; + + use super::{Reader, Transport, Writer}; + use crate::{Error, MAX_FRAME_SIZE, Version}; + + /// Bound on queued frames waiting for the session to drain them. Bytes the + /// session hasn't picked up yet are also buffered in the OS receive window + /// once this fills; the channel just gives a small slack so each `recv()` + /// is a cheap hand-off rather than a syscall. + const RECV_CHANNEL_CAPACITY: usize = 16; + + /// QMux message I/O over any reliable byte stream (`AsyncRead + AsyncWrite`). + /// + /// Handles QMux frame/record delimiting so [`Session`](crate::Session) sees + /// complete frames. Pair it with [`Session::connect`](crate::Session::connect) + /// or [`Session::accept`](crate::Session::accept) to run QMux over a transport + /// the built-in `tcp`/`tls`/`ws` helpers don't cover — a Unix socket, a pipe, + /// an in-memory duplex, a custom tunnel, etc.: + /// + /// ```no_run + /// # async fn f(stream: tokio::net::TcpStream) -> Result<(), qmux::Error> { + /// use qmux::transport::Stream; + /// use qmux::{Config, Session, Version}; + /// + /// let config = Config::new(Version::QMux01); + /// let transport = Stream::new(stream, config.version, config.max_record_size); + /// let session = Session::connect(transport, config).await?; + /// # let _ = session; Ok(()) + /// # } + /// ``` + pub struct Stream { + writer: StreamWriter, + reader: StreamReader, + socket_stats: Option, + } + + /// The send half of a byte-stream [`Stream`]. + pub struct StreamWriter { + writer: BufWriter>, + version: Version, + } + + /// The receive half of a byte-stream [`Stream`]. Owns the reader task's + /// abort handle so the task can't outlive the receive half. + pub struct StreamReader { + rx: mpsc::Receiver>, + /// Aborted on drop so the reader task can't outlive the transport. + reader_task: JoinHandle<()>, + } + + impl Stream { + /// Wrap a byte stream speaking QMux `version`. + /// + /// `our_max_record_size` bounds incoming records (use + /// [`Config::max_record_size`](crate::Config::max_record_size)); it is + /// ignored for draft-00, whose frames are bounded by `max_frame_size`. + pub fn new(stream: T, version: Version, our_max_record_size: u64) -> Self { + let (read, write) = tokio::io::split(stream); + let (tx, rx) = mpsc::channel(RECV_CHANNEL_CAPACITY); + let reader_task = tokio::spawn(reader_loop( + BufReader::new(read), + version, + usize::try_from(our_max_record_size).unwrap_or(usize::MAX), + tx, + )); + Self { + writer: StreamWriter { + writer: BufWriter::new(write), + version, + }, + reader: StreamReader { rx, reader_task }, + socket_stats: None, + } + } + + /// Report statistics for the connection underneath this stream. + /// + /// `Stream` is generic over the byte stream, so it can't discover the socket + /// on its own; the builder that knows the concrete type attaches it. See + /// [`TcpStats`](crate::TcpStats). + pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { + self.socket_stats = Some(stats); + self + } + } + + impl Transport for Stream { + type Writer = StreamWriter; + type Reader = StreamReader; + + fn split(self) -> (StreamWriter, StreamReader) { + (self.writer, self.reader) + } + + fn socket_stats(&self) -> Option { + self.socket_stats.clone() + } + } + + impl Drop for StreamReader { + fn drop(&mut self) { + // Make sure the reader task doesn't outlive the transport; otherwise + // it would hold the read half open until the connection drops. + self.reader_task.abort(); + } + } + + impl Writer for StreamWriter { + async fn send(&mut self, data: Bytes) -> Result<(), Error> { + self.feed(data).await?; + self.flush().await + } + + async fn feed(&mut self, data: Bytes) -> Result<(), Error> { + // Record-framed drafts (QMux01+) travel inside size-prefixed records + // on byte streams. (Records are implicit on WebSocket, where the + // message boundary delimits them.) Left in the BufWriter, which only + // writes to the socket when full; `flush` sends the rest. + if self.version.uses_records() { + let mut size_buf = BytesMut::with_capacity(8); + VarInt::try_from(data.len()) + .map_err(|_| Error::FrameTooLarge)? + .encode(&mut size_buf); + self.writer.write_all(&size_buf).await?; + } + self.writer.write_all(&data).await?; + Ok(()) + } + + async fn flush(&mut self) -> Result<(), Error> { + self.writer.flush().await?; + Ok(()) + } + + async fn close(&mut self) -> Result<(), Error> { + self.writer.shutdown().await?; + Ok(()) + } + } + + impl Reader for StreamReader { + async fn recv(&mut self) -> Result { + // mpsc::Receiver::recv is cancel safe, so dropping this future never + // loses a buffered frame. `None` means the reader task exited without + // sending — treat as a clean close. + self.rx.recv().await.unwrap_or(Err(Error::Closed)) + } + } + + /// Reader task: pull complete frames off the wire and ship them through `tx`. + /// On parse error, send the error and exit. If `tx` is closed (the transport + /// was dropped), exit silently. + async fn reader_loop( + mut reader: BufReader, + version: Version, + our_max_record_size: usize, + tx: mpsc::Sender>, + ) { + loop { + let result = match version.uses_records() { + true => recv_record(&mut reader, our_max_record_size).await, + false => recv_qmux00_frame(&mut reader).await, + }; + let stop = result.is_err(); + if tx.send(result).await.is_err() { + return; + } + if stop { + return; + } + } + } + + /// Read a varint from the stream, returning the decoded value. + /// If `buf` is provided, appends the raw bytes to it. + async fn read_varint_into(reader: &mut R, buf: &mut BytesMut) -> Result { + let first = reader.read_u8().await?; + buf.put_u8(first); + + let tag = first >> 6; + let len = 1usize << tag; + + if len == 1 { + return Ok(VarInt::from(first & 0x3f)); + } + + let start = buf.len(); + buf.resize(start + len - 1, 0); + reader.read_exact(&mut buf[start..]).await?; + + let mut raw = [0u8; 8]; + raw[0] = first & 0x3f; + raw[1..len].copy_from_slice(&buf[start..start + len - 1]); + + let value = match len { + 2 => u16::from_be_bytes([raw[0], raw[1]]) as u64, + 4 => u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as u64, + 8 => u64::from_be_bytes(raw), + _ => unreachable!(), + }; + + // Two tag bits leave 62 for the value, which is exactly a varint's range. + Ok(VarInt::from_u64(value).expect("62-bit value")) + } + + /// Read a varint from the stream without collecting raw bytes. + async fn read_varint(reader: &mut R) -> Result { + let first = reader.read_u8().await?; + let tag = first >> 6; + let len = 1usize << tag; + + if len == 1 { + return Ok(VarInt::from(first & 0x3f)); + } + + let mut raw = [0u8; 8]; + raw[0] = first & 0x3f; + reader.read_exact(&mut raw[1..len]).await?; + + let value = match len { + 2 => u16::from_be_bytes([raw[0], raw[1]]) as u64, + 4 => u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as u64, + 8 => u64::from_be_bytes(raw), + _ => unreachable!(), + }; + + // Two tag bits leave 62 for the value, which is exactly a varint's range. + Ok(VarInt::from_u64(value).expect("62-bit value")) + } + + /// Read exactly `len` bytes, appending to buf. + async fn read_bytes(reader: &mut R, len: usize, buf: &mut BytesMut) -> Result<(), Error> { + let start = buf.len(); + buf.resize(start + len, 0); + reader.read_exact(&mut buf[start..]).await?; + Ok(()) + } + + /// A peer-sent length as `usize`, refused where it does not fit (32-bit targets) + fn peer_len(value: VarInt) -> Result { + usize::try_from(value.into_inner()).map_err(|_| Error::FrameTooLarge) + } + + /// Read one QMux Record from the byte stream (draft-01). + /// Returns the record payload (frames concatenated). + async fn recv_record(reader: &mut R, our_max_record_size: usize) -> Result { + let size = peer_len(read_varint(reader).await?)?; + if size > our_max_record_size { + return Err(Error::FrameTooLarge); + } + let mut buf = BytesMut::zeroed(size); + reader.read_exact(&mut buf).await?; + Ok(buf.freeze()) + } + + /// Read one complete QMux frame from the byte stream (draft-00), returning raw bytes. + async fn recv_qmux00_frame(reader: &mut R) -> Result { + let mut buf = BytesMut::new(); + let frame_type = read_varint_into(reader, &mut buf).await?.into_inner(); + + // STREAM frames: 0x08-0x0f + if (0x08..=0x0f).contains(&frame_type) { + let has_off = frame_type & 0x04 != 0; + let has_len = frame_type & 0x02 != 0; + + read_varint_into(reader, &mut buf).await?; // stream id + + if has_off { + read_varint_into(reader, &mut buf).await?; // offset + } + + if has_len { + let len = peer_len(read_varint_into(reader, &mut buf).await?)?; + // draft-00 §5.2: `max_frame_size` bounds the whole frame, so the + // header bytes read so far count against it. A byte stream cannot + // resynchronize past an oversized frame, so this stays fatal — as + // the draft requires (FRAME_ENCODING_ERROR). + if buf.len().saturating_add(len) > MAX_FRAME_SIZE { + return Err(Error::FrameTooLarge); + } + read_bytes(reader, len, &mut buf).await?; + } else { + // A byte stream cannot delimit a STREAM frame without its length. + return Err(Error::FrameEncoding); + } + + return Ok(buf.freeze()); + } + + match frame_type { + // PADDING + 0x00 => {} + // RESET_STREAM + 0x04 => { + read_varint_into(reader, &mut buf).await?; // id + read_varint_into(reader, &mut buf).await?; // code + read_varint_into(reader, &mut buf).await?; // final_size + } + // STOP_SENDING + 0x05 => { + read_varint_into(reader, &mut buf).await?; // id + read_varint_into(reader, &mut buf).await?; // code + } + // CONNECTION_CLOSE (0x1c) carries the Frame Type field; APPLICATION_CLOSE + // (0x1d) omits it (RFC 9000 §19.19). + 0x1c | 0x1d => { + read_varint_into(reader, &mut buf).await?; // code + if frame_type == 0x1c { + read_varint_into(reader, &mut buf).await?; // frame_type + } + let reason_len = peer_len(read_varint_into(reader, &mut buf).await?)?; + if buf.len().saturating_add(reason_len) > MAX_FRAME_SIZE { + return Err(Error::FrameTooLarge); + } + read_bytes(reader, reason_len, &mut buf).await?; + } + // MAX_DATA + 0x10 => { + read_varint_into(reader, &mut buf).await?; + } + // MAX_STREAM_DATA + 0x11 => { + read_varint_into(reader, &mut buf).await?; // id + read_varint_into(reader, &mut buf).await?; // max + } + // MAX_STREAMS (bidi/uni) + 0x12 | 0x13 => { + read_varint_into(reader, &mut buf).await?; + } + // DATA_BLOCKED + 0x14 => { + read_varint_into(reader, &mut buf).await?; + } + // STREAM_DATA_BLOCKED + 0x15 => { + read_varint_into(reader, &mut buf).await?; // id + read_varint_into(reader, &mut buf).await?; // limit + } + // STREAMS_BLOCKED (bidi/uni) + 0x16 | 0x17 => { + read_varint_into(reader, &mut buf).await?; + } + // QX_TRANSPORT_PARAMETERS + 0x3f5153300d0a0d0a => { + let len = peer_len(read_varint_into(reader, &mut buf).await?)?; + if buf.len().saturating_add(len) > MAX_FRAME_SIZE { + return Err(Error::FrameTooLarge); + } + read_bytes(reader, len, &mut buf).await?; + } + // Draft-00 has no datagrams or QX_PING, and an unknown type cannot be skipped + // on a byte stream. + _ => return Err(Error::FrameEncoding), + } + + Ok(buf.freeze()) + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::transport::{Reader, Transport}; + use tokio::io::AsyncWriteExt; + + // Drip a frame in one byte at a time, racing each `recv` against an + // immediate yield so the recv future is dropped every iteration. With + // a cancel-safe `recv`, the final call must still return the whole + // frame intact. + #[tokio::test] + async fn recv_is_cancel_safe_across_partial_writes() { + let (client, mut server) = tokio::io::duplex(64 * 1024); + let (_writer, mut transport) = Stream::new(client, Version::QMux00, 16 * 1024).split(); + + // STREAM frame, type 0x0a (len bit set), id=4, length=5, payload="hello". + let mut frame = Vec::new(); + frame.push(0x0a); + frame.push(0x04); + frame.push(0x05); + frame.extend_from_slice(b"hello"); + + for chunk in frame.chunks(1).take(frame.len() - 1) { + server.write_all(chunk).await.unwrap(); + server.flush().await.unwrap(); + tokio::select! { + _ = transport.recv() => panic!("recv completed with a partial frame"), + _ = tokio::task::yield_now() => {} + } + } + + server.write_all(&frame[frame.len() - 1..]).await.unwrap(); + server.flush().await.unwrap(); + + let got = transport.recv().await.expect("frame should decode"); + assert_eq!(&got[..], frame.as_slice()); + } + + #[tokio::test] + async fn recv_qmux01_record_is_cancel_safe() { + let (client, mut server) = tokio::io::duplex(64 * 1024); + let (_writer, mut transport) = Stream::new(client, Version::QMux01, 16 * 1024).split(); + + // 1-byte varint length (0x08) followed by 8 bytes of payload. + let mut record = vec![0x08]; + record.extend_from_slice(b"abcdefgh"); + + for chunk in record.chunks(1).take(record.len() - 1) { + server.write_all(chunk).await.unwrap(); + server.flush().await.unwrap(); + tokio::select! { + _ = transport.recv() => panic!("recv completed with a partial record"), + _ = tokio::task::yield_now() => {} + } + } + + server.write_all(&record[record.len() - 1..]).await.unwrap(); + server.flush().await.unwrap(); + + let got = transport.recv().await.expect("record should decode"); + assert_eq!(&got[..], b"abcdefgh"); + } + + // Two frames arrive in a single write. Each recv() must return one + // complete frame, in order. Exercises the channel queue + the reader + // task looping on a buffer that still has bytes after parsing. + #[tokio::test] + async fn recv_returns_consecutive_frames_in_order() { + let (client, mut server) = tokio::io::duplex(64 * 1024); + let (_writer, mut transport) = Stream::new(client, Version::QMux00, 16 * 1024).split(); + + // Two STREAM frames (type 0x0a) for stream ids 4 and 8. + let frame_a: Vec = [0x0a, 0x04, 0x05].into_iter().chain(*b"hello").collect(); + let frame_b: Vec = [0x0a, 0x08, 0x05].into_iter().chain(*b"world").collect(); + let mut combined = frame_a.clone(); + combined.extend_from_slice(&frame_b); + + server.write_all(&combined).await.unwrap(); + server.flush().await.unwrap(); + + let got_a = transport.recv().await.expect("first frame should decode"); + let got_b = transport.recv().await.expect("second frame should decode"); + assert_eq!(&got_a[..], frame_a.as_slice()); + assert_eq!(&got_b[..], frame_b.as_slice()); + } + + // Reader task hits a parse error: `recv()` returns it, and the next + // `recv()` returns Error::Closed since the task has exited. + #[tokio::test] + async fn recv_propagates_parse_error_then_closes() { + let (client, mut server) = tokio::io::duplex(64 * 1024); + let (_writer, mut transport) = Stream::new(client, Version::QMux00, 16 * 1024).split(); + + // Frame type 0x02 isn't a recognized QMux00 frame type. + server.write_all(&[0x02]).await.unwrap(); + server.flush().await.unwrap(); + + let err = transport.recv().await.expect_err("parse error expected"); + assert!(matches!(err, Error::FrameEncoding), "got {err:?}"); + + // Task has exited after sending the error; subsequent recv sees the + // closed channel and reports Error::Closed. + let err = transport.recv().await.expect_err("closed expected"); + assert!(matches!(err, Error::Closed), "got {err:?}"); + } + + // A record whose declared size exceeds `our_max_record_size` is + // rejected with FrameTooLarge before any payload is consumed. + #[tokio::test] + async fn recv_record_exceeding_max_returns_frame_too_large() { + let (client, mut server) = tokio::io::duplex(64 * 1024); + let (_writer, mut transport) = Stream::new(client, Version::QMux01, 4).split(); + + // 1-byte varint length = 5, which exceeds the configured max of 4. + server.write_all(&[0x05]).await.unwrap(); + server.flush().await.unwrap(); + + let err = transport.recv().await.expect_err("FrameTooLarge expected"); + assert!(matches!(err, Error::FrameTooLarge), "got {err:?}"); + } + + /// draft-00 §5.2 bounds the whole frame at `max_frame_size`, header + /// included: a frame of exactly that size parses, and one byte more is a + /// frame-size violation rather than something to read into memory. + #[tokio::test] + async fn recv_qmux00_bounds_the_whole_frame() { + // STREAM with LEN (0x0a), stream id 2, a 2-byte length, then the payload. + let frame = |payload: usize| { + let mut frame = vec![0x0a, 0x02]; + frame.extend_from_slice(&(0x4000 | payload as u16).to_be_bytes()); + frame.resize(frame.len() + payload, 0x5a); + Bytes::from(frame) + }; + + let at_limit = frame(MAX_FRAME_SIZE - 4); + assert_eq!(at_limit.len(), MAX_FRAME_SIZE); + let over_limit = frame(MAX_FRAME_SIZE - 3); + assert_eq!(over_limit.len(), MAX_FRAME_SIZE + 1); + + let (client, mut server) = tokio::io::duplex(64 * 1024); + let (_writer, mut transport) = Stream::new(client, Version::QMux00, 0).split(); + + server.write_all(&at_limit).await.unwrap(); + server.flush().await.unwrap(); + assert_eq!(transport.recv().await.unwrap(), at_limit); + + server.write_all(&over_limit).await.unwrap(); + server.flush().await.unwrap(); + let err = transport.recv().await.expect_err("FrameTooLarge expected"); + assert!(matches!(err, Error::FrameTooLarge), "got {err:?}"); + } + } +} + +#[cfg(any(feature = "tcp", all(unix, feature = "uds")))] +pub use stream_transport::{Stream, StreamReader, StreamWriter}; + +// Shared plumbing for the byte-stream transports (TCP, Unix sockets). +#[cfg(any(feature = "tcp", all(unix, feature = "uds")))] +mod stream_session { + use tokio::io::{AsyncRead, AsyncWrite}; + + use super::Stream; + use crate::protocol::validate_protocol; + use crate::{Config, Error, Protocol, Session}; + + /// Wrap a byte stream in a [`Stream`] and start a session, validating any + /// advertised protocol names first. Used by the `tcp`/`uds` builders. + pub(crate) async fn build( + stream: T, + config: Config, + is_server: bool, + socket_stats: Option, + ) -> Result { + if let Protocol::Negotiate(protocols) = &config.protocol { + for protocol in protocols { + validate_protocol(protocol)?; + } + } + let mut transport = Stream::new(stream, config.version, config.max_record_size); + if let Some(stats) = socket_stats { + transport = transport.with_socket_stats(stats); + } + if is_server { + Session::accept(transport, config).await + } else { + Session::connect(transport, config).await + } + } +} + +#[cfg(any(feature = "tcp", all(unix, feature = "uds")))] +pub(crate) use stream_session::build as build_stream_session; + +// WsTransport: message I/O over WebSocket. +#[cfg(feature = "ws")] +mod ws_transport { + use std::pin::Pin; + use std::time::Duration; + + use bytes::Bytes; + use futures::stream::SplitSink; + use tokio::sync::mpsc; + use tokio::task::JoinHandle; + use tokio::time::{Instant, Interval, MissedTickBehavior, Sleep}; + use tokio_tungstenite::tungstenite; + + use super::{Reader, Transport, Writer}; + use crate::ws::KeepAlive; + use crate::{Error, MAX_FRAME_SIZE, Version}; + + type Message = tungstenite::Message; + + /// Bound on WS frames the pump task reads ahead of the session. Small: the pump + /// only stages already-flow-control-covered frames; a full channel makes the + /// pump park on delivery, which is exactly the backpressure signal the deadline + /// logic keys off. Mirrors the byte-stream reader's `RECV_CHANNEL_CAPACITY`. + const WS_RECV_CHANNEL_CAPACITY: usize = 16; + + /// The combined `Stream + Sink` bound every WebSocket half requires. + pub(crate) trait WsStream: + futures::Stream> + + futures::Sink + + Unpin + + Send + + 'static + { + } + impl WsStream for T where + T: futures::Stream> + + futures::Sink + + Unpin + + Send + + 'static + { + } + + pub(crate) struct WsTransport { + ws: T, + keep_alive: Option, + /// Largest inbound message we accept, or `None` when nothing bounds it. + recv_limit: usize, + socket_stats: Option, + binding: moq_net::transport::Binding, + } + + impl WsTransport { + pub fn new(ws: T, version: Version, max_record_size: u64, binding: moq_net::transport::Binding) -> Self { + // A message is a record on the record-framed drafts, bounded by the + // `max_record_size` we advertised; on draft-00 it is a single frame, + // bounded by `max_frame_size` (§5.2), which we never advertise above + // its initial value. + let recv_limit = match version.uses_records() { + true => usize::try_from(max_record_size).unwrap_or(usize::MAX), + false => MAX_FRAME_SIZE, + }; + Self { + ws, + keep_alive: None, + recv_limit, + socket_stats: None, + binding, + } + } + + /// Refuses a keep-alive that would close a healthy idle peer: a zero interval, or a + /// timeout no longer than the interval, which expires before the first Pong. + pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Result { + if keep_alive.interval.is_zero() { + return Err(Error::InvalidConfig("zero keep-alive interval")); + } + if keep_alive.timeout <= keep_alive.interval { + return Err(Error::InvalidConfig("keep-alive timeout not above its interval")); + } + self.keep_alive = Some(keep_alive); + Ok(self) + } + + pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { + self.socket_stats = Some(stats); + self + } + } + + /// Writer-side keep-alive: emit a Ping every `interval`. + struct PingState { + // Fires on each interval; the writer sends a Ping when it does. + interval: Interval, + } + + impl PingState { + fn new(config: KeepAlive) -> Self { + // Skip catch-up bursts after a long pause; we just want one Ping per tick. + let mut interval = tokio::time::interval(config.interval); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + // First tick fires immediately by default; consume it so we don't ping on connect. + interval.reset(); + Self { interval } + } + } + + /// Reader-side keep-alive: close the session if no frame arrives within `timeout`. + struct DeadlineState { + // Resets every time we receive a frame. If it elapses, the peer is gone. + deadline: Pin>, + timeout: Duration, + } + + impl DeadlineState { + fn new(config: KeepAlive) -> Self { + let timeout = config.timeout; + Self { + deadline: Box::pin(tokio::time::sleep(timeout)), + timeout, + } + } + + fn observe_recv(&mut self) { + self.deadline.as_mut().reset(Instant::now() + self.timeout); + } + } + + /// The send half of a [`WsTransport`]: owns the sink and drives keep-alive Pings. + pub(crate) struct WsWriter { + sink: SplitSink, + ping: Option, + } + + /// The receive half of a [`WsTransport`]. + /// + /// A dedicated pump task owns the socket and the idle deadline; `recv` is just a + /// hand-off from its channel. Reading on its own task is what makes the keep-alive + /// deadline robust: it advances (and resets on the peer's Pong replies) regardless + /// of where the *session* is parked — in particular while the session is wedged + /// handing a stream to a slow `accept_*`, which would otherwise starve a deadline + /// polled inline in `recv` and fire a spurious timeout. Mirrors [`StreamReader`]. + pub(crate) struct WsReader { + rx: mpsc::Receiver>, + /// Aborted on drop so the pump can't outlive the transport. + pump: JoinHandle<()>, + } + + impl Drop for WsReader { + fn drop(&mut self) { + self.pump.abort(); + } + } + + impl Transport for WsTransport { + type Writer = WsWriter; + type Reader = WsReader; + + fn socket_stats(&self) -> Option { + self.socket_stats.clone() + } + + fn binding(&self) -> moq_net::transport::Binding { + self.binding + } + + fn split(self) -> (WsWriter, WsReader) { + use futures::StreamExt; + // BiLock-backed halves: the sink and stream can be polled concurrently + // on separate tasks, briefly serializing on the shared socket. + let (sink, stream) = self.ws.split(); + let (ping, deadline) = match self.keep_alive { + Some(ka) => (Some(PingState::new(ka)), Some(DeadlineState::new(ka))), + None => (None, None), + }; + let (tx, rx) = mpsc::channel(WS_RECV_CHANNEL_CAPACITY); + let pump = tokio::spawn(ws_pump(stream, deadline, self.recv_limit, tx)); + (WsWriter { sink, ping }, WsReader { rx, pump }) + } + } + + impl Writer for WsWriter { + async fn send(&mut self, data: Bytes) -> Result<(), Error> { + use futures::SinkExt; + self.sink.send(Message::Binary(data)).await.map_err(|_| Error::Closed)?; + Ok(()) + } + + async fn feed(&mut self, data: Bytes) -> Result<(), Error> { + use futures::SinkExt; + // Still one WebSocket message per frame (the message boundary is the + // record boundary), but tungstenite only appends it to its write + // buffer. The socket write happens on `flush`, or early once the + // buffer passes `write_buffer_size` (128 KiB by default). + self.sink.feed(Message::Binary(data)).await.map_err(|_| Error::Closed)?; + Ok(()) + } + + async fn flush(&mut self) -> Result<(), Error> { + use futures::SinkExt; + self.sink.flush().await.map_err(|_| Error::Closed)?; + Ok(()) + } + + async fn close(&mut self) -> Result<(), Error> { + use futures::SinkExt; + self.sink.close().await.map_err(|_| Error::Closed)?; + Ok(()) + } + + async fn maintain(&mut self) -> Result<(), Error> { + use futures::SinkExt; + match &mut self.ping { + Some(ping) => { + // Wait for the next interval, then send one keep-alive Ping. The + // session's writer loop re-invokes this each time it resolves, so + // pings keep flowing without a dedicated task. tungstenite's + // auto-queued Pong replies (from the reader) also flush here. + ping.interval.tick().await; + self.sink + .send(Message::Ping(Bytes::new())) + .await + .map_err(|_| Error::Closed)?; + Ok(()) + } + // No keep-alive configured: never resolves, so the writer loop's + // select simply ignores this branch. + None => std::future::pending().await, + } + } + } + + impl Reader for WsReader { + async fn recv(&mut self) -> Result { + // The pump task pushes complete Binary frames (or a terminal error) + // here; `None` means it exited without sending — treat as a clean close. + self.rx.recv().await.unwrap_or(Err(Error::Closed)) + } + } + + /// Pump task: read WS messages off the socket independently of the session, + /// resetting the keep-alive deadline on every message (including the peer's Pong + /// replies) and shipping Binary frames to the session over `tx`. On a timeout, + /// clean close, or socket error it sends a terminal `Err` and exits; if `tx` is + /// closed (the transport was dropped) it exits silently. + /// + /// Why a task rather than an inline deadline in `recv`: the deadline must keep + /// advancing even while the session is parked (e.g. handing a stream to a slow + /// `accept_*`), and the peer's liveness must not be judged while we're merely + /// backpressured. So the deadline is reset both when a message arrives *and* + /// after each delivery to the session — a long delivery park (session + /// backpressure) therefore isn't charged against the peer. + async fn ws_pump( + mut stream: S, + mut deadline: Option, + recv_limit: usize, + tx: mpsc::Sender>, + ) where + S: futures::Stream> + Unpin + Send + 'static, + { + use futures::StreamExt; + + loop { + // Prefer a real message over the deadline (`biased`): if both are ready, + // the peer is alive, so don't spuriously time out. + let message = match &mut deadline { + Some(d) => tokio::select! { + biased; + msg = stream.next() => msg, + _ = d.deadline.as_mut() => { + tracing::debug!("websocket keep_alive timeout"); + let _ = tx.send(Err(Error::Closed)).await; + return; + } + }, + None => stream.next().await, + }; + + // Any read — data or control — proves the peer is alive; reset the deadline. + if let Some(d) = deadline.as_mut() { + d.observe_recv(); + } + + let message = match message { + Some(Ok(message)) => message, + // The socket errored mid-stream: forward the specific WebSocket + // error (matching `StreamReader`, and preserving what the old inline + // `recv()` surfaced via `?`) rather than flattening it to a generic + // close, so callers can still distinguish e.g. a protocol fault. + Some(Err(err)) => { + let _ = tx.send(Err(err.into())).await; + return; + } + // Clean end of stream. + None => { + let _ = tx.send(Err(Error::Closed)).await; + return; + } + }; + + match message { + // The binding makes an empty message a no-op, unlike an empty record + Message::Binary(data) if data.is_empty() => continue, + Message::Binary(data) => { + if data.len() > recv_limit { + // Release the oversized allocation before waiting for room to + // report the terminal error. In particular, never let it enter + // the bounded record queue while the session is backpressured. + drop(data); + let _ = tx.send(Err(Error::FrameTooLarge)).await; + return; + } + if tx.send(Ok(data)).await.is_err() { + return; // session gone + } + // Delivered. Reset the deadline again so a long delivery park + // (the session backpressured on `accept_*`) isn't charged + // against the peer — we only judge liveness while actively + // reading, not while wedged handing data to the app. + if let Some(d) = deadline.as_mut() { + d.observe_recv(); + } + } + Message::Close(_) => { + let _ = tx.send(Err(Error::Closed)).await; + return; + } + // QMux rides binary messages only; text means the peer speaks something else + Message::Text(_) => { + let _ = tx.send(Err(Error::ProtocolViolation)).await; + return; + } + Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => { + // tungstenite auto-queues a Pong reply when it reads a Ping; the + // writer half flushes it on its next send/ping. The pump owns no + // sink, so there's nothing to reply with here. + continue; + } + } + } + } + + #[cfg(test)] + mod tests { + use std::time::Duration; + + use bytes::Bytes; + use tokio::sync::mpsc; + + use super::{DeadlineState, Message, ws_pump}; + use crate::Error; + use crate::ws::KeepAlive; + + type WsResult = Result; + + /// A `Stream` of WS messages backed by a `futures` channel. Holding the + /// returned sender keeps the stream "open but silent" (`next()` pends) when + /// empty; dropping it ends the stream. Standing in for a live peer. + fn mock_stream() -> ( + futures::channel::mpsc::UnboundedSender, + futures::channel::mpsc::UnboundedReceiver, + ) { + futures::channel::mpsc::unbounded() + } + + /// The pump must not spuriously time out a *healthy* peer just because the + /// session is too busy to drain it — the exact failure the split reader had, + /// where the keep-alive deadline was only polled inside `recv` and went stale + /// while the session parked handing a stream to a full `accept_*` channel. + /// + /// Here a capacity-1 channel stands in for that full `accept_*`: the pump + /// parks delivering the second frame while we sit idle well past the + /// keep-alive `timeout`, then we drain — and both frames must arrive intact, + /// with no `Error::Closed` from a phantom timeout. + #[tokio::test(start_paused = true)] + async fn survives_a_slow_consumer() { + let (feed, stream) = mock_stream(); + feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"one")))) + .unwrap(); + feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"two")))) + .unwrap(); + // Keep `feed` alive: the peer stays connected and silent after these two. + let _feed = feed; + + let ka = KeepAlive::new(Duration::from_millis(10), Duration::from_millis(50)); + let (tx, mut rx) = mpsc::channel(1); + let pump = tokio::spawn(ws_pump(stream, Some(DeadlineState::new(ka)), usize::MAX, tx)); + + // Model a session wedged on a full accept channel: don't read for well + // over the 50ms keep-alive timeout while the pump is parked on delivery. + tokio::time::sleep(Duration::from_millis(150)).await; + + assert_eq!( + rx.recv().await.expect("channel open").expect("no timeout"), + Bytes::from_static(b"one"), + ); + assert_eq!( + rx.recv().await.expect("channel open").expect("no timeout"), + Bytes::from_static(b"two"), + ); + + pump.abort(); + } + + /// The deadline must still fire for a genuinely silent peer: once the pump is + /// caught up and reading (not parked on delivery), `timeout` of silence is a + /// dead peer, surfaced as `Error::Closed`. + #[tokio::test(start_paused = true)] + async fn times_out_a_silent_peer() { + // Held open but never fed — `stream.next()` pends forever. + let (_feed, stream) = mock_stream(); + + let ka = KeepAlive::new(Duration::from_millis(10), Duration::from_millis(50)); + let (tx, mut rx) = mpsc::channel(4); + let pump = tokio::spawn(ws_pump(stream, Some(DeadlineState::new(ka)), usize::MAX, tx)); + + let result = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("keep-alive deadline should fire on a silent peer"); + assert!( + matches!(result, Some(Err(Error::Closed))), + "expected a keep-alive timeout, got {result:?}" + ); + + pump.abort(); + } + + /// With no keep-alive configured the pump never times out: a silent-but-open + /// peer just leaves `recv` pending rather than closing. + #[tokio::test(start_paused = true)] + async fn no_keep_alive_never_times_out() { + let (feed, stream) = mock_stream(); + let _feed = feed; + + let (tx, mut rx) = mpsc::channel(4); + let pump = tokio::spawn(ws_pump(stream, None, usize::MAX, tx)); + + // No deadline, so recv stays pending well past any keep-alive window. + let pending = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; + assert!(pending.is_err(), "recv must not resolve without activity"); + + pump.abort(); + } + + #[tokio::test] + async fn skips_empty_messages() { + let (feed, stream) = mock_stream(); + feed.unbounded_send(Ok(Message::Binary(Bytes::new()))).unwrap(); + feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"one")))) + .unwrap(); + let _feed = feed; + + let (tx, mut rx) = mpsc::channel(4); + let pump = tokio::spawn(ws_pump(stream, None, usize::MAX, tx)); + assert_eq!(rx.recv().await.unwrap().unwrap(), Bytes::from_static(b"one")); + pump.abort(); + } + + #[tokio::test] + async fn rejects_record_exceeding_max_before_queueing() { + let (feed, stream) = mock_stream(); + feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"oversized")))) + .unwrap(); + + let (tx, mut rx) = mpsc::channel(1); + let pump = tokio::spawn(ws_pump(stream, None, 4, tx)); + + assert!(matches!(rx.recv().await, Some(Err(Error::FrameTooLarge)))); + assert!(rx.recv().await.is_none(), "pump must stop after the violation"); + pump.await.unwrap(); + } + + #[tokio::test] + async fn rejects_a_text_message() { + let (feed, stream) = mock_stream(); + feed.unbounded_send(Ok(Message::Text("hello".into()))).unwrap(); + + let (tx, mut rx) = mpsc::channel(1); + let pump = tokio::spawn(ws_pump(stream, None, usize::MAX, tx)); + + assert!(matches!(rx.recv().await, Some(Err(Error::ProtocolViolation)))); + assert!(rx.recv().await.is_none(), "pump must stop after the violation"); + pump.await.unwrap(); + } + } +} + +#[cfg(feature = "ws")] +pub(crate) use ws_transport::WsTransport; diff --git a/rs/qmux/src/uds.rs b/rs/qmux/src/uds.rs new file mode 100644 index 00000000000..eb8c40b8dc2 --- /dev/null +++ b/rs/qmux/src/uds.rs @@ -0,0 +1,95 @@ +use std::path::Path; + +use tokio::net::UnixStream; + +use crate::transport::build_stream_session; +use crate::{Error, Protocol, Session, Version}; + +/// Builder for a QMux session over a Unix domain socket. +/// +/// Like [`crate::tcp::Config`], but over a Unix socket — ideal for same-host MoQ +/// where the TLS/ALPN handshake of a network transport would be pure overhead. +/// The application protocol (if any) is negotiated in-band via +/// [`Config::protocols`]. +/// +/// ```no_run +/// # async fn f(path: &str) -> Result<(), qmux::Error> { +/// use qmux::{uds, Version}; +/// +/// let session = uds::Config::new(Version::QMux01) +/// .protocols(["moq-lite-04"]) +/// .connect(path) +/// .await?; +/// # let _ = session; Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct Config { + inner: crate::Config, +} + +impl Config { + /// Start building a Unix-socket session speaking QMux `version`. + pub fn new(version: Version) -> Self { + Self { + inner: crate::Config::new(version), + } + } + + /// Advertise these application protocols for in-band negotiation, in + /// preference order. Omit to run without an application protocol. + pub fn protocols(mut self, protocols: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.inner.protocol = Protocol::Negotiate(protocols.into_iter().map(Into::into).collect()); + self + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which this builder keeps. Call it before + /// [`handshake_timeout`](Self::handshake_timeout), which it would replace. + pub fn session_config(mut self, config: crate::Config) -> Self { + self.inner = crate::Config { + version: self.inner.version, + protocol: self.inner.protocol, + ..config + }; + self + } + + /// Override how long establishment waits for the peer's transport + /// parameters before failing with [`Error::HandshakeTimeout`], or `None` to wait + /// indefinitely. Defaults to 10s. See + /// [`Config::handshake_timeout`](crate::Config::handshake_timeout). + pub fn handshake_timeout(mut self, timeout: Option) -> Self { + self.inner.handshake_timeout = timeout; + self + } + + /// Connect to the socket at `path` and start a client session. + pub async fn connect(self, path: impl AsRef) -> Result { + let stream = UnixStream::connect(path).await?; + finish(stream, self.inner, false).await + } + + /// Start a server session over an accepted Unix-socket stream. + /// + /// This awaits the QMux handshake (the peer's transport parameters), bounded + /// by [`handshake_timeout`](Self::handshake_timeout). Drive each connection + /// with `tokio::spawn` so a slow or non-cooperative peer can't stall your + /// `listener.accept()` loop. + pub async fn accept(self, stream: UnixStream) -> Result { + finish(stream, self.inner, true).await + } +} + +async fn finish(stream: UnixStream, config: crate::Config, is_server: bool) -> Result { + // `build_stream_session` awaits the peer's transport parameters before + // returning, so `protocol()` is resolved on the session we hand back + // (bounded by the config's handshake timeout). + // A Unix socket has no TCP_INFO to read; loopback latency is negligible + // anyway, and QX_PING still covers the round trip. + build_stream_session(stream, config, is_server, None).await +} diff --git a/rs/qmux/src/ws.rs b/rs/qmux/src/ws.rs new file mode 100644 index 00000000000..e146b557638 --- /dev/null +++ b/rs/qmux/src/ws.rs @@ -0,0 +1,464 @@ +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::protocol::validate_protocol; +use crate::transport::WsTransport; +use moq_net::transport::Binding; +use moq_quic::Side; + +use crate::{Config, Error, Session, Version, alpn}; + +/// The `tokio-tungstenite` version this crate is built against. +/// +/// [`Client::with_connector`] and [`Upgraded`] take `tokio-tungstenite` types +/// directly, so use this re-export rather than a separately pinned dependency — +/// a different minor version is a distinct crate to the compiler and fails to +/// typecheck against these APIs. +pub use tokio_tungstenite; + +/// The `tungstenite` version this crate is built against, matching +/// [`tokio_tungstenite`]. +/// +/// [`Client::with_config`] and [`Upgraded`] name `tungstenite` types, and +/// [`Error::WebSocket`] wraps a `tungstenite::Error`. +pub use tokio_tungstenite::tungstenite; + +/// Keep-alive configuration for WebSocket transports. +/// +/// WebSocket has no built-in idle timeout: when the peer's host crashes +/// or its network drops without sending a TCP FIN, the local socket +/// stays "open" until OS-level TCP keep_alive eventually probes — typically +/// hours. Set this to send periodic Pings and close the session if no +/// frame arrives within `timeout`. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct KeepAlive { + /// How often to send a Ping frame to the peer. + pub interval: Duration, + + /// Close the session if no frame is received from the peer within this window. + /// Should be a small multiple of `interval` to tolerate transient drops; a session + /// refuses one no longer than `interval` with [`Error::InvalidConfig`]. + pub timeout: Duration, +} + +impl KeepAlive { + /// Create a keep-alive config with the given interval and timeout. + pub fn new(interval: Duration, timeout: Duration) -> Self { + Self { interval, timeout } + } +} + +impl Default for KeepAlive { + fn default() -> Self { + // Match the QUIC defaults used by moq-native: 5s ping, 30s deadline. + Self { + interval: Duration::from_secs(5), + timeout: Duration::from_secs(30), + } + } +} + +/// Wrap an already-upgraded WebSocket as a QMux session. +/// +/// Use this when the WebSocket handshake was performed by an external +/// framework (e.g. axum) and the caller already has the negotiated +/// `Sec-WebSocket-Protocol` value, which names the QMux wire-format version and +/// application protocol. +pub struct Upgraded { + ws: T, + alpn: String, + keep_alive: Option, + socket_stats: Option, + session: Config, +} + +impl Upgraded +where + T: futures::Stream> + + futures::Sink + + Unpin + + Send + + 'static, +{ + /// Wrap `ws`, whose handshake negotiated the `Sec-WebSocket-Protocol` value `alpn`. + pub fn new(ws: T, alpn: &str) -> Self { + Self { + ws, + alpn: alpn.to_string(), + keep_alive: None, + socket_stats: None, + session: Config::default(), + } + } + + /// Drive a keep-alive Ping/timeout on the WebSocket. + pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Self { + self.keep_alive = Some(keep_alive); + self + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which the handshake negotiates. + /// + /// The WebSocket handshake already settled the protocol, so the session starts + /// without awaiting the peer's transport parameters, failing later with + /// [`Error::HandshakeTimeout`] if they never arrive. Keep + /// [`max_record_size`](Config::max_record_size) within the WebSocket's + /// frame limit (16 MiB by default), since each record is one message. + pub fn with_session_config(mut self, config: Config) -> Self { + self.session = config; + self + } + + /// Report statistics for the socket underneath the upgrade. + /// + /// An HTTP server hands over a type-erased upgraded stream, so QMux can't find + /// the socket itself. A server that kept a handle on the accepted connection + /// can attach it here and get the kernel's RTT (and, on Linux, delivery rate) + /// instead of only QMux's own `QX_PING` measurement. See + /// [`TcpStats`](crate::TcpStats). + pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { + self.socket_stats = Some(stats); + self + } + + /// Wrap as a client-side session. + /// + /// The protocol is already known from the negotiated subprotocol (ALPN), so + /// this returns synchronously without awaiting in-band parameters. Fails when + /// the subprotocol names no QMux version. + pub fn connect(self) -> Result { + self.start(Side::Client) + } + + /// Wrap as a server-side session. + /// + /// As with [`connect`](Self::connect), the protocol is known from the + /// negotiated subprotocol, so this returns synchronously. + pub fn accept(self) -> Result { + self.start(Side::Server) + } + + fn start(self, side: Side) -> Result { + let (version, protocol) = alpn::parse(Some(&self.alpn))?; + let config = self.session.clone().with_negotiated(version, protocol); + let transport = self.into_transport(config.version, config.max_record_size)?; + Session::new(transport, side, config) + } + + fn into_transport(self, version: Version, max_record_size: u64) -> Result, Error> { + let mut transport = WsTransport::new(self.ws, version, max_record_size, Binding::Uri); + if let Some(stats) = self.socket_stats { + transport = transport.with_socket_stats(stats); + } + Ok(match self.keep_alive { + Some(ka) => transport.with_keep_alive(ka)?, + None => transport, + }) + } +} + +/// A QMux client that opens a WebSocket and negotiates an application protocol. +/// +/// Each entry pairs an `alpn` with the QMux wire-format `versions` it can +/// ride on. The wire form `{v.prefix()}{alpn}` is emitted once per listed +/// version, in order; an empty `versions` slice expands to every QMux draft +/// this crate knows about. Add multiple entries to negotiate across drafts in +/// a single handshake; the wire-format version comes from the negotiated +/// subprotocol. By default the bare version ALPNs (`qmux-02`, `qmux-01`, +/// `qmux-00`) are also offered for peers that don't pin an app protocol; +/// call [`Client::require_protocol`] to offer only the configured pairs. +#[derive(Default, Clone)] +pub struct Client { + protocols: Vec<(String, Vec)>, + require_protocol: bool, + config: Option, + keep_alive: Option, + session: Config, + #[cfg(feature = "wss")] + connector: Option, +} + +impl Client { + pub fn new() -> Self { + Self::default() + } + + /// Advertise `alpn` under the listed QMux wire-format versions. + /// + /// Pass `&[v]` to pin to one version, `&[v1, v2]` to enumerate, or `&[]` + /// to let the polyfill expand to every QMux draft it knows about. + pub fn with_protocol(mut self, alpn: &str, versions: &[Version]) -> Self { + self.protocols.push((alpn.to_string(), versions.to_vec())); + self + } + + /// Advertise multiple `(alpn, versions)` entries in preference order. + pub fn with_protocols<'a>(mut self, entries: impl IntoIterator) -> Self { + self.protocols + .extend(entries.into_iter().map(|(a, vs)| (a.to_string(), vs.to_vec()))); + self + } + + /// Offer only the prefixed `(alpn, version)` pairs, suppressing the bare + /// version ALPNs (`qmux-02`, `qmux-01`, `qmux-00`) that are offered by + /// default. Use this when the peer must commit to one of the application + /// protocols you've configured. + pub fn require_protocol(mut self) -> Self { + self.require_protocol = true; + self + } + + /// Set the WebSocket configuration (e.g. max message/frame sizes). + pub fn with_config(mut self, config: tungstenite::protocol::WebSocketConfig) -> Self { + self.config = Some(config); + self + } + + /// Send periodic Pings and close the session if the peer goes silent. + /// + /// WebSocket has no built-in idle timeout, so without this a crashed peer + /// stays "connected" until OS-level TCP keep_alive eventually probes. + pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Self { + self.keep_alive = Some(keep_alive); + self + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which the handshake negotiates. + /// + /// The WebSocket handshake already settled the protocol, so the session starts + /// without awaiting the peer's transport parameters, failing later with + /// [`Error::HandshakeTimeout`] if they never arrive. Keep + /// [`max_record_size`](Config::max_record_size) within the WebSocket's + /// frame limit (16 MiB by default), since each record is one message. + pub fn with_session_config(mut self, config: Config) -> Self { + self.session = config; + self + } + + /// Set the TLS connector for secure WebSocket connections. + #[cfg(feature = "wss")] + pub fn with_connector(mut self, connector: tokio_tungstenite::Connector) -> Self { + self.connector = Some(connector); + self + } + + /// Connect to a WebSocket server, negotiating an advertised `(alpn, version)`. + pub async fn connect(&self, url: &str) -> Result { + use tungstenite::{client::IntoClientRequest, http}; + + for (a, _) in &self.protocols { + validate_protocol(a)?; + } + + let mut request = url.into_client_request().map_err(Error::from)?; + + let entries = self.protocols.iter().map(|(a, vs)| (a.as_str(), vs.as_slice())); + let protocol_value = alpn::build(entries, self.require_protocol).join(", "); + + request.headers_mut().insert( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_str(&protocol_value).map_err(|_| Error::InvalidProtocol(protocol_value))?, + ); + + #[cfg(feature = "wss")] + let (ws_stream, response) = { + tokio_tungstenite::connect_async_tls_with_config(request, self.config, false, self.connector.clone()) + .await + .map_err(Error::from)? + }; + + #[cfg(not(feature = "wss"))] + let (ws_stream, response) = tokio_tungstenite::connect_async_with_config(request, self.config, false) + .await + .map_err(Error::from)?; + + let negotiated = response + .headers() + .get(http::header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|h| h.to_str().ok()); + + let (version, protocol) = alpn::parse(negotiated)?; + + // In strict mode a bare version subprotocol would silently drop the + // application protocol we promised to enforce. + if self.require_protocol && protocol.is_none() { + return Err(Error::InvalidProtocol(negotiated.unwrap_or("").to_string())); + } + + let config = self.session.clone().with_negotiated(version, protocol); + let transport = WsTransport::new(ws_stream, config.version, config.max_record_size, Binding::Uri); + let transport = match self.keep_alive { + Some(ka) => transport.with_keep_alive(ka)?, + None => transport, + }; + // Protocol came from the negotiated subprotocol, so no in-band wait. + Session::new(transport, Side::Client, config) + } +} + +/// A QMux server that accepts WebSocket connections. +/// +/// Each entry pairs an `alpn` with the QMux wire-format `versions` it can +/// ride on. The handshake callback matches each `{v.prefix()}{alpn}` +/// permutation against the client's offered `Sec-WebSocket-Protocol` in +/// declaration order. An empty `versions` slice expands to every QMux draft +/// this crate knows about. By default bare version ALPNs (`qmux-02`, +/// `qmux-01`, `qmux-00`) are also accepted for peers that don't pin an +/// app protocol; call [`Server::require_protocol`] to accept only the +/// configured pairs. +#[derive(Default, Clone)] +pub struct Server { + protocols: Vec<(String, Vec)>, + require_protocol: bool, + keep_alive: Option, + session: Config, +} + +impl Server { + pub fn new() -> Self { + Self::default() + } + + /// Advertise `alpn` under the listed QMux wire-format versions. + /// + /// Pass `&[v]` to support one version, `&[v1, v2]` to enumerate, or `&[]` + /// to accept every QMux draft this crate knows about. + pub fn with_protocol(mut self, alpn: &str, versions: &[Version]) -> Self { + self.protocols.push((alpn.to_string(), versions.to_vec())); + self + } + + /// Advertise multiple `(alpn, versions)` entries in preference order. + pub fn with_protocols<'a>(mut self, entries: impl IntoIterator) -> Self { + self.protocols + .extend(entries.into_iter().map(|(a, vs)| (a.to_string(), vs.to_vec()))); + self + } + + /// Accept only the configured prefixed pairs, rejecting clients that offer + /// just a bare version ALPN (`qmux-02`, `qmux-01`, `qmux-00`) with no + /// application protocol. By default those bare ALPNs are accepted, yielding + /// a session with `Config::protocol == None` and the wire-format version + /// implied by the ALPN. + pub fn require_protocol(mut self) -> Self { + self.require_protocol = true; + self + } + + /// Send periodic Pings and close the session if the peer goes silent. + /// + /// WebSocket has no built-in idle timeout, so without this a crashed peer + /// stays "connected" until OS-level TCP keep_alive eventually probes. + pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Self { + self.keep_alive = Some(keep_alive); + self + } + + /// Use `config` for every session setting except the wire-format version + /// and application protocol, which the handshake negotiates. + /// + /// The WebSocket handshake already settled the protocol, so the session starts + /// without awaiting the peer's transport parameters, failing later with + /// [`Error::HandshakeTimeout`] if they never arrive. Keep + /// [`max_record_size`](Config::max_record_size) within the WebSocket's + /// frame limit (16 MiB by default), since each record is one message. + pub fn with_session_config(mut self, config: Config) -> Self { + self.session = config; + self + } + + /// Accept a WebSocket connection, negotiating an offered `(alpn, version)`. + pub async fn accept( + &self, + socket: T, + ) -> Result { + use std::sync::{Arc, Mutex}; + use tungstenite::{handshake::server, http}; + + for (a, _) in &self.protocols { + validate_protocol(a)?; + } + + let negotiated = Arc::new(Mutex::new(None::<(Version, Option)>)); + let negotiated_clone = negotiated.clone(); + let supported = self.protocols.clone(); + let require_protocol = self.require_protocol; + + #[allow(clippy::result_large_err)] + let callback = move |req: &server::Request, + mut response: server::Response| + -> Result { + let header_protocols: Vec<&str> = req + .headers() + .get_all(http::header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|h| h.split(',')) + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .collect(); + + // Iterate supported entries in preference order; for each, expand + // the listed versions (empty = every supported QMux draft) and pick + // the first `{prefix}{alpn}` permutation the client offered. + for (alpn, versions) in &supported { + for &version in alpn::expand_versions(versions) { + let wire = format!("{}{}", version.prefix(), alpn); + if header_protocols.iter().any(|p| *p == wire) { + response.headers_mut().insert( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_str(&wire).unwrap(), + ); + *negotiated_clone.lock().unwrap() = Some((version, Some(alpn.clone()))); + return Ok(response); + } + } + } + + // Default fallback: accept bare version ALPNs (qmux-02, qmux-01, + // qmux-00) for clients that didn't request an app protocol, unless + // require_protocol was set. The session ends up with + // `protocol = None` and the wire-format version implied by + // whichever bare ALPN won. + if !require_protocol { + for &version in Version::ALL { + let bare = version.alpn(); + if header_protocols.contains(&bare) { + response.headers_mut().insert( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_str(bare).unwrap(), + ); + *negotiated_clone.lock().unwrap() = Some((version, None)); + return Ok(response); + } + } + } + + Err(http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body(Some("no supported protocol".to_string())) + .unwrap()) + }; + + let ws = tokio_tungstenite::accept_hdr_async_with_config(socket, callback, None).await?; + + let (version, protocol) = negotiated + .lock() + .unwrap() + .take() + .expect("negotiated must be set after successful handshake"); + + let config = self.session.clone().with_negotiated(version, protocol); + let transport = WsTransport::new(ws, config.version, config.max_record_size, Binding::Uri); + let transport = match self.keep_alive { + Some(ka) => transport.with_keep_alive(ka)?, + None => transport, + }; + // Protocol came from the negotiated subprotocol, so no in-band wait. + Session::new(transport, Side::Server, config) + } +} diff --git a/rs/qmux/tests/interop.rs b/rs/qmux/tests/interop.rs new file mode 100644 index 00000000000..7df47c96e1d --- /dev/null +++ b/rs/qmux/tests/interop.rs @@ -0,0 +1,174 @@ +//! Wire interop with the last release before the stream state machine moved into +//! `moq-quic`, in both roles and on every version both speak. +//! +//! Each scenario runs over an in-memory byte stream on paused time: a transfer large enough +//! to need credit updates each way, a bidirectional echo, a reset code each way, a datagram +//! each way where records carry them, and an application close. + +use bytes::{Bytes, BytesMut}; +use moq_net::transport::poll::{RecvStream as _, SendOptions, SendStream as _, Session as _}; +use qmux::{Protocol, Session, Version}; +use web_transport_trait::{RecvStream as _, SendStream as _, Session as _}; + +const PROTOCOL: &str = "moq-lite-04"; +const LEN: usize = 1 << 20; + +fn payload(seed: usize) -> Bytes { + (0..LEN).map(|i| ((i * 31 + seed) % 251) as u8).collect() +} + +fn old_version(version: Version) -> qmux06::Version { + match version { + Version::QMux00 => qmux06::Version::QMux00, + Version::QMux01 => qmux06::Version::QMux01, + Version::QMux02 => qmux06::Version::QMux02, + _ => unreachable!(), + } +} + +async fn ours_and_theirs(version: Version, ours_is_client: bool) -> (Session, qmux06::Session) { + let (a, b) = tokio::io::duplex(64 * 1024); + let mut ours = qmux::Config::new(version); + ours.protocol = Protocol::Negotiate(vec![PROTOCOL.into()]); + let mut theirs = qmux06::Config::new(old_version(version)); + theirs.protocol = qmux06::Protocol::Negotiate(vec![PROTOCOL.into()]); + + let ours_transport = qmux::transport::Stream::new(a, ours.version, ours.max_record_size); + let theirs_transport = qmux06::transport::Stream::new(b, theirs.version, theirs.max_record_size); + if ours_is_client { + let (ours, theirs) = tokio::join!( + Session::connect(ours_transport, ours), + qmux06::Session::accept(theirs_transport, theirs) + ); + (ours.unwrap(), theirs.unwrap()) + } else { + let (ours, theirs) = tokio::join!( + Session::accept(ours_transport, ours), + qmux06::Session::connect(theirs_transport, theirs) + ); + (ours.unwrap(), theirs.unwrap()) + } +} + +async fn write_all(send: &mut qmux::SendStream, mut data: Bytes) { + while !data.is_empty() { + send.write_buf(&mut data).await.unwrap(); + } +} + +async fn read_all(recv: &mut qmux::RecvStream) -> Result { + let mut buf = BytesMut::new(); + while let Some(chunk) = recv.read_chunk(usize::MAX).await? { + buf.extend_from_slice(&chunk); + } + Ok(buf.freeze()) +} + +async fn scenario(version: Version, ours_is_client: bool) { + let label = format!( + "{version:?}, ours is the {}", + if ours_is_client { "client" } else { "server" } + ); + let (mut ours, theirs) = ours_and_theirs(version, ours_is_client).await; + assert_eq!(ours.protocol(), Some(PROTOCOL), "{label}"); + assert_eq!(theirs.protocol(), Some(PROTOCOL), "{label}"); + + // Ours -> theirs, many times the default stream window. + let mut send = ours.open_uni(SendOptions::default()).await.unwrap(); + let writer = tokio::spawn(async move { + write_all(&mut send, payload(1)).await; + send.finish().unwrap(); + send.closed().await + }); + let mut recv = theirs.accept_uni().await.unwrap(); + assert_eq!(recv.read_all().await.unwrap(), payload(1), "{label}"); + writer.await.unwrap().unwrap(); + + // Theirs -> ours. + let mut send = theirs.open_uni().await.unwrap(); + let writer = tokio::spawn(async move { + send.write_all(&payload(2)).await.unwrap(); + send.finish().unwrap(); + }); + let mut recv = ours.accept_uni().await.unwrap(); + assert_eq!(read_all(&mut recv).await.unwrap(), payload(2), "{label}"); + writer.await.unwrap(); + + // A bidirectional echo, opened by each side. + let (mut send, mut recv) = ours.open_bi(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"ping")).await; + send.finish().unwrap(); + let (mut their_send, mut their_recv) = theirs.accept_bi().await.unwrap(); + assert_eq!(their_recv.read_all().await.unwrap(), "ping", "{label}"); + their_send.write_all(b"pong").await.unwrap(); + their_send.finish().unwrap(); + assert_eq!(read_all(&mut recv).await.unwrap(), "pong", "{label}"); + + let (mut their_send, mut their_recv) = theirs.open_bi().await.unwrap(); + their_send.write_all(b"ping").await.unwrap(); + their_send.finish().unwrap(); + let (mut send, mut recv) = ours.accept_bi().await.unwrap(); + assert_eq!(read_all(&mut recv).await.unwrap(), "ping", "{label}"); + write_all(&mut send, Bytes::from_static(b"pong")).await; + send.finish().unwrap(); + assert_eq!(their_recv.read_all().await.unwrap(), "pong", "{label}"); + + // Reset codes, each way. The reset waits for the accept: 0.6 never surfaces a stream + // whose first frame is RESET_STREAM. + let mut send = ours.open_uni(SendOptions::default()).await.unwrap(); + write_all(&mut send, Bytes::from_static(b"partial")).await; + let mut their_recv = theirs.accept_uni().await.unwrap(); + send.reset(7); + let err = their_recv.read_all().await.unwrap_err(); + assert!( + matches!(err, qmux06::Error::StreamReset(code) if code.into_inner() == 7), + "{label}: {err:?}" + ); + + let mut their_send = theirs.open_uni().await.unwrap(); + their_send.write_all(b"partial").await.unwrap(); + let mut recv = ours.accept_uni().await.unwrap(); + their_send.reset(8); + let err = read_all(&mut recv).await.unwrap_err(); + assert!(matches!(err, qmux::Error::StreamReset(8)), "{label}: {err:?}"); + + // Datagrams, where records carry them. + if version.uses_records() { + ours.send_datagram(Bytes::from_static(b"ours"), SendOptions::default()) + .unwrap(); + assert_eq!(theirs.recv_datagram().await.unwrap(), "ours", "{label}"); + theirs.send_datagram(Bytes::from_static(b"theirs")).unwrap(); + assert_eq!(ours.recv_datagram().await.unwrap(), "theirs", "{label}"); + } + + // An application close, from each side in turn across the two roles. + if ours_is_client { + ours.close(42, "done"); + let err = theirs.closed().await; + assert!( + matches!(&err, qmux06::Error::ConnectionClosed { code, reason } if code.into_inner() == 42 && reason == "done"), + "{label}: {err:?}" + ); + } else { + theirs.close(43, "done"); + let err = ours.closed().await; + assert!( + matches!(&err, qmux::Error::ConnectionClosed { code: 43, reason } if reason == "done"), + "{label}: {err:?}" + ); + } +} + +#[tokio::test(start_paused = true)] +async fn ours_as_client() { + for &version in Version::ALL { + scenario(version, true).await; + } +} + +#[tokio::test(start_paused = true)] +async fn ours_as_server() { + for &version in Version::ALL { + scenario(version, false).await; + } +} diff --git a/test/justfile b/test/justfile index c4f2e1a0bae..67909caa314 100644 --- a/test/justfile +++ b/test/justfile @@ -13,8 +13,8 @@ set fallback set working-directory := '.' -# Where `just test qmux` installs the Rust qmux release it interoperates with. -qmux_root := env("CARGO_TARGET_DIR", justfile_directory() / "target") / "qmux-0.6.3" +# Where cargo puts the in-tree qmux interop examples `just test qmux` runs. +qmux_examples := absolute_path(env("CARGO_TARGET_DIR", source_directory() / ".." / "target")) / "debug" / "examples" # List the cross-language suites. default: @@ -229,15 +229,15 @@ bare-fin: json-properties: bash json-properties.sh -# The TypeScript qmux peer as client and server against qmux 0.6.3's Rust -# interop examples, per draft. `cargo install` keeps that release out of the -# workspace's dependency graph and skips the build once installed. +# The TypeScript qmux peer as client and server against the in-tree Rust qmux +# interop examples, per draft. The manifest path names the in-tree crate, which +# shares its package name with the 0.6 release its tests interoperate with. # TypeScript-to-Rust qmux interop. qmux: - cargo install --locked --quiet qmux@0.6.3 --features ws --example interop-server --example interop-client --root {{ quote(qmux_root) }} + cargo build --locked --quiet --manifest-path ../rs/qmux/Cargo.toml --example interop-server --example interop-client cd .. && bun install --frozen-lockfile - cd .. && QMUX_INTEROP_BIN={{ quote(qmux_root / "bin") }} bun test test/qmux/interop.test.ts + cd .. && QMUX_INTEROP_BIN={{ quote(qmux_examples) }} bun test test/qmux/interop.test.ts # Complete MoQ SETUP and transfer a frame over a real browser data-channel pair. p2p: diff --git a/test/qmux/interop.test.ts b/test/qmux/interop.test.ts index a843c08a887..48dc78d5f7a 100644 --- a/test/qmux/interop.test.ts +++ b/test/qmux/interop.test.ts @@ -1,5 +1,5 @@ // The TypeScript qmux peer against the Rust one, per draft, in both roles. -// The Rust side is qmux 0.6.3's interop examples, installed by `just test qmux`. +// The Rust side is the in-tree qmux crate's interop examples, built by `just test qmux`. import { expect, test } from "bun:test"; import { join } from "node:path"; import Session, { selectSubprotocol, type Version, type WebSocketStreamLike } from "@moq/qmux";