From 2f113b7e6884e51fb4100d9fcfdfe8600a3ab11a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 16:54:26 -0700 Subject: [PATCH 01/24] quest: claim m1/quic-qmux Co-Authored-By: Claude Opus 5.5 From 8113269a2888342f4743ed66fdfa6b9e4367c3af Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:23:22 -0700 Subject: [PATCH 02/24] feat(quic): a sans-IO QMux connection on the stream state machine `moq_quic::mux`, behind the `mux` feature, runs the QUIC stream state machine over a reliable, ordered transport: QMux drafts 00, 01, and 02. Serializing a frame acknowledges it, so flow control credit, stream retirement, and held resets follow the same paths QUIC uses. It encodes transport parameters in the order qmux 0.6 does, keeps STREAM payloads under the 16352-byte ceiling released peers enforce, and handles QX_PING keep-alives, RTT probes, and the idle and handshake timeouts on a caller-supplied clock. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + rs/moq-quic/Cargo.toml | 5 + rs/moq-quic/src/connection/mod.rs | 2 + rs/moq-quic/src/connection/mux/mod.rs | 840 ++++++++++++++++++++ rs/moq-quic/src/connection/mux/params.rs | 254 ++++++ rs/moq-quic/src/connection/mux/tests.rs | 582 ++++++++++++++ rs/moq-quic/src/connection/mux/timer.rs | 199 +++++ rs/moq-quic/src/connection/mux/wire.rs | 139 ++++ rs/moq-quic/src/connection/streams/recv.rs | 14 + rs/moq-quic/src/connection/streams/state.rs | 16 + rs/moq-quic/src/frame.rs | 6 + rs/moq-quic/src/lib.rs | 2 + 12 files changed, 2060 insertions(+) create mode 100644 rs/moq-quic/src/connection/mux/mod.rs create mode 100644 rs/moq-quic/src/connection/mux/params.rs create mode 100644 rs/moq-quic/src/connection/mux/tests.rs create mode 100644 rs/moq-quic/src/connection/mux/timer.rs create mode 100644 rs/moq-quic/src/connection/mux/wire.rs diff --git a/Cargo.lock b/Cargo.lock index a51cdadcec9..722f265e1f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5060,6 +5060,7 @@ dependencies = [ "hex-literal", "lru-slab", "moq-clock", + "moq-quic", "proptest", "qlog", "rand 0.10.3", 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 f756e7cce7a..eb7d290d397 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..4020eaa0de1 --- /dev/null +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -0,0 +1,840 @@ +//! 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, FrameStruct}, + 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 + 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) { + debug!(%err, "qmux protocol error"); + self.close = Some(Close::from(err.clone())); + self.lost(ConnectionError::TransportError(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(); + match self.config.version.uses_records() { + true if size as u64 > self.config.max_record_size.into_inner() => { + return Err(TransportError::PROTOCOL_VIOLATION( + "record exceeds max_record_size", + )); + } + false if size > MAX_FRAME_SIZE => { + return Err(TransportError::FRAME_ENCODING_ERROR( + "frame exceeds max_frame_size", + )); + } + _ => {} + } + + let mut frames = 0; + while let Some(frame) = wire::next_frame(&mut payload)? { + 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, now)?; + if self.error.is_some() { + // The peer closed; ignore the rest + return Ok(()); + } + } + self.streams.queue_max_stream_id(&mut self.pending); + Ok(()) + } + + fn on_frame( + &mut self, + frame: wire::Frame, + payload_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(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 id.initiator() != self.side { + if id.dir() == Dir::Uni { + return Err(TransportError::STREAM_STATE_ERROR( + "STOP_SENDING on recv-only stream", + )); + } + } else if self.streams.is_local_unopened(id) { + return Err(TransportError::STREAM_STATE_ERROR( + "STOP_SENDING on unopened stream", + )); + } + 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(datagram) => { + let Some(max) = self.config.max_datagram_frame_size else { + return Err(TransportError::PROTOCOL_VIOLATION( + "unexpected DATAGRAM frame", + )); + }; + if datagram.size(true) as u64 > max.into_inner() { + return Err(TransportError::PROTOCOL_VIOLATION("oversized datagram")); + } + 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()), + }; + server.iter().find(|p| client.contains(p)).cloned() + } + (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 = usize::try_from(peer.into_inner()) + .unwrap_or(usize::MAX) + .min(self.conn.budget()); + Some(frame.saturating_sub(Datagram::SIZE_BOUND)) + } + + /// 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..3ec58898239 --- /dev/null +++ b/rs/moq-quic/src/connection/mux/params.rs @@ -0,0 +1,254 @@ +//! 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 { + MAX_RECORD_SIZE => { + 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_floor_is_for_records_only() { + let mut buf = Vec::new(); + write_param(&mut buf, MAX_RECORD_SIZE, 1024); + assert!(decode(buf.clone()).is_err()); + assert!(Params::decode(Side::Client, Version::QMux00, buf.into()).is_ok()); + } + + #[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..ff2d63adc0a --- /dev/null +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -0,0 +1,582 @@ +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_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 + ); +} + +#[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.into(), 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 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, config(Version::QMux01)); + 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:?}"); + } +} 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 afdc4185bda..1863cb07e50 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -470,6 +470,22 @@ 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 { + 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 424a6e23f1b..b5c0bee13c9 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 a52b3f2dd32..b4b34038b00 100644 --- a/rs/moq-quic/src/lib.rs +++ b/rs/moq-quic/src/lib.rs @@ -58,6 +58,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; From 38d07fadd19984f2580c8e9ef33404ad8c75775d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:51:02 -0700 Subject: [PATCH 03/24] feat(quic): let the mux close on transport-level framing violations A byte-stream or WebSocket reader detects an oversized record or broken framing before the connection sees a record. handle_oversized and handle_violation close with the right transport error code so the peer still gets a CONNECTION_CLOSE. Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/mod.rs | 53 ++++++++++++++++++------- rs/moq-quic/src/connection/mux/tests.rs | 11 +++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 4020eaa0de1..137f55b2e2b 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -257,9 +257,23 @@ impl Connection { } self.timer.on_recv(now); if let Err(err) = self.process(payload, now) { - debug!(%err, "qmux protocol error"); - self.close = Some(Close::from(err.clone())); - self.lost(ConnectionError::TransportError(err)); + 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); } } @@ -527,18 +541,12 @@ impl Connection { fn process(&mut self, mut payload: Bytes, now: Instant) -> Result<(), TransportError> { let size = payload.len(); - match self.config.version.uses_records() { - true if size as u64 > self.config.max_record_size.into_inner() => { - return Err(TransportError::PROTOCOL_VIOLATION( - "record exceeds max_record_size", - )); - } - false if size > MAX_FRAME_SIZE => { - return Err(TransportError::FRAME_ENCODING_ERROR( - "frame exceeds max_frame_size", - )); - } - _ => {} + 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; @@ -559,6 +567,21 @@ impl Connection { 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, diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index ff2d63adc0a..818c7b7ffcb 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -303,6 +303,17 @@ fn rejects_oversized_records() { 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] From b0a2fdf403ad65e7b5125918e041e997ae3e25f8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:51:08 -0700 Subject: [PATCH 04/24] feat(qmux): move the qmux crate in tree onto the QUIC stream state machine qmux 0.7 keeps the 0.6 transports (TCP, TLS, WebSocket, Unix sockets) and config shape, but its session drives moq_quic::mux instead of its own stream state machine, and implements moq-net's transport::poll traits directly. The legacy `webtransport` wire format is gone: an ALPN or subprotocol that names no QMux version is now an error. Tests cover the session over an in-memory stream on paused time, and wire interop with qmux 0.6.3 in both roles on every version. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 27 +- Cargo.toml | 4 +- rs/qmux/Cargo.toml | 56 ++ rs/qmux/examples/common/mod.rs | 59 ++ rs/qmux/examples/interop-client.rs | 91 +++ rs/qmux/examples/interop-server.rs | 95 +++ rs/qmux/src/alpn.rs | 156 ++++ rs/qmux/src/config.rs | 170 ++++ rs/qmux/src/error.rs | 199 +++++ rs/qmux/src/lib.rs | 73 ++ rs/qmux/src/protocol.rs | 25 + rs/qmux/src/session.rs | 1190 ++++++++++++++++++++++++++++ rs/qmux/src/session/tests.rs | 391 +++++++++ rs/qmux/src/socket.rs | 298 +++++++ rs/qmux/src/tcp.rs | 128 +++ rs/qmux/src/tls.rs | 184 +++++ rs/qmux/src/transport.rs | 1098 +++++++++++++++++++++++++ rs/qmux/src/uds.rs | 95 +++ rs/qmux/src/ws.rs | 463 +++++++++++ rs/qmux/tests/interop.rs | 174 ++++ 20 files changed, 4972 insertions(+), 4 deletions(-) create mode 100644 rs/qmux/Cargo.toml create mode 100644 rs/qmux/examples/common/mod.rs create mode 100644 rs/qmux/examples/interop-client.rs create mode 100644 rs/qmux/examples/interop-server.rs create mode 100644 rs/qmux/src/alpn.rs create mode 100644 rs/qmux/src/config.rs create mode 100644 rs/qmux/src/error.rs create mode 100644 rs/qmux/src/lib.rs create mode 100644 rs/qmux/src/protocol.rs create mode 100644 rs/qmux/src/session.rs create mode 100644 rs/qmux/src/session/tests.rs create mode 100644 rs/qmux/src/socket.rs create mode 100644 rs/qmux/src/tcp.rs create mode 100644 rs/qmux/src/tls.rs create mode 100644 rs/qmux/src/transport.rs create mode 100644 rs/qmux/src/uds.rs create mode 100644 rs/qmux/src/ws.rs create mode 100644 rs/qmux/tests/interop.rs diff --git a/Cargo.lock b/Cargo.lock index 722f265e1f5..b37c4a6ad15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5117,7 +5117,7 @@ dependencies = [ "moq-stats", "moq-tokio", "moq-uring", - "qmux", + "qmux 0.7.0", "rand 0.10.3", "rcgen 0.14.10", "reqwest", @@ -5336,7 +5336,7 @@ dependencies = [ "noq-proto", "notify", "pin-project-lite", - "qmux", + "qmux 0.7.0", "rand 0.10.3", "rcgen 0.14.10", "reqwest", @@ -7536,13 +7536,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.3", + "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 479044152d1..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.3", 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/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/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..46cc1a9a6ae --- /dev/null +++ b/rs/qmux/src/config.rs @@ -0,0 +1,170 @@ +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. 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. 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+). 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. + /// + /// 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 + pub(crate) fn to_mux(&self) -> Result { + fn varint(value: u64) -> Result { + VarInt::from_u64(value).map_err(|_| crate::Error::TransportParameter) + } + + 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; + 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) + } +} diff --git a/rs/qmux/src/error.rs b/rs/qmux/src/error.rs new file mode 100644 index 00000000000..0e7f191505e --- /dev/null +++ b/rs/qmux/src/error.rs @@ -0,0 +1,199 @@ +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, + + /// 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..2dae475d7bc --- /dev/null +++ b/rs/qmux/src/session.rs @@ -0,0 +1,1190 @@ +//! 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; + batch = kio::wait(|waiter| inner.poll_batch(waiter, &mut timer)) => batch, + // Transport-level maintenance, like a WebSocket keep-alive Ping. + result = writer.maintain() => { + result?; + continue; + } + }; + 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. + Err(moq_quic::FinishError::Stopped(code)) => { + self.reset = 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, + /// 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, + 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 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) => 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 { + 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 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) => 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 { + 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..49a8ae0ba11 --- /dev/null +++ b/rs/qmux/src/session/tests.rs @@ -0,0 +1,391 @@ +//! 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)))); + + 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)))); +} + +/// 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) + )); +} + +/// 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:?}" + ); + } + } +} + +#[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..208e5572a42 --- /dev/null +++ b/rs/qmux/src/transport.rs @@ -0,0 +1,1098 @@ +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, + our_max_record_size as usize, + 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(()) + } + + /// 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 = read_varint(reader).await?.into_inner() as usize; + 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 = read_varint_into(reader, &mut buf).await?.into_inner() as usize; + // 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() + 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 = read_varint_into(reader, &mut buf).await?.into_inner() as usize; + if buf.len() + 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 = read_varint_into(reader, &mut buf).await?.into_inner() as usize; + if buf.len() + 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, + } + } + + pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Self { + self.keep_alive = Some(keep_alive); + 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 { + // tokio::time::interval panics on a zero Duration; floor to 1ms so a + // misconfigured KeepAlive degrades into "very chatty" instead of crashing. + let interval_dur = config.interval.max(Duration::from_millis(1)); + // Skip catch-up bursts after a long pause; we just want one Ping per tick. + let mut interval = tokio::time::interval(interval_dur); + 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 interval_dur = config.interval.max(Duration::from_millis(1)); + // A deadline shorter than the interval would fire before the first ping. + let timeout = config.timeout.max(interval_dur); + 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 { + 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; + } + Message::Ping(_) | Message::Pong(_) | Message::Text(_) | 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 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(); + } + } +} + +#[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..d63dfbb69e1 --- /dev/null +++ b/rs/qmux/src/ws.rs @@ -0,0 +1,463 @@ +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. + 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) -> WsTransport { + 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); + } + 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; + } +} From 1b15385ee6634577f961583d50272c88508b5d19 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:02:02 -0700 Subject: [PATCH 05/24] refactor(tokio): run qmux sessions natively on moq-net's poll traits qmux::Session now implements moq_net::transport::poll itself, so moq-tokio and moq-relay hand it straight to moq-net instead of wrapping it in the async web-transport-trait adapter, which keeps serving quic and iroh. accept_request takes an already-poll session; callers wrap only the async backends. A WebSocket upgrade must now select a QMux subprotocol: the bare `webtransport` fallback and the no-subprotocol legacy route are refused. Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/src/auth/refresh.rs | 23 +--- rs/moq-relay/src/web.rs | 11 +- rs/moq-relay/src/websocket.rs | 124 +++++++----------- .../tests/integration/auth_lifetime.rs | 31 +++-- rs/moq-tokio/src/client.rs | 34 +---- rs/moq-tokio/src/connection/auth.rs | 10 +- rs/moq-tokio/src/server.rs | 94 ++++++------- rs/moq-tokio/src/tcp.rs | 8 +- rs/moq-tokio/src/transport.rs | 14 +- rs/moq-tokio/src/websocket.rs | 98 +++++++------- 10 files changed, 187 insertions(+), 260 deletions(-) diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 5f2f3f602a5..50dac90ed3d 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -523,10 +523,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)); @@ -537,10 +534,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(); @@ -626,23 +620,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 1c057b99be1..209dcc8bd3d 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -2166,17 +2166,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 b0932506101..775099d0591 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. @@ -283,7 +264,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; @@ -421,9 +402,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('.')) @@ -473,10 +453,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)); @@ -556,11 +537,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:?}", ); } @@ -588,12 +569,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), @@ -624,7 +602,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() @@ -633,8 +611,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; @@ -656,7 +634,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); @@ -696,10 +674,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) @@ -812,13 +788,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. @@ -840,7 +815,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(); @@ -862,13 +837,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 c8cf81f10a1..8dff2f841f1 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)) @@ -1781,7 +1793,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), @@ -1790,13 +1805,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/src/client.rs b/rs/moq-tokio/src/client.rs index 446c9f91712..2b4c9aed0e7 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -316,9 +316,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() { @@ -421,11 +419,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)); } @@ -440,11 +434,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)); } @@ -453,11 +443,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)); } @@ -533,11 +519,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)) } @@ -576,11 +558,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 ea686213d3a..168e9353648 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -429,10 +429,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)))) @@ -440,10 +437,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 0cb916e091f..bf83cd1ffc5 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()); } @@ -925,8 +927,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, @@ -1053,16 +1054,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?; @@ -1075,8 +1073,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, @@ -1111,13 +1108,15 @@ impl Deadline { ))] fn new(at: tokio::time::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, @@ -1567,7 +1566,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, @@ -1607,7 +1606,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 @@ -2207,8 +2206,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(), ) } @@ -2216,8 +2215,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; @@ -2233,11 +2232,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); @@ -2280,6 +2279,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 { @@ -2289,7 +2301,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 { @@ -2332,7 +2344,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 { @@ -2355,7 +2367,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 @@ -2435,8 +2447,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 @@ -2482,8 +2493,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 @@ -2509,9 +2519,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(), @@ -2520,7 +2530,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) @@ -2530,8 +2540,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"); @@ -2568,10 +2577,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 467777d52e2..5787360f5ee 100644 --- a/rs/moq-tokio/src/tcp.rs +++ b/rs/moq-tokio/src/tcp.rs @@ -347,8 +347,8 @@ impl Listener { #[cfg(test)] 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] @@ -483,11 +483,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 5f60ad295b9..bf46f70bc19 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)] mod tests { use std::sync::{ diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index 7dee4958e57..0df4808fa2a 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -154,16 +154,16 @@ where .and_then(|timeout| tokio::time::Instant::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] From d50f866f741e601b52628436f33c767b29bf51ab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:03:27 -0700 Subject: [PATCH 06/24] test(qmux): run the TypeScript interop against the in-tree examples Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly.yml | 2 +- test/justfile | 14 +++++++------- test/qmux/interop.test.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c82ac82d9f9..68f07377610 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -326,7 +326,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", "rs quic-stress", "rs audio-devices", "rs multicast", "rs render-sw", "test qmux"] include: - recipe: rs send-depth diff --git a/test/justfile b/test/justfile index 9f16a7112be..3065e51e670 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 := env("CARGO_TARGET_DIR", justfile_directory() / ".." / "target") / "debug" / "examples" # List the cross-language suites. default: @@ -224,15 +224,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"; From 163464413ea2b1ca99536fbe68307c8b9aa9c08c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:04:37 -0700 Subject: [PATCH 07/24] test(quic): the mux record writer follows QUIC's scheduler Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/tests.rs | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index 818c7b7ffcb..40e0d02c0c5 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -591,3 +591,129 @@ fn golden_transport_parameters() { assert_eq!(actual, hex, "{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)]); +} From ecc20ff88df90d24ce289f24b3c6843eb0ad7e21 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:08:58 -0700 Subject: [PATCH 08/24] docs(qmux): migration notes, crate docs, and golden records Adds qmux's MIGRATE guide for 0.6 to 0.7, the moq-tokio and moq-relay entries for refusing WebSocket upgrades without a QMux subprotocol, the crate row in the Rust docs, and golden vectors for the stream, reset, and close frames. Records the 2026-10-21 decisions in the quest. Co-Authored-By: Claude Opus 5.5 --- doc/lib/rs/index.md | 3 +- doc/setup/upgrade.md | 2 +- quest/m1/quic-qmux.md | 22 ++++++++--- rs/moq-quic/src/connection/mux/tests.rs | 51 +++++++++++++++++++++++++ rs/moq-relay/MIGRATE.md | 5 +++ rs/moq-tokio/MIGRATE.md | 6 +++ rs/qmux/MIGRATE.md | 39 +++++++++++++++++++ 7 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 rs/qmux/MIGRATE.md diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index e0127248255..3467c9ef87a 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 c49ef5c4de6..3afd167885f 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -25,7 +25,7 @@ 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 diff --git a/quest/m1/quic-qmux.md b/quest/m1/quic-qmux.md index a2838ccd0b9..4d8d13a2075 100644 --- a/quest/m1/quic-qmux.md +++ b/quest/m1/quic-qmux.md @@ -19,10 +19,9 @@ Decided in the 2026-09-30 plan: `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. +- **Three wire versions stay**: draft-02, draft-01, and draft-00. They + differ only in record framing, so they share the one state machine. The + legacy `webtransport` format is dropped (2026-10-21, below). - **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 @@ -61,7 +60,7 @@ 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, +Add the missing wire evidence before release: golden vectors for each of the three 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`, @@ -98,6 +97,19 @@ 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. +Decided 2026-10-21 (maintainer), while starting: + +- One PR, with layered commits for the mux hooks, the crate, and the consumer switch. +- Keep qmux 0.6's API shape on the new core, so its migration is mostly the + poll traits and config cleanups. +- Drop the legacy `webtransport` format: it predates QMux draft-00 and is only + a WebSocket fallback. WebSocket upgrades without a QMux subprotocol are + refused. +- The transport reports its `Binding`, so WebSocket sessions keep their URI. +- Drop the `h3qx` example; the interop examples move in tree. +- The relay keeps advertising draft-01 and draft-00 only; advertising draft-02 + is a separate change. + ## 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/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index 40e0d02c0c5..9c35a828db8 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -592,6 +592,57 @@ fn golden_transport_parameters() { } } +/// 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 mut 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(); diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 2b207beb239..8d7806a39d5 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 (#PRNUM) + +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. + ### `supervise` takes a `Serving` guard (#6144) `moq_relay::supervise` takes the `shutdown::Serving` from `Observer::serve` in place of the `Observer`. diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index 27b42b1ce42..468ef6b9bb1 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 (#PRNUM) + +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/qmux/MIGRATE.md b/rs/qmux/MIGRATE.md new file mode 100644 index 00000000000..2320deb989d --- /dev/null +++ b/rs/qmux/MIGRATE.md @@ -0,0 +1,39 @@ +# Migrating qmux + +How to update your code for each breaking change, newest first. + +## Unreleased + +### Sessions implement moq-net's poll traits (#PRNUM) + +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 (#PRNUM) + +`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 (#PRNUM) + +`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 (#PRNUM) + +`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 (#PRNUM) + +`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. + +### `StreamId` and `StreamDir` are no longer exported (#PRNUM) + +Stream identity is internal to moq-quic's state machine; nothing in the public API takes one. From f906b93e9ff280c46eac2dee8456edec8e9a429a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:23:44 -0700 Subject: [PATCH 09/24] ci: build the TypeScript suite when qmux changes The ts job builds moq-relay, which now depends on the in-tree qmux, so a qmux change has to trigger it. Also drops a stray `mut` in the golden test. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-gate.yml | 1 + rs/moq-quic/src/connection/mux/tests.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index 9c35a828db8..cdf751f6365 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -599,7 +599,7 @@ fn golden_transport_parameters() { fn golden_records() { for &version in Version::ALL { let mut pair = Pair::new(config(version)); - let mut record = |conn: &mut Connection, now| { + let record = |conn: &mut Connection, now| { let record = conn.poll_transmit(now).unwrap(); assert!(conn.poll_transmit(now).is_none()); record From 973116af8c188437ebaed58983fcc136c8d05954 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:31:56 -0700 Subject: [PATCH 10/24] docs(qmux): name the PR in the migration notes Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/MIGRATE.md | 2 +- rs/moq-tokio/MIGRATE.md | 2 +- rs/qmux/MIGRATE.md | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 8d7806a39d5..1c601b34040 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -5,7 +5,7 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ## Unreleased -### WebSocket clients must offer a QMux subprotocol (#PRNUM) +### 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. diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index 468ef6b9bb1..b697543b30a 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### qmux sessions are qmux 0.7 (#PRNUM) +### 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. diff --git a/rs/qmux/MIGRATE.md b/rs/qmux/MIGRATE.md index 2320deb989d..b2da728a92d 100644 --- a/rs/qmux/MIGRATE.md +++ b/rs/qmux/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Sessions implement moq-net's poll traits (#PRNUM) +### 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`. @@ -12,28 +12,28 @@ Import `moq_net::transport::poll::{Session as _, SendStream as _, RecvStream as 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 (#PRNUM) +### 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 (#PRNUM) +### `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 (#PRNUM) +### `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 (#PRNUM) +### `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. -### `StreamId` and `StreamDir` are no longer exported (#PRNUM) +### `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. From 4e0f3fc5abffb53af12c25e3957cccbb32196c75 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:33:37 -0700 Subject: [PATCH 11/24] quest: finish quic-qmux qmux runs in tree on moq-quic's stream state machine, so the quest is done. Its dependents (tls:// peer certificates, qmux byte counters) are unblocked and point at the in-tree crate; the 2026-10-21 decisions live on the PR. Co-Authored-By: Claude Opus 5.5 --- quest/m0/ffi-shutdown-backport.md | 4 +- quest/m1/README.md | 1 - quest/m1/quic-qmux.md | 115 --------------------------- quest/m1/quic/README.md | 5 +- quest/m1/rs-request-credit.md | 11 +-- quest/m2/drop-web-transport-trait.md | 6 +- quest/m2/quic-ack-hook.md | 2 - quest/m2/tls-listener-mtls.md | 7 +- quest/m2/wire-stats/qmux.md | 13 ++- 9 files changed, 15 insertions(+), 149 deletions(-) delete mode 100644 quest/m1/quic-qmux.md 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 e8509e003ed..818fb01f8bc 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -54,7 +54,6 @@ lands before the cut. - [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 and delivering its close frame - [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - when the maintainer cuts it, lite-07 negotiates as `moq-lite-07` and the next release ships it - [moq-transport passthrough](/quest/m1/ietf-extension/README.md) - object IDs, properties, end status, datagram objects, and unknown track properties survive moq-transport -> moq-relay (and a relay cluster) -> moq-transport; moxygen's moq-test sections 1-7 pass nightly, except cases that use a subgroup other than 0 - [Refresh](/quest/m1/refresh/README.md) - a join asks for a fresh group once the current group's deltas exceed a publisher-advertised byte ratio, at every hop; replaces `Demand::used`/`unused` and moq-json's `delta_ratio` diff --git a/quest/m1/quic-qmux.md b/quest/m1/quic-qmux.md deleted file mode 100644 index 4d8d13a2075..00000000000 --- a/quest/m1/quic-qmux.md +++ /dev/null @@ -1,115 +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`. -- **Three wire versions stay**: draft-02, draft-01, and draft-00. They - differ only in record framing, so they share the one state machine. The - legacy `webtransport` format is dropped (2026-10-21, below). -- **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 three 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. 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. - -Decided 2026-10-21 (maintainer), while starting: - -- One PR, with layered commits for the mux hooks, the crate, and the consumer switch. -- Keep qmux 0.6's API shape on the new core, so its migration is mostly the - poll traits and config cleanups. -- Drop the legacy `webtransport` format: it predates QMux draft-00 and is only - a WebSocket fallback. WebSocket upgrades without a QMux subprotocol are - refused. -- The transport reports its `Binding`, so WebSocket sessions keep their URI. -- Drop the `h3qx` example; the interop examples move in tree. -- The relay keeps advertising draft-01 and draft-00 only; advertising draft-02 - is a separate change. - -## 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 5253f7be4f4..619becffa97 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -9,8 +9,7 @@ MoQ never negotiates. One core serves the tokio backend, the thread-per-core BBR correctness, reliable stream resets, hierarchical stream scheduling with per-broadcast fairness, bounded incomplete-message memory, wider limits for relay peers, and endpoint sharding. -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 @@ -72,8 +71,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/m2/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 9776c235a4f..b06558af3e3 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 210c36ba718..21596395eb3 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. @@ -67,6 +66,5 @@ from that repo as accepted exceptions, and web-transport-proto may stay. ## 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 From 1d89db6e9c36cc693d9ef7f6fff80974222f45fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 18:52:58 -0700 Subject: [PATCH 12/24] fix(qmux): keep stream resets sticky and bound record sizes A RecvStream that saw RESET_STREAM reported a clean end on its next read or closed() check, because reading the reset frees the connection's stream state. It now remembers the code. Draft-00 ignores a peer's max_record_size, which it does not define and which could shrink our frames to nothing, and Config refuses a record size the peer would reject. Co-Authored-By: Claude Opus 5.5 --- doc/setup/upgrade.md | 3 +++ rs/moq-quic/src/connection/mux/params.rs | 10 ++++++---- rs/qmux/src/config.rs | 24 ++++++++++++++++++++++-- rs/qmux/src/session.rs | 24 ++++++++++++++++++++---- rs/qmux/src/session/tests.rs | 3 +++ 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 3afd167885f..714563c4994 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -32,6 +32,9 @@ ignored; its guide says what replaces it. 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/rs/moq-quic/src/connection/mux/params.rs b/rs/moq-quic/src/connection/mux/params.rs index 3ec58898239..5adef1eecd1 100644 --- a/rs/moq-quic/src/connection/mux/params.rs +++ b/rs/moq-quic/src/connection/mux/params.rs @@ -136,7 +136,8 @@ impl Params { return Err(err("duplicate transport parameter")); } match id { - MAX_RECORD_SIZE => { + // 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() { @@ -238,11 +239,12 @@ mod tests { } #[test] - fn record_size_floor_is_for_records_only() { + fn record_size_is_for_records_only() { let mut buf = Vec::new(); - write_param(&mut buf, MAX_RECORD_SIZE, 1024); + write_param(&mut buf, MAX_RECORD_SIZE, 1); assert!(decode(buf.clone()).is_err()); - assert!(Params::decode(Side::Client, Version::QMux00, buf.into()).is_ok()); + let params = Params::decode(Side::Client, Version::QMux00, buf.into()).unwrap(); + assert_eq!(params.max_record_size.into_inner(), DEFAULT_MAX_RECORD_SIZE); } #[test] diff --git a/rs/qmux/src/config.rs b/rs/qmux/src/config.rs index 46cc1a9a6ae..71d902f6ae0 100644 --- a/rs/qmux/src/config.rs +++ b/rs/qmux/src/config.rs @@ -57,7 +57,7 @@ pub struct Config { /// `QX_PING` meanwhile, or `None` to never time out. 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+). Default: 16382. + /// 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 @@ -140,7 +140,8 @@ impl Config { } } - /// The connection's settings, refusing a value the wire cannot carry + /// The connection's settings, refusing a value the wire cannot carry or the peer would + /// refuse pub(crate) fn to_mux(&self) -> Result { fn varint(value: u64) -> Result { VarInt::from_u64(value).map_err(|_| crate::Error::TransportParameter) @@ -153,6 +154,9 @@ impl Config { 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::TransportParameter); + } 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)) @@ -168,3 +172,19 @@ impl Config { 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:?}"); + } + } +} diff --git a/rs/qmux/src/session.rs b/rs/qmux/src/session.rs index 2dae475d7bc..cc63ce75467 100644 --- a/rs/qmux/src/session.rs +++ b/rs/qmux/src/session.rs @@ -997,6 +997,9 @@ pub struct RecvStream { /// 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. @@ -1013,6 +1016,7 @@ impl RecvStream { id, park: kio::Park::default(), finished: false, + reset: None, stopped: false, backlog: BytesMut::new(), backlog_credit: moq_quic::Credit::default(), @@ -1027,6 +1031,9 @@ impl RecvStream { 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)); } @@ -1044,7 +1051,10 @@ impl RecvStream { waiter.register(state.readable.entry(self.id).or_default()); Poll::Pending } - Read::Reset(code) => Poll::Ready(Err(Error::StreamReset(code))), + Read::Reset(code) => { + self.reset = Some(code); + Poll::Ready(Err(Error::StreamReset(code))) + } } } @@ -1067,7 +1077,7 @@ impl RecvStream { let mut state = self.inner.lock(); release(&mut state, std::mem::take(&mut self.backlog_credit)); self.backlog.clear(); - if self.stopped || self.finished { + if self.stopped || self.finished || self.reset.is_some() { return; } // Err means the stream is already gone, which is what we wanted. @@ -1134,6 +1144,9 @@ impl poll::RecvStream for RecvStream { 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(())); } @@ -1162,7 +1175,10 @@ impl poll::RecvStream for RecvStream { waiter.register(state.readable.entry(self.id).or_default()); return Poll::Pending; } - Read::Reset(code) => return Poll::Ready(Err(Error::StreamReset(code))), + Read::Reset(code) => { + self.reset = Some(code); + return Poll::Ready(Err(Error::StreamReset(code))); + } } } } @@ -1173,7 +1189,7 @@ impl Drop for RecvStream { 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 { + if !self.finished && !self.stopped && self.reset.is_none() { let _ = state.conn.recv_stream(self.id).stop(VarInt::from_u32(0)); state.driver.wake(); } diff --git a/rs/qmux/src/session/tests.rs b/rs/qmux/src/session/tests.rs index 49a8ae0ba11..255a4230516 100644 --- a/rs/qmux/src/session/tests.rs +++ b/rs/qmux/src/session/tests.rs @@ -183,6 +183,9 @@ async fn resets_and_stops_carry_their_codes() { 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; From 632bd7c7fa02a80fb669e5b547740f3696aa6cf4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 19:06:11 -0700 Subject: [PATCH 13/24] fix(qmux): refuse zero timers as invalid config A zero rtt_interval made every timer poll due, so the writer sent QX_PING in a hot loop; a zero idle timeout expired at once while telling the peer it was disabled. Both are refused, with None as the way to disable them. Config refusals report Error::InvalidConfig rather than borrowing the peer-facing TransportParameter. Co-Authored-By: Claude Opus 5.5 --- rs/qmux/MIGRATE.md | 2 +- rs/qmux/src/config.rs | 33 +++++++++++++++++++++++++++------ rs/qmux/src/error.rs | 4 ++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/rs/qmux/MIGRATE.md b/rs/qmux/MIGRATE.md index b2da728a92d..b737e1a1973 100644 --- a/rs/qmux/MIGRATE.md +++ b/rs/qmux/MIGRATE.md @@ -32,7 +32,7 @@ Every peer must speak QMux draft-00, 01, or 02; a WebSocket client must offer on `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. +`DatagramTooLarge` is new, and `InvalidConfig` reports a `Config` the session cannot run with, such as a zero timer. ### `StreamId` and `StreamDir` are no longer exported (#6231) diff --git a/rs/qmux/src/config.rs b/rs/qmux/src/config.rs index 71d902f6ae0..5d4f10572da 100644 --- a/rs/qmux/src/config.rs +++ b/rs/qmux/src/config.rs @@ -54,7 +54,7 @@ pub struct Config { 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. The smaller of the two peers' values + /// `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. @@ -77,7 +77,7 @@ pub struct Config { pub handshake_timeout: Option, /// How often to send a `QX_PING` request purely to measure round-trip time, or `None` - /// to not measure it. + /// 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 @@ -140,11 +140,18 @@ impl Config { } } - /// The connection's settings, refusing a value the wire cannot carry or the peer would - /// refuse + /// 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::TransportParameter) + 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")); } let mut config = mux::Config::default(); @@ -155,7 +162,7 @@ impl Config { 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::TransportParameter); + 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) @@ -187,4 +194,18 @@ mod tests { 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(_)))); + } } diff --git a/rs/qmux/src/error.rs b/rs/qmux/src/error.rs index 0e7f191505e..2dfc2b6cfed 100644 --- a/rs/qmux/src/error.rs +++ b/rs/qmux/src/error.rs @@ -50,6 +50,10 @@ pub enum 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, From 93b6a402ff78ab432ab85c6513c264f634b94b98 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 19:11:11 -0700 Subject: [PATCH 14/24] quest: point audit-ci at the PR that restored the bench legs The m0 performance audit it linked finished in #6112, leaving the link dangling on main. Co-Authored-By: Claude Opus 5.5 --- quest/m1/audit-ci.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quest/m1/audit-ci.md b/quest/m1/audit-ci.md index 90a5a646f43..ef56a0b167f 100644 --- a/quest/m1/audit-ci.md +++ b/quest/m1/audit-ci.md @@ -107,7 +107,7 @@ Check first (from the m0 CI pipeline audit, 2026-10-21): by [#6086](https://github.com/moq-dev/moq/pull/6086). Unowned: `rs audio-devices`, new on 2026-10-20, fails all five playback tests on the hosted ARM runner and passes on x86_64 Linux. Owned elsewhere besides the - above: the Rust and JS benchmark legs by the - [performance audit](/quest/m0/audit-perf.md), Audio quality's budgets by + above: the Rust and JS benchmark legs, which + [#6112](https://github.com/moq-dev/moq/pull/6112) restored, Audio quality's budgets by the [audio jitter target](/quest/m1/audio-jitter-target/README.md), and the bun advisories with the Rust ones. From 331adc2f92ecf5ba138a1df4bef7252ec45a982b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 19:45:00 -0700 Subject: [PATCH 15/24] fix(qmux): refuse empty records and text messages, keep finish's stop A finish() refused by STOP_SENDING marked the stream as our own reset, so closed() then reported a clean end; it now reports the stop. A record with no frames and a WebSocket text message are malformed QMux and close the session instead of passing as activity. `just test qmux` found its examples only when run from test/; it now resolves them from the recipe's directory. Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/mod.rs | 5 +++++ rs/moq-quic/src/connection/mux/tests.rs | 10 ++++++++++ rs/qmux/src/session.rs | 5 +++-- rs/qmux/src/session/tests.rs | 9 +++++++++ rs/qmux/src/transport.rs | 20 +++++++++++++++++++- test/justfile | 2 +- 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 8f48d20a6c2..32d40779117 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -563,6 +563,11 @@ impl Connection { 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(()) } diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index cdf751f6365..26c6671c4cd 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -287,6 +287,16 @@ fn draft02_requires_params_first() { } } +#[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 rejects_oversized_records() { let now = Instant::now(); diff --git a/rs/qmux/src/session.rs b/rs/qmux/src/session.rs index cc63ce75467..68688722b40 100644 --- a/rs/qmux/src/session.rs +++ b/rs/qmux/src/session.rs @@ -921,9 +921,10 @@ impl poll::SendStream for SendStream { 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. + // 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.reset = true; + self.fin = true; return Err(Error::StreamStop(code.into_inner())); } // Already finished or reset, so the FIN it wanted is out. diff --git a/rs/qmux/src/session/tests.rs b/rs/qmux/src/session/tests.rs index 2edb133345c..9cfd420bcf9 100644 --- a/rs/qmux/src/session/tests.rs +++ b/rs/qmux/src/session/tests.rs @@ -193,6 +193,15 @@ async fn resets_and_stops_carry_their_codes() { 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. diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs index 208e5572a42..476f04ada1b 100644 --- a/rs/qmux/src/transport.rs +++ b/rs/qmux/src/transport.rs @@ -966,7 +966,12 @@ mod ws_transport { let _ = tx.send(Err(Error::Closed)).await; return; } - Message::Ping(_) | Message::Pong(_) | Message::Text(_) | Message::Frame(_) => { + // 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. @@ -1091,6 +1096,19 @@ mod ws_transport { 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(); + } } } diff --git a/test/justfile b/test/justfile index 3065e51e670..abcfea81126 100644 --- a/test/justfile +++ b/test/justfile @@ -14,7 +14,7 @@ set fallback set working-directory := '.' # Where cargo puts the in-tree qmux interop examples `just test qmux` runs. -qmux_examples := env("CARGO_TARGET_DIR", justfile_directory() / ".." / "target") / "debug" / "examples" +qmux_examples := env("CARGO_TARGET_DIR", source_directory() / ".." / "target") / "debug" / "examples" # List the cross-language suites. default: From 797a544b1c636afc79826216af3141313824a92c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 22:47:06 -0700 Subject: [PATCH 16/24] fix(qmux): refuse QX_PING on draft-00, resolve a relative target dir Draft-00 has no QX_PING, so a message-delimited peer sending one now closes with FRAME_ENCODING_ERROR instead of being answered. `just test qmux` hands bun an absolute examples path, so a relative CARGO_TARGET_DIR resolves the same way cargo did. Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/mod.rs | 5 +++++ rs/moq-quic/src/connection/mux/tests.rs | 15 +++++++++++++++ test/justfile | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 32d40779117..7eb3e5ca5e1 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -609,6 +609,11 @@ impl Connection { 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 diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index 26c6671c4cd..403679d1f63 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -297,6 +297,21 @@ fn rejects_empty_records() { ); } +#[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(); diff --git a/test/justfile b/test/justfile index 210992e51b5..7f3ef7a0b92 100644 --- a/test/justfile +++ b/test/justfile @@ -14,7 +14,7 @@ set fallback set working-directory := '.' # Where cargo puts the in-tree qmux interop examples `just test qmux` runs. -qmux_examples := env("CARGO_TARGET_DIR", source_directory() / ".." / "target") / "debug" / "examples" +qmux_examples := absolute_path(env("CARGO_TARGET_DIR", source_directory() / ".." / "target")) / "debug" / "examples" # List the cross-language suites. default: From 6871e106c4b26cc9023c169b738a6e0819f46a75 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:31:00 -0700 Subject: [PATCH 17/24] fix(qmux): keep WebSocket Pings flowing under load, refuse a failed negotiation The writer polled outbound batches before transport maintenance, so a sender that always had a batch ready never sent its keep-alive Ping and timed out behind a peer that sends nothing back. Maintenance now goes first; it is ready once per interval. In-band negotiation that agrees on no protocol now fails the handshake with TRANSPORT_PARAMETER_ERROR, as ALPN does, instead of connecting with none. Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/mod.rs | 12 +++++- rs/moq-quic/src/connection/mux/tests.rs | 11 +++++- rs/qmux/MIGRATE.md | 5 +++ rs/qmux/src/config.rs | 8 ++-- rs/qmux/src/session.rs | 6 ++- rs/qmux/src/session/tests.rs | 52 +++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 8 deletions(-) diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 7eb3e5ca5e1..0fd88ebce5d 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -91,7 +91,8 @@ pub struct Config { 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 + /// peer's offer a protocol error; a peer that offers none of them fails the + /// handshake pub protocols: Option>, } @@ -762,7 +763,14 @@ impl Connection { Side::Server => (ours.as_slice(), theirs), Side::Client => (theirs, ours.as_slice()), }; - server.iter().find(|p| client.contains(p)).cloned() + 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( diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index 403679d1f63..4c7ac10cf42 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -516,7 +516,16 @@ fn negotiates_the_servers_preference() { 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, config(Version::QMux01)); + 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 diff --git a/rs/qmux/MIGRATE.md b/rs/qmux/MIGRATE.md index b737e1a1973..5992454001c 100644 --- a/rs/qmux/MIGRATE.md +++ b/rs/qmux/MIGRATE.md @@ -34,6 +34,11 @@ Every peer must speak QMux draft-00, 01, or 02; a WebSocket client must offer on `InvalidFrameType`, `InvalidStreamId`, `DuplicateParam`, `Short`, and `UnexpectedProtocols` are removed: malformed input surfaces as `FrameEncoding`, `ProtocolViolation`, or `TransportParameter`. `DatagramTooLarge` is new, and `InvalidConfig` reports a `Config` the session cannot run with, such as a zero timer. +### 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/src/config.rs b/rs/qmux/src/config.rs index 5d4f10572da..df5e5738e49 100644 --- a/rs/qmux/src/config.rs +++ b/rs/qmux/src/config.rs @@ -20,9 +20,11 @@ pub enum Protocol { /// `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. The agreed - /// protocol is resolved by the time [`Session::connect`](crate::Session::connect) / - /// [`accept`](crate::Session::accept) returns. + /// 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; diff --git a/rs/qmux/src/session.rs b/rs/qmux/src/session.rs index 68688722b40..e56cf9e202e 100644 --- a/rs/qmux/src/session.rs +++ b/rs/qmux/src/session.rs @@ -338,12 +338,14 @@ async fn run_writer(inner: Arc, mut writer: W) { loop { let batch = tokio::select! { biased; - batch = kio::wait(|waiter| inner.poll_batch(waiter, &mut timer)) => batch, - // Transport-level maintenance, like a WebSocket keep-alive Ping. + // 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(()); diff --git a/rs/qmux/src/session/tests.rs b/rs/qmux/src/session/tests.rs index 9cfd420bcf9..c28498c4827 100644 --- a/rs/qmux/src/session/tests.rs +++ b/rs/qmux/src/session/tests.rs @@ -275,6 +275,58 @@ async fn datagrams_need_records() { )); } +/// 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))); + 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)] From 1f3c9355ba36597ab39f758fca6a7f9e915ea2e3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:45:12 -0700 Subject: [PATCH 18/24] fix(qmux): accept an empty WebSocket message as a no-op The QMux WebSocket binding requires receivers to accept an empty binary message, but the pump forwarded it as an empty record, which the session now refuses. The pump drops it instead; an empty record on a byte stream is still an error. Co-Authored-By: Claude Opus 5.5 --- rs/qmux/src/transport.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs index 476f04ada1b..c900fdd56aa 100644 --- a/rs/qmux/src/transport.rs +++ b/rs/qmux/src/transport.rs @@ -942,6 +942,8 @@ mod ws_transport { }; 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 @@ -1083,6 +1085,20 @@ mod ws_transport { 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(); From a35b94c74fe52fc94d75ce1e48deec153851cb5f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:53:49 -0700 Subject: [PATCH 19/24] fix(quic): bound datagram retention, order a mux stream's first frame A small QMux datagram kept its whole record alive while the receive buffer charged only its payload, so padded records could pin far more than the buffer allowed. Datagrams under half their record are now copied out. The ordered-offset check skipped a peer stream's first frame, since the stream had no state yet; a new peer stream now expects offset 0. Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/mod.rs | 7 ++++- rs/moq-quic/src/connection/mux/tests.rs | 30 ++++++++++++++++++++- rs/moq-quic/src/connection/streams/state.rs | 4 +++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 0fd88ebce5d..3817b3d9229 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -711,7 +711,7 @@ impl Connection { frame::Frame::Close(Close::Connection(close)) => { self.lost(ConnectionError::ConnectionClosed(close)); } - frame::Frame::Datagram(datagram) => { + frame::Frame::Datagram(mut datagram) => { let Some(max) = self.config.max_datagram_frame_size else { return Err(TransportError::PROTOCOL_VIOLATION( "unexpected DATAGRAM frame", @@ -720,6 +720,11 @@ impl Connection { if datagram.size(true) 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))? diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index 4c7ac10cf42..f9c69efcee4 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -414,7 +414,15 @@ fn stream_offsets_follow_the_transport_order() { let mut server = Connection::new(config(Version::QMux01), Side::Server, now); server.handle_record(after_params(Version::QMux01, &first), now); - server.handle_record(gap.into(), 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 @@ -505,6 +513,26 @@ fn datagrams() { ); } +#[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); diff --git a/rs/moq-quic/src/connection/streams/state.rs b/rs/moq-quic/src/connection/streams/state.rs index 2d8175f02df..d6ff2b0cc8f 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -487,6 +487,10 @@ impl StreamsState { /// 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( From 1bcc38de4fd656df44de4b5cba27ec09ff4f679a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:59:56 -0700 Subject: [PATCH 20/24] fix(qmux): saturate peer lengths instead of truncating them On 32-bit targets a peer-sent record or frame length above usize::MAX truncated with `as usize` and could pass the size check, desynchronizing the reader. Lengths now saturate, so the limit refuses them. Co-Authored-By: Claude Opus 5.5 --- rs/qmux/src/transport.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs index c900fdd56aa..3bd19fce6bd 100644 --- a/rs/qmux/src/transport.rs +++ b/rs/qmux/src/transport.rs @@ -159,7 +159,7 @@ mod stream_transport { let reader_task = tokio::spawn(reader_loop( BufReader::new(read), version, - our_max_record_size as usize, + usize::try_from(our_max_record_size).unwrap_or(usize::MAX), tx, )); Self { @@ -335,10 +335,16 @@ mod stream_transport { Ok(()) } + /// A peer-sent length as `usize`, saturating where it does not fit so the size checks + /// still refuse it on 32-bit targets + fn saturating_len(value: VarInt) -> usize { + usize::try_from(value.into_inner()).unwrap_or(usize::MAX) + } + /// 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 = read_varint(reader).await?.into_inner() as usize; + let size = saturating_len(read_varint(reader).await?); if size > our_max_record_size { return Err(Error::FrameTooLarge); } @@ -364,12 +370,12 @@ mod stream_transport { } if has_len { - let len = read_varint_into(reader, &mut buf).await?.into_inner() as usize; + let len = saturating_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() + len > MAX_FRAME_SIZE { + if buf.len().saturating_add(len) > MAX_FRAME_SIZE { return Err(Error::FrameTooLarge); } read_bytes(reader, len, &mut buf).await?; @@ -402,8 +408,8 @@ mod stream_transport { if frame_type == 0x1c { read_varint_into(reader, &mut buf).await?; // frame_type } - let reason_len = read_varint_into(reader, &mut buf).await?.into_inner() as usize; - if buf.len() + reason_len > MAX_FRAME_SIZE { + let reason_len = saturating_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?; @@ -436,8 +442,8 @@ mod stream_transport { } // QX_TRANSPORT_PARAMETERS 0x3f5153300d0a0d0a => { - let len = read_varint_into(reader, &mut buf).await?.into_inner() as usize; - if buf.len() + len > MAX_FRAME_SIZE { + let len = saturating_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?; From a7cc2013410ae22ff8c48c68c2eefb423666d573 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 00:06:36 -0700 Subject: [PATCH 21/24] fix(qmux): refuse a peer length that does not fit usize Saturating could make an overflowing length equal a saturated limit and slip past the size check on 32-bit targets; refuse it as FrameTooLarge instead. Co-Authored-By: Claude Opus 5.5 --- rs/qmux/src/transport.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs index 3bd19fce6bd..ee6ee32f76a 100644 --- a/rs/qmux/src/transport.rs +++ b/rs/qmux/src/transport.rs @@ -335,16 +335,15 @@ mod stream_transport { Ok(()) } - /// A peer-sent length as `usize`, saturating where it does not fit so the size checks - /// still refuse it on 32-bit targets - fn saturating_len(value: VarInt) -> usize { - usize::try_from(value.into_inner()).unwrap_or(usize::MAX) + /// 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 = saturating_len(read_varint(reader).await?); + let size = peer_len(read_varint(reader).await?)?; if size > our_max_record_size { return Err(Error::FrameTooLarge); } @@ -370,7 +369,7 @@ mod stream_transport { } if has_len { - let len = saturating_len(read_varint_into(reader, &mut buf).await?); + 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 @@ -408,7 +407,7 @@ mod stream_transport { if frame_type == 0x1c { read_varint_into(reader, &mut buf).await?; // frame_type } - let reason_len = saturating_len(read_varint_into(reader, &mut buf).await?); + 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); } @@ -442,7 +441,7 @@ mod stream_transport { } // QX_TRANSPORT_PARAMETERS 0x3f5153300d0a0d0a => { - let len = saturating_len(read_varint_into(reader, &mut buf).await?); + let len = peer_len(read_varint_into(reader, &mut buf).await?)?; if buf.len().saturating_add(len) > MAX_FRAME_SIZE { return Err(Error::FrameTooLarge); } From 3399e1ad84717c9c9bdba03a4cf05969b7fcaa53 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 00:12:08 -0700 Subject: [PATCH 22/24] fix(qmux): refuse a keep-alive that expires before its first Pong A WebSocket KeepAlive whose timeout was not above its interval was clamped to the interval, so the deadline fired as the first Ping went out and closed a healthy idle peer; a zero interval was floored to 1ms. Both now fail the session start with InvalidConfig. Co-Authored-By: Claude Opus 5.5 --- rs/qmux/MIGRATE.md | 2 +- rs/qmux/src/session/tests.rs | 24 +++++++++++++++++++++++- rs/qmux/src/transport.rs | 21 ++++++++++++--------- rs/qmux/src/ws.rs | 17 +++++++++-------- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/rs/qmux/MIGRATE.md b/rs/qmux/MIGRATE.md index 5992454001c..8df46e68a3c 100644 --- a/rs/qmux/MIGRATE.md +++ b/rs/qmux/MIGRATE.md @@ -32,7 +32,7 @@ Every peer must speak QMux draft-00, 01, or 02; a WebSocket client must offer on `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 `Config` the session cannot run with, such as a zero timer. +`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) diff --git a/rs/qmux/src/session/tests.rs b/rs/qmux/src/session/tests.rs index c28498c4827..6dd85d83165 100644 --- a/rs/qmux/src/session/tests.rs +++ b/rs/qmux/src/session/tests.rs @@ -275,6 +275,27 @@ async fn datagrams_need_records() { )); } +/// 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")] @@ -308,7 +329,8 @@ async fn websocket_keep_alive_runs_during_continuous_output() { 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))); + .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(); diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs index ee6ee32f76a..3c82f8bde4f 100644 --- a/rs/qmux/src/transport.rs +++ b/rs/qmux/src/transport.rs @@ -720,9 +720,17 @@ mod ws_transport { } } - pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Self { + /// 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); - self + Ok(self) } pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { @@ -739,11 +747,8 @@ mod ws_transport { impl PingState { fn new(config: KeepAlive) -> Self { - // tokio::time::interval panics on a zero Duration; floor to 1ms so a - // misconfigured KeepAlive degrades into "very chatty" instead of crashing. - let interval_dur = config.interval.max(Duration::from_millis(1)); // Skip catch-up bursts after a long pause; we just want one Ping per tick. - let mut interval = tokio::time::interval(interval_dur); + 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(); @@ -760,9 +765,7 @@ mod ws_transport { impl DeadlineState { fn new(config: KeepAlive) -> Self { - let interval_dur = config.interval.max(Duration::from_millis(1)); - // A deadline shorter than the interval would fire before the first ping. - let timeout = config.timeout.max(interval_dur); + let timeout = config.timeout; Self { deadline: Box::pin(tokio::time::sleep(timeout)), timeout, diff --git a/rs/qmux/src/ws.rs b/rs/qmux/src/ws.rs index d63dfbb69e1..e146b557638 100644 --- a/rs/qmux/src/ws.rs +++ b/rs/qmux/src/ws.rs @@ -38,7 +38,8 @@ pub struct KeepAlive { 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. + /// 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, } @@ -143,19 +144,19 @@ where 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); + 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) -> WsTransport { + 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); } - match self.keep_alive { - Some(ka) => transport.with_keep_alive(ka), + Ok(match self.keep_alive { + Some(ka) => transport.with_keep_alive(ka)?, None => transport, - } + }) } } @@ -291,7 +292,7 @@ impl Client { 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), + Some(ka) => transport.with_keep_alive(ka)?, None => transport, }; // Protocol came from the negotiated subprotocol, so no in-band wait. @@ -454,7 +455,7 @@ impl Server { 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), + Some(ka) => transport.with_keep_alive(ka)?, None => transport, }; // Protocol came from the negotiated subprotocol, so no in-band wait. From 076f5b6a614e1f434624c674aebc1eb1594825a9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 00:21:30 -0700 Subject: [PATCH 23/24] fix(quic): size mux datagrams by their encoded frame Receiving judged every DATAGRAM as if it carried a Length field, refusing a length-less frame that exactly fills the peer's limit; it now checks the frame's bytes on the wire. Sending reserved the worst-case overhead, so max_size understated the limit (1191 of 1197 bytes at 1200) and allowed an empty payload under a 1-byte limit; it now uses Datagram::max_payload. Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/connection/mux/mod.rs | 21 ++++++++----- rs/moq-quic/src/connection/mux/tests.rs | 42 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 3817b3d9229..7462ef35d47 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -30,7 +30,7 @@ use super::{ use crate::{ ApplicationClose, Dir, Duration, Instant, MAX_STREAM_COUNT, SendDatagramError, Side, TransportError, VarInt, - frame::{self, Close, Datagram, FrameStruct}, + frame::{self, Close, Datagram}, transport_parameters::TransportParameters, }; @@ -551,14 +551,19 @@ impl Connection { } let mut frames = 0; - while let Some(frame) = wire::next_frame(&mut payload)? { + 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, now)?; + self.on_frame(frame, size, frame_len, now)?; if self.error.is_some() { // The peer closed; ignore the rest return Ok(()); @@ -592,6 +597,7 @@ impl Connection { &mut self, frame: wire::Frame, payload_len: usize, + frame_len: usize, now: Instant, ) -> Result<(), TransportError> { let params = matches!(frame, wire::Frame::TransportParameters(_)); @@ -717,7 +723,8 @@ impl Connection { "unexpected DATAGRAM frame", )); }; - if datagram.size(true) as u64 > max.into_inner() { + // 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 @@ -862,10 +869,8 @@ impl Datagrams<'_> { 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 = usize::try_from(peer.into_inner()) - .unwrap_or(usize::MAX) - .min(self.conn.budget()); - Some(frame.saturating_sub(Datagram::SIZE_BOUND)) + let frame = peer.into_inner().min(self.conn.budget() as u64); + Datagram::max_payload(frame).map(|payload| payload as usize) } /// Receive the next datagram diff --git a/rs/moq-quic/src/connection/mux/tests.rs b/rs/moq-quic/src/connection/mux/tests.rs index f9c69efcee4..8ab7f0b1ec9 100644 --- a/rs/moq-quic/src/connection/mux/tests.rs +++ b/rs/moq-quic/src/connection/mux/tests.rs @@ -513,6 +513,48 @@ fn datagrams() { ); } +#[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)); From 5babf30ba40b2fcdaa567dda5d33050d83994b78 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 00:29:17 -0700 Subject: [PATCH 24/24] fix(qmux): refuse stream counts above QUIC's 2^60 ceiling A count above 2^60 fit a varint, so the session started, but every peer rejects that transport parameter. Config now refuses it as InvalidConfig. Co-Authored-By: Claude Opus 5.5 --- rs/qmux/src/config.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/rs/qmux/src/config.rs b/rs/qmux/src/config.rs index df5e5738e49..0d68363cccd 100644 --- a/rs/qmux/src/config.rs +++ b/rs/qmux/src/config.rs @@ -156,6 +156,12 @@ impl Config { 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)?; @@ -210,4 +216,22 @@ mod tests { }; 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(_)))); + } + } }