diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index bb1031fa391..76c5a7f17d0 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -70,6 +70,26 @@ For a full mesh, list every other relay, or serve the list from `connect_api`. A session carries both directions, so one dial per pair is enough; listing a pair on both sides opens a redundant second session. +`cluster.max_hops` bounds the hop path this relay accepts or forwards, from 1 +to 32, with 32 as the default. Set it near the mesh's diameter after any single +link or node failure, plus a margin, to cut off long detours during withdrawal +path hunting. Count the publisher's hop too, and the random first hop a relay +puts in front of a publisher that names none. A cap below that diameter can +partition the mesh; a lower cap only drops longer paths and preserves route +selection among the remaining paths. A peer hears the best route with room +for this relay's hop, even when a cheaper route at the cap serves locally. + +```toml +[cluster] +max_hops = 6 +``` + +The same setting is `--cluster-max-hops` or `MOQ_CLUSTER_MAX_HOPS`. It covers +moq-lite-03 through moq-lite-07-wip and moq-transport drafts 17 through 22 when +the MoQ Cluster extension is negotiated. A forwarded path counts this relay's +own hop even when its peer appends it on receipt. Older versions and peers +without hop paths hide their depth, which this setting cannot bound. + ## Link costs Add `?cost=N` to a peer URL to route by price instead of hop count. An unpriced @@ -119,7 +139,10 @@ pushing to it, so each end declares its own and the two need not match. Prices aren't static either. A publisher can re-price a live announcement, which is how a standby transcoder pool seeds a high cost and drops it once it's working, and a relay receiving a GOAWAY re-prices every route learned from that peer to the -maximum so new subscriptions go elsewhere while existing ones finish. +maximum so new subscriptions go elsewhere while existing ones finish. A +relay keeps its session to a peer draining to restart until that peer closes +it, and redials only then, so it reaches the replacement rather than the +draining process. moq-lite-06 announcements carry two prices, *warm* and *cold*. Both accumulate identically today, so routing runs on link costs alone; the split reserves room @@ -216,8 +239,8 @@ your own that grants the cluster CA) behind `--auth-url`. A relay on send no `cluster.token`: public rules refuse a token. LAN peers authenticate with the mDNS credential on `/.cluster/`, a secret the relay minted for itself and checks locally, and never receive -`cluster.token`. Dials retry forever with capped backoff, so a rejected peer -is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls). +`cluster.token`. Dials retry forever with jittered backoff capped at 30s, so a +rejected peer is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls). A relay records whether each route entered here or came from a peer, which the hop list alone cannot say: a client and a peer each add one hop. Routes over a diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index be3b7bd7026..30ed2be99de 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -156,6 +156,7 @@ node = "https://us-west.example.com/" # This relay's own URL. connect_api = "https://api.example.com/peers" # Or fetch the peer list (JSON array of URLs and/or objects) live. token = "cluster.jwt" # JWT for dials without an inline ?jwt=. id = 12345 # Stable Hop ID across restarts. +max_hops = 32 # Longest accepted/forwarded hop path (1 to 32). [cluster.lan] # Find peers on the LAN over mDNS. enabled = true diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index c6dd2844d9c..72be1bbd1ee 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -47,8 +47,8 @@ listen = "127.0.0.1:9101" ### GET /metrics -Prometheus text: bytes, frames, groups, subscriptions, viewers, and sessions -split by `tier` and `role`, plus accept-loop counters per TCP listener. Alert +Prometheus text: bytes, frames, groups, announces, subscriptions, viewers, and +sessions split by `tier` and `role`, plus accept-loop counters per TCP listener. Alert on `moq_relay_accept_failures_total{class="exhausted"}`, which means the process ran out of a resource `accept` needs. Content dropped for drifting past a subscriber's budget is counted separately as `moq_relay_stale_bytes_total` @@ -60,6 +60,12 @@ Traffic and session counters accumulate for the node's lifetime, including broadcasts and sessions that have ended. The stats publishing prefix (normally `.stats`) is excluded to avoid counting the feed's own traffic. +`moq_relay_control_tx_bytes_total` and `moq_relay_control_rx_bytes_total` +count control-message bytes by `tier` and `class` (`session`, `announce`, `track_info`, `subscribe`, `fetch`, +`probe`, `auth`, `goaway`, `other`) to spot control-heavy sessions. They +include message size prefixes but leave out media payload and transport +overhead such as QUIC framing, ACKs, and retransmissions. + With `--runtime-io-uring`, each QUIC worker thread also reports its own `moq_relay_uring_*` counters under a `worker` label: datagrams and syscalls (the ratios are the GRO/GSO batching and the syscall amortization the runtime diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 79e83781678..d63f756d35e 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -93,6 +93,10 @@ puts a random ID, fresh per connection, in front of it to name the publisher. A anonymous hop at any depth ranks below every fully identified route, whatever the costs say; among anonymous routes, cost keeps ordering. +A relay operator can lower the [hop-path cap](/bin/relay/cluster#topology) to +limit the detours explored after a withdrawal. It drops longer paths while +preserving route selection among those within the cap. + On moq-lite 07, an announcement may copy the head of its path and the tail of its relay chain from one still live on the same stream, so many broadcasts from a few origins behind the same relays stop repeating those bytes. Rust diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index b1d1d640351..2eace4d78d8 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -9,6 +9,10 @@ //! down to its prefix and beneath it. Competing routes at that prefix are //! ranked once per change, then each cursor selects its first visible entry. //! +//! A withdrawal wave is swept over the broadcasts it withdraws and the cursors +//! polling through it, and a route churning beneath an unchanged winner over the +//! table and its cursors. +//! //! An equal-cost pool is swept the same way, over its members and the paths it //! already serves, and over those paths and the cursors watching the pool. //! @@ -171,6 +175,97 @@ fn bench_withdraw(c: &mut Criterion) { group.finish(); } +/// Two peers carry the same `broadcasts` of one publisher, and both withdraw them +/// a broadcast at a time, every cursor polled after each withdrawal as a session's +/// task would be. The first peer's withdrawals queue held updates; the second's, in +/// the opposite order, make each ready behind the held rest. A poll that walks what +/// it holds back shows up as a slope over broadcasts, multiplied by the cursors. +fn bench_withdraw_wave(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/withdraw_wave"); + group.sample_size(10); + let route = |chain: &[u64]| { + let mut hops = Hops::new(); + for &hop in chain { + hops.push(Hop::new(hop).unwrap()).unwrap(); + } + origin::Route::default().with_hops(hops) + }; + for broadcasts in [1_000, 10_000] { + for subscribers in [1, 20, 100] { + let id = BenchmarkId::from_parameter(format!("{broadcasts}b_{subscribers}s")); + group.throughput(Throughput::Elements((2 * broadcasts * subscribers) as u64)); + group.bench_function(id, |b| { + b.iter_batched( + || { + let (producer, mut driver) = origin::Producer::new(origin::Config::default()); + // The holds read the driver's last clock reading, which stays put below. + driver + .poll(moq_net::time::Instant::now(), &kio::Waiter::noop()) + .unwrap(); + let near: Vec<_> = (0..broadcasts) + .map(|i| producer.dynamic(format!("room/{i}"), route(&[9, 2])).unwrap()) + .collect(); + let far: Vec<_> = (0..broadcasts) + .map(|i| producer.dynamic(format!("room/{i}"), route(&[9, 3, 4])).unwrap()) + .collect(); + let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); + for cursor in &mut cursors { + while cursor.try_next().is_some() {} + } + (producer, driver, near, far, cursors) + }, + |(producer, driver, near, far, mut cursors)| { + for route in near { + drop(route); + for cursor in &mut cursors { + assert!(cursor.try_next().is_none(), "the update is held"); + } + } + for route in far.into_iter().rev() { + drop(route); + for cursor in &mut cursors { + cursor.try_next().expect("retract delivered"); + } + } + (producer, driver, cursors) + }, + BatchSize::LargeInput, + ); + }); + } + } + group.finish(); +} + +/// A route that never wins arriving at one broadcast's prefix and leaving again, +/// with `broadcasts` in the table and `subscribers` cursors watching. Each change +/// revisits every cursor presenting the prefix and finds its winner unchanged, +/// so a cost per cursor shows up as a slope over subscribers, and one per route +/// in the table as a slope over broadcasts. +fn bench_route_churn(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/route_churn"); + for broadcasts in [1_000, 10_000] { + for subscribers in [1, 20, 100] { + let id = BenchmarkId::from_parameter(format!("{broadcasts}b_{subscribers}s")); + group.bench_function(id, |b| { + let (producer, _driver) = origin::Producer::new(origin::Config::default()); + let _routes: Vec<_> = (0..broadcasts) + .map(|i| producer.dynamic(format!("room/{i}"), peer_route(1, 1)).unwrap()) + .collect(); + let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); + for cursor in &mut cursors { + while cursor.try_next().is_some() {} + } + b.iter(|| drop(producer.dynamic("room/0", peer_route(2, 5)).unwrap())); + for cursor in &mut cursors { + assert!(cursor.try_next().is_none(), "the winner is unchanged"); + } + }); + } + } + group.finish(); +} + /// `bench_announce` read through mounts: `publishers` routes under the fleet-wide /// `.svc/p0`, watched by `subscribers` project sessions that each mount it at /// their own `/.svc`. Every mount aliases the one target, the worst @@ -1298,6 +1393,8 @@ criterion_group!( bench_announce, bench_pending_churn, bench_withdraw, + bench_withdraw_wave, + bench_route_churn, bench_announce_mounted, bench_announce_fleet, bench_announce_duplicate, diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 072dfd578c3..127679e06ff 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -195,6 +195,7 @@ impl Client { let start = lite::start(lite::Config { runtime: runtime.clone(), + stats: self.stats.clone(), session: session.clone(), setup_stream: None, publish, @@ -272,6 +273,7 @@ impl Client { // We advertise the request path in our SETUP for URL-less transports. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), + stats: self.stats.clone(), session: session.clone(), setup: None, request_id_max: None, @@ -348,7 +350,7 @@ impl Client { Some(p) => return Err(Error::UnknownAlpn(p.to_string())), }; - let mut stream = Stream::open(&mut session, encoding).await?; + let mut stream = Stream::open(&mut session, encoding).await?.with_stats(&self.stats); // The encoding is always an IETF version for SETUP negotiation. let ietf_encoding = ietf::Version::try_from(encoding).map_err(|_| Error::Version)?; @@ -387,6 +389,7 @@ impl Client { let stream = stream.with_version(v); let start = lite::start(lite::Config { runtime: runtime.clone(), + stats: self.stats.clone(), session: session.clone(), setup_stream: Some(stream), publish: publish.clone(), @@ -423,6 +426,7 @@ impl Client { // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), + stats: self.stats.clone(), session: session.clone(), setup: Some(stream), request_id_max, diff --git a/rs/moq-net/src/coding/decode.rs b/rs/moq-net/src/coding/decode.rs index 57888613548..1168ca5bfd2 100644 --- a/rs/moq-net/src/coding/decode.rs +++ b/rs/moq-net/src/coding/decode.rs @@ -7,6 +7,17 @@ use thiserror::Error; pub trait Decode: Sized { /// Decode the value from the given buffer. fn decode(buf: &mut B, version: V) -> Result; + + /// The control metric class of this decoded value, or `None` for selectors and media headers. + fn control(&self, _version: V) -> Option { + None + } + + /// Leading bytes of this value left out of its [`Self::control`] count: a type selector + /// the sender writes apart from the message, and so never counts. + fn uncounted(&self) -> usize { + 0 + } } /// A decode error. diff --git a/rs/moq-net/src/coding/encode.rs b/rs/moq-net/src/coding/encode.rs index 04e7560765b..ae528dda44f 100644 --- a/rs/moq-net/src/coding/encode.rs +++ b/rs/moq-net/src/coding/encode.rs @@ -50,6 +50,11 @@ pub trait Encode: Sized { /// Encode the value to the given writer. fn encode(&self, w: &mut W, version: V) -> Result<(), EncodeError>; + /// The control metric class of this value, or `None` for selectors and media headers. + fn control(&self, _version: V) -> Option { + None + } + /// Encode the value into a [Bytes] buffer. /// /// NOTE: This will allocate. diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 8b0605a9edc..f75109d265c 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -19,6 +19,7 @@ pub struct Reader { stream: S, buffer: BytesMut, version: V, + stats: crate::stats::Session, } /// How much payload may pile up behind a parked consumer before @@ -38,11 +39,30 @@ impl Reader { stream, buffer: Default::default(), version, + stats: Default::default(), } } + /// Count control messages read here under `stats`. + pub(crate) fn with_stats(mut self, stats: &crate::stats::Session) -> Self { + self.stats = stats.clone(); + self + } + /// Poll for the next message on the stream. pub fn poll_decode + Debug>(&mut self, cx: &mut Context<'_>) -> Poll> + where + V: Clone, + { + self.poll_decode_sized(cx).map_ok(|(msg, _)| msg) + } + + /// [`Self::poll_decode`], also returning how many bytes the message took, for a + /// handshake to count once the session's stats are known. + pub(crate) fn poll_decode_sized + Debug>( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> where V: Clone, { @@ -50,8 +70,14 @@ impl Reader { let mut cursor = io::Cursor::new(&self.buffer); match T::decode(&mut cursor, self.version.clone()) { Ok(msg) => { - self.buffer.advance(cursor.position() as usize); - return Poll::Ready(Ok(msg)); + let used = cursor.position() as usize; + self.buffer.advance(used); + // Counted only once consumed, so a peek or a retry after a short read + // never counts the same message twice. + if let Some(class) = Decode::control(&msg, self.version.clone()) { + self.stats.control(class, used - msg.uncounted(), true); + } + return Poll::Ready(Ok((msg, used))); } // Stream closed while we still need more data. Err(DecodeError::Short) if !ready!(self.poll_read_more(cx))? => { @@ -71,6 +97,14 @@ impl Reader { std::future::poll_fn(|cx| self.poll_decode(cx)).await } + /// [`Self::decode`], also returning how many bytes the message took. + pub(crate) async fn decode_sized + Debug>(&mut self) -> Result<(T, usize), Error> + where + V: Clone, + { + std::future::poll_fn(|cx| self.poll_decode_sized(cx)).await + } + /// Poll for the next message unless the stream is closed cleanly first. pub fn poll_decode_maybe + Debug>(&mut self, cx: &mut Context<'_>) -> Poll, Error>> where @@ -260,10 +294,23 @@ impl Reader { stream: self.stream, buffer: self.buffer, version, + stats: self.stats, } } } +impl Reader { + /// Read the `[size][body]` of an IETF message whose type `id` was already read, + /// counting both (but not the type) as control bytes once the body arrives. + pub(crate) async fn read_message(&mut self, id: u64) -> Result { + let size: u16 = self.decode().await?; + let body = self.read_exact(size as usize).await?; + self.stats + .control(crate::ietf::message::control(id, self.version), body.len() + 2, true); + Ok(body) + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -299,6 +346,55 @@ mod tests { } } + /// A control message counts once, when it is consumed: never on a peek, a retry after + /// a short read, or a failed decode, and in full when it spans chunks. + #[test] + fn control_counts_consumed_messages_once() { + use crate::{ + coding::Encode, + lite, + stats::{Bytes, Control, Registry, Tier}, + }; + let registry = Registry::new(Default::default()); + let session = registry.tier(Tier::default()).session(""); + let version = lite::Version::Lite07; + let msg = lite::Probe { + bitrate: Some(123), + rtt: Some(4), + }; + let bytes: &'static [u8] = msg.encode_bytes(version).unwrap().to_vec().leak(); + let size = bytes.len() as u64; + let (head, tail) = bytes.split_at(2); + let counted = || registry.snapshot().control(); + let probes = |n: u64| vec![(Tier::default(), Control::Probe, Bytes { tx: 0, rx: size * n })]; + + let mut reader = Reader::new(Chunks([bytes, head].into()), version).with_stats(&session); + let mut cx = Context::from_waker(std::task::Waker::noop()); + assert!(matches!( + reader.poll_decode_peek_maybe::(&mut cx), + Poll::Ready(Ok(Some(_))) + )); + assert!(counted().is_empty()); + assert!(matches!(reader.poll_decode::(&mut cx), Poll::Ready(Ok(_)))); + assert_eq!(counted(), probes(1)); + + // The second probe arrives in two chunks, so the reader parks and retries. + assert!(reader.poll_decode::(&mut cx).is_pending()); + assert!(reader.poll_decode::(&mut cx).is_pending()); + assert_eq!(counted(), probes(1)); + reader.stream.0.push_back(tail); + assert!(matches!(reader.poll_decode::(&mut cx), Poll::Ready(Ok(_)))); + assert_eq!(counted(), probes(2)); + + // A body cut short inside its declared size is malformed, and never counted. + reader.stream.0.push_back(&[1, 0x80]); + assert!(matches!( + reader.poll_decode::(&mut cx), + Poll::Ready(Err(_)) + )); + assert_eq!(counted(), probes(2)); + } + /// STOP_SENDING refuses a stream, so it must carry a stream code. Reusing the session /// table here would have the peer decode it against the wrong registry: `Cancel` would /// arrive as INTERNAL_ERROR, and `Unauthorized` as KEY_VALUE_FORMATTING_ERROR. diff --git a/rs/moq-net/src/coding/stream.rs b/rs/moq-net/src/coding/stream.rs index 07e7b529edb..e4a86d11220 100644 --- a/rs/moq-net/src/coding/stream.rs +++ b/rs/moq-net/src/coding/stream.rs @@ -74,6 +74,14 @@ impl Stream { } } + /// Count control messages sent and received on this stream under `stats`. + pub(crate) fn with_stats(self, stats: &crate::stats::Session) -> Self { + Self { + writer: self.writer.with_stats(stats), + reader: self.reader.with_stats(stats), + } + } + /// Cast the stream to a different version, used during version negotiation. pub fn with_version(self, version: V2) -> Stream { Stream { diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 21dd32863ba..c51b5bdaff4 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -16,6 +16,7 @@ pub struct Writer { stream: Option, buffer: bytes::BytesMut, version: V, + stats: crate::stats::Session, } impl Writer { @@ -25,9 +26,16 @@ impl Writer { stream: Some(stream), buffer: Default::default(), version, + stats: Default::default(), } } + /// Count control messages encoded here under `stats`. + pub(crate) fn with_stats(mut self, stats: &crate::stats::Session) -> Self { + self.stats = stats.clone(); + self + } + /// Encode the given message into the write buffer, to be sent by /// [`Self::poll_flush`]. An encode error leaves the buffer untouched. pub fn buffer + Debug>(&mut self, msg: &T) -> Result<(), Error> @@ -40,6 +48,9 @@ impl Writer { self.buffer.truncate(start); return Err(err.into()); } + if let Some(class) = Encode::control(msg, self.version.clone()) { + self.stats.control(class, self.buffer.len() - start, false); + } Ok(()) } @@ -184,6 +195,7 @@ impl Writer { stream: self.stream.take(), buffer: std::mem::take(&mut self.buffer), version, + stats: self.stats.clone(), } } } @@ -303,6 +315,10 @@ mod tests { w.put_slice(b"junk"); Err(EncodeError::BoundsExceeded) } + + fn control(&self, _: crate::lite::Version) -> Option { + Some(crate::stats::Control::Probe) + } } /// A failed encode must leave the buffer exactly as it was: flushing its @@ -321,6 +337,52 @@ mod tests { assert_eq!(*log.writes.lock().unwrap(), vec![5, 7], "the partial encode leaked"); } + #[test] + fn control_body_counts_successful_encodes_once_before_transport_delivery() { + use crate::{ + lite, + stats::{Bytes, Control, Registry, Tier}, + }; + let stats = Registry::new(Default::default()); + let session = stats.tier(Tier::new("internal")).session(""); + let gate = kio::Producer::new(false); + let mut writer = + Writer::new(SinkSend::gated(Log::default(), gate.consume()), lite::Version::Lite07).with_stats(&session); + writer.buffer(&lite::ControlType::Probe).unwrap(); + let msg = lite::Probe { + bitrate: Some(1_000_000), + rtt: Some(12), + }; + let size = msg.encode_bytes(lite::Version::Lite07).unwrap().len() as u64; + writer.buffer(&msg).unwrap(); + writer.buffer(&Poison).unwrap_err(); + assert!(writer.poll_flush(&mut Context::from_waker(Waker::noop())).is_pending()); + assert!(writer.poll_flush(&mut Context::from_waker(Waker::noop())).is_pending()); + assert_eq!( + stats.snapshot().control(), + vec![(Tier::new("internal"), Control::Probe, Bytes { tx: size, rx: 0 })] + ); + + writer + .buffer(&lite::Track { + broadcast: "test".into(), + track: "video".into(), + }) + .unwrap(); + writer.buffer(&lite::SubscribeStart { group: 3 }).unwrap(); + writer + .buffer(&lite::AnnounceOk { + origin: crate::Hop::new(1).unwrap(), + active: 0, + }) + .unwrap(); + assert_eq!( + stats.snapshot().control().len(), + 4, + "classes do not collapse into the same counter" + ); + } + /// A flush interrupted mid-message resumes where it left off, so an /// abandoned wrapper future never desynchronizes the stream. #[test] diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index e2ab2f2756a..bdddc8600b4 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -707,6 +707,7 @@ impl Shared { #[derive(Clone)] pub struct ControlStreamAdapter { + stats: crate::stats::Session, inner: S, shared: Arc, control: Control, @@ -718,6 +719,7 @@ pub struct ControlStreamAdapter { impl ControlStreamAdapter { pub fn new(inner: S, control: Control, version: Version) -> Self { Self { + stats: Default::default(), inner, shared: Arc::new(Shared::default()), control, @@ -726,6 +728,12 @@ impl ControlStreamAdapter { } } + /// Count the control messages multiplexed here under `stats`. + pub(super) fn with_stats(mut self, stats: &crate::stats::Session) -> Self { + self.stats = stats.clone(); + self + } + /// Open a real (non-virtual) bidi stream, bypassing control stream multiplexing. /// Used for v16 SubscribeNamespace which moved to its own bidi stream. pub async fn open_native_bi(&mut self) -> Result<(AdapterSend, AdapterRecv), crate::Error> { @@ -799,6 +807,7 @@ impl ControlStreamAdapter { return; } raw.extend_from_slice(&body); + self.stats.control(crate::stats::Control::Goaway, body.len() + 2, false); if !self.shared.control.push(raw.freeze()) { tracing::debug!("control stream closed; goaway not sent"); @@ -830,6 +839,10 @@ impl ControlStreamAdapter { let body = reader.read_exact(size as usize).await?; + // Counted once here rather than again by the virtual stream's reader. + self.stats + .control(super::message::control(type_id, self.version), body.len() + 2, true); + // Reconstruct raw message bytes: [type_id][size][body] let raw = encode_raw(type_id, size, &body, self.version); @@ -1415,6 +1428,69 @@ mod tests { encode_raw(M::ID, body.len() as u16, &body, version) } + /// A legacy control body counts once: a typed write at its virtual stream (not again + /// when the multiplexer forwards it), and a receive at the physical reader (not again + /// when the virtual stream decodes it). + #[tokio::test] + async fn legacy_multiplexing_counts_a_control_body_once() { + use crate::{ + coding::Stream, + lite::test_transport::{Log, ScriptedSession, SinkSend}, + stats::{Bytes, Control as Class, Registry, Tier}, + }; + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let registry = Registry::new(Default::default()); + let stats = registry.tier(Tier::default()).session(""); + let msg = ietf::FetchCancel { + request_id: RequestId(0), + }; + let size = msg.encode_bytes(version).unwrap().len() as u64; + let mut session = ScriptedSession::new(encode_msg(&msg, version).to_vec()); + let (_, recv) = crate::transport::poll::Session::open_bi(&mut session).await.unwrap(); + let adapter = ControlStreamAdapter::new(session, Control::new(None, true), version).with_stats(&stats); + let mut virtual_stream = super::super::request_stream::with_stats( + Stream::open(&mut adapter.clone(), version).await.unwrap(), + &stats, + version, + ); + virtual_stream.writer.encode(&ietf::FetchCancel::ID).await.unwrap(); + virtual_stream.writer.encode(&msg).await.unwrap(); + let raw = adapter.shared.control.pop().await.unwrap(); + // Forwarding the already-encoded bytes onto the control stream is a raw write. + let mut physical = Writer::new(SinkSend::new(Log::default()), version).with_stats(&stats); + physical.write_all(&mut raw.clone()).await.unwrap(); + assert_eq!( + registry.snapshot().control(), + vec![(Tier::default(), Class::Fetch, Bytes { tx: size, rx: 0 })], + "{version:?}" + ); + + let (_, goaway) = crate::goaway::Handle::new(false); + let mut reading = std::pin::pin!(adapter.run_read(Reader::new(recv, version), goaway)); + assert!(futures::poll!(reading.as_mut()).is_pending()); + assert_eq!( + virtual_stream.reader.decode::().await.unwrap(), + ietf::FetchCancel::ID + ); + virtual_stream.reader.decode::().await.unwrap(); + assert_eq!( + registry.snapshot().control(), + vec![(Tier::default(), Class::Fetch, Bytes { tx: size, rx: size })], + "virtual decode counted a physical frame twice: {version:?}" + ); + + adapter.send_goaway("", 0, version); + assert!( + registry + .snapshot() + .control() + .iter() + .any(|(_, class, bytes)| *class == Class::Goaway && bytes.tx > 0), + "adapter-generated messages bypassed the counters" + ); + } + } + fn publish_namespace(request_id: RequestId, namespace: &str) -> ietf::PublishNamespace<'_> { ietf::PublishNamespace { request_id, diff --git a/rs/moq-net/src/ietf/message.rs b/rs/moq-net/src/ietf/message.rs index 3dad0df82f1..88feed27a85 100644 --- a/rs/moq-net/src/ietf/message.rs +++ b/rs/moq-net/src/ietf/message.rs @@ -4,6 +4,52 @@ use crate::coding::{Decode, DecodeError, Encode, EncodeError, Sizer}; use super::Version; +/// Draft 14's TRACK_STATUS_OK and TRACK_STATUS_ERROR, which later drafts give to +/// NAMESPACE_DONE and PUBLISH_BLOCKED. +const TRACK_STATUS_OK_14: u64 = 0x0E; +const TRACK_STATUS_ERROR_14: u64 = 0x0F; + +/// The control metric class of an IETF message type on `version`. +pub(crate) fn control(id: u64, version: Version) -> crate::stats::Control { + use super::*; + use crate::stats::Control; + // Draft 14 reuses later drafts' NAMESPACE_DONE type for TRACK_STATUS_OK. + if version == Version::Draft14 && (id == TRACK_STATUS_OK_14 || id == TRACK_STATUS_ERROR_14) { + return Control::TrackInfo; + } + // Before draft 16, 0x02 updates only a subscription; later it is the shared REQUEST_UPDATE. + if matches!(version, Version::Draft14 | Version::Draft15) && id == SubscribeUpdate::ID { + return Control::Subscribe; + } + // Draft 14's SUBSCRIBE_ERROR becomes the shared REQUEST_ERROR in draft 15. + if version == Version::Draft14 && id == SubscribeError::ID { + return Control::Subscribe; + } + match id { + PublishNamespace::ID + | PublishNamespaceError::ID + | PublishNamespaceDone::ID + | PublishNamespaceCancel::ID + | SubscribeNamespace::ID + | SubscribeNamespaceLegacy::ID + | SubscribeNamespaceOk::ID + | SubscribeNamespaceError::ID + | UnsubscribeNamespace::ID + | NamespaceDone::ID => Control::Announce, + TrackStatus::ID | SUBSCRIBE_TRACKS_ID | PublishBlocked::ID => Control::TrackInfo, + Subscribe::ID + | SubscribeOk::ID + | Unsubscribe::ID + | Publish::ID + | PublishOk::ID + | PublishError::ID + | PublishDone::ID => Control::Subscribe, + Fetch::ID | FetchOk::ID | FetchError::ID | FetchCancel::ID => Control::Fetch, + GoAway::ID => Control::Goaway, + _ => Control::Other, + } +} + /// A trait for IETF messages that are automatically size-prefixed during encoding/decoding. /// /// IETF messages use a u16 size prefix and have a message type ID for control stream dispatch. @@ -26,9 +72,17 @@ impl Encode for T { size.encode(w, version)?; self.encode_msg(w, version) } + + fn control(&self, version: Version) -> Option { + Some(control(T::ID, version)) + } } impl Decode for T { + fn control(&self, version: Version) -> Option { + Some(control(T::ID, version)) + } + fn decode(buf: &mut B, version: Version) -> Result { let size = u16::decode(buf, version)? as usize; @@ -71,3 +125,38 @@ impl Decode for T { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::Control; + + /// Draft 14's track-status replies reuse type IDs later drafts give other classes. + #[test] + fn draft14_track_status_ids_are_track_info() { + for id in [TRACK_STATUS_OK_14, TRACK_STATUS_ERROR_14] { + assert_eq!(control(id, Version::Draft14), Control::TrackInfo); + } + assert_eq!( + control(super::super::NamespaceDone::ID, Version::Draft16), + Control::Announce + ); + assert_eq!( + control(super::super::SubscribeUpdate::ID, Version::Draft15), + Control::Subscribe + ); + // Shared replies and updates identify no request class. + for id in [super::super::RequestOk::ID, super::super::SubscribeUpdate::ID] { + assert_eq!(control(id, Version::Draft16), Control::Other); + } + } + + /// A draft 14 subscription refusal counts as subscribe traffic, but not the + /// REQUEST_ERROR that reuses its type ID. + #[test] + fn draft14_subscribe_error_is_subscribe() { + let id = super::super::SubscribeError::ID; + assert_eq!(control(id, Version::Draft14), Control::Subscribe); + assert_eq!(control(id, Version::Draft15), Control::Other); + } +} diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index d81abc60a9f..dd03dbc2df7 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -365,6 +365,11 @@ impl Publisher where S: crate::transport::poll::Boxable, { + /// Where this session's control traffic is counted. + pub(super) fn stats(&self) -> &crate::stats::Session { + self.origin.stats() + } + pub fn new( runtime: crate::time::Clock, session: S, @@ -417,12 +422,23 @@ where self.excluding(&self.peer().await) } - /// Our origin handle with [`Self::exclude`] applied, the view both the data plane - /// and the announce loops read this peer's routes through. + /// Our origin handle with [`Self::exclude`] applied, the view the data plane reads + /// this peer's routes through. The announce loops read [`Self::advertising`]. fn excluding(&self, peer: &cluster::Peer) -> origin::Consumer { self.origin.clone().excluding(self.exclude(peer)) } + /// `origin` as advertised to this peer: never its own routes back (split horizon), + /// and under MoQ Cluster only the routes with room for our hop under the origin's + /// cap, so selection falls back to a shorter route instead of withdrawing. + fn advertising(&self, origin: origin::Consumer, peer: &cluster::Peer) -> origin::Consumer { + let origin = origin.excluding(self.exclude(peer)); + match peer.negotiated() { + true => origin.forwardable(), + false => origin, + } + } + /// The Hop ID whose paths must not be advertised (or served) back to this peer. /// /// A peer that declared an identity supplies its own; otherwise fall back to the one @@ -2012,7 +2028,13 @@ where kio::wait(|waiter| { if let Poll::Ready(res) = waiter.poll_future(open.as_mut()) { - return Poll::Ready(res.map(Some)); + return Poll::Ready(res.map(|stream| { + Some(super::request_stream::with_stats( + stream, + self.origin.stats(), + self.version, + )) + })); } if timeout.poll(waiter).is_ready() { return Poll::Ready(Ok(None)); @@ -2031,8 +2053,7 @@ where async fn read_response(&self, request: &mut Stream) -> Result, Error> { let mut read = std::pin::pin!(async { let type_id: u64 = request.reader.decode().await?; - let size: u16 = request.reader.decode().await?; - let data = request.reader.read_exact(size as usize).await?; + let data = request.reader.read_message(type_id).await?; Ok::<_, Error>((type_id, data)) }); let mut timeout = crate::runtime::Deadline::after(&self.runtime, ADVERTISE_TIMEOUT); @@ -2131,7 +2152,7 @@ where // Split horizon, as the solicited loop applies it: never advertise a route back // to the peer it came from. let origin = self - .excluding(&peer) + .advertising(self.origin.clone(), &peer) .discovery(!self.peer_setup.get().await.hidden || self.origin.includes_hidden()); let ns = Namespaces::new(peer, Target::Requests(None)); @@ -2202,7 +2223,7 @@ where // Register the split-horizon peer on the announce cursor too. The origin // model uses this exposure to park a reflected copy before it can replace // the source we are currently advertising to that peer. - let origin = origin.excluding(self.exclude(&peer)); + let origin = self.advertising(origin, &peer); // Draft-14/15 predate NAMESPACE, so they answer with their own PUBLISH_NAMESPACE // requests and keep this stream open for the subscription's lifetime. diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index bf98967a39b..faf903e4658 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -6,6 +6,20 @@ use crate::{ coding::{Decode, DecodeError, Stream}, }; +/// Legacy receives are framed once by the physical multiplexer, not again by +/// the virtual request decoder. Encodes still count at their typed codec. +pub(super) fn with_stats( + stream: Stream, + stats: &crate::stats::Session, + version: Version, +) -> Stream { + let mut stream = stream.with_stats(stats); + if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + stream.reader = stream.reader.with_stats(&Default::default()); + } + stream +} + /// Whether the requester's FIN cancels its request; later drafts only end its updates. pub(super) fn fin_cancels(version: Version) -> bool { matches!( @@ -41,20 +55,33 @@ pub(super) struct Update { pub(super) priority: Option, /// It asks for something other than a priority change, which we cannot apply. pub(super) unsupported: bool, + /// The encoded size of the type selector, which the sender writes apart from the message. + selector: usize, } impl Decode for Update { fn decode(r: &mut R, version: Version) -> Result { + let start = r.remaining(); if u64::decode(r, version)? != super::SubscribeUpdate::ID { return Err(DecodeError::InvalidValue); } + let selector = start - r.remaining(); let size = u16::decode(r, version)? as usize; if r.remaining() < size { return Err(DecodeError::Short); } let mut data = r.copy_to_bytes(size); // The complete frame is present; a short field inside it is malformed. - Self::decode_body(&mut data, version).map_err(DecodeError::complete) + let update = Self::decode_body(&mut data, version).map_err(DecodeError::complete)?; + Ok(Self { selector, ..update }) + } + + fn control(&self, version: Version) -> Option { + Some(super::message::control(super::SubscribeUpdate::ID, version)) + } + + fn uncounted(&self) -> usize { + self.selector } } @@ -87,6 +114,7 @@ impl Update { return Err(DecodeError::Long); } Ok(Self { + selector: 0, priority, unsupported: forward == Some(false) || filter.is_some() @@ -103,3 +131,34 @@ impl Update { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A request-stream message counts as its sender counts it: the length and body, not the + /// type that selects it. + #[tokio::test] + async fn request_stream_messages_count_without_their_type() { + use crate::lite::test_transport::ScriptedSession; + use crate::stats::{Control, Registry, Tier}; + let registry = Registry::new(Default::default()); + let stats = registry.tier(Tier::default()).session(""); + let version = Version::Draft22; + // GOAWAY, REQUEST_OK, then a REQUEST_UPDATE with no parameters. + let mut session = ScriptedSession::new(vec![0x10, 0, 2, 0, 0, 0x07, 0, 0, 0x02, 0, 2, 0, 0]); + let mut stream = with_stats(Stream::open(&mut session, version).await.unwrap(), &stats, version); + for expected in [super::super::GoAway::ID, super::super::RequestOk::ID] { + let id: u64 = stream.reader.decode().await.unwrap(); + assert_eq!(id, expected); + stream.reader.read_message(id).await.unwrap(); + } + stream.reader.decode::().await.unwrap(); + let rx = |class| { + let rows = registry.snapshot().control(); + rows.into_iter().find(|row| row.1 == class).map(|row| row.2.rx) + }; + assert_eq!(rx(Control::Goaway), Some(4)); + assert_eq!(rx(Control::Other), Some(2 + 4)); + } +} diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 976363242fb..c00df225e31 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -17,6 +17,9 @@ pub struct Config { /// The runtime that arms the session's timers. pub runtime: crate::time::Clock, + /// Where the session's control traffic is counted, even with neither origin set. + pub stats: crate::stats::Session, + pub session: S, /// The bidi SETUP stream (draft-14 through draft-16 only). Draft-17+ passes `None` @@ -89,6 +92,7 @@ where { let Config { runtime, + stats, mut session, setup, request_id_max, @@ -124,8 +128,12 @@ where // moq-transport threads concrete origins through the publisher/subscriber. // An unset half gets an empty origin: an empty publish origin announces // nothing, and an empty subscribe origin issues no SUBSCRIBE_NAMESPACE. - let publish = publish.unwrap_or_else(|| origin::Producer::empty(Hop::random()).consume()); - let subscribe = subscribe.unwrap_or_else(|| origin::Producer::empty(Hop::random())); + let publish = publish.unwrap_or_else(|| { + origin::Producer::empty(Hop::random()) + .consume() + .with_stats(stats.clone()) + }); + let subscribe = subscribe.unwrap_or_else(|| origin::Producer::empty(Hop::random()).with_stats(stats.clone())); // What the peer declared in its SETUP. Seeded now when that stream was already // read (the legacy handshake, or a gated server accept), and filled by the uni @@ -149,7 +157,7 @@ where return Err(err); }; let control = Control::new(request_id_max, client); - let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version); + let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version).with_stats(&stats); let publisher = Publisher::new( runtime.clone(), @@ -307,9 +315,20 @@ where let runtime = runtime.clone(); let session = session.clone(); let goaway = goaway.clone(); + let stats = stats.clone(); async move { - if let Err(err) = - run_setup(runtime, session, version, path, authority, self_origin, cost, goaway).await + if let Err(err) = run_setup( + runtime, + session, + version, + path, + authority, + self_origin, + cost, + goaway, + stats, + ) + .await { tracing::warn!(%err, "setup send error"); } @@ -350,9 +369,12 @@ where // arrives on the wire. let goaway_recv = { let goaway = goaway.clone(); + let stats = stats.clone(); async move { match peer_setup_stream { - Some(reader) => run_goaway(reader.with_version(version), version, goaway).await, + Some(reader) => { + run_goaway(reader.with_stats(&stats).with_version(version), version, goaway).await + } None => std::future::pending().await, } } @@ -483,6 +505,9 @@ pub struct PeerSetup { /// The SETUP stream, which becomes the GOAWAY channel. pub stream: Reader, + /// The SETUP's encoded size, counted once the session's stats are known. + pub size: usize, + /// The request path the peer advertised, for URL-less transports. pub path: Option, @@ -542,7 +567,7 @@ pub async fn accept_setup( continue; } - let setup: setup::Setup = reader.decode().await?; + let (setup, size) = reader.decode_sized::().await?; let mut bytes = setup.parameters.clone(); let params = ietf::Parameters::decode(&mut bytes, version)?; let path = match params.get_bytes(ietf::ParameterBytes::Path) { @@ -558,6 +583,7 @@ pub async fn accept_setup( return Ok(PeerSetup { stream: reader, + size, path, token, declared, @@ -600,11 +626,12 @@ async fn run_setup( self_origin: Hop, cost: Option, goaway: crate::goaway::Protocol, + stats: crate::stats::Session, ) -> Result<(), Error> { let outer_version = crate::Version::Ietf(version); let send = session.open_uni().await.map_err(Error::from_transport)?; - let mut writer: Writer = Writer::new(send, outer_version); + let mut writer: Writer = Writer::new(send, outer_version).with_stats(&stats); let mut parameters = ietf::Parameters::default(); parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); @@ -653,6 +680,7 @@ async fn run_setup( let mut writer = writer.with_version(version); writer.encode(&ietf::GoAway::ID).await?; writer.encode(&size).await?; + stats.control(crate::stats::Control::Goaway, body.len() + 2, false); writer.write_all(&mut std::io::Cursor::new(body)).await?; crate::goaway::enforce(&runtime, &mut session, payload.timeout).await; @@ -832,6 +860,7 @@ impl Clone for Uni { impl Uni { /// Read the stream's type and serve it, failing only when the stream breaks the session. async fn serve(self, mut reader: Reader) -> Result<(), Error> { + reader = reader.with_stats(self.subscriber.stats()); let Self { mut session, mut subscriber, @@ -1033,6 +1062,20 @@ where Err(err) => return Err(err), }; + // Drafts 14-16 multiplex requests over the control stream, whose reader counts + // them; only a real bidi stream counts its request here. Draft 16 opens one + // for SUBSCRIBE_NAMESPACE. + let native = !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + || (version == Version::Draft16 && id == ietf::SubscribeNamespaceLegacy::ID); + let stream = match native { + true => { + let stats = publisher.stats(); + stats.control(super::message::control(id, version), data.len() + 2, true); + stream.with_stats(stats) + } + false => super::request_stream::with_stats(stream, publisher.stats(), version), + }; + match id { // Publisher handles: Subscribe, Fetch, SubscribeNamespace (0x50 modern / // 0x11 legacy), SubscribeTracks, TrackStatus @@ -1068,8 +1111,7 @@ async fn run_goaway( None => return Ok(()), }; - let size: u16 = reader.decode::().await?; - let mut data = reader.read_exact(size as usize).await?; + let mut data = reader.read_message(id).await?; if id != ietf::GoAway::ID { return Err(Error::UnexpectedMessage); @@ -1100,8 +1142,7 @@ async fn run_goaway( Some(id) => id, None => return Ok(()), }; - let size: u16 = reader.decode::().await?; - let mut data = reader.read_exact(size as usize).await?; + let mut data = reader.read_message(id).await?; if id == ietf::GoAway::ID { let msg = ietf::GoAway::decode_msg(&mut data, version)?; @@ -1169,6 +1210,7 @@ mod tests { let (driver, _goaway) = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup: None, request_id_max: None, @@ -1229,6 +1271,7 @@ mod tests { let (driver, _goaway) = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup: None, request_id_max: None, @@ -1282,6 +1325,7 @@ mod tests { let (driver, _goaway) = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup: None, request_id_max: None, @@ -1391,6 +1435,7 @@ mod tests { let (driver, _goaway) = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup: None, request_id_max: None, @@ -1432,6 +1477,7 @@ mod tests { let (driver, _goaway) = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup: None, request_id_max: None, @@ -1785,6 +1831,7 @@ mod tests { let (driver, _goaway) = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup: Some(setup), request_id_max: None, diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 8fe0ccc348d..56fa1292aea 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -641,7 +641,8 @@ where /// A negotiated peer supplies the path and cost, so the route is what the mesh /// actually knows: the full chain, and the accumulated cost plus this link's price. /// A path starting with 0 gets this connection's stamp in front of it; the 0s stay. An advertisement whose path already contains our own Hop ID looped back, - /// and neither forwarding it nor subscribing through it is safe. + /// and neither forwarding it nor subscribing through it is safe. One longer than + /// the origin's hop limit, stamp included, is discarded too. fn route(&self, advert: Option<&cluster::Advert>, peer: &cluster::Peer) -> Option { let Some(advert) = advert else { return Some(Advertised { @@ -657,6 +658,9 @@ where .route(cluster::link_cost(self.cost, peer)) .with_via(self.via(peer)); route.hops.stamp(self.stamp).ok()?; + if route.hops.len() > self.origin.max_hops().get() { + return None; + } Some(Advertised { route }) } @@ -736,6 +740,11 @@ where crate::model::interest_prefixes(&self.origin.allowed()) } + /// Where this session's control traffic is counted. + pub(super) fn stats(&self) -> &crate::stats::Session { + self.origin.stats() + } + /// Send SUBSCRIBE_NAMESPACE for one prefix on a bidi stream. /// The caller is responsible for opening the appropriate stream type /// (virtual for v14/v15, real bidi for v16+), one per prefix. @@ -748,6 +757,12 @@ where mut stream: Stream, prefix: PathOwned, ) -> Result<(), Error> { + // Draft 16 opens a real bidi stream for this, so its reader counts too. + stream = match self.version { + Version::Draft16 => stream.with_stats(self.origin.stats()), + _ => super::request_stream::with_stats(stream, self.origin.stats(), self.version), + }; + // A peer that sent GOAWAY told us to stop opening requests on this session, // announce-interest included (draft-19 sect 10.4). if self.going_away.is_set() { @@ -790,8 +805,7 @@ where // Read response let type_id: u64 = stream.reader.decode().await?; - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; + let mut data = stream.reader.read_message(type_id).await?; match type_id { ietf::SubscribeNamespaceOk::ID if self.version == Version::Draft14 => { @@ -855,8 +869,7 @@ where Some(id) => id, None => break, // Stream closed }; - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; + let mut data = stream.reader.read_message(type_id).await?; match type_id { // The suffix is relative to the prefix we subscribed, which is itself @@ -1116,8 +1129,7 @@ where return Err(Error::UnexpectedMessage); } - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; + let mut data = stream.reader.read_message(type_id).await?; if terminal { ietf::PublishNamespaceDone::decode_msg(&mut data, self.version)?; @@ -1631,7 +1643,7 @@ where }; let mut stream = match Stream::open(&mut self.session.clone(), self.version).await { - Ok(s) => s, + Ok(s) => super::request_stream::with_stats(s, self.origin.stats(), self.version), Err(err) => { tracing::debug!(%err, "failed to open subscribe stream"); request.reject(err); @@ -2079,7 +2091,7 @@ where } let mut stream = match Stream::open(&mut self.session.clone(), self.version).await { - Ok(s) => s, + Ok(s) => super::request_stream::with_stats(s, self.origin.stats(), self.version), Err(err) => { tracing::debug!(%err, "failed to open joining FETCH stream"); settle_join_live(&fill); @@ -2148,8 +2160,7 @@ where /// refusal, not a session error: the live subscription continues. async fn read_fetch_response(&self, stream: &mut Stream) -> Result { let type_id: u64 = stream.reader.decode().await?; - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; + let mut data = stream.reader.read_message(type_id).await?; match type_id { ietf::FetchOk::ID => { @@ -2171,8 +2182,7 @@ where async fn read_subscribe_response(&self, stream: &mut Stream) -> Result, Error> { // Read type_id + size + body from the stream let type_id: u64 = stream.reader.decode().await?; - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; + let mut data = stream.reader.read_message(type_id).await?; match type_id { ietf::SubscribeOk::ID => { @@ -3022,7 +3032,7 @@ where let _registered = GroupFetchEntry::new(&self.state, fetch_id, slot.clone()); let mut stream = match Stream::open(&mut self.session.clone(), self.version).await { - Ok(stream) => stream, + Ok(stream) => super::request_stream::with_stats(stream, self.origin.stats(), self.version), Err(err) => return request.reject(err), }; @@ -3193,8 +3203,7 @@ where /// Read the answer to a group FETCH: FETCH_OK, or the publisher's refusal as an error. async fn read_group_fetch_response(&self, stream: &mut Stream) -> Result { let type_id: u64 = stream.reader.decode().await?; - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; + let mut data = stream.reader.read_message(type_id).await?; match type_id { ietf::FetchOk::ID => Ok(ietf::FetchOk::decode_msg(&mut data, self.version)?), diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index 415fe598d3c..c4dd433e69d 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -212,6 +212,10 @@ impl Decode for Cost { } impl Encode for AnnounceBroadcast<'_> { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Announce) + } + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { if version.has_announce_id() { // Lite06+: outer type discriminator, then a size-prefixed body (like the @@ -277,6 +281,10 @@ impl Encode for AnnounceBroadcast<'_> { } impl Decode for AnnounceBroadcast<'_> { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Announce) + } + fn decode(buf: &mut B, version: Version) -> Result { if version.has_announce_id() { // Lite06+: outer type, then a size-prefixed body decoded within its bounds. @@ -397,6 +405,7 @@ pub struct AnnounceRequest<'a> { } impl Message for AnnounceRequest<'_> { + const CONTROL: Option = Some(crate::stats::Control::Announce); fn decode_msg(r: &mut R, version: Version) -> Result { let prefix = Path::decode(r, version)?; let exclude_hop = match version.has_exclude_hop() { @@ -439,6 +448,10 @@ enum AnnounceStatus { } impl Decode for AnnounceStatus { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Announce) + } + fn decode(r: &mut R, version: Version) -> Result { let status = u8::decode(r, version)?; status.try_into().map_err(|_| DecodeError::InvalidValue) @@ -446,6 +459,10 @@ impl Decode for AnnounceStatus { } impl Encode for AnnounceStatus { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Announce) + } + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { (*self as u8).encode(w, version) } @@ -461,6 +478,7 @@ pub struct AnnounceInit<'a> { } impl Message for AnnounceInit<'_> { + const CONTROL: Option = Some(crate::stats::Control::Announce); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => {} @@ -513,6 +531,7 @@ pub struct AnnounceOk { } impl Message for AnnounceOk { + const CONTROL: Option = Some(crate::stats::Control::Announce); fn decode_msg(r: &mut R, version: Version) -> Result { if !version.has_announce_ok() { return Err(DecodeError::Version); diff --git a/rs/moq-net/src/lite/fetch.rs b/rs/moq-net/src/lite/fetch.rs index c098c5990a9..5679467adc4 100644 --- a/rs/moq-net/src/lite/fetch.rs +++ b/rs/moq-net/src/lite/fetch.rs @@ -24,6 +24,7 @@ pub struct Fetch<'a> { } impl Message for Fetch<'_> { + const CONTROL: Option = Some(crate::stats::Control::Fetch); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { diff --git a/rs/moq-net/src/lite/goaway.rs b/rs/moq-net/src/lite/goaway.rs index 0152c2d4bd5..bca47b5e41e 100644 --- a/rs/moq-net/src/lite/goaway.rs +++ b/rs/moq-net/src/lite/goaway.rs @@ -13,6 +13,7 @@ pub struct Goaway<'a> { } impl Message for Goaway<'_> { + const CONTROL: Option = Some(crate::stats::Control::Goaway); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 | Version::Lite03 => { diff --git a/rs/moq-net/src/lite/info.rs b/rs/moq-net/src/lite/info.rs index d85827e8fac..1bc1a961963 100644 --- a/rs/moq-net/src/lite/info.rs +++ b/rs/moq-net/src/lite/info.rs @@ -8,6 +8,7 @@ pub struct SessionInfo { } impl Message for SessionInfo { + const CONTROL: Option = Some(crate::stats::Control::Session); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => {} diff --git a/rs/moq-net/src/lite/message.rs b/rs/moq-net/src/lite/message.rs index c3c0ceb61a9..2385882a08c 100644 --- a/rs/moq-net/src/lite/message.rs +++ b/rs/moq-net/src/lite/message.rs @@ -23,6 +23,8 @@ pub(super) fn decode_size(buf: &mut B, version: Version) -> Result = None; /// The largest body this receiver accepts for this message. const MAX_SIZE: usize = MAX_MESSAGE_SIZE; @@ -45,9 +47,17 @@ impl Encode for T { sizer.size.encode(w, version)?; self.encode_msg(w, version) } + + fn control(&self, _: Version) -> Option { + T::CONTROL + } } impl Decode for T { + fn control(&self, _: Version) -> Option { + T::CONTROL + } + fn decode(buf: &mut B, version: Version) -> Result { let size = decode_size(buf, version)?; if size > Self::MAX_SIZE { diff --git a/rs/moq-net/src/lite/probe.rs b/rs/moq-net/src/lite/probe.rs index dc64fb5b647..c48f3191a68 100644 --- a/rs/moq-net/src/lite/probe.rs +++ b/rs/moq-net/src/lite/probe.rs @@ -13,6 +13,7 @@ pub struct Probe { } impl Message for Probe { + const CONTROL: Option = Some(crate::stats::Control::Probe); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 3fe2eb07498..c18974705bc 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -184,6 +184,7 @@ where loop { match Stream::poll_accept(&mut self.accept, self.shared.version, &mut cx) { Poll::Ready(Ok(stream)) => { + let stream = stream.with_stats(self.shared.origin.stats()); self.children.push(Control { shared: self.shared.clone(), runtime: self.runtime.clone(), @@ -650,6 +651,12 @@ impl AnnounceServe { // model uses this exposure to park a reflected copy before it can replace // the source we are currently advertising to that peer. let origin = origin.excluding(Hop::new(exclude_hop).unwrap_or(Hop::UNKNOWN)); + // Lite03 and later carry the hop path, so only routes with room for our hop + // under the origin's cap are advertised; Lite01/02 hide the depth entirely. + let origin = match self.shared.version { + Version::Lite01 | Version::Lite02 => origin, + _ => origin.forwardable(), + }; // Hidden routes are left out unless the peer opted in. A publish origin that // already opted in (the caller's choice for this peer) keeps them either way. let origin = match hidden { diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index c64069ba730..700532a643a 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -33,17 +33,18 @@ pub(crate) struct SessionStart { /// out-of-order tolerance is the full session loop with deferred origin binding. /// /// Pass the returned [`Setup`] to [`start`] as its `peer_setup` so PROBE gating still -/// resolves without re-reading the (consumed) stream. +/// resolves without re-reading the (consumed) stream. Also returns the SETUP's encoded +/// size, counted once the session's stats are known. pub async fn accept_setup( session: &mut S, version: Version, -) -> Result { +) -> Result<(Setup, usize), Error> { loop { let stream = session.accept_uni().await.map_err(Error::from_transport)?; let mut reader = Reader::new(stream, version); match reader.decode::().await? { - DataType::Setup => return reader.decode::().await, + DataType::Setup => return reader.decode_sized::().await, // A non-SETUP uni stream this early is unexpected (GROUP needs a prior // subscribe). Reject it and keep waiting rather than failing the session. _ => reader.abort(&Error::UnexpectedStream), @@ -56,6 +57,9 @@ pub struct Config { /// The runtime that arms the session's timers. pub runtime: crate::time::Clock, + /// Where the session's control traffic is counted, even with neither origin set. + pub stats: crate::stats::Session, + /// The transport carrying the session. Cloned into every loop that outlives /// [`start`], so the connection closes when the last of them drops. pub session: S, @@ -99,6 +103,7 @@ where { let Config { runtime, + stats, session, setup_stream, publish, @@ -140,8 +145,12 @@ where // An unset half gets an empty origin: an empty publish origin announces nothing // (and answers the peer's announce-interest with an empty set), and an empty // subscribe origin issues no ANNOUNCE_PLEASE. - let publish = publish.unwrap_or_else(|| origin::Producer::empty(Hop::random()).consume()); - let subscribe = subscribe.unwrap_or_else(|| origin::Producer::empty(Hop::random())); + let publish = publish.unwrap_or_else(|| { + origin::Producer::empty(Hop::random()) + .consume() + .with_stats(stats.clone()) + }); + let subscribe = subscribe.unwrap_or_else(|| origin::Producer::empty(Hop::random()).with_stats(stats.clone())); // Publisher and Subscriber each derive their identity from their own // attached origin (publish.info / subscribe.info). This is what gets @@ -196,9 +205,15 @@ where local_close: Default::default(), setup: version .has_setup_stream() - .then(|| SendSetup::new(session.clone(), our_setup, version)), - goaway: Some(SendGoaway::new(runtime, session.clone(), goaway, version)), - session_stream: setup_stream, + .then(|| SendSetup::new(session.clone(), our_setup, version, stats.clone())), + goaway: Some(SendGoaway::new( + runtime, + session.clone(), + goaway, + version, + stats.clone(), + )), + session_stream: setup_stream.map(|stream| stream.with_stats(&stats)), publisher, subscriber: SubscriberDriver::new(subscriber), session, @@ -316,6 +331,7 @@ fn poll_session_stream( /// error is logged and the machine finishes; the peer falls back to "no /// capabilities" for us. struct SendSetup { + stats: crate::stats::Session, version: Version, state: SendSetupState, } @@ -336,8 +352,9 @@ enum SendSetupState { } impl SendSetup { - fn new(session: S, setup: Setup, version: Version) -> Self { + fn new(session: S, setup: Setup, version: Version, stats: crate::stats::Session) -> Self { Self { + stats, version, state: SendSetupState::Open { session, @@ -360,7 +377,7 @@ impl SendSetup { match &mut self.state { SendSetupState::Open { session, setup } => { let stream = ready!(session.poll_open_uni(cx)).map_err(Error::from_transport)?; - let mut writer = Writer::new(stream, self.version); + let mut writer = Writer::new(stream, self.version).with_stats(&self.stats); writer.buffer(&super::DataType::Setup)?; writer.buffer(&**setup)?; self.state = SendSetupState::Send { @@ -389,6 +406,7 @@ impl SendSetup { /// the sender's own timer, so a caller draining a lite-03 peer still gets the /// session closed on schedule; the peer just never learns why. struct SendGoaway { + stats: crate::stats::Session, version: Version, runtime: crate::time::Clock, goaway: crate::goaway::Protocol, @@ -419,8 +437,15 @@ enum SendGoawayState { } impl SendGoaway { - fn new(runtime: crate::time::Clock, session: S, goaway: crate::goaway::Protocol, version: Version) -> Self { + fn new( + runtime: crate::time::Clock, + session: S, + goaway: crate::goaway::Protocol, + version: Version, + stats: crate::stats::Session, + ) -> Self { Self { + stats, version, runtime, goaway, @@ -469,7 +494,7 @@ impl SendGoaway { SendGoawayState::Open { payload } => { let timeout = payload.timeout; let mut stream = match ready!(Stream::poll_open(&mut self.session, self.version, &mut cx)) { - Ok(stream) => stream, + Ok(stream) => stream.with_stats(&self.stats), Err(err) => { self.enforce_after(err, timeout); continue; @@ -544,6 +569,7 @@ mod tests { let log = session.log.clone(); let mut started = start(Config { runtime: crate::time::Clock::tokio(), + stats: Default::default(), session, setup_stream: None, publish: None, diff --git a/rs/moq-net/src/lite/setup.rs b/rs/moq-net/src/lite/setup.rs index 3bfe511e253..b0b7758aea7 100644 --- a/rs/moq-net/src/lite/setup.rs +++ b/rs/moq-net/src/lite/setup.rs @@ -185,6 +185,7 @@ pub struct Setup { } impl Message for Setup { + const CONTROL: Option = Some(crate::stats::Control::Session); const MAX_SIZE: usize = crate::setup::MAX_SETUP_SIZE; fn decode_msg(r: &mut R, version: Version) -> Result { diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 22f753b5a42..080e647fa6b 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -44,6 +44,7 @@ impl Version { } impl Message for Subscribe<'_> { + const CONTROL: Option = Some(crate::stats::Control::Subscribe); fn decode_msg(r: &mut R, version: Version) -> Result { let id = u64::decode(r, version)?; let broadcast = Path::decode(r, version)?; @@ -232,6 +233,7 @@ pub struct SubscribeOk { } impl Message for SubscribeOk { + const CONTROL: Option = Some(crate::stats::Control::Subscribe); fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { match version { Version::Lite01 => { @@ -298,6 +300,7 @@ pub struct SubscribeStart { } impl Message for SubscribeStart { + const CONTROL: Option = Some(crate::stats::Control::Subscribe); fn decode_msg(r: &mut R, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); @@ -328,6 +331,7 @@ pub struct SubscribeEnd { } impl Message for SubscribeEnd { + const CONTROL: Option = Some(crate::stats::Control::Subscribe); fn decode_msg(r: &mut R, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); @@ -369,6 +373,7 @@ pub struct SubscribeUpdate { } impl Message for SubscribeUpdate { + const CONTROL: Option = Some(crate::stats::Control::Subscribe); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { @@ -454,6 +459,7 @@ pub struct SubscribeDrop { } impl Message for SubscribeDrop { + const CONTROL: Option = Some(crate::stats::Control::Subscribe); fn decode_msg(r: &mut R, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { @@ -518,6 +524,10 @@ fn encode_typed( } impl Encode for SubscribeResponse { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Subscribe) + } + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { match version { Version::Lite01 | Version::Lite02 => match self { @@ -548,6 +558,10 @@ impl Encode for SubscribeResponse { } impl Decode for SubscribeResponse { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Subscribe) + } + fn decode(buf: &mut B, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => Ok(Self::Ok(SubscribeOk::decode(buf, version)?)), diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1579aa0d4e9..7d30aa5ceed 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -320,6 +320,9 @@ impl Subscriber { tracing::debug!(route = %self.log_path(&path), "dropping announce; no room to stamp the chain"); return Ok(false); } + if self.exceeds_hop_limit(&path, &hops) { + return Ok(false); + } tracing::debug!(route = %self.log_path(&path), hops = hops.len(), "announce"); @@ -337,6 +340,16 @@ impl Subscriber { Ok(true) } + /// Whether a received chain, as stamped, is longer than the origin's hop limit. + /// Lite01/02 carry no path, so their depth is hidden and never capped. + fn exceeds_hop_limit(&self, path: &PathOwned, hops: &crate::Hops) -> bool { + if matches!(self.version, Version::Lite01 | Version::Lite02) || hops.len() <= self.origin.max_hops().get() { + return false; + } + tracing::debug!(route = %self.log_path(path), hops = hops.len(), "dropping announce beyond hop limit"); + true + } + /// The route to announce for a prefix this peer advertised, charging our /// link's price on top of the cost it advertised. /// @@ -413,7 +426,7 @@ impl Subscriber { // Named by this connection's stamp when the chain names no publisher, exactly as // the announce was, so a restart of the same unnamed publisher stays in place. - if hops.stamp(self.stamp).is_err() { + if hops.stamp(self.stamp).is_err() || self.exceeds_hop_limit(&path, &hops) { announced.declined(path); return Ok(false); } @@ -633,7 +646,8 @@ impl UniAccept { self.children.push(UniServe { subscriber: self.subscriber.clone(), state: UniState::Start { - reader: Reader::new(stream, self.subscriber.version), + reader: Reader::new(stream, self.subscriber.version) + .with_stats(self.subscriber.origin.stats()), }, }); } @@ -1143,6 +1157,7 @@ impl ProbeStream { return Poll::Ready(Ok(())); } let mut stream = ready!(Stream::poll_open(&mut self.session, self.subscriber.version, &mut cx))?; + stream = stream.with_stats(self.subscriber.origin.stats()); stream.writer.buffer(&lite::ControlType::Probe)?; self.state = ProbeState::Send { stream }; } @@ -1231,6 +1246,8 @@ impl AnnouncePrefix { &mut cx ))?; + stream = stream.with_stats(self.subscriber.origin.stats()); + stream.writer.buffer(&lite::ControlType::Announce)?; // Lite04/05: ask the peer to filter out announces that already passed // through us, so the reflected ones never hit the wire. Encoding drops @@ -3200,6 +3217,9 @@ impl SubStream { } } +// One per track copy, which holds a live stream while anyone reads it, so boxing it +// would add an allocation per upstream subscription without saving memory. +#[allow(clippy::large_enum_variant)] enum Sub { None, Active(SubStream), @@ -3702,6 +3722,7 @@ impl Establish { self.serve.subscriber.version, &mut cx ))?; + stream = stream.with_stats(self.serve.subscriber.origin.stats()); let bounds = WireBounds::new(self.subscription.start, self.subscription.end); let msg = lite::Subscribe { @@ -3928,6 +3949,7 @@ impl TrackInfoFetch { TrackInfoState::Open => { serve.subscriber.check_going_away()?; let mut stream = ready!(Stream::poll_open(&mut self.session, serve.subscriber.version, &mut cx))?; + stream = stream.with_stats(serve.subscriber.origin.stats()); stream.writer.buffer(&lite::ControlType::Track)?; stream.writer.buffer(&lite::Track { broadcast: serve.path.as_path(), @@ -4453,7 +4475,7 @@ impl kio::Task for FetchServeRun { self.serve.subscriber.version, &mut cx )) { - Ok(stream) => stream, + Ok(stream) => stream.with_stats(self.serve.subscriber.origin.stats()), Err(err) => { tracing::warn!(track = %self.serve.name, %err, "fetch stream open failed"); request.take().expect("request pending").reject(err); diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs index 1d4ad41da06..01380e8c06f 100644 --- a/rs/moq-net/src/lite/track.rs +++ b/rs/moq-net/src/lite/track.rs @@ -18,6 +18,7 @@ pub struct Track<'a> { } impl Message for Track<'_> { + const CONTROL: Option = Some(crate::stats::Control::TrackInfo); fn decode_msg(r: &mut R, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); @@ -58,6 +59,7 @@ pub struct TrackInfo { } impl Message for TrackInfo { + const CONTROL: Option = Some(crate::stats::Control::TrackInfo); fn decode_msg(r: &mut R, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 78bbe59c922..48f9f9ac387 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -41,7 +41,8 @@ pub use timed::Timed; /// Publishing broadcasts, announcing routes, and consuming both through an origin. pub mod origin { pub use super::origin_impl::{ - Config, Consumer, Cost, DEFAULT_UPDATE_HOLD, Driver, Dynamic, Producer, Request, Requesting, Route, Source, + Config, Consumer, Cost, DEFAULT_UPDATE_HOLD, Driver, Dynamic, HopLimit, Producer, Request, Requesting, Route, + Source, }; } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 671c3c9dc9f..05b6581b2fe 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2,7 +2,7 @@ use crate::{broadcast, cache, group, stats, track}; use kio::Pollable; use std::{ cmp::Reverse, - collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, + collections::{BTreeSet, HashMap, HashSet, VecDeque}, fmt, sync::Arc, sync::atomic::{AtomicU64, Ordering}, @@ -99,6 +99,14 @@ pub struct Config { /// detection and shortest-path routing. pub hop: Hop, + /// Longest hop path a session using this origin accepts or forwards (default 32). + /// + /// Applies to moq-lite-03+ hop paths and the negotiated MoQ Cluster extension on + /// moq-transport. A forwarded path counts this origin's own hop too, including + /// when the receiving peer reconstructs that hop from the announce handshake. + /// Protocols without a hop path cannot reveal or bound the depth they hide. + pub max_hops: HopLimit, + /// The cache pool broadcasts under this origin charge their groups into. It flows /// down the ownership chain (origin -> broadcast -> track -> group): a track opens /// an account against it, and its groups charge through that. It has no byte target @@ -161,6 +169,7 @@ impl Default for Config { let pool = cache::Pool::new(cache::Config::default().with_expiry(cache::DEFAULT_EXPIRY)); Self { hop: Hop::random(), + max_hops: HopLimit::default(), pool, cache_duration: Duration::MAX, default_max_age: track::DEFAULT_MAX_AGE, @@ -222,6 +231,32 @@ where /// silently truncating. pub(crate) const MAX_HOPS: usize = 32; +/// The longest advertised hop path an origin accepts or forwards, from 1 to 32. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct HopLimit(usize); + +impl HopLimit { + /// A hop-path cap from 1 to 32, or `None` outside that range. + pub const fn new(max: usize) -> Option { + if max == 0 || max > MAX_HOPS { + None + } else { + Some(Self(max)) + } + } + + /// The maximum number of hops in an advertised path. + pub const fn get(self) -> usize { + self.0 + } +} + +impl Default for HopLimit { + fn default() -> Self { + Self(MAX_HOPS) + } +} + /// Bounded, loop-free list of [`Hop`] entries: the hop chain of a broadcast. /// /// Guarantees `len() <= MAX_HOPS` and that no non-zero [`Hop`] appears twice. Both @@ -697,7 +732,7 @@ impl Quarantine { } } -/// A pending update's hold, keyed by prefix in its [`AnnounceConsumer`]. +/// A pending update's hold, kept with it in its [`AnnounceConsumer`]'s queue. /// /// A consumer reads the origin's clock outside the driver, where it holds the /// driver's last reading, not the time the update arrived. So a hold first waits @@ -723,13 +758,31 @@ enum PendingUpdate { UnannounceAnnounce { old: AnnounceMeta, new: AnnounceMeta }, } -/// Pending updates keyed by prefix. `BTreeMap` keeps memory strictly bounded by -/// the number of distinct prefixes with outstanding work (collapsed pairs are -/// fully erased) and gives a deterministic lexicographic delivery order so -/// tests can predict it. +/// A prefix's queued update and where it waits in the queue. +struct Pending { + update: Option, + /// When a poll looks at the entry again, in [`OriginConsumerState::parked`], + /// or `None` while it is in [`OriginConsumerState::due`]. + wake: Option, + /// The update hold, from the first poll that saw the entry as one. + held: Option, +} + +/// Pending updates keyed by prefix. Memory stays strictly bounded by the number +/// of distinct prefixes with outstanding work (collapsed pairs are fully erased), +/// and delivery takes the first ready prefix in lexicographic order, so tests can +/// predict it. +/// +/// Every entry sits in exactly one of `due` and `parked`, so a poll touches only +/// what changed or came due since the last one, never the entries it holds back. #[derive(Default)] struct OriginConsumerState { - pending: BTreeMap, + pending: HashMap, + /// The entries a poll looks at next, in delivery order: queued or changed since, + /// or come due. + due: BTreeSet, + /// The entries held back, by when a poll looks at them again. + parked: BTreeSet<(Instant, PathOwned)>, /// Prefixes whose most recently delivered update was an announce. A pending /// `Announce` is ambiguous on its own: it is an unseen initial announce (a /// retraction cancels it entirely) or a metadata update on a route the @@ -738,12 +791,57 @@ struct OriginConsumerState { /// Set by the origin's teardown: the cursor drains `pending`, then reports /// the end instead of parking forever on a table that can never fire again. ended: bool, + /// Entries the polls have looked at, which the CPU a poll costs follows. + #[cfg(test)] + steps: usize, } impl OriginConsumerState { + /// Queue `update` at `prefix`, or replace the one queued there in its place. A + /// replaced entry is looked at again on the next poll. + fn put(&mut self, prefix: PathOwned, update: PendingUpdate) { + let Some(pending) = self.pending.get_mut(&prefix) else { + self.due.insert(prefix.clone()); + self.pending.insert( + prefix, + Pending { + update: Some(update), + wake: None, + held: None, + }, + ); + return; + }; + pending.update = Some(update); + if let Some(wake) = pending.wake.take() { + let parked = (wake, prefix); + self.parked.remove(&parked); + self.due.insert(parked.1); + } + } + + /// Unqueue the entry at `prefix`. + fn remove(&mut self, prefix: &PathOwned) -> Option { + let (prefix, pending) = self.pending.remove_entry(prefix)?; + match pending.wake { + Some(wake) => self.parked.remove(&(wake, prefix)), + None => self.due.remove(&prefix), + }; + Some(pending) + } + + /// The origin tore down: drain everything queued, holds and all, then end. + fn end(&mut self) { + self.ended = true; + for (_, prefix) in std::mem::take(&mut self.parked) { + self.pending.get_mut(&prefix).unwrap().wake = None; + self.due.insert(prefix); + } + } + fn apply_announce(&mut self, prefix: PathOwned, meta: RouteMeta, captures: Option>) { let meta = (meta, captures); - let new = match self.pending.remove(&prefix) { + let new = match self.pending.get_mut(&prefix).and_then(|pending| pending.update.take()) { // First announce, a stale announce being replaced, or a metadata update. None | Some(PendingUpdate::Announce(_)) => PendingUpdate::Announce(meta), // Consumer needs to observe the retraction before this announce. @@ -751,85 +849,117 @@ impl OriginConsumerState { PendingUpdate::UnannounceAnnounce { old, new: meta } } }; - self.pending.insert(prefix, new); + self.put(prefix, new); } fn apply_unannounce(&mut self, prefix: PathOwned, last: RouteMeta, captures: Option>) { let last = (last, captures); - match self.pending.remove(&prefix) { + match self.pending.get_mut(&prefix).and_then(|pending| pending.update.take()) { // The pending announce was never delivered and neither was any earlier // one, so the pair cancels entirely. - Some(PendingUpdate::Announce(_)) if !self.delivered.contains(&prefix) => {} + Some(PendingUpdate::Announce(_)) if !self.delivered.contains(&prefix) => { + self.remove(&prefix); + } // Either nothing is pending or the pending announce was a metadata // update on a delivered route; the consumer still owes a retraction. None | Some(PendingUpdate::Announce(_) | PendingUpdate::Unannounce(_)) => { - self.pending.insert(prefix, PendingUpdate::Unannounce(last)); + self.put(prefix, PendingUpdate::Unannounce(last)); } // The embedded announce cancels with this retraction; the consumer still // needs the leading one. Some(PendingUpdate::UnannounceAnnounce { old, .. }) => { - self.pending.insert(prefix, PendingUpdate::Unannounce(old)); + self.put(prefix, PendingUpdate::Unannounce(old)); } } } - /// Whether the pending entry at `prefix` changes a route the consumer holds - /// to another route, rather than adding or removing one. - fn is_update(&self, prefix: &PathOwned, update: &PendingUpdate) -> bool { - matches!(update, PendingUpdate::Announce(_)) && self.delivered.contains(prefix) + /// Whether a poll at `now` has an entry to look at: one queued, changed, or come + /// due since the last, or the end of a torn-down origin. + fn due(&self, now: Option) -> bool { + !self.due.is_empty() + || self.wake().is_some_and(|wake| now.is_some_and(|now| wake <= now)) + || (self.ended && self.pending.is_empty()) + } + + /// When the next held entry is due, if any is held. + fn wake(&self) -> Option { + self.parked.first().map(|(wake, _)| *wake) } - /// The first pending prefix ready to deliver, or when the next held one is. - /// See [`Config::update_hold`]; a hold starts the first time a scan sees it, - /// and a quarantine in `retracted` (settled against `now`) holds until it ends. + /// The first pending prefix ready to deliver at `now`, or when the next held one + /// is. Each entry looked at is either ready or parked until it could be. + /// + /// A pending entry that changes a route the consumer holds to another route, + /// rather than adding or removing one, waits out the update hold (see + /// [`Config::update_hold`]), which starts the first time a poll sees it. A stale + /// path to a retracted publisher waits out its quarantine in `retracted`, + /// settled against `now`. fn scan( - &self, - held: &mut HashMap, + &mut self, retracted: &HashMap, now: Option, hold: Duration, ) -> Result> { - let mut wake = None; - for (prefix, update) in &self.pending { + while let Some((wake, _)) = self.parked.first() + && now.is_some_and(|now| *wake <= now) + { + let (_, prefix) = self.parked.pop_first().unwrap(); + self.pending.get_mut(&prefix).unwrap().wake = None; + self.due.insert(prefix); + } + while let Some(prefix) = self.due.first() { + #[cfg(test)] + { + self.steps += 1; + } let Some(now) = now.filter(|_| !hold.is_zero() && !self.ended) else { return Ok(prefix.clone()); }; - if !self.is_update(prefix, update) { - let quarantine = match update { - PendingUpdate::Announce((meta, _)) => retracted.get(prefix).filter(|q| q.holds(meta)), - _ => None, - }; - let until = match quarantine.map(|q| q.held) { - Some(Held::Anchoring(seen)) => seen + Duration::from_nanos(1), - Some(Held::Until(until)) if now < until => until, - _ => return Ok(prefix.clone()), - }; - wake = Some(wake.map_or(until, |w: Instant| w.min(until))); - continue; - } - // The clock reads the driver's last advance, which may be stale: anchor the - // hold on the first advance after the update arrived, by waking the driver. - let until = match held.get(prefix).copied() { - None => { - held.insert(prefix.clone(), Held::Anchoring(now)); - now + Duration::from_nanos(1) - } - Some(Held::Anchoring(seen)) if now <= seen => seen + Duration::from_nanos(1), - Some(Held::Anchoring(_)) => { - held.insert(prefix.clone(), Held::Until(now + hold)); - now + hold + let pending = self.pending.get_mut(prefix).unwrap(); + let wake = match pending.update.as_ref().unwrap() { + PendingUpdate::Announce(_) if self.delivered.contains(prefix) => { + // The clock reads the driver's last advance, which may be stale: anchor + // the hold on the first advance after the update arrived, by waking the + // driver. + match pending.held { + None => { + pending.held = Some(Held::Anchoring(now)); + Some(now + Duration::from_nanos(1)) + } + Some(Held::Anchoring(seen)) if now <= seen => Some(seen + Duration::from_nanos(1)), + Some(Held::Anchoring(_)) => { + pending.held = Some(Held::Until(now + hold)); + Some(now + hold) + } + Some(Held::Until(until)) => Some(until).filter(|until| *until > now), + } } - Some(Held::Until(until)) if until <= now => return Ok(prefix.clone()), - Some(Held::Until(until)) => until, + PendingUpdate::Announce((meta, _)) => match retracted.get(prefix).filter(|q| q.holds(meta)) { + Some(Quarantine { + held: Held::Anchoring(seen), + .. + }) => Some(*seen + Duration::from_nanos(1)), + Some(Quarantine { + held: Held::Until(until), + .. + }) => Some(*until).filter(|until| *until > now), + None => None, + }, + _ => None, + }; + let Some(wake) = wake else { + return Ok(prefix.clone()); }; - wake = Some(wake.map_or(until, |w: Instant| w.min(until))); + pending.wake = Some(wake); + let prefix = self.due.pop_first().unwrap(); + self.parked.insert((wake, prefix)); } - Err(wake) + Err(self.wake()) } /// Take the pending update at `prefix` for delivery. fn take_prefix(&mut self, prefix: PathOwned) -> Option { - let ((meta, captures), kind) = match self.pending.remove(&prefix).unwrap() { + let ((meta, captures), kind) = match self.remove(&prefix).unwrap().update.unwrap() { PendingUpdate::Announce(meta) => { // The consumer has seen this prefix before, so it is a metadata update. let kind = match self.delivered.insert(prefix.clone()) { @@ -846,7 +976,7 @@ impl OriginConsumerState { // Deliver the retraction now; leave the trailing announce pending so // the next take returns it for the same prefix. self.delivered.remove(&prefix); - self.pending.insert(prefix.clone(), PendingUpdate::Announce(new)); + self.put(prefix.clone(), PendingUpdate::Announce(new)); (old, AnnounceKind::Retracted) } }; @@ -905,6 +1035,10 @@ struct RouteEntry { /// and building one allocates a segment vector and a canonical string. Holding /// it makes that visit a comparison. claim: Pattern, + /// Each [`Self::scope`] member intersected with [`Self::claim`], built once at + /// announce time for the same reason: the paths beneath the prefix the route + /// may serve. A member whose intersection is too large to spell reaches nothing. + reach: Vec, } impl RouteEntry { @@ -925,6 +1059,17 @@ impl RouteEntry { } } + /// The metadata an announce cursor delivers for this entry. + fn meta(&self) -> RouteMeta { + (self.hops.clone(), self.cost, self.entered()) + } + + /// Whether [`Self::meta`] would equal `meta`, without building it. + fn delivers(&self, meta: &RouteMeta) -> bool { + let (hops, cost, source) = meta; + *hops == self.hops && *cost == self.cost && *source == self.entered() + } + /// Whether a request for `path` can be served through this entry. A served /// route covers everything beneath its prefix; a broadcast published here /// is only itself, so it serves its exact path and shadows what is beneath. @@ -956,14 +1101,19 @@ impl RouteEntry { /// Whether this route and `allowed` share any path beneath the advertised prefix. fn overlaps(&self, allowed: &Patterns) -> bool { - self.scope.iter().any(|scope| { - scope - .intersect(&self.claim) - .is_ok_and(|scoped| scoped.iter().any(|restriction| allowed.overlaps(restriction))) - }) + self.reach.iter().any(|reach| allowed.overlaps(reach)) } } +/// What `scope` lets a route at `claim` serve: see [`RouteEntry::reach`]. +fn reach(scope: &Patterns, claim: &Pattern) -> Vec { + scope + .iter() + .filter_map(|scope| scope.intersect(claim).ok()) + .flatten() + .collect() +} + /// The paths a prefix can cover, using an exact pattern at the path depth limit. fn prefix_claim(prefix: &Path) -> Result { if prefix.parts().count() == Path::MAX_PARTS { @@ -1012,12 +1162,18 @@ struct Horizon { exclude: Option, /// Hide the routes that entered from a cluster peer ([`Consumer::local`]). local: bool, + /// Hide the routes with no room for our hop under this cap, because this reader + /// forwards them ([`Consumer::forwardable`]). Selection then falls back to a + /// shorter route instead of withdrawing a prefix an in-cap path still reaches. + max_hops: Option, } impl Horizon { /// Whether `entry` may be observed or served through this horizon. fn admits(&self, entry: &RouteEntry) -> bool { - !(self.local && entry.peer) && entry.visible_to(self.exclude) + !(self.local && entry.peer) + && entry.visible_to(self.exclude) + && self.max_hops.is_none_or(|max| entry.hops.len() < max.get()) } /// This horizon as it applies to the routes covering `path`: an excluded peer @@ -1029,10 +1185,17 @@ impl Horizon { /// front on its next request, while what it already reads stays on the plain /// front. That cannot loop, since the peer serves our requests through its own /// split horizon, which never routes back through us. + /// + /// The hop cap bounds what is advertised, not what serves a request, so it never + /// applies here. fn effective(self, routes: &RouteTable, path: &Path) -> Self { - match self.exclude { - Some(peer) if routes.covering(path).any(|entry| !entry.visible_to(Some(peer))) => self, - _ => Self { exclude: None, ..self }, + let horizon = Self { max_hops: None, ..self }; + match horizon.exclude { + Some(peer) if routes.covering(path).any(|entry| !entry.visible_to(Some(peer))) => horizon, + _ => Self { + exclude: None, + ..horizon + }, } } } @@ -1053,8 +1216,17 @@ struct RemoteFront { pin: kio::Lock, } -/// The last route a cursor observed: entry id, metadata, servability, and captures. -type CursorRoute = (u64, RouteMeta, bool, Option>); +/// The last route a cursor observed at a presented prefix. +struct CursorRoute { + /// What the cursor delivered with it. + meta: RouteMeta, + /// Whether it serves requests: part of the dedupe key, but never delivered. + served: bool, + /// The absolute prefix it was announced at, which decides `captures`. + prefix: PathOwned, + /// What the cursor's scope captured from `prefix`. + captures: Option>, +} impl WeakEntry for RemoteFront { fn is_closed(&self) -> bool { @@ -1094,9 +1266,7 @@ struct TableCursor { /// patterns, so a wildcard captures what the handle names rather than the target. named: Option<(Mount, Patterns)>, /// The last delivered best route per presented (relative) prefix, for change - /// detection: `(entry id, hops, cost)`. - // entry id, metadata, and whether the entry could serve requests: the last - // is part of the dedupe key (see `sync_cursor`) but never leaves the model. + /// detection. current: HashMap, } @@ -1149,48 +1319,66 @@ impl TableCursor { /// Deliver a changed winner at this presented prefix. fn update(&mut self, presented: &PathOwned, best: Option<&RouteEntry>) { - match best { - Some(entry) => { - let meta = (entry.hops.clone(), entry.cost, entry.entered()); - let served = entry.server.is_some(); - let captures = self.captures(&entry.prefix); - let previous = self - .current - .insert(presented.clone(), (entry.id, meta.clone(), served, captures.clone())); - match previous { - // Unchanged metadata and servability: nothing the consumer could - // act on, even if the winning entry itself changed (a reconnect - // under an identical route is invisible, which is the point). A - // servability flip is delivered: a request that failed Unroutable - // under an advertise-only route retries on the update, and hiding - // it would park that waiter forever. - Some((_, prev, prev_served, prev_captures)) - if prev == meta && prev_served == served && prev_captures == captures => {} - // Captures are consumer identity, not route metadata. Replace the - // old identity explicitly so capture-keyed consumers can remove it. - Some((_, prev, _, prev_captures)) if prev_captures != captures => { - if let Ok(mut state) = self.state.write() { - state.apply_unannounce(self.under.join(presented), prev, prev_captures); - state.apply_announce(self.under.join(presented), meta, captures); - } - } - _ => { - if let Ok(mut state) = self.state.write() { - state.apply_announce(self.under.join(presented), meta, captures); - } - } + let Some(entry) = best else { + if let Some(last) = self.current.remove(presented) + && let Ok(mut state) = self.state.write() + { + state.apply_unannounce(self.under.join(presented), last.meta, last.captures); + } + return; + }; + let served = entry.server.is_some(); + // Compared in place first: most visits find the winner unchanged, and building + // what it delivers allocates. Captures follow from the prefix. + if self.current.get(presented).is_some_and(|previous| { + previous.prefix == entry.prefix && previous.served == served && entry.delivers(&previous.meta) + }) { + return; + } + + let meta = entry.meta(); + let captures = self.captures(&entry.prefix); + let previous = self.current.insert( + presented.clone(), + CursorRoute { + meta: meta.clone(), + served, + prefix: entry.prefix.clone(), + captures: captures.clone(), + }, + ); + match previous { + // Unchanged metadata and servability: nothing the consumer could + // act on, even if the winning entry itself changed (a reconnect + // under an identical route is invisible, which is the point). A + // servability flip is delivered: a request that failed Unroutable + // under an advertise-only route retries on the update, and hiding + // it would park that waiter forever. + Some(previous) if previous.meta == meta && previous.served == served && previous.captures == captures => {} + // Captures are consumer identity, not route metadata. Replace the + // old identity explicitly so capture-keyed consumers can remove it. + Some(previous) if previous.captures != captures => { + if let Ok(mut state) = self.state.write() { + state.apply_unannounce(self.under.join(presented), previous.meta, previous.captures); + state.apply_announce(self.under.join(presented), meta, captures); } } - None => { - if let Some((_, last, _, captures)) = self.current.remove(presented) - && let Ok(mut state) = self.state.write() - { - state.apply_unannounce(self.under.join(presented), last, captures); + _ => { + if let Ok(mut state) = self.state.write() { + state.apply_announce(self.under.join(presented), meta, captures); } } } } + /// The visible entry among `candidates` that presents here: the longest prefix, + /// then the first in [`route_order`]. + fn best<'a>(&self, candidates: impl Iterator) -> Option<&'a RouteEntry> { + candidates + .filter(|entry| self.visible(entry)) + .min_by_key(|entry| (Reverse(entry.prefix.len()), route_order(&entry.prefix, entry))) + } + /// Whether the hidden rule lets this cursor discover a route at `prefix`. fn discovers(&self, prefix: &Path) -> bool { self.hidden.discovers(&self.heads, prefix) @@ -1442,6 +1630,7 @@ pub struct Producer { // Identity for this origin. Appended to route hops when re-announcing so // downstream relays can detect loops and prefer the shortest path. hop: Hop, + max_hops: HopLimit, // The absolute patterns this handle may publish under. scope: OriginScope, @@ -1498,6 +1687,7 @@ impl Producer { let pool = config.pool.clone(); let producer = Self { hop: config.hop, + max_hops: config.max_hops, scope: scope.clone(), root: PathOwned::default(), shared: shared.clone(), @@ -1529,6 +1719,11 @@ impl Producer { self } + /// The stats context this handle attributes traffic to. + pub(crate) fn stats(&self) -> &stats::Session { + &self.stats + } + /// Mark this handle (and any handle derived from it) as a cluster peer's: /// every route it announces reports [`Source::Peer`], and /// [`Consumer::local`] hides it. @@ -1545,6 +1740,7 @@ impl Producer { pub fn config(&self) -> Config { Config { hop: self.hop, + max_hops: self.max_hops, pool: self.pool.clone(), cache_duration: self.cache_duration, default_max_age: self.default_max_age, @@ -1563,6 +1759,11 @@ impl Producer { self.default_max_age } + // Sessions enforce the hop cap configured once on the origin. + pub(crate) fn max_hops(&self) -> HopLimit { + self.max_hops + } + /// A producer with *no* allowed prefixes: it can't publish anything and /// advertises no subscribe interest (its `allowed()` is empty, so the /// subscriber issues no ANNOUNCE_PLEASE). Used to fill an unset session half @@ -1573,6 +1774,7 @@ impl Producer { let (tasks, _) = TaskSet::new(); Self { hop, + max_hops: HopLimit::default(), scope: OriginScope::empty(), root: PathOwned::default(), shared: kio::Shared::new(OriginState::new(DEFAULT_UPDATE_HOLD)), @@ -1768,6 +1970,7 @@ impl Producer { let scope = self.scope.narrow(&rooted).ok_or(Error::Unauthorized)?; Ok(Producer { hop: self.hop, + max_hops: self.max_hops, scope, root, shared: self.shared.clone(), @@ -1948,6 +2151,7 @@ impl Announcing { advertised: serving.advertised, stale, claim: claim.clone(), + reach: reach(&self.scope, claim), }); shared.sync_route(prefix, claim); entries.push((prefix.clone(), id)); @@ -2250,7 +2454,7 @@ impl DriverState { // the end. Registrations stay (the cursors remove themselves on drop). for state in cursors { if let Ok(mut state) = state.write() { - state.ended = true; + state.end(); } } } @@ -3275,8 +3479,16 @@ impl RouteTable { /// The routes covering `path`: those announced at it and at every prefix of it. fn covering(&self, path: &Path) -> impl Iterator { - let (above, at) = self.split(path); - above.into_iter().chain(at).flat_map(|node| node.entries.iter()) + // Walked lazily rather than through [`Self::split`], which allocates: the + // announce sync asks this per cursor presenting a prefix as its root. + let mut parts = path.parts(); + let mut next = Some(&self.root); + std::iter::from_fn(move || { + let node = next?; + next = parts.next().and_then(|part| node.children.get(part)); + Some(node) + }) + .flat_map(|node| node.entries.iter()) } /// Whether the live route `id` still covers `path`. @@ -3568,23 +3780,13 @@ impl OriginState { // above it (all of which present as the empty path). Among them, the // longest prefix wins outright, so the metadata a cursor advertises // matches what a request through it actually resolves. - let candidates: Vec<&RouteEntry> = match presented.is_empty() { - true => routes - .covering(&cursor.root) - .filter(|entry| cursor.visible(entry)) - .collect(), + let best = match presented.is_empty() { + true => cursor.best(routes.covering(&cursor.root)), false => { let absolute = cursor.root.join(presented); - routes.at(&absolute).filter(|entry| cursor.visible(entry)).collect() + cursor.best(routes.at(&absolute)) } }; - let most = candidates.iter().map(|entry| entry.prefix.len()).max(); - let best = most.and_then(|most| { - candidates - .into_iter() - .filter(|entry| entry.prefix.len() == most) - .min_by_key(|entry| route_order(&entry.prefix, entry)) - }); cursor.update(presented, best); } @@ -4015,6 +4217,7 @@ impl Consume for track::Consumer { pub struct Consumer { // Identity of the origin this consumer was derived from. hop: Hop, + max_hops: HopLimit, scope: OriginScope, // A prefix that is automatically stripped from all paths. @@ -4057,6 +4260,7 @@ impl Consumer { fn from_producer(producer: &Producer, stats: stats::Session) -> Self { Self { hop: producer.hop, + max_hops: producer.max_hops, scope: producer.scope.clone(), root: producer.root.clone(), shared: producer.shared.clone(), @@ -4087,6 +4291,14 @@ impl Consumer { self } + /// A clone whose announcements skip the routes this origin cannot forward: a + /// chain already at [`Config::max_hops`] has no room for our hop. Sessions whose + /// wire carries the hop path apply this to the routes they advertise. + pub(crate) fn forwardable(mut self) -> Self { + self.horizon.max_hops = Some(self.max_hops); + self + } + /// A view of the routes that entered here: every route a handle marked /// [`Producer::peer`] announced is hidden from [`Self::announced`] and never /// resolved by [`Self::request_broadcast`]. @@ -4141,6 +4353,11 @@ impl Consumer { self } + /// The stats context this handle attributes traffic to. + pub(crate) fn stats(&self) -> &stats::Session { + &self.stats + } + /// A clone of this consumer with its stats context cleared, so an internal /// lookup stream (e.g. [`Self::routed`]) doesn't drive the egress /// announce guards; the caller re-attributes the result itself. @@ -4576,10 +4793,9 @@ pub struct AnnounceConsumer { // borrow never collides with the body's. park: kio::Park, - // Held updates (see [`Config::update_hold`]): their holds by prefix, the - // clock they count on, and the timer that wakes the next. + // Held updates (see [`Config::update_hold`]): the hold, the clock it counts + // on, and the timer that wakes the next. update_hold: Duration, - held: HashMap, // Quarantines by prefix, and their retractions in clock order: first waiting // for a fresh reading (by the stale one), then quarantined (by when each ends). retracted: HashMap, @@ -4626,7 +4842,6 @@ impl AnnounceConsumer { guards: HashMap::new(), park: kio::Park::default(), update_hold, - held: HashMap::new(), retracted: HashMap::new(), anchoring: VecDeque::new(), expiring: VecDeque::new(), @@ -4735,50 +4950,40 @@ impl AnnounceConsumer { loop { let now = self.timers.try_now(); let settling = self.settle(now); - let hold = self.update_hold; - let held = &mut self.held; - let retracted = &self.retracted; - let mut ready = None; let mut wake = None; - let update = match self.state.poll(waiter, |state| { - if state.pending.is_empty() { - return match state.ended { - true => Poll::Ready(()), - false => Poll::Pending, - }; - } - match state.scan(held, retracted, now, hold) { - Ok(prefix) => { - ready = Some(prefix); - Poll::Ready(()) - } - Err(at) => { - wake = at; - Poll::Pending - } + // Decided without touching the queue: parking what it holds back is a write, + // which would wake whoever waits on it, this poll included. + let mut state = match self.state.poll(waiter, |state| match state.due(now) { + true => Poll::Ready(()), + false => { + wake = state.wake(); + Poll::Pending } }) { - Poll::Ready(Ok(mut state)) => match ready { - Some(prefix) => state.take_prefix(prefix), - None => { - // Ended by the origin's teardown, pending updates already - // drained; close the channel so every closure signal agrees. - state.close(); - None - } - }, + Poll::Ready(Ok(state)) => state, // Closed: discard the Ref so its MutexGuard doesn't escape this call. - Poll::Ready(Err(_)) => None, + Poll::Ready(Err(_)) => return Poll::Ready(None), Poll::Pending => { self.hold.set(earliest(wake, settling)); ready!(self.hold.poll(waiter)); continue; } }; + if state.pending.is_empty() { + // Ended by the origin's teardown, pending updates already drained; close + // the channel so every closure signal agrees. + state.close(); + return Poll::Ready(None); + } + // Whatever was due may all be held, parked until a later poll. + let Ok(prefix) = state.scan(&self.retracted, now, self.update_hold) else { + continue; + }; + let update = state.take_prefix(prefix); + drop(state); let Some(update) = update else { return Poll::Ready(None); }; - self.held.remove(&update.prefix); self.quarantine(&update, now); return Poll::Ready(Some(self.hand_out(update))); } @@ -4791,21 +4996,29 @@ impl AnnounceConsumer { pub fn try_next(&mut self) -> Option { let now = self.timers.try_now(); let settling = self.settle(now); - let mut state = self.state.write().ok()?; - let prefix = match state.scan(&mut self.held, &self.retracted, now, self.update_hold) { - Ok(prefix) => prefix, + // Read first, so a call with nothing due writes nothing; see `poll_next`. + let idle = { + let state = self.state.read(); + (!state.due(now) || state.pending.is_empty()).then(|| state.wake()) + }; + let scanned = match idle { + Some(wake) => Err(wake), + None => { + let mut state = self.state.write().ok()?; + state + .scan(&self.retracted, now, self.update_hold) + .map(|prefix| state.take_prefix(prefix)) + } + }; + let update = match scanned { + Ok(update) => update?, Err(wake) => { - let wake = earliest(wake, settling); // Arm the hold even though nothing polls it: the armed timer wakes the // driver, so its clock reaches the deadline for a later call to see. - drop(state); - self.hold.set(wake); + self.hold.set(earliest(wake, settling)); return None; } }; - self.held.remove(&prefix); - let update = state.take_prefix(prefix)?; - drop(state); self.quarantine(&update, now); Some(self.hand_out(update)) } @@ -4944,6 +5157,53 @@ mod tests { .collect() } + #[test] + fn hop_limit_refuses_values_outside_the_wire_bound() { + for invalid in [0, 33, usize::MAX] { + assert_eq!(HopLimit::new(invalid), None); + } + for valid in [1, 6, 32] { + assert_eq!(HopLimit::new(valid).unwrap().get(), valid); + } + assert_eq!(HopLimit::default().get(), 32); + let config = Config { + max_hops: HopLimit::new(6).unwrap(), + ..Default::default() + }; + let (producer, _driver) = Producer::new(config); + let scoped = producer.scope("room", &Patterns::from(Pattern::all())).unwrap(); + assert_eq!(scoped.config().max_hops.get(), 6); + assert_eq!(scoped.consume().forwardable().horizon.max_hops, HopLimit::new(6)); + } + + /// A forwarding cursor skips a route with no room for our hop and announces the + /// best one that has room, while requests still resolve through the full route. + #[tokio::test] + async fn forwardable_caps_announcements_but_not_requests() { + let producer = Config { + max_hops: HopLimit::new(2).unwrap(), + ..Config::new(origin(1)) + } + .produce(); + let mut plain = producer.consume().announced(); + let mut forwarded = producer.consume().forwardable().announced(); + + let _full = producer + .dynamic("room", Route::default().with_hops(hops(&[7, 8])).with_cost(1)) + .unwrap(); + assert_eq!(plain.assert_next_active("room").hops, hops(&[7, 8])); + forwarded.assert_next_wait(); + + let pending = producer.consume().forwardable().request_broadcast("room/alice"); + assert!(pending.now_or_never().is_none(), "the cap must not refuse a request"); + + let _short = producer + .dynamic("room", Route::default().with_hops(hops(&[9])).with_cost(50)) + .unwrap(); + assert_eq!(forwarded.assert_next_active("room").hops, hops(&[9])); + plain.assert_next_wait(); + } + #[test] fn default_config_mints_a_real_hop() { let config = Config::default(); @@ -5447,7 +5707,7 @@ mod tests { let _b = producer.announce("room/bob", Route::default()).unwrap(); let mut announced = producer.consume().announced(); - // BTreeMap order: lexicographic by prefix. + // Delivery order: lexicographic by prefix. announced.assert_next_active("room/alice"); announced.assert_next_active("room/bob"); announced.assert_next_wait(); @@ -7211,6 +7471,48 @@ mod tests { assert_eq!(update.route.hops, hops(&[9, 3, 4])); } + /// Two peers carry the same `k` broadcasts of one publisher, and both go away a + /// broadcast at a time, the cursor polled after each change. The first peer's + /// withdrawals queue `k` held updates; the second's, in the opposite order, make + /// each ready behind the held rest. A poll touches only what is due, so the wave + /// costs each cursor O(k log k) steps, not O(k²). + #[test] + fn a_withdrawal_wave_costs_k_log_k_steps() { + for k in [64usize, 256, 1024] { + let (producer, mut driver) = Producer::new(Config::new(origin(1))); + // The holds read the driver's last clock reading, which stays put below. + driver.poll(Instant::now(), &kio::Waiter::noop()).unwrap(); + let peer = producer.clone().peer(); + let route = |chain: &[u64], via| Route::default().with_hops(hops(chain)).with_via(origin(via)); + let mut near = Vec::new(); + let mut far = Vec::new(); + for i in 0..k { + near.push(peer.dynamic(format!("room/{i}"), route(&[9, 2], 2)).unwrap()); + far.push(peer.dynamic(format!("room/{i}"), route(&[9, 3, 4], 4)).unwrap()); + } + let mut announced = producer.consume().announced(); + for _ in 0..k { + assert_eq!(announced.try_next().unwrap().kind, AnnounceKind::Announced); + } + let before = announced.state.read().steps; + + for route in near { + drop(route); + assert!(announced.try_next().is_none(), "the update is held"); + } + for (i, route) in far.into_iter().enumerate().rev() { + drop(route); + let update = announced.try_next().expect("the retraction is delivered"); + assert_eq!(update.kind, AnnounceKind::Retracted); + assert_eq!(update.prefix, Path::new(&format!("room/{i}"))); + } + + let steps = announced.state.read().steps - before; + let bound = 4 * k * (k.ilog2() as usize + 1); + assert!(steps <= bound, "{k} broadcasts took {steps} steps, over {bound}"); + } + } + /// A new route and a removal are delivered at once. A change of the best route /// waits out the update hold, and a newer change during the wait replaces it. #[tokio::test(start_paused = true)] diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 21d9efd6dd6..f97ceb33de8 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -111,6 +111,7 @@ impl Server { let start = lite::start(lite::Config { runtime: runtime.clone(), + stats: self.stats.clone(), session: session.clone(), setup_stream: None, publish, @@ -179,7 +180,7 @@ impl Server { // caller can scope by the advertised path. Seeded back into // `start` on `ok()` so PROBE gating resolves without // re-reading the (consumed) Setup Stream. - let client_setup = lite::accept_setup(&mut session, version).await?; + let (client_setup, client_setup_size) = lite::accept_setup(&mut session, version).await?; ( client_setup.path.clone(), client_setup.role, @@ -188,6 +189,7 @@ impl Server { session, version, client_setup, + client_setup_size, }, ) } @@ -334,8 +336,9 @@ impl Server { // Legacy bidi SETUP exchange (IETF 14-16, lite 01/02). Read the client's // SETUP to choose the version; `ok()` sends the server SETUP and starts. + // Stats attach in `ok()`: a caller may still replace them on the handshake. let mut stream = Stream::accept(&mut session, encoding).await?; - let mut client: setup::Client = stream.reader.decode().await?; + let (mut client, client_setup_size) = stream.reader.decode_sized::().await?; let version = client .versions @@ -387,6 +390,7 @@ impl Server { version, request_id_max, peer_declared, + client_setup_size, })), }), }) @@ -466,6 +470,8 @@ enum PausedHandshake { session: S, version: lite::Version, client_setup: lite::Setup, + /// The client SETUP's encoded size, counted under the final stats. + client_setup_size: usize, }, /// An IETF (or legacy bidi-SETUP) handshake, boxed where its /// thread-affinity bounds held. The boxing is what keeps [`Handshake`] and @@ -515,12 +521,14 @@ where version, peer_setup, } = *self; + server.stats.control(stats::Control::Session, peer_setup.size, true); let (publish, subscribe) = server.stat_tagged_origins(); // The client's SETUP was read at the pause; hand the stream back // for GOAWAY. A server never advertises a path, hence `None`. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), + stats: server.stats.clone(), session: session.clone(), setup: None, request_id_max: None, @@ -566,6 +574,8 @@ struct PausedLegacy { request_id_max: Option, /// What the client's SETUP declared, for the options `ok` acts on. peer_declared: ietf::peer::Peer, + /// The client SETUP's encoded size, counted under the final stats. + client_setup_size: usize, } impl Paused for PausedLegacy @@ -579,11 +589,14 @@ where async move { let Self { session, - mut stream, + stream, version, request_id_max, peer_declared, + client_setup_size, } = *self; + let mut stream = stream.with_stats(&server.stats); + server.stats.control(stats::Control::Session, client_setup_size, true); let (publish, subscribe) = server.stat_tagged_origins(); // Encode parameters using the version-appropriate type. @@ -611,6 +624,7 @@ where // Pre-lite-05: no Setup Stream, so nothing to advertise or seed. let start = lite::start(lite::Config { runtime: runtime.clone(), + stats: server.stats.clone(), session: session.clone(), setup_stream: Some(stream), publish, @@ -632,6 +646,7 @@ where // Draft 14-16: path came in the bidi SETUP, no uni SETUP to hand back. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), + stats: server.stats.clone(), session: session.clone(), setup: Some(stream), request_id_max, @@ -769,7 +784,11 @@ where session, version, client_setup, - } => server.start_lite(runtime, session, version, Some(client_setup), peer_hop), + client_setup_size, + } => { + server.stats.control(stats::Control::Session, client_setup_size, true); + server.start_lite(runtime, session, version, Some(client_setup), peer_hop) + } PausedHandshake::Boxed(paused) => paused.ok(server, runtime, peer_hop).await, } } @@ -1272,6 +1291,7 @@ mod tests { crate::lite::test_transport::PendingRecv, Version::Ietf(version), ), + size: 0, path: None, token: None, declared: ietf::peer::Peer::default(), diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 24caaffebb7..970169b2492 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -54,6 +54,10 @@ impl Setup { } impl Encode for Setup { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Session) + } + fn encode(&self, w: &mut W, v: Version) -> Result<(), EncodeError> { Self::check_version(v); SETUP_V17.encode(w, v)?; @@ -69,6 +73,10 @@ impl Encode for Setup { } impl Decode for Setup { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Session) + } + fn decode(r: &mut R, v: Version) -> Result { Self::check_version(v); let kind = u64::decode(r, v)?; @@ -140,6 +148,10 @@ impl Client { } impl Decode for Client { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Session) + } + /// Decode a client setup message (draft-14 through draft-16 only). fn decode(r: &mut R, v: Version) -> Result { let kind = u8::decode(r, v)?; @@ -185,6 +197,10 @@ impl Decode for Client { } impl Encode for Client { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Session) + } + /// Encode a client setup message (draft-14 through draft-16 only). fn encode(&self, w: &mut W, v: Version) -> Result<(), EncodeError> { CLIENT_SETUP.encode(w, v)?; @@ -235,6 +251,10 @@ impl Server { } impl Encode for Server { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Session) + } + /// Encode a server setup message (draft-14 through draft-16 only). fn encode(&self, w: &mut W, v: Version) -> Result<(), EncodeError> { SERVER_SETUP.encode(w, v)?; @@ -259,6 +279,10 @@ impl Encode for Server { } impl Decode for Server { + fn control(&self, _: Version) -> Option { + Some(crate::stats::Control::Session) + } + /// Decode a server setup message (draft-14 through draft-16 only). fn decode(r: &mut R, v: Version) -> Result { let kind = u8::decode(r, v)?; diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index d6cf90368e0..b69429559ed 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -264,6 +264,7 @@ impl Content { struct SessionCounters { sessions_started: AtomicU64, sessions_ended: AtomicU64, + control: [[AtomicU64; 2]; Control::ALL.len()], } impl SessionCounters { @@ -278,6 +279,14 @@ impl SessionCounters { sessions_ended, } } + + fn control(&self, totals: &mut [[u64; 2]; Control::ALL.len()]) { + for (total, counters) in totals.iter_mut().zip(&self.control) { + for (total, counter) in total.iter_mut().zip(counters) { + *total += counter.load(Ordering::Relaxed); + } + } + } } /// A cumulative traffic counter readout for one slice (a broadcast on a @@ -670,6 +679,73 @@ impl Role { } } +/// Cumulative bytes sent and received, returned per [`Control`] class by [`Snapshot::control`]. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Bytes { + /// Bytes successfully encoded for sending, counted even if the stream later fails. + pub tx: u64, + /// Bytes of complete messages decoded and consumed. + pub rx: u64, +} + +/// The bounded class of a successfully encoded or decoded control message. +/// +/// Counters include prefixes inside a complete codec value, but exclude separately +/// coded selectors and raw payload writes. A framed IETF body counts after its +/// framing decodes, before request validation. Shared reply IDs use [`Self::Other`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Control { + /// Session setup and information. + Session, + /// Namespace advertisements and listings. + Announce, + /// Track metadata and listings. + TrackInfo, + /// Live subscriptions and their updates. + Subscribe, + /// Finite fetch requests. + Fetch, + /// Bandwidth probes. + Probe, + /// Authorization messages. + Auth, + /// Session or request migration. + Goaway, + /// Other protocol control messages, including shared request responses. + Other, +} + +impl Control { + const ALL: [Self; 9] = [ + Self::Session, + Self::Announce, + Self::TrackInfo, + Self::Subscribe, + Self::Fetch, + Self::Probe, + Self::Auth, + Self::Goaway, + Self::Other, + ]; + + /// The stable label used by control metrics. + pub fn as_str(self) -> &'static str { + match self { + Self::Session => "session", + Self::Announce => "announce", + Self::TrackInfo => "track_info", + Self::Subscribe => "subscribe", + Self::Fetch => "fetch", + Self::Probe => "probe", + Self::Auth => "auth", + Self::Goaway => "goaway", + Self::Other => "other", + } + } +} + /// A point-in-time, host-level rollup of a registry's counters, returned /// by [`Registry::snapshot`]. /// @@ -688,9 +764,27 @@ pub struct Snapshot { traffic: HashMap, /// Session presence per tier; read via [`Self::sessions`]. sessions: HashMap, + control: HashMap, } impl Snapshot { + /// The `(tier, class, totals)` control-message rows with any traffic, sorted by tier and class. + /// Includes prefixes inside complete codec values, but excludes separately coded type selectors, + /// media payload, and transport framing, acknowledgements, and retransmissions. + pub fn control(&self) -> Vec<(Tier, Control, Bytes)> { + let mut rows = Vec::new(); + for (tier, totals) in &self.control { + for class in Control::ALL { + let [tx, rx] = totals[class as usize]; + if tx != 0 || rx != 0 { + rows.push((tier.clone(), class, Bytes { tx, rx })); + } + } + } + rows.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then(a.1.as_str().cmp(b.1.as_str()))); + rows + } + /// The `(tier, role, totals)` traffic rows, one publisher and one subscriber /// row per tier present. Sorted by tier label then role for stable output. pub fn traffic(&self) -> Vec<(Tier, Role, Traffic)> { @@ -951,6 +1045,7 @@ impl Registry { let totals = snap.sessions.entry(tier.clone()).or_default(); for counters in roots.values() { totals.add(counters.snapshot()); + counters.control(snap.control.entry(tier.clone()).or_default()); } } } @@ -1029,6 +1124,7 @@ impl Registry { .entry(tier.clone()) .or_default() .add(counters.snapshot()); + counters.control(retired.control.entry(tier.clone()).or_default()); false }); } @@ -1157,6 +1253,14 @@ struct Viewer { } impl Session { + pub(crate) fn control(&self, class: Control, bytes: usize, received: bool) { + let Some(inner) = &self.inner else { return }; + let current = inner.current.lock().expect("stats session poisoned"); + if let Some(counters) = ¤t.presence { + counters.control[class as usize][usize::from(received)].fetch_add(bytes as u64, Ordering::Relaxed); + } + } + fn new(registry: Registry, tier: Tier, root: impl AsPath) -> Self { let root = root.as_path().to_owned(); let presence = registry.session_counters(&tier, &root); @@ -1760,6 +1864,26 @@ mod tests { assert!(session_snapshot(&stats, &Tier::default(), "acme").is_none()); } + #[test] + fn control_body_totals_follow_retags_and_survive_retirement() { + let registry = Registry::new(Default::default()); + let first = Tier::new("internal"); + let second = Tier::new("edge"); + let context = registry.tier(first.clone()).session(""); + context.control(Control::Announce, 17, false); + context.set_tier(second.clone()); + context.control(Control::TrackInfo, 23, true); + let expected = vec![ + (second, Control::TrackInfo, Bytes { tx: 0, rx: 23 }), + (first, Control::Announce, Bytes { tx: 17, rx: 0 }), + ]; + assert_eq!(registry.snapshot().control(), expected); + drop(context); + registry.report(&mut Report::default()); + assert_eq!(registry.snapshot().control(), expected); + assert_eq!(registry.snapshot().control(), expected, "scrapes do not drain totals"); + } + #[test] fn snapshot_preserves_retired_counters() { let stats = test_stats(); diff --git a/rs/moq-net/tests/announce_allocs.rs b/rs/moq-net/tests/announce_allocs.rs new file mode 100644 index 00000000000..a273c13601f --- /dev/null +++ b/rs/moq-net/tests/announce_allocs.rs @@ -0,0 +1,97 @@ +//! Allocation counting for an announce cursor whose winning route is unchanged. +//! +//! Every route change at a prefix revisits each cursor presenting it, and on a +//! dense mesh most of those visits find the cursor's winner unchanged: a worse +//! path arriving or leaving. Such a visit must not allocate, or the cost of +//! route churn multiplies by the cursors watching. A counting allocator needs +//! the whole binary to itself, hence a test of its own. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use moq_net::{Hop, Hops, Pattern, Patterns, origin}; + +thread_local! { + /// Fresh allocations made by *this* thread. Per-thread because the harness may + /// run tests concurrently in one process. + /// + /// `const` initialised, so reading it from inside the allocator cannot allocate + /// and recurse. + static ALLOCS: Cell = const { Cell::new(0) }; +} + +struct Counting; + +// Counting only. Every call forwards to the system allocator unchanged. A +// `realloc` grows a buffer that already exists, which amortizes, so only fresh +// allocations count. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // `try_with`, because TLS is unavailable while a thread is being torn down. + let _ = ALLOCS.try_with(|allocs| allocs.set(allocs.get() + 1)); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static ALLOCATOR: Counting = Counting; + +/// A route as `peer` would have announced it: one hop, at `cost`. +fn peer_route(peer: u64, cost: u64) -> origin::Route { + let mut hops = Hops::new(); + hops.push(Hop::new(peer).unwrap()).unwrap(); + origin::Route::default().with_hops(hops).with_cost(cost) +} + +/// Allocations made by a worse route arriving at a watched prefix and leaving +/// again, with `cursors` cursors rooted at `root` watching it. +fn churn_allocs(root: &str, cursors: usize) -> usize { + let (producer, _driver) = origin::Producer::new(origin::Config::default()); + let _winner = producer.dynamic("room/a", peer_route(1, 1)).unwrap(); + let all = Patterns::from(Pattern::all()); + let mut cursors: Vec<_> = (0..cursors) + .map(|_| producer.consume().scope(root, &all).unwrap().announced()) + .collect(); + for cursor in &mut cursors { + while cursor.try_next().is_some() {} + } + + let cycle = || drop(producer.dynamic("room/a", peer_route(2, 5)).unwrap()); + // Reach the steady state first, with every table grown to what it settles at. + for _ in 0..4 { + cycle(); + } + let before = ALLOCS.with(Cell::get); + cycle(); + let allocs = ALLOCS.with(Cell::get) - before; + + for cursor in &mut cursors { + assert!(cursor.try_next().is_none(), "the winner is unchanged"); + } + allocs +} + +/// A route that never wins costs the same however many cursors watch it, whether +/// they present its prefix beneath their root or as the root itself, which ranks +/// every route covering it. +#[test] +fn an_unchanged_winner_allocates_nothing_per_cursor() { + for root in ["", "room", "room/a"] { + let one = churn_allocs(root, 1); + for cursors in [20, 100] { + assert_eq!( + churn_allocs(root, cursors), + one, + "{cursors} cursors rooted at {root:?} allocated more than one" + ); + } + } +} diff --git a/rs/moq-net/tests/control_stats.rs b/rs/moq-net/tests/control_stats.rs new file mode 100644 index 00000000000..5ec42fc4fe6 --- /dev/null +++ b/rs/moq-net/tests/control_stats.rs @@ -0,0 +1,132 @@ +//! Control counters cross real protocol drivers, including legacy multiplexing. +//! +//! Time is paused, so the waits below cost nothing and never touch the wall clock. + +mod support; + +use std::time::Duration; + +use moq_net::{ + Hop, Timestamp, Version, origin, + stats::{Control, Registry, Tier}, +}; +use support::harness::{MockConnectOptions, connect_mock, now, run}; +use support::mock::create_mock_session_pair; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn origin(hop: u64) -> origin::Producer { + let (origin, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(run(driver)); + origin +} + +#[tokio::test(start_paused = true)] +async fn control_classes_cross_lite_and_ietf_sessions_without_counting_payload() { + for version in [ + "moq-lite-03", + "moq-lite-06", + "moq-lite-07-wip", + "moq-transport-14", + "moq-transport-15", + "moq-transport-16", + "moq-transport-22", + ] { + let publisher = origin(1); + let subscriber = origin(2); + let registry = Registry::new(Default::default()); + let tier = Tier::new("internal"); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_stats = registry.tier(tier.clone()).session(""); + options.client_publish = Some(publisher.consume()); + options.server_subscribe = Some(subscriber.clone()); + let _pair = connect_mock(options).await; + let broadcast = publisher.create_broadcast("room").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + let consumer = subscriber.consume(); + tokio::time::timeout(TIMEOUT, consumer.routed("room")) + .await + .expect("announce timeout") + .expect("routed"); + let remote = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("room")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + let reader = tokio::spawn(async move { + let mut sub = remote.track("video").unwrap().subscribe(None).await.unwrap(); + let mut group = sub.recv_group().await.unwrap().unwrap(); + group.read_frame().await.unwrap().unwrap().payload.len() + }); + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .unwrap(); + let mut group = track.append_group().unwrap(); + group + .write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![7; 64 * 1024])) + .unwrap(); + group.finish().unwrap(); + let read = tokio::time::timeout(TIMEOUT, reader) + .await + .expect("group timeout") + .unwrap(); + assert_eq!(read, 64 * 1024, "{version}"); + let rows = registry.snapshot().control(); + for class in [Control::Announce, Control::Subscribe] { + assert!( + rows.iter() + .any(|(row_tier, row_class, bytes)| *row_tier == tier && *row_class == class && bytes.tx > 0), + "{version}: {class:?}: {rows:?}" + ); + } + let rx: u64 = rows.iter().map(|(_, _, bytes)| bytes.rx).sum(); + assert!( + rx > 0 && rx < 64 * 1024, + "{version}: media payload entered control counters: {rows:?}" + ); + } +} + +/// The SETUP a server reads before admission counts under the stats the handshake is +/// admitted with, even for a session with neither origin. +#[tokio::test(start_paused = true)] +async fn setup_counts_under_the_admitted_stats() { + for version in ["moq-lite-07-wip", "moq-transport-14", "moq-transport-22"] { + let version: Version = version.parse().unwrap(); + let registry = Registry::new(Default::default()); + let tier = Tier::new("internal"); + let (client_transport, server_transport) = create_mock_session_pair(Some(version.alpn())); + let client = moq_net::Client::new().with_versions(version.into()); + let server = moq_net::Server::new().with_versions(version.into()); + let stats = registry.tier(tier.clone()).session(""); + let client = async { + let (session, driver) = client.connect(now(), client_transport).await.unwrap(); + tokio::spawn(run(driver)); + session + }; + let server = async { + let request = server.accept_request(now(), server_transport).await.unwrap(); + let (session, driver) = request.with_stats(stats).ok().await.unwrap(); + tokio::spawn(run(driver)); + session + }; + let _sessions = tokio::join!(client, server); + let counted = || { + registry + .snapshot() + .control() + .into_iter() + .any(|(row_tier, class, bytes)| { + row_tier == tier && class == Control::Session && bytes.tx > 0 && bytes.rx > 0 + }) + }; + tokio::time::timeout(TIMEOUT, async { + while !counted() { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("{version}: {:?}", registry.snapshot().control())); + } +} diff --git a/rs/moq-net/tests/mesh_withdraw.rs b/rs/moq-net/tests/mesh_withdraw.rs index 0d175a58636..bf7f0338023 100644 --- a/rs/moq-net/tests/mesh_withdraw.rs +++ b/rs/moq-net/tests/mesh_withdraw.rs @@ -11,11 +11,17 @@ use std::{collections::HashMap, time::Duration}; use tokio::sync::mpsc; -use moq_net::{Hop, Version, announce, broadcast, origin}; +use moq_net::{Hop, Hops, Version, announce, broadcast, origin}; use support::harness::{MockPair, peer, peer_with_latency}; fn produce_origin(hop: u64) -> origin::Producer { - let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + produce_capped(hop, origin::HopLimit::default()) +} + +fn produce_capped(hop: u64, max_hops: origin::HopLimit) -> origin::Producer { + let mut config = origin::Config::new(Hop::new(hop).unwrap()); + config.max_hops = max_hops; + let (producer, driver) = origin::Producer::new(config); tokio::spawn(support::harness::run(driver)); producer } @@ -219,17 +225,20 @@ async fn partial_mesh_withdraw_retracts_once() { } } +/// Eight relays with link latencies in ms, publishing from the last. +#[rustfmt::skip] +const STALE_PATHS: &[((usize, usize), u64)] = &[ + ((0, 1), 35), ((1, 2), 90), ((0, 3), 129), ((2, 4), 140), ((1, 5), 137), ((4, 6), 148), + ((2, 7), 30), ((3, 5), 124), ((1, 4), 111), ((0, 5), 21), ((3, 4), 34), +]; + /// A relay that already retracted a withdrawn broadcast must not announce it /// again. Its neighbors, two or more hops from the publisher's relay, still hunt /// through stale paths; one that falls back to a path not through this relay /// offers it as a new route after the relay forgot the withdrawal. #[tokio::test(start_paused = true)] async fn retracted_relay_ignores_a_stale_path() { - #[rustfmt::skip] - const EDGES: &[((usize, usize), u64)] = &[ - ((0, 1), 35), ((1, 2), 90), ((0, 3), 129), ((2, 4), 140), ((1, 5), 137), ((4, 6), 148), - ((2, 7), 30), ((3, 5), 124), ((1, 4), 111), ((0, 5), 21), ((3, 4), 34), - ]; + const EDGES: &[((usize, usize), u64)] = STALE_PATHS; let version: Version = "moq-lite-06".parse().unwrap(); let nodes: Vec<_> = (1..=8).map(produce_origin).collect(); let mut pairs = Vec::new(); @@ -262,3 +271,193 @@ async fn retracted_relay_ignores_a_stale_path() { } assert!(revived.is_empty(), "{revived:?}"); } + +/// The longest shortest path between two relays over `edges`. +fn diameter(edges: &[((usize, usize), u64)]) -> usize { + let n = edges.iter().map(|&((a, b), _)| a.max(b) + 1).max().unwrap(); + (0..n) + .map(|start| { + let mut distance = vec![usize::MAX; n]; + distance[start] = 0; + let mut queue = std::collections::VecDeque::from([start]); + while let Some(a) = queue.pop_front() { + for &((x, y), _) in edges { + let b = if x == a { + y + } else if y == a { + x + } else { + continue; + }; + if distance[b] == usize::MAX { + distance[b] = distance[a] + 1; + queue.push_back(b); + } + } + } + *distance.iter().max().unwrap() + }) + .max() + .unwrap() +} + +/// With room for the diameter and two extra hops, including the publisher's own, +/// the cap only cuts detours: every relay still learns the broadcast and ends +/// retracted once it is withdrawn. +#[tokio::test(start_paused = true)] +async fn capped_mesh_withdraw_retracts() { + let version: Version = "moq-lite-06".parse().unwrap(); + let cap = origin::HopLimit::new(diameter(STALE_PATHS) + 2).unwrap(); + let nodes: Vec<_> = (1..=8).map(|id| produce_capped(id, cap)).collect(); + let mut pairs = Vec::new(); + for &((a, b), ms) in STALE_PATHS { + pairs.push(peer_with_latency(version, &nodes[a], &nodes[b], Duration::from_millis(ms)).await); + } + let mut watched: Vec<_> = nodes.iter().map(|node| watch(node.consume().announced())).collect(); + + let broadcast = nodes[7].create_broadcast("room/7").unwrap(); + broadcast.announce(Default::default()).unwrap(); + for (relay, watched) in watched.iter_mut().enumerate() { + let kinds = drain(watched).await.remove("room/7").unwrap(); + assert!(kinds.last().unwrap().is_active(), "relay {relay}: {kinds:?}"); + } + + drop(broadcast); + for (relay, watched) in watched.iter_mut().enumerate() { + let kinds = drain(watched).await.remove("room/7").unwrap(); + assert_eq!( + kinds.last(), + Some(&announce::Kind::Retracted), + "relay {relay}: {kinds:?}" + ); + } +} + +/// Every version whose wire carries a hop path. +const HOP_PATH_VERSIONS: &[&str] = &[ + "moq-lite-03", + "moq-lite-04", + "moq-lite-05", + "moq-lite-06", + "moq-lite-07-wip", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", +]; + +/// A route whose chain is `count` hops starting at `first`, before the +/// announcing relay's own, at `cost`. +fn capped_route(first: u64, count: u64, cost: u64) -> origin::Route { + let hops = (first..first + count) + .map(|id| Hop::new(id).unwrap()) + .collect::>(); + origin::Route::default() + .with_hops(Hops::try_from(hops).unwrap()) + .with_cost(origin::Cost::new(cost)) +} + +/// A permissive source sends an over-limit path to prove ingress refusal; an +/// accepted boundary path then proves egress counts the relay's own stamp. +#[tokio::test(start_paused = true)] +async fn hop_limit_bounds_receive_and_send() { + for &name in HOP_PATH_VERSIONS { + let version: Version = name.parse().unwrap(); + // Lite03 carries only placeholder hops, so the relay names the unknown + // publisher with a stamp of its own, one more entry in the stored chain. + let stamp = usize::from(name == "moq-lite-03"); + let source = produce_origin(1); + let relay = produce_capped(2, origin::HopLimit::new(2 + stamp).unwrap()); + let sink = produce_origin(3); + let _upstream = peer(version, &source, &relay).await; + let _downstream = peer(version, &relay, &sink).await; + let mut received = watch(relay.consume().announced()); + let mut sent = watch(sink.consume().announced()); + drain(&mut received).await; + + let route = |count: u64| capped_route(100, count, 1); + let _long = source.dynamic("long", route(2)).unwrap(); + let _boundary = source.dynamic("boundary", route(1)).unwrap(); + let short = source.dynamic("short", route(0)).unwrap(); + let received = drain(&mut received).await; + assert!(!received.contains_key("long"), "{name}: accepted over-limit path"); + assert!(received.contains_key("boundary"), "{name}: refused boundary path"); + assert!(received.contains_key("short"), "{name}: refused short path"); + let sent_initial = drain(&mut sent).await; + assert_eq!(sent_initial.len(), 1, "{name}: {sent_initial:?}"); + assert!(sent_initial.contains_key("short"), "{name}: forwarded over-limit path"); + + if matches!(name, "moq-lite-03" | "moq-lite-04") { + // These wires have no route metadata update: withdraw, then replace it. + drop(short); + assert_eq!( + drain(&mut sent).await.get("short").and_then(|kinds| kinds.last()), + Some(&announce::Kind::Retracted), + "{name}" + ); + let _replacement = source.dynamic("short", route(1)).unwrap(); + assert!( + drain(&mut sent).await.is_empty(), + "{name}: forwarded over-limit replacement" + ); + continue; + } + short.update(route(1)).unwrap(); + let ended = drain(&mut sent).await; + assert_eq!( + ended.get("short").and_then(|kinds| kinds.last()), + Some(&announce::Kind::Retracted), + "{name}: {ended:?}" + ); + } +} + +/// A cheaper route at the cap wins locally but cannot be forwarded, so the relay +/// advertises the pricier in-cap route instead of withdrawing the prefix, and +/// withdraws only once no in-cap route is left. +#[tokio::test(start_paused = true)] +async fn hop_limit_falls_back_to_a_forwardable_route() { + // Only wires carrying a route cost can rank a longer route first. + for &name in HOP_PATH_VERSIONS { + if matches!(name, "moq-lite-03" | "moq-lite-04" | "moq-lite-05") { + continue; + } + let version: Version = name.parse().unwrap(); + let near = produce_origin(1); + let far = produce_origin(4); + let relay = produce_capped(2, origin::HopLimit::new(3).unwrap()); + let sink = produce_origin(3); + let _near = peer(version, &near, &relay).await; + let _far = peer(version, &far, &relay).await; + let _downstream = peer(version, &relay, &sink).await; + let mut local = watch(relay.consume().announced()); + let mut sent = watch(sink.consume().announced()); + drain(&mut sent).await; + + // Three hops at the relay once the sender is appended: in the cap, but full. + let _cheap = far.dynamic("room", capped_route(100, 2, 1)).unwrap(); + // Two hops at the relay, so it has room for the relay's own. + let pricey = near.dynamic("room", capped_route(200, 1, 50)).unwrap(); + let started = drain(&mut sent).await; + assert_eq!( + started.get("room").and_then(|kinds| kinds.last()), + Some(&announce::Kind::Announced), + "{name}: withdrew a prefix an in-cap route reaches: {started:?}" + ); + // The relay itself still selects the cheaper route at the cap. + assert!(drain(&mut local).await.contains_key("room"), "{name}"); + let mut selected = relay.consume().announced(); + let update = selected.next().await.unwrap(); + assert_eq!(update.route.hops.len(), 3, "{name}: {:?}", update.route); + + drop(pricey); + let ended = drain(&mut sent).await; + assert_eq!( + ended.get("room").and_then(|kinds| kinds.last()), + Some(&announce::Kind::Retracted), + "{name}: forwarded a route at the cap: {ended:?}" + ); + } +} diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 091b7ac0b86..5e67edd5817 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -56,6 +56,8 @@ pub struct MockConnectOptions { pub stats: MockStats, /// Count server driver polls into this counter when supplied. pub server_polls: Option>, + /// The server's per-connection stats context. + pub server_stats: moq_net::stats::Session, } impl MockConnectOptions { @@ -70,6 +72,7 @@ impl MockConnectOptions { latency: std::time::Duration::ZERO, stats: MockStats::default(), server_polls: None, + server_stats: Default::default(), } } } @@ -110,7 +113,9 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { client = client.with_subscriber(subscribe); } - let mut server = Server::new().with_versions(opts.version.into()); + let mut server = Server::new() + .with_versions(opts.version.into()) + .with_stats(opts.server_stats); if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); } diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 1107c8b9d04..efa3438a1e9 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -65,6 +65,7 @@ moq-net = { workspace = true } moq-stats = { workspace = true } moq-tokio = { workspace = true, default-features = false, features = ["aws-lc-rs", "watch", "tcp"] } qmux = { workspace = true, features = ["ws"], optional = true } +rand = { workspace = true } reqwest = { workspace = true, features = ["rustls"] } reqwest-middleware = "0.5" rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } @@ -93,7 +94,6 @@ sd-notify = { workspace = true } moq-auth = { path = "../moq-auth", features = ["client", "serve"] } moq-shaper = { path = "../moq-shaper" } qmux = { workspace = true, features = ["tcp"] } -rand = { workspace = true } rcgen = "0.14" tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index bdfa5d91499..0655772b8d4 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -553,6 +553,15 @@ pub struct Config { )] pub id: Option, + /// Longest hop path accepted or forwarded, from 1 to 32 (default 32). + #[usage( + name = "cluster-max-hops", + long = "cluster-max-hops", + env = "MOQ_CLUSTER_MAX_HOPS", + setting = "cluster.max_hops" + )] + pub max_hops: Option, + /// Connect to one or more other cluster nodes. Each entry is a full URL, e.g. /// `https://host/?jwt=TOKEN`, or an object with `url`, `cost`, `egress`, /// and `token`; see [`Peer`]. A bare host or `host:port` is refused; pass the @@ -992,6 +1001,9 @@ impl Cluster { // keeping the first entry when dials spawn. parse_peer_list(config.connect.clone(), None).context("invalid --cluster-connect peer list")?; let mut origin_config = origin::Config::new(id); + if let Some(max) = config.max_hops { + origin_config.max_hops = origin::HopLimit::new(max).context("--cluster-max-hops must be from 1 to 32")?; + } if let Some(cache) = cache { origin_config.pool = cache.pool; origin_config.cache_duration = cache.duration; @@ -1579,14 +1591,12 @@ impl Cluster { let addrs = moq_tokio::Addrs::collect(urls).context("peer advertised no reachable address")?; - let base_backoff = tokio::time::Duration::from_secs(1); - let max_backoff = tokio::time::Duration::from_secs(300); // Sessions shorter than this are treated as churn: we keep backing off // instead of resetting, otherwise a peer that rejects us instantly would // turn into a tight reconnect loop. - let stable_threshold = tokio::time::Duration::from_secs(10); + let stable_threshold = std::time::Duration::from_secs(10); - let mut backoff = base_backoff; + let mut redial = Redial::default(); loop { let started = tokio::time::Instant::now(); @@ -1596,18 +1606,13 @@ impl Cluster { let elapsed = started.elapsed(); match result { - Ok(()) if elapsed >= stable_threshold => backoff = base_backoff, - Ok(()) => { - tracing::warn!(?elapsed, "cluster peer session closed cleanly but quickly; backing off"); - backoff = (backoff * 2).min(max_backoff); - } - Err(err) => { - tracing::warn!(%err, "cluster peer error; will retry"); - backoff = (backoff * 2).min(max_backoff); - } + Ok(()) if elapsed >= stable_threshold => redial = Redial::default(), + Ok(()) => tracing::warn!(?elapsed, "cluster peer session closed cleanly but quickly; backing off"), + Err(err) => tracing::warn!(%err, "cluster peer error; will retry"), } - tokio::time::sleep(backoff).await; + let wait = redial.next(&mut rand::rng()); + tokio::time::sleep(wait).await; } } @@ -1672,7 +1677,16 @@ impl Cluster { // relay's own upstream dial, and a cluster peer link that stopped following // GOAWAY would break rolling handoff, redialing the drained URL after the // old session finally closed instead of migrating to the replacement. - let mut reconnect = client.with_reconnect(true).connect(addrs.clone()); + // + // A GOAWAY naming no URI is a relay draining to restart. A peer URL names + // that one node, so a redial would only reach the draining process again + // and replay both announce tables there for the rest of its window. Its + // routes are already re-priced to the maximum, so the session serves on + // until the node closes it, and the redial after that finds the replacement. + let mut reconnect = client + .with_reconnect(true) + .with_restart(moq_tokio::Restart::Wait) + .connect(addrs.clone()); let mut connection = None; loop { match reconnect.status().await? { @@ -1890,6 +1904,33 @@ pub(crate) fn canonicalize_peer_key(peer: &str) -> String { } } +/// The wait between dials of one cluster peer: exponential with full jitter, so a +/// mesh recovering from one outage spreads its redials instead of sending them in +/// lockstep, and capped low enough that a peer back from a long outage is picked +/// up within tens of seconds. +struct Redial { + /// The longest the next wait may be. + ceiling: std::time::Duration, +} + +impl Redial { + const FIRST: std::time::Duration = std::time::Duration::from_secs(1); + const CAP: std::time::Duration = std::time::Duration::from_secs(30); + + /// A wait drawn uniformly below the ceiling, which then doubles up to [`Self::CAP`]. + fn next(&mut self, rng: &mut impl rand::RngExt) -> std::time::Duration { + let wait = self.ceiling.mul_f64(rng.random()); + self.ceiling = (self.ceiling * 2).min(Self::CAP); + wait + } +} + +impl Default for Redial { + fn default() -> Self { + Self { ceiling: Self::FIRST } + } +} + /// Deserialize a field that accepts either a TOML boolean or string into an /// `Option` (booleans become `"true"` / `"false"`). Lets the removed /// `cluster.mesh` parse in both its released forms, `mesh = true` and @@ -1924,6 +1965,31 @@ mod tests { Cluster::new(Options::new(config)) } + #[tokio::test] + async fn cluster_hop_limit_reaches_the_shared_origin() { + assert_eq!( + new_cluster(Config::default()).unwrap().origin.config().max_hops.get(), + 32 + ); + for cap in [1, 6, 32] { + let cluster = new_cluster(Config { + max_hops: Some(cap), + ..Default::default() + }) + .unwrap(); + assert_eq!(cluster.origin.config().max_hops.get(), cap); + } + for cap in [0, 33, usize::MAX] { + let err = new_cluster(Config { + max_hops: Some(cap), + ..Default::default() + }) + .err() + .unwrap(); + assert!(err.to_string().contains("--cluster-max-hops"), "{err}"); + } + } + /// A grant's mount reaches the target for subscribe and refuses publish, both /// named from the session's root. #[tokio::test] @@ -2024,6 +2090,28 @@ mod tests { drop(broadcast); } + /// Each wait is drawn across the whole range below a ceiling that doubles to the + /// cap, so peers that failed together spread out instead of redialing in step. + #[test] + fn redial_waits_spread_below_a_doubling_capped_ceiling() { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(0x7265_6469_616c); + let ceilings = [1, 2, 4, 8, 16, 30, 30].map(std::time::Duration::from_secs); + let mut redials: Vec = (0..1000).map(|_| Redial::default()).collect(); + for ceiling in ceilings { + let waits: Vec<_> = redials.iter_mut().map(|redial| redial.next(&mut rng)).collect(); + assert!(waits.iter().all(|wait| *wait <= ceiling), "a wait passed {ceiling:?}"); + // Uniform over the range: every tenth of it is hit, so none is a lockstep. + for tenth in 0..10 { + let (low, high) = (ceiling * tenth / 10, ceiling * (tenth + 1) / 10); + assert!( + waits.iter().any(|wait| (low..high).contains(wait)), + "no wait in {low:?}..{high:?} below {ceiling:?}" + ); + } + } + } + /// `?cost=` is read and stripped (it rides SETUP, not the URL), other /// query params survive, and a garbage value is an error rather than a /// silent default. diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index e4448b2abca..13bd1a77b3d 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -1278,6 +1278,33 @@ uid = [1001] }, source: "generate", }, + Case { + name: "hop-limit-from-file", + toml: "[cluster]\nmax_hops = 8\n", + env: &[], + cli: &[], + key: "cluster.max_hops", + check: |config| assert_eq!(config.cluster.max_hops, Some(8)), + source: "max_hops", + }, + Case { + name: "hop-limit-env-overrides-file", + toml: "[cluster]\nmax_hops = 8\n", + env: &[("MOQ_CLUSTER_MAX_HOPS", "6")], + cli: &[], + key: "cluster.max_hops", + check: |config| assert_eq!(config.cluster.max_hops, Some(6)), + source: "MOQ_CLUSTER_MAX_HOPS", + }, + Case { + name: "hop-limit-cli-overrides-env-and-file", + toml: "[cluster]\nmax_hops = 8\n", + env: &[("MOQ_CLUSTER_MAX_HOPS", "6")], + cli: &["--cluster-max-hops", "4"], + key: "cluster.max_hops", + check: |config| assert_eq!(config.cluster.max_hops, Some(4)), + source: "--cluster-max-hops", + }, Case { name: "env-overrides-file", toml: "[stats]\nenabled = false\n", @@ -1305,6 +1332,7 @@ uid = [1001] "MOQ_LISTEN_VERSION", "MOQ_LISTEN_TLS_GENERATE", "MOQ_RUNTIME_PIN", + "MOQ_CLUSTER_MAX_HOPS", ]); for (key, value) in case.env { // SAFETY: EnvGuard serializes env mutation across these tests. diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index 31682691848..b67fa08115b 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -288,7 +288,7 @@ async fn serve_health() -> Response { } /// Prometheus text-exposition metrics for this node's own MoQ traffic counters -/// (bytes/frames/groups, subscriptions, viewers, and connected sessions), +/// (bytes/frames/groups, announces, subscriptions, viewers, and connected sessions), /// summed across broadcasts and split by `tier`/`role`. /// /// Unauthenticated, which is why it lives on the internal listener rather than @@ -421,6 +421,18 @@ fn render_metrics( "One-shot group fetches requested.", |c| c.fetches, ); + counter( + &mut out, + "moq_relay_announces_started_total", + "Broadcast announces started.", + |c| c.announces_started, + ); + counter( + &mut out, + "moq_relay_announces_ended_total", + "Broadcast announces ended; subtract from started for live announces.", + |c| c.announces_ended, + ); counter( &mut out, "moq_relay_subscriptions_opened_total", @@ -471,6 +483,32 @@ fn render_metrics( ); } + let control = snap.control(); + for (name, help, field) in [ + ( + "moq_relay_control_tx_bytes_total", + "Control-message bytes encoded for sending, including codec prefixes; excludes transport overhead.", + (|b: &moq_net::stats::Bytes| b.tx) as fn(&moq_net::stats::Bytes) -> u64, + ), + ( + "moq_relay_control_rx_bytes_total", + "Control-message bytes decoded, including codec prefixes; excludes transport overhead.", + (|b: &moq_net::stats::Bytes| b.rx) as fn(&moq_net::stats::Bytes) -> u64, + ), + ] { + let _ = writeln!(out, "# HELP {name} {help}"); + let _ = writeln!(out, "# TYPE {name} counter"); + for (tier, class, bytes) in &control { + let _ = writeln!( + out, + "{name}{{tier=\"{}\",class=\"{}\"}} {}", + tier.as_str(), + class.as_str(), + field(bytes) + ); + } + } + render_accepts(&mut out, listeners); render_uring(&mut out, uring); @@ -916,6 +954,41 @@ mod tests { assert_eq!(snapshot.nodes[0].node, "https://relay-b.example/"); } + #[tokio::test] + async fn announce_metrics_keep_retired_totals() { + let stats = moq_net::stats::Registry::new(Default::default()); + let producer = + moq_tokio::origin::spawn().with_stats(stats.tier(moq_net::stats::Tier::new("edge")).session("root")); + let broadcast = producer.create_broadcast("demo/retired").unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let body = render_metrics(&stats.snapshot(), &[], &[]); + assert!( + body.contains("moq_relay_announces_started_total{tier=\"edge\",role=\"subscriber\"} 1"), + "live announce start:\n{body}" + ); + assert!( + body.contains("moq_relay_announces_ended_total{tier=\"edge\",role=\"subscriber\"} 0"), + "live announce end:\n{body}" + ); + + broadcast.unannounce(); + drop(broadcast); + drop(producer); + let mut report = moq_net::stats::Report::default(); + stats.report(&mut report); + + let body = render_metrics(&stats.snapshot(), &[], &[]); + assert!( + body.contains("moq_relay_announces_started_total{tier=\"edge\",role=\"subscriber\"} 1"), + "retired announce start:\n{body}" + ); + assert!( + body.contains("moq_relay_announces_ended_total{tier=\"edge\",role=\"subscriber\"} 1"), + "retired announce end:\n{body}" + ); + } + /// The `/metrics` renderer emits well-formed Prometheus exposition: a /// HELP/TYPE header per metric and a labeled line carrying the live counter /// value, summed across broadcasts. diff --git a/rs/moq-relay/src/settings.rs b/rs/moq-relay/src/settings.rs index 63a0ddfdb94..a02e4a7f800 100644 --- a/rs/moq-relay/src/settings.rs +++ b/rs/moq-relay/src/settings.rs @@ -69,6 +69,9 @@ struct Cluster { #[usage(env = "MOQ_CLUSTER_ID", cli("--cluster-id"))] id: Option, + #[usage(env = "MOQ_CLUSTER_MAX_HOPS", cli("--cluster-max-hops"))] + max_hops: Option, + #[usage(env = "MOQ_CLUSTER_CONNECT", cli("--cluster-connect"), parse = "list_by_comma")] connect: Option>, diff --git a/rs/moq-relay/tests/cluster_drain.rs b/rs/moq-relay/tests/cluster_drain.rs new file mode 100644 index 00000000000..5847150080e --- /dev/null +++ b/rs/moq-relay/tests/cluster_drain.rs @@ -0,0 +1,206 @@ +//! A draining relay's cluster peers stay on the session it drains instead of +//! redialing it, and reconnect once its replacement is up. +//! +//! Three relays over real loopback TCP: B drains while A and C peer with it. The +//! runtime runs on Tokio's paused clock, so the drain window and every backoff +//! pass only once the loopback traffic in flight has landed and the runtime idles. +//! Each peer dials B through its own forwarder, which counts the sessions opened. + +use std::{ + net::SocketAddr, + sync::{Arc, Mutex}, + time::Duration, +}; + +use moq_relay::{ + Relay, + cluster::{self, Peer}, +}; + +/// How long B drains before it force-closes what is left. +const DRAIN: Duration = Duration::from_secs(30); + +/// How far the paused clock moves per check while waiting for a reconnect. +const STEP: Duration = Duration::from_millis(500); + +/// Bound on anything the test waits for, on the paused clock. +const WAIT: Duration = Duration::from_secs(10); + +async fn within(step: &str, fut: impl std::future::Future) -> T { + tokio::time::timeout(WAIT, fut) + .await + .unwrap_or_else(|_| panic!("timed out: {step}")) +} + +/// Run on a 32 MiB thread, as `goaway_cluster.rs` does: the multi-transport +/// client and session types make the test future large. +fn run_cluster_test(fut: F) +where + F: std::future::Future + Send + 'static, +{ + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("build test runtime") + .block_on(fut); + }) + .expect("spawn test thread") + .join() + .expect("test thread panicked"); +} + +/// A TCP forwarder standing in for one peer's route to B: it records the +/// upstream each accepted connection reached, and refuses while there is none. +#[derive(Clone)] +struct Forwarder { + port: u16, + target: Arc>>, + reached: Arc>>, +} + +impl Forwarder { + async fn spawn() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind forwarder"); + let forwarder = Self { + port: listener.local_addr().expect("local addr").port(), + target: Default::default(), + reached: Default::default(), + }; + let this = forwarder.clone(); + tokio::spawn(async move { + while let Ok((mut inbound, _)) = listener.accept().await { + let Some(target) = *this.target.lock().unwrap() else { + continue; + }; + let Ok(mut outbound) = tokio::net::TcpStream::connect(target).await else { + continue; + }; + this.reached.lock().unwrap().push(target); + tokio::spawn(async move { + let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await; + }); + } + }); + forwarder + } + + fn point(&self, target: Option) { + *self.target.lock().unwrap() = target; + } + + /// How many connections reached `target`. + fn sessions(&self, target: SocketAddr) -> usize { + self.reached.lock().unwrap().iter().filter(|&&t| t == target).count() + } +} + +/// A relay with a plaintext TCP listener and an open auth policy, publishing `name`. +async fn relay(name: &str) -> (Relay, SocketAddr, moq_net::broadcast::Producer) { + let mut config = moq_relay::Config::default(); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.auth.public = vec![moq_auth::Pattern::all()]; + config.drain_timeout = DRAIN; + let relay = Relay::load(config).await.expect("load relay").with_signals(false); + let addr = relay.tcp_addr().expect("tcp listener"); + let broadcast = relay.cluster().origin.create_broadcast(name).expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + (relay, addr, broadcast) +} + +/// A peer relay publishing `name` and dialing B through `forwarder`. +async fn peer(name: &str, forwarder: &Forwarder) -> (cluster::Cluster, moq_net::broadcast::Producer) { + let mut config = cluster::Config::default(); + config.connect = vec![Peer::new(format!("tcp://127.0.0.1:{}/", forwarder.port))]; + let client = moq_tokio::connect::Config::default() + .init(Default::default()) + .expect("client init"); + let cluster = cluster::Cluster::new(cluster::Options::new(config)) + .expect("cluster init") + .with_client(client); + let broadcast = cluster.origin.create_broadcast(name).expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let started = cluster.clone().start().await.expect("cluster start"); + tokio::spawn(started.run()); + (cluster, broadcast) +} + +#[test] +fn draining_relay_keeps_its_peers_until_it_restarts() { + run_cluster_test(draining_relay_keeps_its_peers_until_it_restarts_inner()); +} + +async fn draining_relay_keeps_its_peers_until_it_restarts_inner() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let (b, b_addr, _b_broadcast) = relay("b").await; + let b_origin = b.cluster().origin.clone(); + let trigger = b.shutdown_trigger().clone(); + let b_run = tokio::spawn(b.run()); + + let forward_a = Forwarder::spawn().await; + let forward_c = Forwarder::spawn().await; + forward_a.point(Some(b_addr)); + forward_c.point(Some(b_addr)); + let (a, _a_broadcast) = peer("a", &forward_a).await; + let (c, _c_broadcast) = peer("c", &forward_c).await; + + // The mesh converges: each side learns the other's broadcasts. + within("A learns b", a.origin.consume().routed("b")).await; + within("C learns b", c.origin.consume().routed("b")).await; + within("B learns a", b_origin.consume().routed("a")).await; + within("B learns c", b_origin.consume().routed("c")).await; + assert_eq!(forward_a.sessions(b_addr), 1); + assert_eq!(forward_c.sessions(b_addr), 1); + + // B drains for its whole window: its peers hold their sessions to the end. + trigger.start(); + tokio::time::timeout(DRAIN + WAIT, b_run) + .await + .expect("timed out: B exits after its drain") + .expect("relay task") + .expect("relay run"); + assert_eq!(forward_a.sessions(b_addr), 1, "A redialed the draining relay"); + assert_eq!(forward_c.sessions(b_addr), 1, "C redialed the draining relay"); + + // Nothing answers while B is down. Then its replacement comes up. + forward_a.point(None); + forward_c.point(None); + tokio::time::sleep(Duration::from_secs(5)).await; + let (b2, b2_addr, _b2_broadcast) = relay("b").await; + let b2_origin = b2.cluster().origin.clone(); + let _b2_run = tokio::spawn(b2.run()); + forward_a.point(Some(b2_addr)); + forward_c.point(Some(b2_addr)); + + // Each peer reconnects within the capped backoff. + let mut waited = Duration::ZERO; + while forward_a.sessions(b2_addr) == 0 || forward_c.sessions(b2_addr) == 0 { + assert!( + waited < Duration::from_secs(120), + "a peer never reconnected to the replacement" + ); + tokio::time::sleep(STEP).await; + waited += STEP; + } + within("B' learns a", b2_origin.consume().routed("a")).await; + within("B' learns c", b2_origin.consume().routed("c")).await; + + // And reconnects exactly once. + tokio::time::sleep(Duration::from_secs(30)).await; + assert_eq!( + forward_a.sessions(b2_addr), + 1, + "A dialed the replacement more than once" + ); + assert_eq!( + forward_c.sessions(b2_addr), + 1, + "C dialed the replacement more than once" + ); +} diff --git a/rs/moq-relay/tests/control_stats.rs b/rs/moq-relay/tests/control_stats.rs new file mode 100644 index 00000000000..4f44aaca6b1 --- /dev/null +++ b/rs/moq-relay/tests/control_stats.rs @@ -0,0 +1,112 @@ +//! Control-message byte counters across the shared-runtime and io_uring listener boundary. +#![cfg(feature = "_quic")] + +use moq_net::stats::{Control, Registry}; +use moq_relay::{Config, Relay}; +use moq_tokio::moq_net; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Whether the relay has both sent and received announce control bytes. +fn announced(registry: &Registry) -> bool { + registry + .snapshot() + .control() + .iter() + .any(|(_, class, bytes)| *class == Control::Announce && bytes.tx > 0 && bytes.rx > 0) +} + +async fn control_counters(io_uring: bool) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let dir = tempfile::tempdir().unwrap(); + let key = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["localhost".into()]) + .unwrap() + .self_signed(&key) + .unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + std::fs::write(&cert_path, cert.pem()).unwrap(); + std::fs::write(&key_path, key.serialize_pem()).unwrap(); + let mut config = Config::default(); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); + config.listen.tls.cert = vec![cert_path]; + config.listen.tls.key = vec![key_path]; + config.auth.public = vec![moq_auth::Pattern::all()]; + config.stats.enabled = true; + config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); + config.stats.interval = 3600; + config.drain_timeout = Duration::ZERO; + if io_uring { + config.runtime.workers = Some(1); + config.runtime.pin = false; + config.runtime.io_uring = true; + } + let relay = Relay::load(config).await.unwrap().with_signals(false); + let addr = relay.addr().unwrap(); + let metrics = format!("http://{}/metrics", relay.internal().addr().unwrap()); + let registry = relay.cluster().stats.clone(); + let trigger = relay.shutdown_trigger().clone(); + let running = tokio::spawn(relay.run()); + for scheme in ["moql", "https"] { + let mut client = moq_tokio::connect::Config::default(); + client.tls.insecure = Some(true); + client.bind = Some("127.0.0.1:0".parse().unwrap()); + let client = client.init(Default::default()).unwrap(); + let url: url::Url = format!("{scheme}://{addr}/control").parse().unwrap(); + let connection = tokio::time::timeout(TIMEOUT, client.with_reconnect(false).connect(url).established()) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(TIMEOUT, async { + while !announced(®istry) { + tokio::task::yield_now().await; + } + }) + .await + .expect("the session exchanges announce control messages"); + let body = reqwest::get(&metrics) + .await + .unwrap() + .error_for_status() + .unwrap() + .text() + .await + .unwrap(); + for direction in ["tx", "rx"] { + let metric = format!("moq_relay_control_{direction}_bytes_total"); + assert!(body.contains(&format!("# TYPE {metric} counter")), "{body}"); + let prefix = format!("{metric}{{tier=\"\",class=\"announce\"}} "); + let total: u64 = body + .lines() + .find_map(|line| line.strip_prefix(&prefix)) + .expect("control metric") + .parse() + .unwrap(); + assert!(total > 0, "{scheme}: {body}"); + } + drop(connection); + } + trigger.start(); + tokio::time::timeout(TIMEOUT, running).await.unwrap().unwrap().unwrap(); +} + +#[tokio::test] +async fn native_sessions_count_control_bytes() { + control_counters(false).await; +} + +#[cfg(all(target_os = "linux", feature = "_uring"))] +#[tokio::test] +async fn uring_sessions_count_control_bytes() { + match moq_uring::Worker::new(Default::default()) { + Ok(_) => (), + Err(moq_uring::Error::Unsupported(reason)) if std::env::var_os("MOQ_STRICT").is_none() => { + eprintln!("skipping io_uring control stats test: {reason}"); + return; + } + Err(error) => panic!("io_uring worker setup failed: {error}"), + } + control_counters(true).await; +} diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index 874aa53d9a0..3e72ecf9050 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -217,11 +217,6 @@ async fn cluster_continues_a_group_split_by_goaway_inner() { cluster_run.abort(); } -#[test] -fn cluster_reconnects_on_empty_uri_goaway() { - run_cluster_test(cluster_reconnects_on_empty_uri_goaway_inner()); -} - /// A fake sibling upstream: a stream-only moq server publishing `origin`'s /// broadcasts to whoever connects. Returns its port, a receiver yielding each /// accepted [`moq_net::Session`] (so the test can drain it), and the task. @@ -720,99 +715,6 @@ async fn collect_group(sub: &mut moq_net::track::Subscriber, seen: &mut BTreeSet ); } -/// An empty-URI GOAWAY ("reconnect to me") makes the cluster redial the same -/// endpoint. Both sessions' routes name the same first hop, so the subscription -/// through the drained session re-splices onto the redial and keeps delivering. -async fn cluster_reconnects_on_empty_uri_goaway_inner() { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - let upstream_origin = moq_tokio::origin::spawn(); - let broadcast = upstream_origin.create_broadcast("cam").expect("create broadcast"); - broadcast.announce(Default::default()).expect("create broadcast"); - let track = broadcast.create_track("video", None).expect("create track"); - - let (port, mut accepted, _handle) = spawn_upstream(upstream_origin.clone()).await; - - let mut client_config = moq_tokio::connect::Config::default(); - client_config.tls.insecure = Some(true); - // Short handover so the test observes the old session close quickly. - client_config.goaway.handover = Duration::from_secs(2); - let client = client_config.init(Default::default()).expect("client init"); - - let mut cluster_config = cluster::Config::default(); - cluster_config.connect = vec![Peer::new(format!("tcp://127.0.0.1:{port}/"))]; - let cluster = cluster::Cluster::new(cluster::Options::new(cluster_config)) - .expect("cluster init") - .with_client(client); - let started = cluster.clone().start().await.expect("cluster start"); - let cluster_run = tokio::spawn(started.run()); - - let first_dial = within("upstream accepts the cluster dial", accepted.recv()) - .await - .expect("accept channel closed"); - - // Downstream consumer sees group 0 through the first session. - let bc = within("broadcast announced", async { - let consumer = cluster.origin.consume(); - consumer.routed("cam").await?; - consumer.request_broadcast("cam").await.ok() - }) - .await - .expect("broadcast announced"); - let mut sub = within("subscribe", async { - bc.track("video").expect("track handle").subscribe(None).await - }) - .await - .expect("subscribe"); - - let mut g = track.append_group().expect("append group"); - g.write_frame(moq_net::Timestamp::ZERO, b"empty_g0".as_ref()) - .expect("write frame"); - g.finish().expect("finish"); - let mut g0 = within("recv g0", sub.recv_group()) - .await - .expect("recv") - .expect("track ended early"); - assert_eq!(g0.sequence, 0); - assert_eq!( - g0.read_frame().await.expect("read").expect("frame").payload[..], - b"empty_g0"[..] - ); - - // Drain with an EMPTY URI: the cluster must fall back to redialing the - // originally configured endpoint. - first_dial - .drain() - .send(moq_net::goaway::Goaway::new()) - .expect("send goaway"); - - // Positive gate: the upstream accepts a SECOND session (the redial). - let _second_dial = within("cluster redials the same endpoint", accepted.recv()) - .await - .expect("accept channel closed"); - - within("old session drains", first_dial.closed()).await; - - // The redialed session's route names the same first hop, so the broadcast - // re-splices onto it: the SAME subscription delivers the next group, with - // nothing re-delivered and no visible end. - let mut g = track.append_group().expect("append group"); - g.write_frame(moq_net::Timestamp::ZERO, b"empty_g1".as_ref()) - .expect("write frame"); - g.finish().expect("finish"); - let mut g1 = within("recv g1 through the redial", sub.recv_group()) - .await - .expect("subscription survives the redial") - .expect("track ended early"); - assert_eq!(g1.sequence, 1, "the redialed session must deliver the new group"); - assert_eq!( - g1.read_frame().await.expect("read").expect("frame").payload[..], - b"empty_g1"[..] - ); - - cluster_run.abort(); -} - /// Dial once and hand back the client with its connection. /// /// These tests want a single transport, so reconnecting is off: there is nothing diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 8b97eaf7781..dcd7317e1c7 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -5,7 +5,7 @@ //! accept side is [`crate::server`]. use crate::connection::Goaway; -use crate::{Addrs, Backoff, Connection, Error}; +use crate::{Addrs, Backoff, Connection, Error, Restart}; #[cfg(all(feature = "websocket", feature = "noq"))] use std::future::Future; use url::Url; @@ -62,6 +62,7 @@ pub struct Client { pub(crate) reconnect: bool, pub(crate) backoff: Backoff, pub(crate) goaway: Goaway, + pub(crate) restart: Restart, /// Whether the TLS config pins a certificate fingerprint, which only verifies /// the configured host, so a GOAWAY may not redirect elsewhere. pub(crate) pinned: bool, @@ -149,6 +150,7 @@ impl Client { reconnect: !config.once.unwrap_or(false), backoff: config.backoff, goaway: config.goaway, + restart: Restart::default(), pinned: !config.tls.fingerprint.is_empty(), #[cfg(feature = "tcp")] failover_delay, @@ -235,6 +237,12 @@ impl Client { self } + /// Choose what a GOAWAY naming no URI does. Defaults to [`Restart::Redial`]. + pub fn with_restart(mut self, restart: Restart) -> Self { + self.restart = restart; + self + } + /// Open a connection to the given peer. /// /// A background task dials, completes the MoQ handshake, and (by default) diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index cbffa9c49d5..7f426eaf778 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -206,6 +206,22 @@ pub enum Redirect { Ignore, } +/// What to do when a peer's GOAWAY names no URI, meaning it is restarting where we reached it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum Restart { + /// Redial the same addresses at once while the drained session keeps serving. Right for + /// an address that can lead to another node, such as a fleet name a fresh resolve moves + /// off the drained one. The default. + #[default] + Redial, + /// Keep serving on the drained session until the peer closes it, then reconnect as after + /// any drop. Right for an address that names one node, where a redial only reaches the + /// draining process again. The peer must close the session itself, as a relay does at + /// its drain deadline. + Wait, +} + impl Redirect { /// Resolve the URL to dial after a GOAWAY, falling back to `current` when the /// redirect is empty ("reconnect to me"), malformed, or refused by policy. @@ -817,7 +833,7 @@ impl Connection { // Wait for the session to end, forwarding its bandwidth estimates into the // persistent producers meanwhile so consumers track the live stats across the // connection, and draining any predecessor left over from a migration. - let ended = run_session(shared, &session, &mut draining).await; + let ended = run_session(shared, &session, &mut draining, client.restart).await; // A session that stayed up past the initial backoff is healthy; one that // ended sooner counts as a failed attempt however it ended. @@ -1341,10 +1357,19 @@ async fn sleep_draining(delay: Duration, draining: &mut Option, shared /// GOAWAY consumer is a kio channel, and the transport's close future (the one non-kio source) is /// polled through the waiter's own waker. `migrate` is whether a GOAWAY ends this session's /// tenure ([`Ended::Goaway`]); when false the arm isn't polled and only a close returns. -async fn run_session(shared: &Shared, session: &moq_net::Session, draining: &mut Option) -> Ended { +/// +/// Under [`Restart::Wait`] a GOAWAY naming no URI does not end the wait: the session serves on +/// until it closes. +async fn run_session( + shared: &Shared, + session: &moq_net::Session, + draining: &mut Option, + restart: Restart, +) -> Ended { let mut send = session.send_bandwidth(); let mut recv = session.recv_bandwidth(); - let goaway = session.draining(); + // Dropped once a restart is waited out: it is a latch, so it would keep firing. + let mut goaway = Some(session.draining()); let closed = session.closed(); tokio::pin!(closed); @@ -1364,8 +1389,13 @@ async fn run_session(shared: &Shared, session: &moq_net::Session, draining: &mut // accepting requests and waits for us to leave, so ignoring it is how both // sides end up waiting on each other forever. The caller ends the connection // instead: asked to leave, with no way to migrate, leaving is the answer. - if let Poll::Ready(Ok(msg)) = goaway.poll(waiter) { - return Poll::Ready(Ended::Goaway(msg)); + if let Some(Poll::Ready(Ok(msg))) = goaway.as_ref().map(|goaway| goaway.poll(waiter)) { + if restart == Restart::Wait && msg.uri().is_empty() { + tracing::info!("peer is restarting; serving until it closes the session"); + goaway = None; + } else { + return Poll::Ready(Ended::Goaway(msg)); + } } waiter.poll_future(closed.as_mut()).map(|err| Ended::Closed(Err(err))) @@ -2209,4 +2239,59 @@ mod tests { "a cached resolve redialed the drained relay" ); } + + /// Under [`Restart::Wait`] an empty-URI GOAWAY changes nothing while the peer keeps + /// the session: no migration and no redial. Its close then reconnects like any drop. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_waited_restart_redials_only_once_the_session_closes() { + use futures::FutureExt; + const WAIT: Duration = Duration::from_secs(10); + + let origin = crate::origin::spawn(); + let (addr, mut accepted, _task) = serve(origin.clone()).await; + + let subscriber = crate::origin::spawn(); + let mut config = crate::connect::Config::default(); + config.backoff.initial = MIN_BACKOFF; + let client = config + .init(Default::default()) + .unwrap() + .with_subscriber(subscriber.clone()) + .with_restart(Restart::Wait); + let mut connection = client.connect(url(&format!("tcp://{addr}/"))); + let status = tokio::time::timeout(WAIT, connection.status()).await.unwrap(); + assert_eq!(status.unwrap(), Status::Connected); + let session = tokio::time::timeout(WAIT, accepted.recv()).await.unwrap().unwrap(); + + session.drain().send(moq_net::goaway::Goaway::new()).unwrap(); + + // A broadcast announced after the GOAWAY still arrives: the drained session serves. + let late = origin.create_broadcast("late").unwrap(); + late.announce(Default::default()).unwrap(); + tokio::time::timeout(WAIT, subscriber.consume().routed("late")) + .await + .expect("the drained session stopped serving") + .unwrap(); + assert!( + connection.draining().and_then(|goaway| goaway.peek()).is_some(), + "the GOAWAY never arrived" + ); + assert_eq!( + connection.status().now_or_never().transpose().unwrap(), + None, + "the loop left the session" + ); + assert!(accepted.try_recv().is_err(), "redialed while the session served"); + + session.abort(moq_net::Error::GoawayTimeout); + let status = tokio::time::timeout(WAIT, connection.status()).await.unwrap(); + assert_eq!(status.unwrap(), Status::Disconnected); + let status = tokio::time::timeout(WAIT, connection.status()).await.unwrap(); + assert_eq!(status.unwrap(), Status::Connected); + tokio::time::timeout(WAIT, accepted.recv()) + .await + .expect("never redialed after the close") + .unwrap(); + } } diff --git a/rs/moq-tokio/src/lib.rs b/rs/moq-tokio/src/lib.rs index 303091d4bc1..b121bf3bd85 100644 --- a/rs/moq-tokio/src/lib.rs +++ b/rs/moq-tokio/src/lib.rs @@ -83,7 +83,7 @@ fn qmux_config() -> qmux::Config { // new `pub` item in these modules doesn't silently join it. pub use client::Client; pub use connect::{Addrs, ConnectError}; -pub use connection::{Backoff, Connection, Redirect, Status}; +pub use connection::{Backoff, Connection, Redirect, Restart, Status}; pub use error::{Error, Result}; pub use log::{Log, RedactedUrl}; #[cfg(feature = "_transport")]