From fd31194eeea45cc2a4ac096fc5bb04a19161dd6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 05:44:22 -0700 Subject: [PATCH 01/21] test(moq-tokio): bind reconnect and worker tests to their own ports (#4127) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/tokio-reconnect-ports.md | 25 -------------- rs/moq-tokio/src/server.rs | 15 ++++++++- rs/moq-tokio/tests/reconnect.rs | 49 +++++++++++---------------- rs/moq-tokio/tests/worker.rs | 55 +++++++++++-------------------- 5 files changed, 53 insertions(+), 92 deletions(-) delete mode 100644 quest/m1/tokio-reconnect-ports.md diff --git a/quest/m1/README.md b/quest/m1/README.md index e81237726e..60c9c1c16f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -51,7 +51,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish -- [Reconnect test ports](/quest/m1/tokio-reconnect-ports.md) - moq-tokio reconnect and worker tests bind their own ports, with a `tcp_local_addr()` accessor - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders diff --git a/quest/m1/tokio-reconnect-ports.md b/quest/m1/tokio-reconnect-ports.md deleted file mode 100644 index df925e6a82..0000000000 --- a/quest/m1/tokio-reconnect-ports.md +++ /dev/null @@ -1,25 +0,0 @@ -# [S] moq-tokio reconnect and worker tests bind their own ports - -## Goal - -The moq-tokio integration tests stop picking a free port, releasing it, and -binding it again, a race another process can win. `reconnect.rs`'s -`spawn_server` loses its retry loop, and the worker tests that do not need a -known port bind `:0`. - -## Plan - -- Add `Server::tcp_local_addr()` and `Listener::tcp_local_addr()`, mirroring - `websocket_local_addr()`, reporting the bound address of the plain TCP - (qmux) listener. Today `StreamListeners` keeps only the configured bind and - moves the bound listener into its accept task. `spawn_server` binds `:0` and - reads the address back. This is an additive public API. -- In `worker.rs`, the tests that only need some port bind `:0` through the - group and use `Group::local_addr()`. The tests that rebind the same port - after a drop, or probe it while the group holds it, keep a known port, which - is the behavior under test. -- No retries or sleeps. - -## Related - -- [Test ports](https://github.com/moq-dev/moq/pull/4084) - the same fix for `websocket_forbidden_does_not_end_a_quic_connect` diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 3eff0a194f..d71a05f658 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -737,6 +737,12 @@ impl Listener { self.server.websocket_local_addr() } + /// The address the plain TCP (qmux) listener bound to, if one was configured. + #[cfg(feature = "tcp")] + pub fn tcp_local_addr(&self) -> Option { + self.server.streams.tcp_local_addr + } + /// A live handle to the certificates this server is serving. /// /// See [`Server::certificates`], which is also readable before listening. @@ -833,6 +839,9 @@ struct StreamListeners { versions: moq_net::Versions, #[cfg(all(feature = "uds", unix))] unix_allow: Option, + /// The address the TCP listener bound, once [`Self::start`] has run. + #[cfg(feature = "tcp")] + tcp_local_addr: Option, rx: Option>, tasks: Vec>, } @@ -854,6 +863,8 @@ impl StreamListeners { versions, #[cfg(all(feature = "uds", unix))] unix_allow, + #[cfg(feature = "tcp")] + tcp_local_addr: None, rx: None, tasks: Vec::new(), } @@ -886,7 +897,9 @@ impl StreamListeners { .await? .with_protocols(alpns) .with_accept_health(health); - tracing::info!(%addr, "listening (tcp)"); + let local = listener.local_addr()?; + tracing::info!(addr = %local, "listening (tcp)"); + self.tcp_local_addr = Some(local); bound.push(BoundListener::Tcp(listener)); } #[cfg(all(feature = "uds", unix))] diff --git a/rs/moq-tokio/tests/reconnect.rs b/rs/moq-tokio/tests/reconnect.rs index 4eef51b3eb..a3532e354c 100644 --- a/rs/moq-tokio/tests/reconnect.rs +++ b/rs/moq-tokio/tests/reconnect.rs @@ -6,7 +6,6 @@ #![cfg(feature = "tcp")] -use std::net::TcpListener; use std::time::Duration; use moq_tokio::moq_net; @@ -103,45 +102,35 @@ async fn monitor_is_cloneable_without_keeping_the_connection_alive() { assert!(monitor.snapshot().is_none()); } -/// A stream-only moq server on a free loopback TCP port. +/// A stream-only moq server on an ephemeral loopback TCP port. /// /// Returns the port, a receiver yielding every accepted session (so a test can -/// drain one), and the listener task. The free-port probe can lose a race with -/// another test between the probe closing and the real bind, so retry rather -/// than panicking in `listen`. +/// drain one), and the listener task. async fn spawn_server() -> ( u16, tokio::sync::mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>, ) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - let server = config.init(Default::default()).expect("init server"); - let Ok(mut server) = server.listen().await else { - continue; - }; - - let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); - let handle = tokio::spawn(async move { - while let Some(request) = server.accept().await { - let origin = moq_tokio::origin::spawn(); - match request.with_publisher(&origin).ok().await { - Ok(session) => { - let _ = accepted.send(session); - } - Err(err) => tracing::warn!(%err, "accept failed"), + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + let server = config.init(Default::default()).expect("init server"); + let mut server = server.listen().await.expect("bind tcp listener"); + let port = server.tcp_local_addr().expect("tcp listener bound").port(); + + let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); + let handle = tokio::spawn(async move { + while let Some(request) = server.accept().await { + let origin = moq_tokio::origin::spawn(); + match request.with_publisher(&origin).ok().await { + Ok(session) => { + let _ = accepted.send(session); } + Err(err) => tracing::warn!(%err, "accept failed"), } - }); + } + }); - return (port, sessions, handle); - } - panic!("could not bind a free TCP port after 20 attempts"); + (port, sessions, handle) } /// A client that redials fast, so a refused redirect lands back on the original diff --git a/rs/moq-tokio/tests/worker.rs b/rs/moq-tokio/tests/worker.rs index f35ce6641d..70b727d005 100644 --- a/rs/moq-tokio/tests/worker.rs +++ b/rs/moq-tokio/tests/worker.rs @@ -5,7 +5,7 @@ //! the group. #![cfg(all(target_os = "linux", feature = "noq"))] -use std::net::{SocketAddr, UdpSocket}; +use std::net::UdpSocket; use moq_tokio::worker::{self, Workers}; @@ -13,8 +13,8 @@ const WORKERS: u16 = 4; /// A UDP port nothing is bound to. /// -/// Named rather than ephemeral because these tests rebind the port, or probe it -/// while the group holds it, which needs a port known before the group starts. +/// Only for the port-lock tests: an ephemeral group takes no lock, so the first +/// group has to name its port. Everything else binds `:0` and reads it back. fn free_udp_port() -> u16 { let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); let port = probe.local_addr().expect("local addr").port(); @@ -70,10 +70,9 @@ async fn dropping_the_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); assert_eq!(workers.len(), usize::from(WORKERS)); // Serving first is the case that used to strand the threads. The accept @@ -89,7 +88,6 @@ async fn dropping_the_workers_releases_the_port() { // A plain bind refuses a port any socket still holds, reuseport or not, so this // succeeds only if every worker's socket is really gone. - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -226,13 +224,11 @@ async fn dropping_unserved_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); drop(workers); - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -397,7 +393,7 @@ async fn generated_certificates_are_refused() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some(format!("127.0.0.1:{}", free_udp_port()).parse().unwrap()); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); listen.tls.generate = vec!["localhost".to_string()]; let err = @@ -446,9 +442,8 @@ async fn dropping_a_server_keeps_its_socket() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let port = workers.local_addr().port(); let mut group = workers.split(); let sockets = udp_sockets_on(port); assert!(sockets >= 2, "every member holds at least one socket"); @@ -489,9 +484,8 @@ async fn completing_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); assert_eq!(members.len(), 2); @@ -529,7 +523,6 @@ async fn completing_a_member_stops_its_siblings() { .expect("a stopped sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -540,9 +533,8 @@ async fn cancelling_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -574,7 +566,6 @@ async fn cancelling_a_member_stops_its_siblings() { .expect("a cancelled sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -586,9 +577,8 @@ async fn a_panicking_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -620,7 +610,6 @@ async fn a_panicking_member_stops_its_siblings() { .expect("a panicking sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -631,9 +620,8 @@ async fn shutdown_with_work_in_flight_joins() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); for member in group.members() { @@ -650,7 +638,6 @@ async fn shutdown_with_work_in_flight_joins() { .expect("shutdown must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("shutdown must release the port"); } @@ -662,9 +649,8 @@ async fn dropping_the_group_with_work_in_flight_stops() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); { @@ -684,6 +670,5 @@ async fn dropping_the_group_with_work_in_flight_stops() { .expect("dropping the group must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("dropping the group must release the port"); } From 0beaad377570276055d1ea2afe8b2420c51f0473 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 06:29:00 -0700 Subject: [PATCH 02/21] docs(quest): drop auth-embedder, completed on dev in #3943 (#4108) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/auth-embedder.md | 46 ----------------------------------- quest/m1/auth-expiry-clock.md | 3 +-- 3 files changed, 1 insertion(+), 49 deletions(-) delete mode 100644 quest/m1/auth-embedder.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 60c9c1c16f..9f5f59a2cb 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -26,7 +26,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for -- [Auth embedder](/quest/m1/auth-embedder.md) - the lease owns its re-check clock, a gateway session holds a lease, and `Cluster::admit` scopes and tags origins in one call - [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below diff --git a/quest/m1/auth-embedder.md b/quest/m1/auth-embedder.md deleted file mode 100644 index 54376be625..0000000000 --- a/quest/m1/auth-embedder.md +++ /dev/null @@ -1,46 +0,0 @@ -# [M] An embedder admits a gateway session with the relay's own loop - -## Goal - -An in-process decider and a gateway session get what the HTTP path and a -QUIC session get for free: the lease owns its re-check clock, a gateway -session holds a lease so revocation reaches RTMP, SRT, WHIP, and WHEP, and -the relay scopes and tags the origins from the grant in one call. moq.pro's -`rs/edge/src/gateway.rs` re-implements `Connection::run`'s authorize, scope, -and stats-tier step for four gateways and holds no lease for any of them. - -## Plan - -Additive on `moq-auth` and `moq-relay`, so on main: - -- `lease::Producer::new(grant)` records `revalidate`/`expires`; - `producer.due().await -> Due::{Revalidate, Expired}`, `update(grant)` - reschedules, `failed()` applies the backoff. `moq_auth::Client` becomes a - ten-line loop over it, and an embedder answering `Admissions` writes the - same ten lines instead of a second driver. -- `Cluster::admit(&self, auth: &Auth, request: moq_auth::Request) -> - Result` with - `Admitted { lease: Lease, publisher: Option, subscriber: Option, stats: stats::Session }` - already scoped and tagged from `grant.tier`, with the lease holding the same - `stats::Session` (`Lease::with_stats`) so a re-checked tier retags it; today `authorize` and - `Grants` are `pub(crate)` in `rs/moq-relay/src/connection.rs` and - `Cluster::publisher(&Token)` drops the `Lease`. `auth::hold(lease, work)` - holds non-session work for as long as the lease allows, the way - `supervise` does for a `moq_net::Session`. -- `moq_auth::Transport::{Rtmp, Srt, WebRtc}` so gateway sessions produce - `connect`/`end` events and count toward `serve::Limits`; the `@moq/auth` - `TransportSchema` is a strict enum, so it gains the three values with - interop coverage in the same PR. -- `Key::decode(token) -> Result` (signature, - algorithm pinning, `kid`, nothing else) with `verify` built on it, and - `decode()` beside `verify()` in `@moq/auth`; `Claims` refuses unknown - fields on purpose, so moq.pro keeps a second JWT decoder in each language - for its migration window. -- `lease::Reason::{Narrowed, Shutdown}` and the relay's own spellings in - `doc/bin/relay/auth.md`; the `end.reason` vocabulary is an open string - today. -- `serve::Keys::Set(PathBuf)` reading a JWKS through `KeySet::verify`, so a - key file, a directory, and a set all enforce `kid`. - -Public API: additive. Wire: the auth JSON gains transport values and end -reasons. diff --git a/quest/m1/auth-expiry-clock.md b/quest/m1/auth-expiry-clock.md index 89d7e3b892..c2a069c104 100644 --- a/quest/m1/auth-expiry-clock.md +++ b/quest/m1/auth-expiry-clock.md @@ -11,7 +11,7 @@ ends immediately in the relay. ## Plan -- Build on the lease clock auth-embedder introduces: `lease::Producer` sets +- Build on the lease clock `lease::Producer` owns (#3943): it sets its deadline once per grant and measures it on the tokio clock, so the client's outage tests run on a paused clock like the relay's (#3969 fixed the relay side). @@ -21,5 +21,4 @@ ends immediately in the relay. ## Required -- [Auth embedder](/quest/m1/auth-embedder.md) - introduces the lease clock this fixes - The relay's fixed expiry deadline (#3969) has merged From fc4669988c20d34fd46bd7caa54b8dccfe04f722 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 06:51:25 -0700 Subject: [PATCH 03/21] docs(quest): plan lite-07 announce compression (#4147) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/announce-compression.md | 108 ++++++++++++++++++++++++++++++ quest/m2/README.md | 1 - quest/m2/announce-prefix-table.md | 47 ------------- 4 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 quest/m1/announce-compression.md delete mode 100644 quest/m2/announce-prefix-table.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 9f5f59a2cb..95586d0b02 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -22,6 +22,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [JS track tail](/quest/m1/js-track-tail.md) - a `@moq/net` subscriber delivers every group up to the declared end over lite and IETF, and JS publishers drain their groups before ending a subscription - [lite-07 stream count](/quest/m1/lite-stream-count.md) - moq-lite-07 replaces SUBSCRIBE_DROP with a group-stream count in SUBSCRIBE_END, like moq-transport +- [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them - [Rust track tail](/quest/m1/rust-track-tail.md) - a moq-net subscriber accepts groups that arrive after the subscription's end, and PublishDone carries the real stream count - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race diff --git a/quest/m1/announce-compression.md b/quest/m1/announce-compression.md new file mode 100644 index 0000000000..c7c61169c1 --- /dev/null +++ b/quest/m1/announce-compression.md @@ -0,0 +1,108 @@ +# [L] Announce compression + +## Goal + +A moq-lite-07 ANNOUNCE_START can reuse the path of any live announcement on +its stream, and ANNOUNCE_START and ANNOUNCE_UPDATE can reuse the tail of any +live announcement's hop chain, so repeated names and relay paths stop costing +full bytes on every announce. Decoded routes are identical to the literal +encoding. Compression is mandatory for every lite-07 endpoint to decode, and +any encoder may still send everything literally. lite-06 and older are +unchanged. + +Non-goals: fewer announces (tree routing was dropped in favour of this), a +separate dictionary with its own bounds, and a production byte counter. + +## Plan + +### Wire + +A base is a live announcement on the same stream, named by its distance back +from the next unassigned Announce ID: `d` names `next - d`, so `1` is the +latest START, and `0` means none. Only the stream's ordered history is +consulted, so there is no new state, table limit, eviction, or reset rule: the +receiver already stores every live route, and a decoded route owns its values, +so a base ending later changes nothing. + +``` +ANNOUNCE_START { + Type (i) = 0x0 + Message Length (i) + Path Base (i), + Path Keep (i), // leading segments of the base's suffix + Route Prefix Suffix (s), // remaining segments, literal + Hops, + Warm Route Cost (i), + Cold Route Cost (i), +} + +ANNOUNCE_UPDATE { + Type (i) = 0x2 + Message Length (i) + Announce ID (i), + Hops, + Warm Route Cost (i), + Cold Route Cost (i), +} + +Hops { + Hop Base (i), + Hop Count (i), + Hop ID (i) ..., // literal leading hops, e.g. a new origin + Hop Keep (i), // trailing hops copied from the base's chain +} +``` + +- Paths share their head, so `Path Keep` counts whole leading segments of the + base's wire suffix (relative to the requested prefix). Segments, not bytes, + to keep the codec simple. +- Chains share their tail: the origin differs per publisher while the relays + behind it repeat. `Hop Keep` counts trailing entries of the base's wire hop + list (the one excluding ANNOUNCE_OK's Hop ID), appended after the literal + hops. The hop base is independent of the path base, since the best path + match can arrive over a different relay path. +- PROTOCOL_VIOLATION: a base that was never assigned or is already retired, a + non-zero keep with base 0, a keep larger than the base has, or a result that + breaks the existing path and hop rules (`MAX_PARTS`, 32 hops, repeated + non-zero Hop ID, duplicate live route). +- ANNOUNCE_END and ANNOUNCE_OK are unchanged. + +### Implementation + +- Rust (`rs/moq-net/src/lite/`): the codec is stateless today, so decoding + moves beside `PrefixRun` (subscriber) and encoding beside `AnnounceRun` + (publisher), which already hold the live set by Announce ID. The encoder + picks bases from two ordered indexes over its live announcements, one keyed + by path segments and one by reversed hop chain, so the longest shared head + or tail is a neighbour lookup. +- JS (`js/net/src/lite/`): decode everything, encode literally (both bases 0). +- Fuzz: `fuzz.rs` round-trips single messages; add a stream-level harness that + feeds message sequences through the stateful decoder. +- Update the MoQ-lite draft's ANNOUNCE_START and ANNOUNCE_UPDATE sections and + its lite-07 changelog, plus `doc/concept/moq-lite.md` where it describes the + format. + +### Verification + +- Codec tests for each violation, base reuse after ENDs, keep of the full + path/chain, anonymous (0) hops, and a new stream starting empty. +- Rust encoder against JS decoder (and literal JS against Rust), plus + `just test interop --all`. +- A benchmark of encoded announce bytes and encode/decode CPU against lite-06 + literal framing, swept over live routes and churn: a health-shaped workload + (`/private/channel_N/stream-health-` from several origins sharing + relay tails) and an all-unique control, whose overhead is the four zero + base/keep bytes per START. An earlier + prototype with separate path and chain dictionaries saved 69% on a similar + workload; report what this shape achieves. + +## Required + +- moq-lite-07 ships as `moq-lite-07-wip`, off by default, so its wire can still change ([#4148](https://github.com/moq-dev/moq/pull/4148)) + +## Related + +- [lite-07 stream count](/quest/m1/lite-stream-count.md) - the other lite-07 + wire change, landing in the same unreleased version +- [Relay memory](/quest/m1/relay-memory.md) - route state per relay, which + this leaves unchanged diff --git a/quest/m2/README.md b/quest/m2/README.md index 1a0426d872..f7cc1a225c 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -61,7 +61,6 @@ upstream release waits in [m4](/quest/m4/README.md). - [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) - a redial starts at the previous connection's rate - [Keep-alive by deadline](/quest/m2/quic-keep-alive.md) - a PING only when the idle deadline nears, no fixed timer - [noq socket close](/quest/m2/noq-socket-close.md) - noq releases an endpoint's socket on close, so moq-tokio drops its wrapper -- [Bounded announce prefix table](/quest/m2/announce-prefix-table.md) - compress repeated path tuples on each ordered lite-08 announce stream, with bounded state and measured QUIC-byte savings - [Drop the hidden cluster exemption](/quest/m2/hidden-exemption.md) - relays stop forcing hidden broadcasts on cluster peers once every peer opts in on the wire - [Routing cost domains](/quest/m2/routing-cost-domains.md) - design operator boundaries and policy without adding incomparable costs - [Kernel pacing](/quest/m2/quic-kernel-pacing.md) - whether SO_TXTIME pacing beats a userspace pacer the io_uring driver ignores today diff --git a/quest/m2/announce-prefix-table.md b/quest/m2/announce-prefix-table.md deleted file mode 100644 index 564bbae493..0000000000 --- a/quest/m2/announce-prefix-table.md +++ /dev/null @@ -1,47 +0,0 @@ -# [L] Bounded announce prefix table - -## Goal - -MoQ-lite announcement starts can refer to repeated path-segment prefixes, -including a customer PID, using a bounded table scoped to one ordered Announce -Stream. Repeated-name traffic uses fewer actual network bytes without changing -the reconstructed path, route behavior, or the raw path length reported for -usage. The new encoding uses lite-08, while lite-07 and older versions remain -compatible. - -## Plan - -Extend the MoQ-lite draft and Rust encoder/decoder together. After the -ANNOUNCE_REQUEST and ANNOUNCE_OK exchange, each ordered Announce Stream owns an -initially empty prefix table. Define explicit insert/reference/literal forms -for `ANNOUNCE_START` path suffixes with whole-segment matching. Bound entries -and total bytes, specify deterministic eviction and stream reset, and allow a -literal when the table would not save bytes. Both peers process updates in -stream order, so an announcement never waits on a different stream's state. -Reject invalid references and lengths as protocol violations. Keep -`ANNOUNCE_END` and `ANNOUNCE_UPDATE` on their lite-06 IDs rather than re-sending -the path. - -Introduce `moq-lite-08` after lite-07. Keep lite-07 framing intact; do not -silently reinterpret an already negotiated stream. Prove mixed-version peers negotiate a common older version and that a new -stream after reconnect starts with an empty table. Exercise -literal fallback, repeated PID, nested tuple prefixes, table-full eviction, -malformed reference, duplicate route, and interleaved starts/ends in codec and -end-to-end relay tests. Update the MoQ-lite draft and any changed wire examples -in the same PR. - -Benchmark encoded control-message bytes and actual QUIC bytes with identical -announcement events and mesh topology, using a path sample shaped like the -health project: many unique timestamp suffixes under repeated -`/private/channel_.../stream-health-*` prefixes. Report the table hit -rate, network byte delta, CPU time, memory bound, and start/end counts. Compare -against lite-06 ID-based END alone so the table's incremental gain is clear. -Do not require customer clients to use the new version; the first adopter is -the internal moq.pro mesh after lite-06 has rolled out. - -## Related - -- [Relay memory](/quest/m1/relay-memory.md) - route state footprint, which - path encoding does not remove -- [PoP skipping](/quest/m1/pop-skipping/README.md) - the lite-06 rollout - work that must complete before moq.pro activates this encoding From 3b4ad5bf02ad9acc3e21a6391560390a7f797095 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 07:09:30 -0700 Subject: [PATCH 04/21] fix(net): hold a parked track's warm cache until the upstream confirms it (#4104) Co-authored-by: Claude Opus 5.5 --- doc/lib/c/index.md | 2 +- rs/moq-net/src/lite/subscriber.rs | 15 +- rs/moq-net/src/model/front.rs | 10 +- rs/moq-net/src/model/origin.rs | 370 ++++++++++++++++++++++++++++-- rs/moq-net/src/model/resume.rs | 114 ++++++++- rs/moq-net/src/model/track.rs | 57 ++++- rs/moq-tokio/tests/broadcast.rs | 240 +++++++++++++++++++ 7 files changed, 776 insertions(+), 32 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index b77ae8a4bd..7a3c5b08dd 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -40,7 +40,7 @@ and `target/include/moq.h`. - **Encoded video metadata.** `moq_video_init.hint` is a zero-initialized `moq_video_hint` with `has_*` flags for coded dimensions, bitrate (bits per second), frame rate, and latency preference. Hints seed a video codec track's catalog; detected dimensions take precedence. - **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`/`_delay_us`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. -- **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. +- **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge, so a cache linger never delays it. Only a relay keeps what it already delivered warm for 30 seconds, and a returning subscriber is served that cache only once the publisher confirms it is still current. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. - **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 387ba189b8..9013f381cc 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2777,8 +2777,13 @@ impl TrackServe { // The floor tracks the requested start until this subscription's own // SUBSCRIBE_START refines it: the peer never serves below the request, a // previous subscription's declaration must not outlive its demand, and - // live-edge demand (None) starts with no floor at all. - let _ = producer.start_at(subscription.start.map(|start| start.group)); + // live-edge demand (None) starts with no floor at all. Lite-06+ only resolves + // the start with its SUBSCRIBE_START, so until then the floor is just a request. + let floor = subscription.start.map(|start| start.group); + let _ = match self.subscriber.version.resolves_start() { + true => producer.request_start(floor), + false => producer.start_at(floor), + }; tracing::info!(id, broadcast = %self.subscriber.log_path(&self.path), track = %self.name, "subscribe started"); @@ -3200,6 +3205,12 @@ impl ServeLoop { // request's own fetch gate, and a cache-miss fetch queued while TRACK_INFO // was in flight would be drained as NotFound in the gap. let dynamic = request.dynamic(); + // Lite-06+ resolves each subscription's start from its budget, so the floor + // the SUBSCRIBE asks for is not where delivery begins until SUBSCRIBE_START says. + let request = match serve.subscriber.version.resolves_start() { + true => request.resolving_start(), + false => request, + }; let serving = request.accept(info); Self { serving, diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index c651082fb7..df6a582a65 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -105,7 +105,8 @@ pub(super) enum Action { /// Drop the source copy of `track` but keep the delivered groups spliced, /// so resume stays seamless while nobody reads. Park { track: Arc }, - /// Drop the delivered groups of `track` too: the linger expired unread. + /// Drop the source copy of `track` and every delivered group: the linger expired + /// unread, or the source is local and keeps its own cache. Release { track: Arc }, /// The logical track completed. Finish { track: Arc }, @@ -587,6 +588,13 @@ impl Front { }; track.used = false; match track.state { + // A local source keeps its own cache, so a warm copy would only be a staler + // duplicate of it: drop the copy outright, and a returning reader re-splices + // the source and reads its cache against the real live edge. + TrackState::Spliced { .. } if self.identity == Identity::Local => { + track.state = TrackState::Idle; + actions.push(Action::Release { track: name }); + } // Drop the copy so the source goes idle at once; the delivered // groups stay spliced for the linger. TrackState::Spliced { .. } => { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 8beaa32a75..2f4c0af840 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1,4 +1,4 @@ -use crate::{broadcast, cache, stats, track}; +use crate::{broadcast, cache, group, stats, track}; use kio::Pollable; use std::{ cmp::Reverse, @@ -1815,6 +1815,9 @@ const TRACK_IDLE_LINGER: Duration = Duration::from_secs(30); struct WarmCopy { track: track::Producer, _dynamic: track::Dynamic, + /// The newest group, where the copy spliced after this one picks up (see + /// [`TrackIo::head`]). + edge: Option, } impl Drop for WarmCopy { @@ -1823,25 +1826,142 @@ impl Drop for WarmCopy { } } -/// Cache `source`'s finished groups on a new local track the origin owns. -fn warm_copy(source: &track::Consumer) -> Option { +/// A warm copy's newest group, which the copy spliced after it continues. +/// +/// Kept past that splice: an open one must stay open for the continuation (dropping +/// an unfinished producer clears its frames), and either kind supplies the head the +/// continuation lacks when the next park rebuilds the group. Nothing will ever finish +/// an open one, so it aborts on drop. +struct WarmGroup(group::Producer); + +impl Drop for WarmGroup { + fn drop(&mut self) { + if !self.0.is_finished() { + let _ = self.0.clone().abort(Error::Cancel); + } + } +} + +/// Cache what `source` delivered on a new local track the origin owns: its complete +/// groups, and its open live edge rebuilt from the frames already delivered. +/// +/// `head` is the previous park's edge. A copy spliced after a warm cache continues its +/// edge group from the next frame, so its copy of that group lacks the head, which +/// `head` supplies. +fn warm_copy(source: &track::Consumer, head: Option<&WarmGroup>) -> Option { let info = source.cached_info()?; let mut track = track::Producer::new(Arc::new(source.broadcast().clone()), source.name(), info); - for (group, visible) in source.cached_groups() { - // An open group is left for the re-splice to deliver whole. Dropping the source - // copy resets it mid-transfer, and its dead head would anchor the next takeover - // mid-group, asking upstream for a tail no returning reader can use. - if group.is_finished() { - let _ = track.adopt_group(group, visible); + let head = head.map(|head| &head.0); + let groups = source.cached_groups(); + // Not `source.latest()`: datagrams share the sequence counter and can run past it. + let latest = groups + .iter() + .filter(|(_, visible)| *visible) + .map(|(group, _)| group.sequence) + .max(); + let mut edge = None; + + // A spliced copy hides the group it continued (its halves sit in two segments), so + // carry the previous edge over when the copy has no version of it at all. First, + // since it arrived before anything the copy holds. + if let Some(head) = head + && !groups.iter().any(|(group, _)| group.sequence == head.sequence) + { + let is_latest = latest.is_none_or(|latest| head.sequence >= latest); + if head.is_finished() { + let _ = track.adopt_group(head.clone(), true); + if is_latest { + edge = Some(WarmGroup(head.clone())); + } + } else if is_latest { + edge = warm_rebuild(&track, head, None); + } + } + + for (group, visible) in groups { + let finished = group.is_finished(); + let whole = group.live_first_frame() == Some(0); + let is_latest = visible && Some(group.sequence) == latest; + let warm = if finished && whole { + let _ = track.adopt_group(group.clone(), visible); + Some(WarmGroup(group)) + } else if (finished || is_latest) && (whole || head.is_some_and(|head| head.sequence == group.sequence)) { + // Dropping the source copy resets an open live edge mid-transfer, so rebuild + // it from the frames already delivered: the re-splice asks for the next + // frame, and a group that stays open for good (a JSON log in group 0) + // continues instead of being re-sent whole on every resume. A continuation + // is rebuilt whole from the previous edge's head the same way. + warm_rebuild(&track, &group, head) + } else { + // Mid-transfer backlog, or a continuation with no head to complete it. + None + }; + if is_latest { + edge = warm; } } let dynamic = track.dynamic(); Some(WarmCopy { track, _dynamic: dynamic, + edge, }) } +/// Rebuild `live` on `track` from its delivered frames, prefixed by `head`'s when `live` +/// only holds a continuation of it. Finished like `live`, or left open. +fn warm_rebuild(track: &track::Producer, live: &group::Producer, head: Option<&group::Producer>) -> Option { + // A continuation holds nothing below its offset. + let mut start = live.live_first_frame()? as u64; + let mut tail = live.consume(); + tail.start_at(start); + let mut frames = Vec::new(); + if start > 0 + && let Some(head) = head.filter(|head| head.sequence == live.sequence) + && let Some(head_start) = head.live_first_frame() + { + let mut head = head.consume(); + head.start_at(head_start as u64); + while head.index() < start { + match head.poll_read_frame(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(frame))) => frames.push(frame), + _ => break, + } + } + match head.index() == start { + true => start = head_start as u64, + // The head doesn't reach the continuation: keep only the continuation. + false => frames.clear(), + } + } + while let Poll::Ready(Ok(Some(frame))) = tail.poll_read_frame(&kio::Waiter::noop()) { + frames.push(frame); + } + if frames.is_empty() { + return None; + } + + // Wrapped first, so a failed write aborts it rather than dropping it unfinished. + let rebuilt = WarmGroup( + track + .create_group(group::Info { + sequence: live.sequence, + }) + .ok()?, + ); + let mut writer = rebuilt.0.clone(); + if start > 0 { + writer.start_at(start).ok()?; + } + for frame in frames { + writer.write_frame(frame.timestamp, frame.payload).ok()?; + } + if live.is_finished() { + writer.finish().ok()?; + } + Some(rebuilt) +} + /// Everything [`run_front`] owns, queued by [`Consumer::request_broadcast`]. struct FrontTask { /// The route table the front selects from. @@ -1879,6 +1999,9 @@ struct TrackIo { /// Delivered groups kept after the copy was dropped, so resume stays spliced /// through the linger without pinning the source as a reader. warm: Option, + /// The last warm copy's newest group, outliving it so the copy spliced after it + /// can continue the group (see [`WarmGroup`]). Released at the next park. + head: Option, /// Whether the track had a reader as of the last demand edge. used: bool, } @@ -2106,7 +2229,7 @@ async fn run_front(task: FrontTask) { tracks.remove(&name); continue; } - io.warm = None; + io.head = io.warm.take().and_then(|mut warm| warm.edge.take()); // The new segment has produced nothing yet: this is the // edge the copy is asked to advance. io.edge = io.resume.resume_position(); @@ -2118,24 +2241,25 @@ async fn run_front(task: FrontTask) { // Drop the source copy so its producer goes idle at once; keep // the groups it delivered on a local track so resume stays // spliced until the linger expires. - let warm = warm_copy(©); + let warm = warm_copy(©, io.head.as_ref()); drop(copy); - if io.resume.release().is_err() { + io.head = None; + let parked = match &warm { + Some(warm) => io.resume.park(&warm.track), + None => io.resume.release(), + }; + if parked.is_err() { tracks.remove(&name); continue; } - if let Some(warm) = warm { - if let Err(err) = io.resume.takeover(&warm.track) { - let _ = io.resume.abort(err); - tracks.remove(&name); - continue; - } - io.warm = Some(warm); - } + io.warm = warm; } Action::Release { track: name } => { let Some(io) = tracks.get_mut(&name) else { continue }; + // A local source releases straight from the spliced copy. + io.copy = None; io.warm = None; + io.head = None; if io.resume.release().is_err() { tracks.remove(&name); } @@ -2252,6 +2376,7 @@ async fn run_front(task: FrontTask) { copy: None, edge: None, warm: None, + head: None, used: false, }, ); @@ -4615,6 +4740,211 @@ mod tests { assert!(matches!(subscription.recv_group().await, Ok(None)), "ends cleanly"); } + /// A front resolved through a served route, as a relay's upstream session serves + /// one: the upstream broadcast's track requests arrive on the returned handle. + async fn served_front() -> (Dynamic, broadcast::Producer, broadcast::Dynamic, broadcast::Consumer) { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let server = producer + .dynamic("room/alice", Route::default().with_hops(hops(&[10]))) + .unwrap(); + let pending = consumer.request_broadcast("room/alice"); + let upstream = broadcast::Info::new().produce(); + let dynamic = upstream.dynamic(); + queued(&server).await.accept(&upstream); + let resolved = pending.await.expect("resolves"); + (server, upstream, dynamic, resolved) + } + + /// A reader returning to a parked track waits for the fresh copy to resolve its + /// start, and skips the warm cache when the copy resolves past it: the source + /// judged the groups in between stale, so the older cache is stale too. Without the + /// hold the reader was handed the whole warm cache first, seconds behind live. + #[tokio::test] + async fn returning_reader_skips_a_warm_cache_the_copy_resolved_past() { + let ms = |v: u64| crate::Timestamp::from_millis(v).unwrap(); + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + let budget = track::Subscription::default().with_max_age(Duration::from_millis(100)); + + let track = resolved.track("audio").unwrap(); + let b = budget.clone(); + let subscribing = tokio::spawn(async move { track.subscribe(b).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source") + .expect("request"); + let source = request.resolving_start().accept(None); + for seq in 0..4u64 { + let mut group = source.create_group(seq.into()).unwrap(); + group.write_frame(ms(seq * 20), b"old".as_ref()).unwrap(); + group.finish().unwrap(); + } + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + subscription.recv_group().await.unwrap().expect("the live group"); + drop(subscription); + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(source); + + let track = resolved.track("audio").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(budget).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source again") + .expect("request"); + let mut source = request.resolving_start().accept(None); + let mut subscription = subscribing.await.unwrap().expect("resubscribe"); + + // The copy has not resolved its start: nothing is handed out yet. + assert!( + tokio::time::timeout(Duration::from_millis(50), subscription.recv_group()) + .await + .is_err(), + "the warm cache was served before the copy resolved its start" + ); + + // The source resolves past the floor (lite-06 skipped 4..20 as stale). + source.start_at(20).unwrap(); + let mut group = source.create_group(20u64.into()).unwrap(); + group.write_frame(ms(2000), b"new".as_ref()).unwrap(); + group.finish().unwrap(); + let group = subscription.recv_group().await.unwrap().expect("the live group"); + assert_eq!(group.sequence, 20, "a stale warm group was served"); + } + + /// A warm cache whose newest group finished still resumes when the source has + /// nothing newer: the re-splice asks for that group's tail, which a source that + /// resolves starts lazily (with its first served group) can answer at once. Asking + /// past it left a returning catalog reader waiting for the next catalog change. + #[tokio::test] + async fn returning_reader_replays_a_current_warm_cache() { + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + + let track = resolved.track("catalog").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(None).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source") + .expect("request"); + let source = request.resolving_start().accept(None); + let mut group = source.create_group(0u64.into()).unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"snapshot".as_ref()).unwrap(); + group.finish().unwrap(); + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + subscription.recv_group().await.unwrap().expect("the catalog"); + drop(subscription); + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(source); + + let track = resolved.track("catalog").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(None).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source again") + .expect("request"); + let mut source = request.resolving_start().accept(None); + let mut subscription = subscribing.await.unwrap().expect("resubscribe"); + + // The source still has group 0 as its newest: it serves the empty tail, and + // that is when its start resolves. + let reading = tokio::spawn(async move { + let mut group = subscription.recv_group().await.unwrap().expect("the catalog"); + assert_eq!(group.sequence, 0); + group.read_frame().await.unwrap().expect("the snapshot").payload + }); + tokio::task::yield_now().await; + assert_eq!( + source.subscription().and_then(|sub| sub.start), + Some(track::Position { group: 0, frame: 1 }), + "the re-splice asked past the cached catalog" + ); + source.start_at(0).unwrap(); + let mut tail = source.create_group(0u64.into()).unwrap(); + tail.start_at(1).unwrap(); + tail.finish().unwrap(); + let payload = tokio::time::timeout(Duration::from_secs(1), reading) + .await + .expect("the returning reader never got the catalog") + .unwrap(); + assert_eq!(&payload[..], b"snapshot"); + } + + /// A group that stays open for good (a JSON log in group 0) survives a park: the + /// returning reader gets the frames delivered before it from the warm cache, and the + /// re-splice asks the source only for the frames after them, across repeated parks. + /// A datagram sequenced past the group does not hide it as the live edge. + #[tokio::test] + async fn returning_reader_continues_an_open_warm_group() { + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + + async fn read(group: &mut group::Consumer) -> Vec { + let frame = tokio::time::timeout(Duration::from_secs(1), group.read_frame()) + .await + .expect("frame") + .unwrap() + .expect("group ended"); + frame.payload.to_vec() + } + + let mut expect: Vec<&[u8]> = Vec::new(); + let mut floor: Option = None; + for (round, payload) in [b"a".as_ref(), b"b", b"c"].into_iter().enumerate() { + let track = resolved.track("log").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(None).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source") + .expect("request"); + let mut source = request.resolving_start().accept(None); + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + + // The source resolves at the floor's group, continuing group 0. + source.start_at(0).unwrap(); + let mut group = source.create_group(0u64.into()).unwrap(); + if let Some(floor) = floor { + group.start_at(floor.frame).unwrap(); + } + group.write_frame(crate::Timestamp::ZERO, payload).unwrap(); + expect.push(payload); + source + .insert_datagram(10, crate::Timestamp::ZERO, b"datagram".as_ref()) + .unwrap(); + + let mut reading = tokio::time::timeout(Duration::from_secs(1), subscription.recv_group()) + .await + .expect("group 0") + .unwrap() + .expect("track ended"); + assert_eq!(reading.sequence, 0); + for frame in &expect { + assert_eq!(read(&mut reading).await, *frame, "round {round}"); + } + assert_eq!( + source.subscription().and_then(|sub| sub.start), + floor, + "round {round} asked for the wrong continuation" + ); + + drop(reading); + drop(subscription); + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(group); + drop(source); + floor = Some(track::Position { + group: 0, + frame: expect.len() as u64, + }); + } + } + /// The same holds for a reader returning to a parked track: its warm cache /// does not stand in for the copy it is waiting on. #[tokio::test] diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index da250e34be..2fa3d0c048 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -43,6 +43,12 @@ struct Segment { end: Option, /// The underlying per-session track. track: track::Consumer, + /// Where the source is asked to start: `start`, except after a warm cache (see + /// [`Segment::warm_edge`]). + ask: Option, + /// A parked track's warm cache (see [`Producer::park`]), which live readers hold + /// until the next segment's copy resolves its start. + warm: bool, } impl Segment { @@ -72,6 +78,23 @@ impl Segment { }) } + /// Where a copy spliced after this warm cache is asked to start: the end of the + /// cache's newest group, even a finished one, rather than the head of the next. + /// + /// A source only resolves a start once it has a group to serve, so asking past its + /// newest group would leave a returning reader waiting on the next one. Asking for the + /// newest group's tail lets a source that is still there answer at once, and one that + /// moved on answer past it. Only the ask moves: the boundary stays where the cache + /// stops, so whatever the source sends for a finished group's empty tail (a FIN, or a + /// reset from a publisher that refuses empty ranges) sits outside the new segment. + fn warm_edge(&self) -> Option { + let group = self.track.peek_latest()?; + Some(Position { + group: group.sequence, + frame: group.frame_count() as u64, + }) + } + /// The newest cached group this segment serves below `before` (exclusive), or its /// newest cached group at all when `before` is `None`. /// @@ -234,6 +257,8 @@ impl ResumeState { start, end: None, track, + ask: start, + warm: false, }); self.epoch += 1; self.prune(); @@ -339,11 +364,23 @@ impl Producer { // segments at all) there is nothing to splice around, so the replacement // replaces them outright and starts unbounded, exactly like a first splice. // `switch` rejects a `None` start once a segment exists, hence the clear. + let ask = state + .segments + .last() + .filter(|last| last.warm) + .and_then(Segment::warm_edge); let start = state.resume_position(); if start.is_none() { state.segments.clear(); } - state.switch(track, start) + state.switch(track, start)?; + if let Some(ask) = ask + && start.is_some() + && let Some(last) = state.segments.last_mut() + { + last.ask = Some(ask); + } + Ok(()) } /// Drop every segment, releasing the underlying tracks while keeping the @@ -370,6 +407,30 @@ impl Producer { Ok(()) } + /// Replace every segment with `warm`, a cache of what they delivered, for a track + /// nobody reads anymore: [`Self::release`] followed by an unbounded first splice. + /// + /// Live readers hold the cache until the next [`Self::takeover`]'s copy resolves + /// where its feed starts. A copy that picks up at the cache's edge proves the cache + /// still leads into the live feed, so it is read as usual (and a group the cache + /// holds open continues from its next frame). A copy that starts past the edge + /// skipped groups its source already judged stale, so the older cache is stale too + /// and live readers skip it. + pub(crate) fn park(&mut self, warm: impl super::origin_impl::Consume) -> Result<()> { + let track = warm.consume(); + let mut state = self.state.write().map_err(|_| Error::Dropped)?; + if state.finished || state.abort.is_some() { + return Err(Error::Closed); + } + state.segments.clear(); + state.pruned = None; + state.switch(track, None)?; + if let Some(segment) = state.segments.last_mut() { + segment.warm = true; + } + Ok(()) + } + /// Whether any segment is spliced in, and so whether there is anything for /// [`Self::release`] to drop. /// @@ -1250,6 +1311,8 @@ struct SegmentSub { id: u64, start: Option, end: Option, + /// Where the source is asked to start; see [`Segment::ask`]. + ask: Option, sub: SubState, /// A completed segment's cursor, retained while parked groups may need their /// max age budget re-evaluated after the outer cap rises. @@ -1264,6 +1327,16 @@ struct SegmentSub { /// the lowest is re-offered first; holding them here (rather than blocking on /// the first) keeps in-range groups that arrive behind a capped one flowing. parked: BTreeMap, + /// Set while this is a warm segment (see [`Producer::park`]) that has not been + /// cleared for live reads: the copy spliced after it, once there is one. + warm: Option, +} + +/// A warm segment waiting on the copy spliced after it; see [`Subscriber::poll_activate`]. +struct Warm { + /// The cache's newest group. + edge: Option, + next: Option, } impl SegmentSub { @@ -1432,7 +1505,7 @@ impl Subscriber { }; self.last_prefs = prefs; for seg in &mut self.segments { - let prefs = slice(&self.last_prefs, seg.start, seg.end); + let prefs = slice(&self.last_prefs, seg.ask, seg.end); if let Some(sub) = seg.stale_sub_mut() { let _ = sub.update(prefs); } @@ -1499,9 +1572,14 @@ impl Subscriber { self.segments.retain(|s| !s.retired()); let anchor = self.anchor_end(); - for segment in segments { + let nexts: Vec<_> = segments.iter().skip(1).map(|next| Some(next.track.clone())).collect(); + let nexts = nexts.into_iter().chain(std::iter::once(None)); + for (segment, next) in segments.into_iter().zip(nexts) { match self.segments.iter_mut().find(|s| s.id == segment.id) { Some(existing) => { + if let Some(warm) = &mut existing.warm { + warm.next = next; + } if existing.end != segment.end { existing.end = segment.end; let cap = Self::stale_cap(existing, anchor); @@ -1512,7 +1590,7 @@ impl Subscriber { // read bounds stay on this subscriber (see `poll_recv_group`): // an inner `end_at` would park boundary-crossing groups in the // inner cursor, hiding the segment's completion. - let _ = sub.update(slice(&self.last_prefs, segment.start, segment.end)); + let _ = sub.update(slice(&self.last_prefs, segment.ask, segment.end)); } // A still-pending subscription picks the moved boundary up // when it activates (see `poll_activate`). Groups already handed @@ -1523,15 +1601,20 @@ impl Subscriber { None => { let sub = segment .track - .subscribe(slice(&self.last_prefs, segment.start, segment.end)); + .subscribe(slice(&self.last_prefs, segment.ask, segment.end)); self.segments.push(SegmentSub { id: segment.id, start: segment.start, end: segment.end, + ask: segment.ask, sub: SubState::Pending(sub), terminal: None, pruned: false, parked: BTreeMap::new(), + warm: segment.warm.then(|| Warm { + edge: segment.track.latest(), + next, + }), }); } } @@ -1657,6 +1740,25 @@ impl Subscriber { anchor_end: Option, waiter: &kio::Waiter, ) -> Poll<()> { + if matches!(seg.sub, SubState::Pending(_)) + && let Some(warm) = &seg.warm + { + // Nothing spliced after the cache yet, so nothing says it still leads into + // the live feed. A splice bumps the epoch, which wakes this waiter. + let Some(next) = &warm.next else { + return Poll::Pending; + }; + let start = ready!(next.poll_start(waiter)); + let edge = warm.edge; + seg.warm = None; + // The copy was asked for the cache's newest group and started past it: its + // source judged that group stale, so the older cache is no use to live reads. + if start.is_some_and(|start| edge.is_some_and(|edge| start > edge)) { + seg.complete(None); + return Poll::Ready(()); + } + } + if let SubState::Pending(pending) = &mut seg.sub { match ready!(pending.poll_ok(waiter)) { Ok(mut sub) => { @@ -1669,7 +1771,7 @@ impl Subscriber { // budget and floor, and this must not rewind past it. sub.raise_start_to(seg.first_group().max(min_sequence)); sub.set_stale_cap(Self::stale_cap(seg, anchor_end)); - let _ = sub.update(slice(prefs, seg.start, seg.end)); + let _ = sub.update(slice(prefs, seg.ask, seg.end)); seg.sub = SubState::Active(Box::new(sub)); } // The underlying track was rejected or closed: stall, not error. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 531a71db12..17bec9149b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -223,6 +223,12 @@ pub(crate) struct TrackState { // fetch can still create them. start_sequence: Option, + // Whether `start_sequence` is only the floor a subscription asked for, still + // waiting on the serving session to resolve where the live feed begins (a + // lite-06+ SUBSCRIBE_START). Readers that must know the resolved start (see + // [`Consumer::poll_start`]) wait on it; everything else treats the floor as usual. + start_pending: bool, + // Where production stopped, snapshotted when the cached groups are released (an // abort, or the last producer dropping). Computed live from the cache otherwise; // see [`Self::resume_position`]. @@ -986,8 +992,9 @@ impl TrackState { /// declaration: the signal is scoped to the current subscription's demand, /// which may legitimately move in either direction. `None` clears it (the /// demand dropped to the live edge, whose floor is unknown until declared). - fn set_start(&mut self, start_sequence: Option) { + fn set_start(&mut self, start_sequence: Option, pending: bool) { self.start_sequence = start_sequence; + self.start_pending = pending; } /// Record the exclusive final sequence, rejecting a re-finish or a boundary that @@ -1381,7 +1388,16 @@ impl Producer { /// still promised. Pass `None` to clear it, for demand at the live edge: /// its floor is unknown until the feed declares one. pub fn start_at(&mut self, sequence: impl Into>) -> Result<()> { - self.modify()?.set_start(sequence.into()); + self.modify()?.set_start(sequence.into(), false); + Ok(()) + } + + /// Declare the floor a subscription asked for while the serving session has yet to + /// resolve its start: nothing below `sequence` arrives, exactly as [`Self::start_at`], + /// but [`Consumer::poll_start`] keeps waiting until a later [`Self::start_at`] + /// resolves it. + pub(crate) fn request_start(&mut self, sequence: Option) -> Result<()> { + self.modify()?.set_start(sequence, true); Ok(()) } @@ -2318,6 +2334,28 @@ impl Consumer { }) } + /// Poll for the first group the live feed serves, once the serving session has + /// resolved it: `Some` for a declared start, `None` for none (the live edge, or a + /// source that never declares one). Parks while a lite-06+ session still owes its + /// SUBSCRIBE_START (see [`Producer::request_start`]); a closed track is ready with + /// whatever it last declared, since nothing will resolve it anymore. + pub(crate) fn poll_start(&self, waiter: &kio::Waiter) -> Poll> { + match &self.inner { + ConsumerKind::Plain(state) => { + let res = state.poll(waiter, |state| match state.start_pending && state.abort.is_none() { + true => Poll::Pending, + false => Poll::Ready(state.start_sequence), + }); + match res { + Poll::Ready(Ok(start)) => Poll::Ready(start), + Poll::Ready(Err(state)) => Poll::Ready(state.start_sequence), + Poll::Pending => Poll::Pending, + } + } + ConsumerKind::Spliced(_) => Poll::Ready(None), + } + } + /// The newest group, when it is already cached: resolved synchronously, without /// counting as a fetch or a delivery. The IETF publisher snapshots its frame count to /// resolve Largest Object; a group that is not immediately available reads as no edge. @@ -3908,6 +3946,10 @@ pub struct Request { // Ingress stats scope, threaded into the accepted [`Producer`]. Empty (no-op) // unless this request was reserved on a tagged broadcast. stats: stats::Scope, + + // The serving session resolves the start of each subscription itself, so the + // accepted track's start is unknown until it says (see [`Self::resolving_start`]). + resolving_start: bool, } impl Request { @@ -3924,9 +3966,19 @@ impl Request { alive, _dynamic: dynamic, stats: stats::Scope::default(), + resolving_start: false, } } + /// Mark the track as served by a session that resolves each subscription's start + /// (lite-06+), so [`Consumer::poll_start`] waits for its declaration instead of + /// reading the requested floor as the start. Applied atomically with + /// [`Self::accept`], before any reader can see the track. + pub(crate) fn resolving_start(mut self) -> Self { + self.resolving_start = true; + self + } + /// Attach an ingress stats scope, applied to the [`Producer`] on accept. Set by /// a tagged [`broadcast::Producer::reserve_track`]. pub(crate) fn with_stats(mut self, scope: stats::Scope) -> Self { @@ -3991,6 +4043,7 @@ impl Request { // tolerate it: the Producer we hand back simply can't write. if let Ok(mut state) = self.state.write() { state.accept(info.clone()); + state.start_pending = self.resolving_start; } // Accepting the request creates the track producer: count it as one ingress // subscription (closed when the last handle drops). No-op when untagged. diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 5b7f244665..4c2953a72d 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -994,6 +994,246 @@ async fn broadcast_route_migration() { handle_b.await.expect("server b panicked").expect("server b failed"); } +/// A subscriber returning to a parked track is not handed the parked cache when the +/// upstream resolves its start past it (lite-06+ resolves the start from the budget). +/// +/// The front keeps what an unread track delivered as a warm cache. While parked, the +/// publisher moved on, so the resumed upstream subscription starts well past that cache. +/// The cache was only fresh against its own frozen edge; serving it first put a +/// rejoining player seconds behind live. +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_rejoin_skips_a_stale_warm_cache() { + use moq_net::Timestamp; + + let ms = |ms: u64| Timestamp::from_millis(ms).unwrap(); + let write = |track: &moq_net::track::Producer, sequence: u64, at: u64| { + let mut group = track + .create_group(moq_net::group::Info { sequence }) + .expect("create group"); + group.write_frame(ms(at), b"frame".as_ref()).expect("write frame"); + group.finish().expect("finish group"); + }; + + let pub_origin = moq_tokio::origin::spawn(); + let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("audio", None).expect("create track"); + let live = track.clone(); + for sequence in 0..4u64 { + write(&track, sequence, sequence * 20); + } + + let mut config = moq_tokio::listen::Config::default(); + config.bind = Some("[::]:0".parse().unwrap()); + config.tls.generate = vec!["localhost".into()]; + let mut server = config + .init(Default::default()) + .expect("init server") + .listen() + .await + .expect("listen"); + let addr = server.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let request = server.accept().await.expect("accept"); + let session = request.with_publisher(&pub_origin).ok().await?; + let _broadcast = broadcast; + let _track = track; + let _ = session.closed().await; + Ok::<_, anyhow::Error>(()) + }); + + let sub_origin = moq_tokio::origin::spawn(); + let sub_consumer = sub_origin.consume(); + let mut announcements = sub_consumer.announced(); + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + let client = config.init(Default::default()).expect("init client"); + let url: url::Url = format!("moqt://localhost:{}", addr.port()).parse().unwrap(); + let (_client, session) = tokio::time::timeout(TIMEOUT, connect_once(client.with_subscriber(sub_origin), url)) + .await + .expect("connect timeout") + .expect("connect failed"); + + assert!(next_announce(&mut announcements).await.kind.is_active()); + let remote = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) + .await + .expect("request timeout") + .expect("broadcast resolves"); + let budget = moq_net::track::Subscription::default().with_max_age(Duration::from_millis(100)); + async fn recv(sub: &mut moq_net::track::Subscriber) -> u64 { + tokio::time::timeout(TIMEOUT, sub.recv_group()) + .await + .expect("recv timeout") + .expect("recv failed") + .expect("track ended") + .sequence + } + + let mut sub = remote + .track("audio") + .unwrap() + .subscribe(budget.clone()) + .await + .expect("subscribe"); + recv(&mut sub).await; + drop(sub); + + // The front parks the track and cancels upstream, while the publisher moves on. + tokio::time::timeout(TIMEOUT, live.unused()) + .await + .expect("upstream never canceled") + .expect("track open"); + for sequence in 4..=20u64 { + write(&live, sequence, 10_000 + (sequence - 4) * 20); + } + + let mut sub = remote + .track("audio") + .unwrap() + .subscribe(budget) + .await + .expect("resubscribe"); + let first = recv(&mut sub).await; + assert!( + first > 4, + "a rejoining reader was served the stale cache first: group {first}" + ); + let mut sequence = first; + while sequence < 20 { + sequence = recv(&mut sub).await; + assert!(sequence >= 4, "a rejoining reader was served stale group {sequence}"); + } + + drop(sub); + drop(session); + server.await.expect("server panicked").expect("server failed"); +} + +/// A subscriber returning to a parked track whose newest group is still current gets it +/// back, then the live feed resumes: the re-splice asks for that group's tail, which the +/// publisher answers at once. Asking past it would wait for a group that a quiet track (a +/// catalog) may never send. Covers a finished newest group (a catalog) and one that stays +/// open (a JSON log appending frames to group 0), on every version. +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_rejoin_replays_a_current_warm_cache() { + for version in moq_net::Version::names() { + for open in [false, true] { + rejoin_replays_a_current_warm_cache(version, open).await; + } + } +} + +async fn rejoin_replays_a_current_warm_cache(version: &str, open: bool) { + let pub_origin = moq_tokio::origin::spawn(); + let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("catalog.json", None).expect("create track"); + let live = track.clone(); + let mut group = live.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, b"v0".as_ref()) + .expect("write frame"); + if !open { + group.finish().expect("finish group"); + } + + let mut config = moq_tokio::listen::Config::default(); + config.bind = Some("[::]:0".parse().unwrap()); + config.tls.generate = vec!["localhost".into()]; + config.version = vec![version.parse().unwrap()]; + let mut server = config + .init(Default::default()) + .expect("init server") + .listen() + .await + .expect("listen"); + let addr = server.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let request = server.accept().await.expect("accept"); + let session = request.with_publisher(&pub_origin).ok().await?; + let _broadcast = broadcast; + let _track = track; + let _ = session.closed().await; + Ok::<_, anyhow::Error>(()) + }); + + let sub_origin = moq_tokio::origin::spawn(); + let sub_consumer = sub_origin.consume(); + let mut announcements = sub_consumer.announced(); + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.version = vec![version.parse().unwrap()]; + let client = config.init(Default::default()).expect("init client"); + let url: url::Url = format!("moqt://localhost:{}", addr.port()).parse().unwrap(); + let (_client, session) = tokio::time::timeout(TIMEOUT, connect_once(client.with_subscriber(sub_origin), url)) + .await + .expect("connect timeout") + .expect("connect failed"); + + assert!(next_announce(&mut announcements).await.kind.is_active()); + let remote = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) + .await + .expect("request timeout") + .expect("broadcast resolves"); + + async fn recv(sub: &mut moq_net::track::Subscriber, ctx: &str) -> moq_net::group::Consumer { + tokio::time::timeout(TIMEOUT, sub.recv_group()) + .await + .unwrap_or_else(|_| panic!("{ctx}: recv_group timeout")) + .expect("recv_group failed") + .expect("track closed") + } + async fn read(group: &mut moq_net::group::Consumer, ctx: &str) -> String { + let frame = tokio::time::timeout(TIMEOUT, group.read_frame()) + .await + .unwrap_or_else(|_| panic!("{ctx}: read_frame timeout")) + .expect("read_frame failed") + .expect("group ended"); + String::from_utf8(frame.payload.to_vec()).unwrap() + } + + for round in 1..=3 { + let ctx = format!("{version} open={open} round {round}"); + let update = format!("v{round}"); + let mut sub = remote + .track("catalog.json") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + let mut reading = recv(&mut sub, &ctx).await; + if open { + for frame in 0..round { + assert_eq!(read(&mut reading, &ctx).await, format!("v{frame}"), "{ctx}"); + } + group + .write_frame(moq_net::Timestamp::ZERO, update.as_bytes()) + .expect("write frame"); + } else { + assert_eq!(read(&mut reading, &ctx).await, format!("v{}", round - 1), "{ctx}"); + let mut next = live.append_group().expect("append group"); + next.write_frame(moq_net::Timestamp::ZERO, update.as_bytes()) + .expect("write frame"); + next.finish().expect("finish group"); + reading = recv(&mut sub, &ctx).await; + } + // The live feed resumed behind the replayed cache. + assert_eq!(read(&mut reading, &ctx).await, update, "{ctx}"); + drop(reading); + drop(sub); + // The front parks the track and cancels upstream before the next round rejoins. + tokio::time::timeout(TIMEOUT, live.unused()) + .await + .unwrap_or_else(|_| panic!("{ctx}: upstream never canceled")) + .expect("track open"); + } + + drop(session); + server.await.expect("server panicked").expect("server failed"); +} + /// A publisher-side route update re-advertises downstream as a restart. /// /// The publisher re-prices its announced route with a longer chain; the From 0848a8afaff0508a3e0970b61c307fc6e7326041 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 07:13:47 -0700 Subject: [PATCH 05/21] chore(quest): drop Required bullets that have cleared (#4109) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- quest/m1/binding-surface.md | 2 -- quest/m1/fetch-missing-group.md | 4 ---- quest/m1/loc-duration-marker.md | 17 ++++++----------- quest/m1/play-harness.md | 4 ---- 4 files changed, 6 insertions(+), 21 deletions(-) diff --git a/quest/m1/binding-surface.md b/quest/m1/binding-surface.md index db6b94268c..87e3937bba 100644 --- a/quest/m1/binding-surface.md +++ b/quest/m1/binding-surface.md @@ -23,8 +23,6 @@ fallback settings. moq.pro's Python sidecar reads route sources from here. ## Required - Native decode delay (#3967) has merged -- Route source (#3972) has merged -- moq-ffi WebSocket fallback settings (#3961) have merged ## Related diff --git a/quest/m1/fetch-missing-group.md b/quest/m1/fetch-missing-group.md index ae1002961e..e144de69aa 100644 --- a/quest/m1/fetch-missing-group.md +++ b/quest/m1/fetch-missing-group.md @@ -16,7 +16,3 @@ cut-off body. call; propose it in the PR. - Test: a missing group is a 404 over HTTP and a non-zero `moq fetch` exit with no stdout; an existing group is byte-identical to today. - -## Required - -- `moq fetch` (#3965) has merged diff --git a/quest/m1/loc-duration-marker.md b/quest/m1/loc-duration-marker.md index 5d0f71e01b..1151d24487 100644 --- a/quest/m1/loc-duration-marker.md +++ b/quest/m1/loc-duration-marker.md @@ -3,18 +3,13 @@ ## Goal LOC video groups end with the empty frame that closes their last frame's -duration, the same contract the legacy container carries, once every released -LOC video consumer skips it. +duration, the same contract the legacy container carries. ## Plan The consumer-side skip has landed in `rs/moq-mux/src/container/loc` and -`js/loc`; endpoint recognition is configured for media tracks so empty data -frames remain data. LOC producers are still silent, because a released LOC consumer -submits an empty payload to the decoder. When the bullet below clears, have -the LOC producers write the marker at `cut` and `finish` exactly as the -legacy producer does (`Container::finish_group` on `loc::Wire(Kind::Video)`), and extend the same tests. - -## Required - -- A release of `moq-mux` and `@moq/loc` whose video consumers skip an empty LOC payload has shipped +`js/loc`, and shipped in `moq-mux` 0.10.3 and `@moq/loc` 0.2.3. Endpoint +recognition is configured for media tracks, so empty data frames remain data. +Have the LOC producers write the marker at `cut` and `finish` exactly as the +legacy producer does (`Container::finish_group` on `loc::Wire(Kind::Video)`), +and extend the same tests. diff --git a/quest/m1/play-harness.md b/quest/m1/play-harness.md index 2efdd2c7bb..9bcfb1c862 100644 --- a/quest/m1/play-harness.md +++ b/quest/m1/play-harness.md @@ -19,7 +19,3 @@ feature, which CI only compiles in the nightly clippy run, so its regressions The tune-in burst test moves onto the harness with [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md), which owns that fix. - -## Required - -- The rendition-switch gap fix (#3966) has merged From 3590e448940967a2ba570b4b27d10d733288e9dc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 07:16:17 -0700 Subject: [PATCH 06/21] fix(net): deliver a track's tail up to its declared end (#4086) Co-authored-by: Claude Opus 5.5 --- doc/lib/js/net.md | 1 + js/net/src/ietf/adapter.ts | 6 +- js/net/src/ietf/object.ts | 23 +++- js/net/src/ietf/publish.ts | 40 +++++- js/net/src/ietf/publisher.test.ts | 76 ++++++++++- js/net/src/ietf/publisher.ts | 105 ++++++++++++-- js/net/src/ietf/subscriber.ts | 163 +++++++++++++++++----- js/net/src/ietf/tail.test.ts | 177 ++++++++++++++++++++++++ js/net/src/lite/publisher.test.ts | 13 +- js/net/src/lite/publisher.ts | 28 +++- js/net/src/lite/subscriber.ts | 118 ++++++++++++---- js/net/src/lite/tail.test.ts | 219 ++++++++++++++++++++++++++++++ js/net/src/tail.test.ts | 105 ++++++++++++++ js/net/src/tail.ts | 125 +++++++++++++++++ js/net/src/track.test.ts | 66 +++++++++ js/net/src/track.ts | 123 +++++++++++++---- quest/m1/README.md | 1 - quest/m1/js-track-tail.md | 111 --------------- quest/m1/quic/reliable-reset.md | 4 +- quest/m1/rust-track-tail.md | 30 ++-- quest/m1/session-death-error.md | 3 +- 21 files changed, 1288 insertions(+), 249 deletions(-) create mode 100644 js/net/src/ietf/tail.test.ts create mode 100644 js/net/src/lite/tail.test.ts create mode 100644 js/net/src/tail.test.ts create mode 100644 js/net/src/tail.ts delete mode 100644 quest/m1/js-track-tail.md diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 3b6b3c693a..f086c11cd9 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -52,6 +52,7 @@ for (;;) { - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. +- **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/js/net/src/ietf/adapter.ts b/js/net/src/ietf/adapter.ts index 11d11dff5a..fd16a5349d 100644 --- a/js/net/src/ietf/adapter.ts +++ b/js/net/src/ietf/adapter.ts @@ -58,7 +58,7 @@ export class NativeSession implements Session { const Route = { NewRequest: 0, // Create virtual bidi stream, push initial message Response: 1, // Push message to existing stream (keep open) - ErrorResponse: 2, // Push message to existing stream, then close + ErrorResponse: 2, // Push a final message to existing stream, then close CloseStream: 3, // Close stream recv (no bytes pushed) FollowUp: 4, // Push follow-up message to existing stream MaxRequestId: 5, // Update flow control @@ -658,9 +658,9 @@ export class ControlStreamAdapter implements Session { return { route: Route.CloseStream, requestId }; } case 0x0b: { - // PublishDone + // PublishDone: the subscriber reads its status and stream count before the end. const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; + return { route: Route.ErrorResponse, requestId }; } case 0x17: { // FetchCancel diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index 6599d52216..974b44706b 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -3,6 +3,7 @@ import { Timescale, Timestamp } from "../time.ts"; import { type IetfVersion, Version } from "./version.ts"; const GROUP_END = 0x03; +const END_OF_TRACK = 0x04; // MOQ Object Property ids, shared with draft-ietf-moq-loc-04. const PROP_TIMESCALE = 0x08n; @@ -259,14 +260,24 @@ export class Group { /** A moq-transport object inside a group stream. */ export class Frame { - /** The object payload, or `undefined` for the end of group marker. */ + /** The object payload, or `undefined` for an end of group or end of track marker. */ payload?: Uint8Array; /** The presentation timestamp carried in object properties, when present. */ timestamp?: Timestamp; + /** + * An END_OF_TRACK marker: no object at or past its location exists. At object 0 its group + * does not exist either, so the track ends at that group; later in a group it ends after it. + */ + endOfTrack: boolean; - constructor({ payload, timestamp }: { payload?: Uint8Array; timestamp?: Timestamp } = {}) { + constructor({ + payload, + timestamp, + endOfTrack = false, + }: { payload?: Uint8Array; timestamp?: Timestamp; endOfTrack?: boolean } = {}) { this.payload = payload; this.timestamp = timestamp; + this.endOfTrack = endOfTrack; } /** @@ -284,7 +295,10 @@ export class Frame { await w.write(extensions); } - if (this.payload !== undefined) { + if (this.endOfTrack) { + await w.u53(0); // length = 0 + await w.u53(END_OF_TRACK); + } else if (this.payload !== undefined) { await w.u53(this.payload.byteLength); if (this.payload.byteLength === 0) { @@ -334,6 +348,9 @@ export class Frame { const status = await r.u53(); + // Defined on every implemented draft, whether or not the header marks the group's end. + if (status === END_OF_TRACK) return new Frame({ endOfTrack: true }); + if (flags.hasEnd) { // Empty frame if (status === 0) return new Frame({ payload: new Uint8Array(0), timestamp }); diff --git a/js/net/src/ietf/publish.ts b/js/net/src/ietf/publish.ts index 200d19cf78..40f105338a 100644 --- a/js/net/src/ietf/publish.ts +++ b/js/net/src/ietf/publish.ts @@ -213,21 +213,53 @@ export class PublishError { } } +/** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 14 through 22. */ +export const PublishDoneStatus = { + INTERNAL_ERROR: 0x0, + TRACK_ENDED: 0x2, + /** Removed in draft-20, where 0x3 is unassigned. */ + SUBSCRIPTION_ENDED: 0x3, +} as const; + +/** Whether a PUBLISH_DONE status ends the track cleanly rather than aborting it. */ +export function publishDoneClean(statusCode: number, version: IetfVersion): boolean { + if (statusCode === PublishDoneStatus.TRACK_ENDED) return true; + if (statusCode !== PublishDoneStatus.SUBSCRIPTION_ENDED) return false; + switch (version) { + case Version.DRAFT_14: + case Version.DRAFT_15: + case Version.DRAFT_16: + case Version.DRAFT_17: + case Version.DRAFT_18: + case Version.DRAFT_19: + return true; + default: + return false; + } +} + // In draft-14, this message is renamed from SUBSCRIBE_DONE to PUBLISH_DONE export class PublishDone { static readonly id = 0x0b; requestId: bigint | undefined; statusCode: number; + /** + * How many data streams the publisher opened for the subscription, fill streams included. + * A hint: a peer may send 0 or the "unknown" sentinel regardless. + */ + streamCount: bigint; reasonPhrase: string; constructor({ requestId, statusCode, + streamCount = 0n, reasonPhrase, - }: { requestId?: bigint; statusCode: number; reasonPhrase: string }) { + }: { requestId?: bigint; statusCode: number; streamCount?: bigint; reasonPhrase: string }) { this.requestId = requestId; this.statusCode = statusCode; + this.streamCount = streamCount; this.reasonPhrase = reasonPhrase; } @@ -237,7 +269,7 @@ export class PublishDone { await w.u62(this.requestId); } await w.u62(BigInt(this.statusCode)); - await w.u62(BigInt(0)); // stream_count = 0 (unsupported) + await w.u62(this.streamCount); await w.string(this.reasonPhrase); } @@ -255,9 +287,9 @@ export class PublishDone { ? await r.u62() : undefined; const statusCode = Number(await r.u62()); - await r.u62(); // ignore stream_count + const streamCount = await r.u62(); const reasonPhrase = await r.string(); - return new PublishDone({ requestId, statusCode, reasonPhrase }); + return new PublishDone({ requestId, statusCode, streamCount, reasonPhrase }); } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 0d0908fb2f..a16ce4ccbc 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -14,7 +14,7 @@ import { wireOf } from "../wire.ts"; import { NativeSession, type Session } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; import { FetchHeader } from "./fetch.ts"; -import { Group as GroupMessage } from "./object.ts"; +import { Frame, Group as GroupMessage } from "./object.ts"; import { PublishDone } from "./publish.ts"; import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; @@ -908,6 +908,16 @@ async function readGroup(stream: ReadableStream): Promise): Promise { + const reader = new Reader(stream, undefined, V20); + const header = await GroupMessage.decode(reader, V20); + const frame = await Frame.decode(reader, header.flags, undefined, V20); + expect(frame.endOfTrack).toBe(true); + expect(await reader.done()).toBe(true); + return header.groupId; +} + /** * Read a fill's fetch stream to its end, reporting a reset rather than throwing. * @@ -1339,8 +1349,12 @@ test("draft-20: a clean close past a bounded filter's end still sends PUBLISH_DO expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, V20); expect(done.statusCode).toBe(TRACK_ENDED_STATUS); + expect(done.streamCount).toBe(2n); - // Only the in-range group was ever opened. + // Only the in-range group was ever served; the other stream marks the track's end. + const end = await nextUni(fx.uni); + if (!end) throw new Error("the track's end was never marked"); + expect(await readEndOfTrack(end)).toBe(2); expect(await nextUni(fx.uni)).toBeUndefined(); } finally { fx.close(); @@ -1454,3 +1468,61 @@ test("draft-20: a fill works on a dynamically requested track", async () => { client.close(); } }); + +// PUBLISH_DONE MUST wait until every stream the subscription will open is closed, so its +// Stream Count is final. A group still queued for a stream slot when the track ends is one. +test("draft-20: PUBLISH_DONE waits for a queued group and counts every stream", async () => { + const fx = fixture(); + const track = fx.broadcast.createTrack("video"); + + // Park the first stream open, the way a transport at its stream cap does. + const slot = Promise.withResolvers(); + const create = fx.pair.server.createUnidirectionalStream.bind(fx.pair.server); + let parked = false; + fx.pair.server.createUnidirectionalStream = async (options?: WebTransportSendStreamOptions) => { + if (!parked) { + parked = true; + await slot.promise; + } + return create(options); + }; + + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 7n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + filter: { kind: "absolute", startGroup: 0n, startObject: 0n }, + }), + ); + + try { + writeGroup(track, 1); + track.close(); + + // Nothing ends the subscription while the group waits for its slot. + const response = client.reader.u53(); + const idle = new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)); + expect(await Promise.race([response, idle])).toBe("pending"); + + slot.resolve(); + const served = await nextUni(fx.uni); + if (!served) throw new Error("the queued group was never served"); + expect((await readGroup(served)).sequence).toBe(0); + + expect(await response).toBe(PublishDone.id); + const done = await PublishDone.decode(client.reader, V20); + expect(done.statusCode).toBe(TRACK_ENDED_STATUS); + // The group's stream and the END_OF_TRACK marker's. + expect(done.streamCount).toBe(2n); + + const end = await nextUni(fx.uni); + if (!end) throw new Error("the track's end was never marked"); + expect(await readEndOfTrack(end)).toBe(1); + } finally { + fx.close(); + client.close(); + } +}); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 0074eef517..a14dbb4ce2 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -7,7 +7,7 @@ import { hiddenBelow, hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; -import { Milli, type Timescale } from "../time.ts"; +import { Milli, Timescale } from "../time.ts"; import type { Subscriber as TrackSubscriber } from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; @@ -20,7 +20,7 @@ import * as Filter from "./filter.ts"; import { FetchFrame, Frame, Group as GroupMessage } from "./object.ts"; import { fromWire, toWire } from "./priority.ts"; import * as Properties from "./properties.ts"; -import { PublishDone } from "./publish.ts"; +import { PublishDone, PublishDoneStatus } from "./publish.ts"; import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk } from "./publish_namespace.ts"; import { RequestError, RequestOk } from "./request.ts"; import { type Subscribe, SubscribeError, SubscribeOk } from "./subscribe.ts"; @@ -52,12 +52,6 @@ function sameAdvert(a: Advertised | undefined, b: Advertised | undefined): boole return a !== undefined && b !== undefined && a.identity === b.identity && routesEqual(a.route, b.route); } -/** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */ -const PUBLISH_DONE_STATUS = { - INTERNAL_ERROR: 0x0, - TRACK_ENDED: 0x2, -} as const; - /** * How long one advertisement may take to be answered. Matches the Rust publisher, and the * peer accepting the stream is only half the exchange: one it never answers on holds the @@ -108,8 +102,14 @@ interface RunGroup { /** Settles when the subscriber leaves, dropping a group still queued for a stream slot. */ unsubscribed: Promise; + + /** The subscription's data stream count, which PUBLISH_DONE reports. */ + streams: StreamCount; } +/** How many data streams a subscription opened, fill streams included. */ +type StreamCount = { opened: number }; + /** What {@link Publisher.runFill} needs to serve one subscription's backfill. */ interface RunFill { /** The subscription's request ID, which the fetch stream names. */ @@ -141,6 +141,9 @@ interface RunFill { /** Settles when the subscriber leaves, releasing a fill still waiting on its group. */ unsubscribed: Promise; + + /** The subscription's data stream count, which PUBLISH_DONE reports. */ + streams: StreamCount; } /** @@ -349,6 +352,11 @@ export class Publisher { () => unsubscribe(), ); + // Every group started, until its stream finishes or resets, and the data streams + // opened for PUBLISH_DONE to report. + const groups = new Set>(); + const streams: StreamCount = { opened: 0 }; + // Serve track groups, racing with stream close (= Unsubscribe) const serving = (async () => { for (;;) { @@ -365,7 +373,7 @@ export class Publisher { continue; } - void this.#runGroup({ + const task = this.#runGroup({ requestId: msg.requestId, group, timescale, @@ -373,7 +381,10 @@ export class Publisher { stamped: msg.propertiesWanted, slice: groupSlice(range, group.sequence), unsubscribed, + streams, }); + groups.add(task); + void task.finally(() => groups.delete(task)); } })(); @@ -389,16 +400,38 @@ export class Publisher { timescale, stamped: msg.propertiesWanted, unsubscribed, + streams, }) : Promise.resolve(); let publishError: Error | undefined; + let ended = false; try { - await race([Promise.all([serving, filling]), stream.reader.closed]); + const served = Symbol("served"); + ended = + (await race([Promise.all([serving, filling]).then(() => served), stream.reader.closed])) === served; } catch (err: unknown) { publishError = error(err); } + // PUBLISH_DONE waits until every stream this subscription will open is closed, as + // the draft requires, so its count is final. The subscriber leaving cancels the + // ones still queued instead. + await race([Promise.all(groups), unsubscribed]); + + // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what + // tells the subscriber where the track ended. + const final = track.final(); + if (ended && !publishError && final !== undefined) { + await this.#runEndOfTrack({ + requestId: msg.requestId, + final, + publisherPriority, + unsubscribed, + streams, + }); + } + console.debug(`publish done: broadcast=${name} track=${track.name}`); if (publishError) { console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(publishError)}`); @@ -413,7 +446,8 @@ export class Publisher { version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED, + statusCode: publishError ? PublishDoneStatus.INTERNAL_ERROR : PublishDoneStatus.TRACK_ENDED, + streamCount: BigInt(streams.opened), reasonPhrase: publishError ? "internal error" : "track ended", }); await done.encode(stream.writer, version); @@ -442,7 +476,7 @@ export class Publisher { * Runs a group and sends its frames using ObjectStream (Subgroup delivery mode). */ async #runGroup(options: RunGroup) { - const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed } = options; + const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed, streams } = options; try { // One stream per group is faster than a peer at its limit can retire them, so this // is the one path that doesn't wait for a slot: the transport would serve the opens @@ -457,6 +491,7 @@ export class Publisher { group.close(new Error("no stream slot")); return; } + streams.opened += 1; const header = new GroupMessage({ trackAlias: requestId, @@ -525,6 +560,49 @@ export class Publisher { } } + /** + * Mark the track's end with an END_OF_TRACK object on its own stream, at object 0 of the + * group that will never exist. + * + * The last group's stream has usually finished before the track ends, so the marker cannot + * ride on it. A failure only costs the subscriber the early boundary. + */ + async #runEndOfTrack(options: { + requestId: bigint; + final: number; + publisherPriority: number; + unsubscribed: Promise; + streams: StreamCount; + }) { + const { requestId, final, publisherPriority, unsubscribed, streams } = options; + const version = this.#session.version; + const stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version }).catch(() => undefined); + if (!stream) return; + streams.opened += 1; + + try { + const header = new GroupMessage({ + trackAlias: requestId, + groupId: final, + subGroupId: 0, + publisherPriority, + flags: { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: false, + hasPriority: true, + firstObject: true, + }, + }); + await header.encode(stream, version); + await new Frame({ endOfTrack: true }).encode(stream, header.flags, Timescale.MILLI, version); + stream.close(); + } catch (err: unknown) { + stream.reset(error(err)); + } + } + /** * Serve a draft-20 fill on its own fetch stream: the requested range, read from the * group cache, capped at the Largest Object snapshot. @@ -534,7 +612,7 @@ export class Publisher { * fill-failure signal. Nothing here touches the subscription either way. */ async #runFill(options: RunFill) { - const { requestId, fill, cache, timescale, stamped, unsubscribed } = options; + const { requestId, fill, cache, timescale, stamped, unsubscribed, streams } = options; const version = this.#session.version; // Everything is inside the try so the cache fork is released on every path out, @@ -548,6 +626,7 @@ export class Publisher { console.debug(`fill stream failed to open: fill=${requestId}`); return; } + streams.opened += 1; await stream.u53(FetchHeader.type); await new FetchHeader({ requestId }).encode(stream, version); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 33e8595b92..b107b0ca23 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -8,6 +8,7 @@ import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; import type { Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import { type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; @@ -18,7 +19,7 @@ import * as Cluster from "./cluster.ts"; import { requestReason, toRequestCode } from "./error.ts"; import { Frame, type Group as GroupMessage } from "./object.ts"; import { fromWire, toWire } from "./priority.ts"; -import { type Publish, PublishError } from "./publish.ts"; +import { type Publish, PublishDone, PublishError, publishDoneClean } from "./publish.ts"; import { type PublishNamespace, PublishNamespaceDone, @@ -44,6 +45,15 @@ import { Version } from "./version.ts"; // blocks. The timeout turns that into a clear error. const SUBSCRIBE_OK_TIMEOUT_MS = 10_000; +// A live subscription, as the track alias its data streams name resolves to. +type Subscription = { + // The write side incoming group streams are routed into. + track: track.Producer; + // The group streams received, so the subscription can wait for the ones PUBLISH_DONE + // says are still owed. + tail: Tail; +}; + // Out-parameter for #openSubscribe: lets the caller observe partial progress // (stream opened, trackAlias registered) so it can clean up on timeout even // before the setup promise settles. @@ -93,7 +103,7 @@ export class Subscriber { #cluster?: Cluster.Hops; // Publisher-chosen aliases used by incoming group streams. - #aliases = new TrackAliases(); + #aliases = new TrackAliases(); // Units for each track's object Timestamps, from the TIMESCALE Track Property in // SUBSCRIBE_OK. A track missing from this map declared no timeline, so the publisher @@ -481,12 +491,13 @@ export class Subscriber { // Keep the request pending until SUBSCRIBE_OK supplies immutable track metadata. // Group streams already wait on the alias, so early data stays behind this response. const producer = hooks.pendingTrackProducer(request); + const subscription: Subscription = { track: producer, tail: new Tail() }; // Open the stream and wait for SUBSCRIBE_OK under a timeout. State // flows back via `state` so the timeout path can clean up the stream // and any registration if setup eventually finishes. const state: SubscribeSetupState = {}; - const setup = this.#openSubscribe(state, broadcast, request, producer, requestId); + const setup = this.#openSubscribe(state, broadcast, request, subscription, requestId); // The publisher can be serving before it answers, so waiting only on the response // would miss the local side going away and leave it serving a track nobody reads. @@ -534,7 +545,7 @@ export class Subscriber { const cleanup = async (afterSetup: boolean) => { state.cancelled = true; - if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, producer)) { + if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, subscription)) { this.#timescales.delete(state.registeredAlias); } @@ -575,10 +586,10 @@ export class Subscriber { const localEnded = Symbol("local"); const idle = Symbol("idle"); - // Terminal conditions settle at most once (stream close = PublishDone, track close = - // local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. + // Terminal conditions settle at most once (PublishDone, track close = local + // unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. const done = race([ - stream.reader.closed.then(() => publisherEnded), + this.#runPublishDone(stream, subscription).then(() => publisherEnded), producer.closed.then(() => localEnded), ]); @@ -615,8 +626,37 @@ export class Subscriber { } finally { // Only the owner tears down the alias metadata: a later subscription may have // reclaimed the alias and installed its own timescale. - if (this.#aliases.retire(trackAlias, producer)) this.#timescales.delete(trackAlias); + if (this.#aliases.retire(trackAlias, subscription)) this.#timescales.delete(trackAlias); + } + } + + /** + * Read the PUBLISH_DONE that ends a subscription, then wait for the data streams it counts. + * + * An error status aborts the track with it. A clean one leaves streams in flight, since + * QUIC does not order them, so wait until the Stream Count many have been read, or a + * bounded grace for the ones that never arrive (the draft says to use a timeout). The count + * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request stream + * that ends without one ends the track the same way. + */ + async #runPublishDone(stream: Stream, subscription: Subscription): Promise { + const version = this.#session.version; + let count: bigint | undefined; + if (!(await stream.reader.done())) { + const typeId = await stream.reader.u53(); + if (typeId !== PublishDone.id) { + throw new ProtocolViolation(`unexpected message on a subscription: 0x${typeId.toString(16)}`); + } + const done = await PublishDone.decode(stream.reader, version); + if (!publishDoneClean(done.statusCode, version)) { + throw new Error(`publish done: status=0x${done.statusCode.toString(16)} reason=${done.reasonPhrase}`); + } + count = done.streamCount; } + + const { tail, track } = subscription; + const complete = () => count !== undefined && count > 0n && BigInt(tail.streams) >= count; + await tail.settle(complete, TAIL_GRACE_MS, track.closed); } /** @@ -648,7 +688,7 @@ export class Subscriber { state: SubscribeSetupState, broadcast: Path.Valid, request: track.Request, - producer: track.Producer, + subscription: Subscription, requestId: bigint, ): Promise<{ stream: Stream; alias: bigint }> { const version = this.#session.version; @@ -706,7 +746,7 @@ export class Subscriber { request.accept({ priority: fromWire(ok.properties.priority ?? 128) }); try { - this.#aliases.set(ok.trackAlias, producer, { broadcast, name: request.name }); + this.#aliases.set(ok.trackAlias, subscription, { broadcast, name: request.name }); const timescale = ok.properties.timescale; if (timescale !== undefined) { this.#timescales.set(ok.trackAlias, timescale); @@ -935,35 +975,62 @@ export class Subscriber { throw new Error("subgroups are not supported"); } - // FIRST_OBJECT clear says this stream starts partway through the group, which the - // draft lets a publisher do to answer a filter. Nothing above here can use it: the - // objects that would arrive are not decodable without the missing head, and a group - // is the unit an application resyncs on. Drop it and pick up at the next group, the - // same degradation as a publisher that no longer holds the head. - // - // This only saves reading a stream we would throw away. The bit is the publisher's - // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every - // object to starting at 0 and incrementing by 1, whatever the header said and on the - // drafts that have no such bit to read. - if (!group.flags.firstObject) { - console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); - stream.stop(new Error("a group must start at object 0")); + let subscription: Subscription; + try { + // The control message establishing this alias can arrive after the data stream. + subscription = await this.#aliases.get(group.trackAlias); + } catch (err: unknown) { + const e = error(err); + // Ours: we cancelled the subscription and the publisher has not stopped yet. + // Anything else on this alias is the publisher sending data for a track it never + // acknowledged, which is worth seeing. + if (e instanceof RetiredTrackAlias) { + console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`); + } + stream.stop(e); return; } - const producer = new netGroup.Producer(group.groupId); + const { track, tail } = subscription; + // Every data stream counts toward PUBLISH_DONE's Stream Count, even one dropped below. + const read = tail.open(group.groupId); + + // Created on the first object rather than the header: an END_OF_TRACK at object 0 + // means the group does not exist at all. + let producer: netGroup.Producer | undefined; + const open = () => { + if (!producer) { + producer = new netGroup.Producer(group.groupId); + track.writeGroup(producer); + } + return producer; + }; try { - // The control message establishing this alias can arrive after the data stream. - const track = await this.#aliases.get(group.trackAlias); + // FIRST_OBJECT clear says this stream starts partway through the group, which the + // draft lets a publisher do to answer a filter. Nothing above here can use it: the + // objects that would arrive are not decodable without the missing head, and a group + // is the unit an application resyncs on. Drop it and pick up at the next group, the + // same degradation as a publisher that no longer holds the head. + // + // This only saves reading a stream we would throw away. The bit is the publisher's + // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every + // object to starting at 0 and incrementing by 1, whatever the header said and on the + // drafts that have no such bit to read. + if (!group.flags.firstObject) { + console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); + stream.stop(new Error("a group must start at object 0")); + return; + } + // The alias binds after SUBSCRIBE_OK commits the track property; an omitted // header priority inherits it (draft-21 section 10.4). if (!group.flags.hasPriority) group.publisherPriority = toWire((await track.info()).priority); - track.writeGroup(producer); - for (;;) { - const done = await race([stream.done(), producer.closed, track.closed]); + // Only the group's own stream ends it: a track that closes first has already + // closed (or aborted) this group through its cache. + const done = await (producer ? race([stream.done(), producer.closed]) : stream.done()); if (done !== false) break; const frame = await Frame.decode( @@ -972,22 +1039,44 @@ export class Subscriber { this.#timescales.get(group.trackAlias), this.#session.version, ); + + if (frame.endOfTrack) { + // No object at or past this location exists: after the group's last object + // the track ends with it, and at object 0 it ends before it. + const end = producer ? group.groupId + 1 : group.groupId; + producer?.close(); + try { + track.finishAt(end); + } catch (err: unknown) { + throw new ProtocolViolation(`invalid END_OF_TRACK: ${reason(error(err))}`); + } + return; + } if (frame.payload === undefined) break; - producer.writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() }); + open().writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() }); } - producer.close(); + // A group with no objects still exists. + open().close(); } catch (err: unknown) { const e = error(err); - // Ours: we cancelled the subscription and the publisher has not stopped yet. - // Anything else on this alias is the publisher sending data for a track it never - // acknowledged, which is worth seeing. - if (e instanceof RetiredTrackAlias) { - console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`); + if (e instanceof ProtocolViolation) { + // The publisher broke the track's end, which no later group can repair. + producer?.close(e); + track.close(e); + } else { + // A stream that fails before its first object still names a group, which the + // reader sees fail rather than silently go missing. + try { + open().close(e); + } catch { + // The track has already closed or ended below this group. + } } - producer.close(e); stream.stop(e); + } finally { + read(); } } } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts new file mode 100644 index 0000000000..d7a505f642 --- /dev/null +++ b/js/net/src/ietf/tail.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from "bun:test"; +import type { Consumer as GroupConsumer } from "../group.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS } from "../tail.ts"; +import { Milli } from "../time.ts"; +import { NativeSession } from "./adapter.ts"; +import { type GroupFlags, Group as GroupMessage } from "./object.ts"; +import { PublishDone } from "./publish.ts"; +import { Subscribe, SubscribeOk } from "./subscribe.ts"; +import { Subscriber } from "./subscriber.ts"; +import { ALPN, Version } from "./version.ts"; + +const VERSION = Version.DRAFT_19; +const ALIAS = 9n; +const TRACK_ENDED = 0x2; +const INTERNAL_ERROR = 0x0; + +// A plain subgroup stream: no extensions, no subgroup id, end of group on FIN. +const FLAGS: GroupFlags = { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, +}; + +/** One object with a zero id delta. Every field is under 64, so each is a one-byte varint. */ +function object(payload: string): Uint8Array { + const bytes = new TextEncoder().encode(payload); + return new Uint8Array([0, bytes.byteLength, ...bytes]); +} + +/** An END_OF_TRACK object: zero length, then status 0x4. */ +const END_OF_TRACK = new Uint8Array([0, 0, 0x4]); + +/** A group stream the test writes by hand, handed to the subscriber as if it arrived. */ +function groupStream(subscriber: Subscriber, groupId: number) { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ start: (c) => (controller = c) }); + const header = new GroupMessage({ trackAlias: ALIAS, groupId, subGroupId: 0, publisherPriority: 0, flags: FLAGS }); + const handled = subscriber.handleGroup(header, new Reader(readable, undefined, VERSION)); + return { + write: (bytes: Uint8Array) => controller.enqueue(bytes), + finish: () => controller.close(), + handled, + }; +} + +/** A subscriber with one track subscribed and answered; the test plays the publisher. */ +async function subscribed() { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session }); + const reader = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ maxAge: Milli(60_000) }); + + const peer = await Stream.accept(pair.client, VERSION); + if (!peer) throw new Error("the subscriber never opened a subscribe stream"); + expect(await peer.reader.u53()).toBe(Subscribe.id); + const request = await Subscribe.decode(peer.reader, VERSION); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, VERSION); + + return { + subscriber, + reader, + done: async (statusCode: number, streamCount: bigint) => { + await peer.writer.u53(PublishDone.id); + await new PublishDone({ statusCode, streamCount, reasonPhrase: "done" }).encode(peer.writer, VERSION); + peer.writer.close(); + }, + }; +} + +async function readAll(group: GroupConsumer | undefined): Promise { + if (!group) throw new Error("no group"); + const out: string[] = []; + for (;;) { + const next = await group.readString(); + if (next === undefined) return out; + out.push(next); + } +} + +test("a group stream that arrives after PUBLISH_DONE is delivered", async () => { + const { subscriber, reader, done } = await subscribed(); + const first = groupStream(subscriber, 0); + first.write(object("0.0")); + first.finish(); + await first.handled; + await done(TRACK_ENDED, 2n); + + // QUIC does not order streams, so the second one lands after PUBLISH_DONE. + const started = performance.now(); + const late = groupStream(subscriber, 1); + late.write(object("1.0")); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + // The Stream Count was met, so nothing waited out the grace. + expect(performance.now() - started).toBeLessThan(TAIL_GRACE_MS); +}); + +test("a group read across PUBLISH_DONE is delivered whole", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 0); + group.write(object("0.0")); + await done(TRACK_ENDED, 1n); + + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.write(object("0.1")); + group.finish(); + expect(await readAll(received)).toEqual(["0.1"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); +}); + +test("a Stream Count of 0 is a hint, so a late stream within the grace is still delivered", async () => { + const { subscriber, reader, done } = await subscribed(); + const started = performance.now(); + await done(TRACK_ENDED, 0n); + + const late = groupStream(subscriber, 0); + late.write(object("0.0")); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + expect(performance.now() - started).toBeGreaterThanOrEqual(TAIL_GRACE_MS - 5); +}); + +test("a PUBLISH_DONE with an error status aborts the track", async () => { + const { reader, done } = await subscribed(); + await done(INTERNAL_ERROR, 0n); + expect(await reader.closed).toBeInstanceOf(Error); +}); + +test("END_OF_TRACK after a group's last object ends the track after that group", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 4); + group.write(object("4.0")); + group.write(END_OF_TRACK); + group.finish(); + + expect(await reader.finished()).toBe(5); + expect(await readAll(await reader.recvGroup())).toEqual(["4.0"]); + + await done(TRACK_ENDED, 1n); + expect(await reader.recvGroup()).toBeUndefined(); + expect(reader.final()).toBe(5); +}); + +test("END_OF_TRACK at object 0 ends the track before its group, which never exists", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 0); + group.write(object("0.0")); + group.finish(); + const end = groupStream(subscriber, 2); + end.write(END_OF_TRACK); + end.finish(); + + expect(await reader.finished()).toBe(2); + await done(TRACK_ENDED, 2n); + expect((await reader.recvGroup())?.sequence).toBe(0); + expect(await reader.recvGroup()).toBeUndefined(); + expect(reader.final()).toBe(2); +}); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index d8092a083f..30b1f5bbce 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1325,22 +1325,27 @@ test("lite draft-05: a group waiting for a stream slot is dropped when the subsc // The publisher FINs the subscribe stream itself once a track ends, which must not be // mistaken for the subscriber leaving: SUBSCRIBE_END counts those queued groups as -// delivered, so dropping them here would strand the tail of every finite track. +// delivered, so dropping them here would strand the tail of every finite track. The FIN +// tells the subscriber every group is accounted for, so it waits for the queued group. test("lite draft-05: a group waiting for a stream slot survives the track finishing", async () => { const { client, track, freeSlot, outcome, close } = await saturatedGroup(); track.close(); - // Read to the FIN the publisher sends after SUBSCRIBE_END. That FIN is the moment a - // cancel keyed on our own close would fire, so the slot must not free up before it. + // SUBSCRIBE_END goes out while the group is still waiting for its slot. for (;;) { const resp = await decodeSubscribeResponse(client.reader, Version.DRAFT_05); if ("end" in resp) break; } - await client.reader.closed; + + // The FIN holds until the queued group is on the wire. + const fin = client.reader.closed.then(() => "fin" as const); + const idle = new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)); + expect(await Promise.race([fin, idle])).toBe("pending"); freeSlot(); expect(await outcome).toBe("sent"); + expect(await fin).toBe("fin"); close(); }); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index bff615b0e9..b9fb8dc098 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -245,6 +245,11 @@ class SubscriptionControls { return false; } + /** Settles once the stream is over: `null` when it ended cleanly, or the failure. */ + get ended(): Promise { + return this.#ended; + } + #finish(end: Error | null) { if (this.#end !== undefined) return; this.#end = end; @@ -755,6 +760,9 @@ export class Publisher { // One ranking for the whole subscription, shared by every group it serves. const priority = new Priority(track); + // Every group this subscription started serving, until its stream finishes or resets. + const groups = new Set>(); + // Cancels groups still queued for a stream slot. Only the subscriber leaving counts: // a track that ran out of groups still has to flush the ones already queued, and the // caller FINs the subscribe stream to say so. @@ -802,6 +810,12 @@ export class Publisher { case "error": throw recv.error; case "idle": + // An end declared ahead of the live edge goes out as soon as it is + // known, while the remaining groups are still being produced. + if (!endSent && track.final() !== undefined) { + if (!(await sendEnd())) return; + continue; + } await waitForSubscription(controls, track); continue; case "boundary": @@ -813,13 +827,21 @@ export class Publisher { } await waitForSubscription(controls, track); continue; - case "done": + case "done": { if (!endSent) { if (!(await sendEnd())) return; continue; } + // The FIN tells the subscriber every group is accounted for, so it waits + // until each group stream finished or reset. The subscriber leaving + // instead cancels whatever is still queued. + const drained = Symbol("drained"); + const end = await Promise.race([Promise.all(groups).then(() => drained), controls.ended]); + if (end instanceof Error) throw end; + if (end !== drained) return; finished = true; return; + } } const group = recv.group; @@ -846,7 +868,7 @@ export class Publisher { return; } - void this.#runGroup({ + const task = this.#runGroup({ sub, group, timescale, @@ -855,6 +877,8 @@ export class Publisher { start: range.start, end: range.end, }); + groups.add(task); + void task.finally(() => groups.delete(task)); } } finally { if (!finished) unsubscribe(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 67b4c34240..6b6f97f18b 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -9,6 +9,7 @@ import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from " import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; import { type Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; @@ -69,6 +70,13 @@ interface SubscribeEntry { // runGroup must consume to stay in sync; group streams block on it before decoding, // since a group's QUIC stream can race ahead of the subscribe stream. timescale: Signal; + // The group streams received, so the subscription can wait for the ones still owed + // after the publisher ends it. + tail: Tail; + // The first group the publisher serves (SUBSCRIBE_START) and the track's exclusive end + // (SUBSCRIBE_END), once it declares them. + start?: number; + end?: number; } /** @@ -531,7 +539,7 @@ export class Subscriber { const state: { stream?: Stream } = {}; const setup = this.#openSubscribe(state, msg, request, id, timescale); - let opened: { stream: Stream; producer: track.Producer }; + let opened: { stream: Stream; entry: SubscribeEntry }; try { opened = await withTimeout( setup, @@ -556,16 +564,20 @@ export class Subscriber { return; } - const { stream, producer } = opened; + const { stream, entry } = opened; + const producer = entry.track; try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there // and just wait on the stream/track like before. // - // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream; - // drain them (we don't drive delivery off the resolved range) so the FIN is - // observed. Older drafts just wait for the stream to close. - const closed = supportsTrackStream(this.version) ? this.#drainResponses(stream) : stream.reader.closed; + // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream until + // its FIN; older drafts just close it. Either way group streams can still be in + // flight, so the track ends only once the tail is accounted for. + const responses = supportsTrackStream(this.version) + ? this.#runResponses(stream, entry) + : stream.reader.closed; + const closed = responses.then(() => this.#settleTail(entry)); const subscriptionUpdates = this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02 ? undefined @@ -573,8 +585,10 @@ export class Subscriber { // Terminal conditions (stream end, track close, a failed subscription update) settle at most // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. + // Updates stop quietly at the FIN, which can land before the responses ahead of it are + // decoded, so only their failure is terminal on its own. const terminal: PromiseLike[] = [closed, producer.closed]; - if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates); + if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates.then(() => closed)); const done = race(terminal); // Serve until a terminal condition fires or the last local subscriber leaves. The unused @@ -615,7 +629,7 @@ export class Subscriber { request: track.Request, id: bigint, timescale: Signal, - ): Promise<{ stream: Stream; producer: track.Producer }> { + ): Promise<{ stream: Stream; entry: SubscribeEntry }> { let producer: track.Producer; let drainOk = false; @@ -632,7 +646,8 @@ export class Subscriber { } // Register before opening SUBSCRIBE so a racing GROUP stream finds the entry. - this.#subscribes.set(id, { track: producer, timescale }); + const entry: SubscribeEntry = { track: producer, timescale, tail: new Tail() }; + this.#subscribes.set(id, entry); state.stream = await Stream.open(this.#quic); await state.stream.writer.u53(StreamId.Subscribe); @@ -646,7 +661,7 @@ export class Subscriber { } } - return { stream: state.stream, producer }; + return { stream: state.stream, entry }; } // Opens a TRACK stream, reads the single TRACK_INFO, and FINs. Lite-05+ only. @@ -792,21 +807,68 @@ export class Subscriber { } } - // Drains SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+). - // The resolved range is informational here; the producer already orders groups. - // Resolves (never rejects) on FIN or on the stream being reset out from under it, - // so it's safe to drop from a race without an unhandled rejection. - async #drainResponses(stream: Stream): Promise { - try { - for (;;) { - const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); - if (!resp) return; + // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording + // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right + // away, so a consumer learns it before the last groups arrive. Resolves on FIN or on the + // stream being reset out from under it; rejects only on a response that breaks the range. + async #runResponses(stream: Stream, entry: SubscribeEntry): Promise { + for (;;) { + let resp: Awaited>; + try { + resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); + } catch { + // Stream closed or reset; nothing more to read. + return; + } + if (!resp) return; + + if ("start" in resp) { + entry.start = resp.start.group; + } else if ("end" in resp) { + if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); + entry.end = resp.end.group; + // A local close can win the race with the response; there is nothing left to end. + if (entry.track.closed.peek() !== undefined) continue; + try { + entry.track.finishAt(entry.end); + } catch (err) { + throw new ProtocolViolation(`invalid SUBSCRIBE_END: ${reason(error(err))}`); + } + } else if ("drop" in resp) { + entry.tail.account(resp.drop.start, resp.drop.end + 1); } - } catch { - // Stream closed or reset; nothing more to drain. } } + // Wait for the group streams the publisher still owes once it has ended the subscription. + // + // Its FIN says every group below the end is accounted for, but QUIC does not order streams, + // so one can still be in flight. Wait until each group from SUBSCRIBE_START to the end has + // a stream (read to its end) or a SUBSCRIBE_DROP. A group reset before its header arrived + // never shows up, so give up on missing groups after the subscription's effective max age, + // then end cleanly with them skipped like any stale group. That is a wall-clock stopgap for + // a presentation-time budget; a publisher sending SUBSCRIBE_DROP for every group it reset + // would account for them with no timer at all. + #settleTail(entry: SubscribeEntry): Promise { + const { tail, track } = entry; + // Already the smaller of the subscriber's and the track's max age. + const maxAge = track.subscription.peek()?.maxAge ?? Time.Milli.zero; + const grace = maxAge > 0 ? maxAge : TAIL_GRACE_MS; + + const complete = () => { + // Without SUBSCRIBE_END (older drafts) nothing says which groups are owed. + if (entry.end === undefined) return false; + // Without SUBSCRIBE_START the publisher served no group at all. + if (entry.start === undefined) return true; + const bounds = groupBounds(track.subscription.peek()?.groups ?? {}); + const start = Math.max(entry.start, bounds.start); + const end = bounds.end === undefined ? entry.end : Math.min(entry.end, bounds.end); + return tail.covers(start, end); + }; + + return tail.settle(complete, grace, track.closed); + } + /** * Send SUBSCRIBE_UPDATE messages whenever the track's aggregate subscription changes. * @@ -891,11 +953,13 @@ export class Subscriber { return; } - const { track, timescale } = entry; + const { track, timescale, tail } = entry; const producer = new netGroup.Producer(group.sequence); - track.writeGroup(producer); + const read = tail.open(group.sequence); try { + track.writeGroup(producer); + // Block until the timescale is known; the group's stream can arrive before // TRACK_INFO (or implicit defaults) resolves it on the subscribe stream. let scale = timescale.peek(); @@ -916,7 +980,9 @@ export class Subscriber { let prevTs = 0n; for (;;) { - const done = await race([stream.done(), track.closed, producer.closed]); + // Only the group's own stream ends it: a track that closes first has already + // closed (or aborted) this group through its cache. + const done = await race([stream.done(), producer.closed]); if (done !== false) break; let timestamp: Time.Timestamp; @@ -940,6 +1006,8 @@ export class Subscriber { const e = error(err); producer.close(e); stream.stop(e); + } finally { + read(); } } @@ -1002,6 +1070,8 @@ export class Subscriber { if (!scale) return; const timestamp = new Time.Timestamp(dg.timestamp, Time.Timescale(scale)); + // A datagram's sequence is never owed a stream, so it never holds the tail open. + entry.tail.account(dg.sequence, dg.sequence + 1); entry.track.insertDatagram(dg.sequence, timestamp, dg.payload); } diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts new file mode 100644 index 0000000000..b74636c55c --- /dev/null +++ b/js/net/src/lite/tail.test.ts @@ -0,0 +1,219 @@ +import { expect, test } from "bun:test"; +import type { Consumer as GroupConsumer } from "../group.ts"; +import { randomHop } from "../hop.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { Reader, Stream } from "../stream.ts"; +import { Milli } from "../time.ts"; +import { Group as GroupMessage } from "./group.ts"; +import { StreamId } from "./stream.ts"; +import { + encodeSubscribeResponse, + Subscribe, + SubscribeDrop, + SubscribeEnd, + type SubscribeResponse, + SubscribeStart, +} from "./subscribe.ts"; +import { Subscriber } from "./subscriber.ts"; +import { TrackInfo, Track as TrackMessage } from "./track.ts"; +import { ALPN_05, Version } from "./version.ts"; + +const VERSION = Version.DRAFT_05; + +// The subscription's max age, which is also how long it waits for a group that never arrives. +const GRACE = Milli(100); + +/** One lite-05 frame: a zero timestamp delta, then the length-prefixed payload. */ +function frame(payload: string): Uint8Array { + const bytes = new TextEncoder().encode(payload); + // Every field is under 64, so each is a one-byte varint. + return new Uint8Array([0, bytes.byteLength, ...bytes]); +} + +/** A group stream the test writes by hand, handed to the subscriber as if it arrived. */ +function groupStream(subscriber: Subscriber, sequence: number) { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ start: (c) => (controller = c) }); + const handled = subscriber.runGroup( + new GroupMessage({ subscribe: 0n, sequence }), + new Reader(readable, undefined, undefined), + ); + return { + write: (payload: string) => controller.enqueue(frame(payload)), + finish: () => controller.close(), + reset: () => controller.error(new Error("reset")), + handled, + }; +} + +/** + * A lite-05 subscriber with one track subscribed, whose publisher the test plays by hand: + * it answers TRACK_INFO, then writes whatever responses the test asks for on the subscribe + * stream and FINs it when told. + */ +async function subscribed(maxAge = GRACE) { + const pair = createMockTransportPair(ALPN_05); + const subscriber = new Subscriber(pair.client, VERSION, randomHop()); + const reader = subscriber.consume(Path.from("room")).track("video").subscribe({ maxAge }); + + const info = await Stream.accept(pair.server); + if (!info) throw new Error("the subscriber never asked for TRACK_INFO"); + expect(await info.reader.u53()).toBe(StreamId.Track); + await TrackMessage.decode(info.reader, VERSION); + await new TrackInfo({ maxAge: 60_000 }).encode(info.writer, VERSION); + info.close(); + + const sub = await Stream.accept(pair.server); + if (!sub) throw new Error("the subscriber never subscribed"); + expect(await sub.reader.u53()).toBe(StreamId.Subscribe); + await Subscribe.decode(sub.reader, VERSION); + + return { + subscriber, + reader, + respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, VERSION), + fin: () => sub.writer.close(), + }; +} + +/** Read one group to its end, or the error it ended with. */ +async function readAll(group: GroupConsumer | undefined): Promise { + if (!group) throw new Error("no group"); + const payloads: string[] = []; + for (;;) { + const frame = await group.readString(); + if (frame === undefined) return payloads; + payloads.push(frame); + } +} + +/** Whether `promise` settles within `ms`. */ +async function settlesWithin(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const pending = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ms); + }); + try { + return await Promise.race([promise.then(() => true), pending]); + } finally { + clearTimeout(timer); + } +} + +test("a group stream that arrives after the subscribe stream's FIN is delivered", async () => { + const { subscriber, reader, respond, fin } = await subscribed(); + await respond({ start: new SubscribeStart(0) }); + const first = groupStream(subscriber, 0); + first.write("0.0"); + first.finish(); + await respond({ end: new SubscribeEnd(2) }); + await fin(); + + // The end is known before the last group arrives. + expect(await reader.finished()).toBe(2); + + // QUIC does not order streams, so group 1 lands after the FIN. + const late = groupStream(subscriber, 1); + late.write("1.0"); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(2); +}); + +test("a group read across the subscribe stream's FIN is delivered whole", async () => { + const { subscriber, reader, respond, fin } = await subscribed(); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 0); + group.write("0.0"); + + await respond({ end: new SubscribeEnd(1) }); + await fin(); + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + + // The FIN does not end the group: only its own stream does. + group.write("0.1"); + group.finish(); + expect(await readAll(received)).toEqual(["0.1"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); +}); + +test("a group reset after the subscribe stream's FIN is not presented as complete", async () => { + const { subscriber, reader, respond, fin } = await subscribed(); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 0); + group.write("0.0"); + await respond({ end: new SubscribeEnd(1) }); + await fin(); + + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.reset(); + await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow(); + + // The track still ends cleanly: the group was accounted for, as a reset. + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); +}); + +test("a group that never arrives is given up on after the subscription's max age", async () => { + const { subscriber, reader, respond, fin } = await subscribed(); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 1); + group.write("1.0"); + group.finish(); + await respond({ end: new SubscribeEnd(2) }); + await fin(); + + // Group 0 was reset before its header arrived, so nothing ever accounts for it. + expect((await reader.recvGroup())?.sequence).toBe(1); + const started = performance.now(); + expect(await reader.recvGroup()).toBeUndefined(); + expect(performance.now() - started).toBeGreaterThanOrEqual(GRACE - 5); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(2); +}); + +test("a subscription ends without waiting once every group is accounted for", async () => { + // A max age far past the test's patience: only the accounting may end it. + const { subscriber, reader, respond, fin } = await subscribed(Milli(60_000)); + await respond({ start: new SubscribeStart(0) }); + await respond({ drop: new SubscribeDrop({ start: 0, end: 0, error: 0 }) }); + const group = groupStream(subscriber, 1); + group.write("1.0"); + group.finish(); + await respond({ end: new SubscribeEnd(2) }); + await fin(); + + expect((await reader.recvGroup())?.sequence).toBe(1); + expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); + expect(await reader.closed).toBeNull(); +}); + +test("a subscription that served nothing ends at SUBSCRIBE_END", async () => { + const { reader, respond, fin } = await subscribed(Milli(60_000)); + await respond({ end: new SubscribeEnd(0) }); + await fin(); + + expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(0); +}); + +test("a SUBSCRIBE_END below a group already received aborts the track", async () => { + const { subscriber, reader, respond } = await subscribed(); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 3); + group.finish(); + await group.handled; + await respond({ end: new SubscribeEnd(2) }); + + const closed = await reader.closed; + expect(closed).toBeInstanceOf(Error); +}); diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts new file mode 100644 index 0000000000..9daa4eaa56 --- /dev/null +++ b/js/net/src/tail.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test"; +import { accept, connect } from "./connection/index.ts"; +import * as Ietf from "./ietf/index.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair } from "./mock.ts"; +import { Producer as OriginProducer } from "./origin.ts"; +import * as Path from "./path.ts"; +import { TAIL_GRACE_MS } from "./tail.ts"; +import { Milli } from "./time.ts"; +import type { Ordered } from "./track.ts"; +import { wireOf } from "./wire.ts"; + +const url = new URL("https://localhost:4443/test"); + +// Long enough that no group is skipped as stale, and the moq-lite grace for the one group the +// IETF case never produces. +const MAX_AGE = Milli(100); + +async function session(protocol: string) { + const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ transport: pair.server, url, publish: origin.consume() }), + ]); + const broadcast = origin.createBroadcast(Path.from("test")); + broadcast.announce(); + const video = broadcast.createTrack("video"); + const remote = wireOf(client).consume(Path.from("test")); + const reader = remote.track("video").subscribe({ maxAge: MAX_AGE }).ordered(); + + return { + video, + reader, + close: () => { + broadcast.close(); + remote.close(); + client.close(); + server.close(); + }, + }; +} + +async function readAll(reader: Ordered): Promise { + const out: string[] = []; + for (;;) { + const next = await reader.readString(); + if (next === undefined) return out; + out.push(next); + } +} + +// moq-lite carries the end in SUBSCRIBE_END as soon as it is declared, so a subscriber +// learns it while the last group is still to come. +test.each([Lite.ALPN_05, Lite.ALPN_06])( + "%s: an end declared ahead of the live edge reaches the subscriber", + async (alpn) => { + const { video, reader, close } = await session(alpn); + try { + video.writeString("0"); + expect(await reader.readString()).toBe("0"); + video.writeString("1"); + expect(await reader.readString()).toBe("1"); + + video.finishAt(3); + expect(await reader.finished()).toBe(3); + + video.writeString("2"); + video.close(); + expect(await readAll(reader)).toEqual(["2"]); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(3); + } finally { + close(); + } + }, +); + +// moq-transport carries the end in an END_OF_TRACK object, so it survives a track that +// declared an end past the groups it produced, and the Stream Count lets the subscriber stop +// waiting for streams at once. +test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_20])( + "%s: END_OF_TRACK carries the declared end", + async (alpn) => { + const { video, reader, close } = await session(alpn); + try { + video.writeString("0"); + expect(await reader.readString()).toBe("0"); + + video.finishAt(4); + video.writeString("1"); + video.writeString("2"); + const ended = performance.now(); + video.close(); + + expect(await readAll(reader)).toEqual(["1", "2"]); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(4); + // Every counted stream arrived, so nothing waited out the grace. + expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); + } finally { + close(); + } + }, +); diff --git a/js/net/src/tail.ts b/js/net/src/tail.ts new file mode 100644 index 0000000000..e516f1ea08 --- /dev/null +++ b/js/net/src/tail.ts @@ -0,0 +1,125 @@ +import { type GetPromise, Signal } from "@moq/signals"; +import { Milli } from "./time.ts"; + +/** + * How long a subscriber waits for a group stream it cannot account for once the publisher + * has ended the subscription. + * + * A group reset before its header arrived leaves no trace, and QUIC does not order streams, + * so a stream opened before the end can still be in flight after it. This bounds the wait on + * IETF, and on moq-lite when the subscription has no max age to bound it with. + */ +export const TAIL_GRACE_MS = Milli(1000); + +// setTimeout truncates a longer delay to a signed 32-bit int and fires at once. +const MAX_TIMEOUT_MS = 2 ** 31 - 1; + +/** + * The group streams a subscription has received, so its end can wait for the ones still owed. + * + * A publisher ends a subscription before every group stream it opened has necessarily + * arrived. This records which sequences are accounted for (a stream's header arrived, or + * the publisher dropped them) and how many streams are still being read, and {@link settle} + * waits on them. + * + * @internal + */ +export class Tail { + // Disjoint, sorted, exclusive-end ranges of accounted sequences. A gap splits a range, so + // this stays as small as the number of gaps rather than the number of groups. + #accounted: [number, number][] = []; + // Group streams whose header arrived, and those still being read. + #streams = 0; + #active = 0; + #changed = new Signal(0); + + /** Group streams whose header arrived, whether they finished or were reset. */ + get streams(): number { + return this.#streams; + } + + /** + * Record a group stream whose header arrived. Returns the call that marks it read to + * its end, which is idempotent. + */ + open(sequence: number): () => void { + this.#streams += 1; + this.#active += 1; + this.#account(sequence, sequence + 1); + + let closed = false; + return () => { + if (closed) return; + closed = true; + this.#active -= 1; + this.#bump(); + }; + } + + /** Record sequences `[start, end)` as accounted for without a stream: dropped, or a datagram. */ + account(start: number, end: number): void { + this.#account(start, end); + } + + /** Whether every sequence in `[start, end)` is accounted for. */ + covers(start: number, end: number): boolean { + if (start >= end) return true; + // Ranges are merged on insert, so one range covers the span or none does. + return this.#accounted.some(([lo, hi]) => lo <= start && end <= hi); + } + + /** + * Wait until every stream is read to its end and `complete()` holds, or `grace` + * milliseconds pass with nothing left being read, or `closed` settles. + * + * A stream still being read is always waited for: a group ends on its own stream's FIN + * or reset, never because its track ended. The grace only gives up on streams that never + * arrived. + */ + async settle(complete: () => boolean, grace: Milli, closed: GetPromise): Promise { + let expired = false; + const timer = setTimeout( + () => { + expired = true; + this.#bump(); + }, + Math.min(grace, MAX_TIMEOUT_MS), + ); + try { + while (closed.peek() === undefined) { + if (this.#active === 0 && (expired || complete())) return; + await Signal.race(this.#changed, closed); + } + } finally { + clearTimeout(timer); + } + } + + #account(start: number, end: number): void { + if (start >= end) return; + + const merged: [number, number][] = []; + let lo = start; + let hi = end; + let placed = false; + for (const range of this.#accounted) { + if (range[1] < lo) { + merged.push(range); + } else if (hi < range[0]) { + if (!placed) merged.push([lo, hi]); + placed = true; + merged.push(range); + } else { + lo = Math.min(lo, range[0]); + hi = Math.max(hi, range[1]); + } + } + if (!placed) merged.push([lo, hi]); + this.#accounted = merged; + this.#bump(); + } + + #bump(): void { + this.#changed.update((revision) => revision + 1); + } +} diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index b5bdcd4af1..60836121ce 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1568,3 +1568,69 @@ test("malformed group bounds do not partially advance the cursor", () => { expect(track.tryRecvGroup()?.sequence).toBe(0); track.close(); }); + +test("finishAt refuses an end at or below a produced sequence", () => { + const producer = new TrackProducer("test"); + producer.writeGroup(new GroupProducer(5)); + + // Exclusive, so it must be above the highest produced sequence. + expect(() => producer.finishAt(5)).toThrow(); + expect(() => producer.finishAt(4)).toThrow(); + producer.finishAt(6); + + // A second end is refused, as is any group at or past the first. + expect(() => producer.finishAt(7)).toThrow(); + expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); + expect(() => producer.appendGroup()).toThrow(); + expect(() => producer.insertDatagram(6, Timestamp.now(), new Uint8Array(1))).toThrow(); + producer.close(); +}); + +test("finishAt declares an end ahead of the live edge without ending the track", async () => { + const producer = new TrackProducer("test"); + const track = producer.subscribe({ maxAge: Milli(60_000) }); + producer.writeGroup(new GroupProducer(5)); + const first = await track.recvGroup(); + expect(first?.sequence).toBe(5); + + producer.finishAt(8); + expect(track.final()).toBe(8); + expect(await track.finished()).toBe(8); + + // Not terminal: groups below the end still arrive, including a straggler. + const late = new GroupProducer(7); + late.close(); + producer.writeGroup(late); + const straggler = new GroupProducer(6); + straggler.close(); + producer.writeGroup(straggler); + expect((await track.recvGroup())?.sequence).toBe(6); + expect((await track.recvGroup())?.sequence).toBe(7); + + // A clean close keeps the declared end rather than the live edge. + producer.close(); + expect(await track.recvGroup()).toBeUndefined(); + expect(track.final()).toBe(8); + + // A late subscriber sees the same end. + expect(producer.subscribe().final()).toBe(8); +}); + +test("finished rejects when the track aborts or closes without an end", async () => { + const aborted = new TrackProducer("test"); + const pending = aborted.subscribe().finished(); + aborted.close(new Error("boom")); + await expect(pending).rejects.toThrow("boom"); + + const producer = new TrackProducer("test"); + const track = producer.subscribe(); + track.close(); + await expect(track.finished()).rejects.toThrow(); + + // A clean close is an end, so it resolves. + const clean = new TrackProducer("test"); + const reader = clean.subscribe(); + clean.appendGroup().close(); + clean.close(); + expect(await reader.finished()).toBe(1); +}); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index e94f0343fc..913d1ce70f 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -302,11 +302,12 @@ class TrackState { datagrams = new Signal([]); latest?: number; /** - * The exclusive final boundary, stamped when the producer closes cleanly: one past the - * highest sequence produced. Groups and datagrams share the namespace, so this can - * exceed `latest + 1` (which only tracks groups). Mirrors the Rust `final_sequence`. + * The exclusive final boundary, declared by {@link Producer.finishAt} or stamped by a + * clean close as one past the highest sequence produced. Groups and datagrams share the + * namespace, so this can exceed `latest + 1` (which only tracks groups). Mirrors the + * Rust `final_sequence`. */ - final?: number; + final = new Signal(undefined); closed = new Once(); update: Signal; /** Resolved once the producer commits the immutable properties. */ @@ -549,10 +550,8 @@ export class Producer { this.#prune(); for (const entry of this.#cache) this.#mirror(entry, sink); - if (closed !== undefined) { - sink.final = this.#state.final; - closeTrackState(sink, closed instanceof Error ? closed : undefined); - } + sink.final.set(this.#state.final.peek()); + if (closed !== undefined) closeTrackState(sink, closed instanceof Error ? closed : undefined); } // Recompute from every live sink because an update or close can narrow as well as widen @@ -669,11 +668,19 @@ export class Producer { this.#prune(); } - /** Append a new group with the next sequence number. */ - appendGroup(): GroupProducer { + // Refuse a write once the track is closed, or at or past its declared end. + #writable(sequence: number): void { if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + const final = this.#state.final.peek(); + if (final !== undefined && sequence >= final) { + throw new Error(`sequence ${sequence} is at or past the track's end ${final}`); + } + } + /** Append a new group with the next sequence number. */ + appendGroup(): GroupProducer { const sequence = this.#sequence; + this.#writable(sequence.next); const group = new GroupProducer(sequence.next); sequence.next = group.sequence + 1; this.#publish(group); @@ -690,7 +697,7 @@ export class Producer { * entry is already gone, so a long-evicted sequence is accepted as new. */ writeGroup(group: GroupProducer) { - if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + this.#writable(group.sequence); const existing = this.#cache.findIndex((entry) => entry.group.sequence === group.sequence); if (existing >= 0) { @@ -735,11 +742,11 @@ export class Producer { * relay preserving upstream numbering uses {@link insertDatagram}. */ appendDatagram(timestamp: Timestamp, payload: Uint8Array): number { - if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); - if (payload.byteLength > MAX_DATAGRAM_BYTES) throw new Error("datagram payload too large"); - const counter = this.#sequence; const sequence = counter.next; + this.#writable(sequence); + if (payload.byteLength > MAX_DATAGRAM_BYTES) throw new Error("datagram payload too large"); + counter.next = sequence + 1; this.#publishDatagram({ sequence, timestamp, payload }); return sequence; @@ -753,7 +760,7 @@ export class Producer { * apply. Most origin publishers want {@link appendDatagram} instead. */ insertDatagram(sequence: number, timestamp: Timestamp, payload: Uint8Array) { - if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + this.#writable(sequence); if (payload.byteLength > MAX_DATAGRAM_BYTES) throw new Error("datagram payload too large"); const counter = this.#sequence; @@ -763,12 +770,41 @@ export class Producer { this.#publishDatagram({ sequence, timestamp, payload }); } - /** Close the track and every subscriber, mirroring the abort to their groups. Idempotent. */ + /** + * Declare the track's exclusive end, possibly ahead of the live edge, mirroring the Rust + * `finish_at`. + * + * `final` is the first sequence that will never be produced, so a track whose last group + * is 89 finishes at 90. Groups and datagrams below it are still accepted; anything at or + * above it is refused. Unlike {@link close} it is not terminal: call `close()` once the + * remaining groups are written. Throws if the track is closed, already has an end, or + * `final` is at or below a sequence already produced. + */ + finishAt(final: number): void { + if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + if (!Number.isSafeInteger(final) || final < 0) throw new RangeError(`invalid track end: ${final}`); + const declared = this.#state.final.peek(); + if (declared !== undefined) throw new Error(`track already ends at ${declared}`); + if (final < this.#sequence.next) { + throw new Error(`track end ${final} is below the next sequence ${this.#sequence.next}`); + } + this.#declareFinal(final); + } + + #declareFinal(final: number): void { + this.#state.final.set(final); + for (const sink of this.#sinks) sink.final.set(final); + } + + /** + * Close the track and every subscriber, mirroring the abort to their groups. Idempotent. + * + * A clean close keeps the end {@link finishAt} declared, or declares one past the highest + * sequence produced; an abort ends without one. + */ close(abort?: Error) { - // A clean close declares the final boundary; an abort ends without one. - if (abort === undefined && this.#state.closed.peek() === undefined) { - this.#state.final = this.#sequence.next; - for (const sink of this.#sinks) sink.final = this.#state.final; + if (abort === undefined && this.#state.closed.peek() === undefined && this.#state.final.peek() === undefined) { + this.#declareFinal(this.#sequence.next); } closeTrackState(this.#state, abort); clearTimeout(this.#pruneTimer); @@ -999,13 +1035,35 @@ export class Subscriber { } /** - * The track's exclusive final boundary, known once the producer closes cleanly: - * one past the highest sequence produced, or 0 for a track that produced none. - * Groups and datagrams share the sequence namespace, so this can exceed - * `latest() + 1`. Undefined while the track is live or after an abort. + * The track's exclusive final boundary: the end {@link Producer.finishAt} declared, which + * can be ahead of the live edge, or one past the highest sequence produced once the + * producer closes cleanly (0 for a track that produced none). Groups and datagrams share + * the sequence namespace, so this can exceed `latest() + 1`. Undefined until declared, + * and after an abort that declared none. */ final(): number | undefined { - return this.#state.final; + return this.#state.final.peek(); + } + + /** + * Resolve with the track's exclusive final boundary once it is known, mirroring the Rust + * `finished`. + * + * Resolves as soon as the end is declared, which may be ahead of the live edge, so it + * says nothing about every group having arrived: read until the cursor returns + * `undefined` for that. Rejects with the abort, or if the track closes without an end. + */ + async finished(): Promise { + for (;;) { + const final = this.#state.final.peek(); + if (final !== undefined) return final; + + const closed = this.#state.closed.peek(); + if (closed instanceof Error) throw closed; + if (closed !== undefined) throw new Error("track closed before its end was known"); + + await Signal.race(this.#state.final, this.#state.closed); + } } /** @@ -1158,9 +1216,15 @@ export class Subscriber { } // Package-internal readiness half of recvGroup. Each registration fires at most once, and - // the caller disposes the losers after whichever source wakes it. + // the caller disposes the losers after whichever source wakes it. A declared end wakes it + // too, so a publisher can forward the end before the live edge reaches it. #groupChanged(fn: () => void): Dispose { - const dispose = [this.#state.groups.changed(fn), this.#cursor.changed(fn), this.#state.closed.changed(fn)]; + const dispose = [ + this.#state.groups.changed(fn), + this.#cursor.changed(fn), + this.#state.closed.changed(fn), + this.#state.final.changed(fn), + ]; return () => { for (const close of dispose) close(); }; @@ -1410,6 +1474,11 @@ export class Ordered { return this.#subscriber.final(); } + /** Resolve with the track's exclusive final boundary once known; see {@link Subscriber.finished}. */ + finished(): Promise { + return this.#subscriber.finished(); + } + /** Limit subsequent reads to these groups and return this reader for chaining. */ withGroups(groups: Groups): this { this.setGroups(groups); diff --git a/quest/m1/README.md b/quest/m1/README.md index 95586d0b02..3aff7303c9 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -20,7 +20,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Play harness](/quest/m1/play-harness.md) - moq play's tune-in, rendition-switch, and drain logic runs in per-PR CI without a device - [Missing fetch group](/quest/m1/fetch-missing-group.md) - HTTP /fetch answers 404 and `moq fetch` fails cleanly for a group the track lacks - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts -- [JS track tail](/quest/m1/js-track-tail.md) - a `@moq/net` subscriber delivers every group up to the declared end over lite and IETF, and JS publishers drain their groups before ending a subscription - [lite-07 stream count](/quest/m1/lite-stream-count.md) - moq-lite-07 replaces SUBSCRIBE_DROP with a group-stream count in SUBSCRIBE_END, like moq-transport - [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them - [Rust track tail](/quest/m1/rust-track-tail.md) - a moq-net subscriber accepts groups that arrive after the subscription's end, and PublishDone carries the real stream count diff --git a/quest/m1/js-track-tail.md b/quest/m1/js-track-tail.md deleted file mode 100644 index 8fb114d8b4..0000000000 --- a/quest/m1/js-track-tail.md +++ /dev/null @@ -1,111 +0,0 @@ -# [L] JS track tail - -## Goal - -A `@moq/net` subscriber delivers every stream-delivered group of a track up to -the publisher's declared end, over moq-lite and IETF alike, then ends cleanly. A group cut off -mid-read is never presented as complete. JS publishers finish their group -streams before they end a subscription, as the drafts require. A browser -publisher can also declare a track's end ahead of the live edge, and a browser -consumer can await it, matching Rust's `finish_at` and `finished()`. - -## Plan - -Today the tail of a track can be lost in the browser: - -- The lite subscriber discards SUBSCRIBE_END and closes the track for good - when the subscribe stream FINs. A group stream that arrives later is - dropped, and a group still being read is closed cleanly, so a truncated - group looks whole. `final()` is stamped from the highest group received, - not the declared end. The IETF subscriber does the same on PublishDone. -- The lite publisher FINs the subscribe stream while its group streams are - still being written (`void this.#runGroup`), against the moq-lite draft: - "The publisher closes the stream (FIN) only once every group from start to - end has been accounted for". The IETF publisher sends PublishDone the same - way. Rust drains its group tasks first. -- Even a compliant publisher races: QUIC does not order streams, so a group - below the boundary can arrive after the FIN. - -Build the primitive first, which absorbs #2318's remaining work: - -- `Track.Producer.finishAt(final)`, mirroring Rust's `finish_at`: the boundary - must exceed the highest produced sequence; groups below it are still - accepted and groups at or above it are refused. Unlike `close()`, it is not - terminal. -- Feed the boundary into the consumer's `final()`, so a remote clean end is - observable before the live edge reaches it. -- An awaitable `finished()` twin of `final()`, mirroring Rust: it resolves - with the boundary once known and rejects on abort. - -Then the subscribers, lite and IETF: - -- The declared end calls `finishAt`, and the subscribe stream's FIN no longer - closes the track. On moq-lite it is SUBSCRIBE_END. On IETF it depends on the - draft: draft-07's SUBSCRIBE_DONE carries a Final Group and Object, while on - drafts 14-22 PUBLISH_DONE carries no location, so the boundary comes from the - END_OF_TRACK object. Verify this against each draft. -- A PublishDone whose status is an error (INTERNAL_ERROR or similar) aborts - the track with it; only a clean status (TRACK_ENDED or equivalent) ends it - cleanly. -- Keep accepting groups below the boundary until each is accounted for: - completed, reset, dropped via SUBSCRIBE_DROP, or covered by the stream - count. Then end cleanly. -- A group reset before its header arrived can never be accounted for, so - after the boundary is known the subscriber gives up on missing groups after - a grace, then ends cleanly, skipping them like any stale group: - - moq-lite: the subscription's effective `max_age` (the smaller of the - subscriber's and the track's), used as a wall-clock duration. This is the - wrong clock on purpose, as a stopgap: `max_age` measures presentation-time - drift and elsewhere never adds wall-clock delay. Define a fallback for a - subscription and track with no `max_age`, since a zero grace reintroduces - the race. The correct fix is the publisher sending SUBSCRIBE_DROP for - every group it reset or never finished, which accounts for every group - with no timer; reliable reset is probably better still. - - IETF: a bounded wall-clock wait, since moq-transport has no per-group drop - and itself says subscribers SHOULD use a timeout here. Settle its value. - - The reliable-reset quest removes both waits once a reset group stream - keeps its header. -- The complete-tail guarantee covers groups delivered on streams only. - Datagrams are unreliable by design and a lost one leaves no signal, so the - subscriber never waits for a datagram-delivered sequence, and a - datagram-only subscription ends at the boundary at once. -- A group ends on its own stream's FIN or reset, never because its track - ended. A reset aborts the group. - -And the publishers: - -- Lite FINs the subscribe stream only after every group stream it started has - finished or been reset. -- IETF sends PublishDone after the same drain, with the real number of data - streams it opened instead of the hardcoded 0. -- IETF on drafts 14-22 writes the END_OF_TRACK object at the boundary; today - it writes only GROUP_END (`js/net/src/ietf/object.ts`), so no in-repo - subscriber could learn the end. Assert the boundary end to end. -- A received stream count is a hint: stop waiting once that many streams are - accounted for, but accept a late stream below the boundary within the grace. - A published peer's 0 then behaves like today's lite FIN plus the grace. - -Confirm Stream Count's meaning for the implemented IETF drafts (07, 14-22) -before relying on it, and bring any draft that disagrees back as a question. - -Reproduce each race deterministically before fixing it. The mock transport -(`js/net/src/mock.ts`) delivers streams in creation order, so drive the -publisher by hand: answer TRACK_INFO, write SUBSCRIBE_START, open a group -stream and write part of it, then write SUBSCRIBE_END and FIN, then finish -the group or open another one. `gateWrites` in -`js/net/src/lite/publisher.test.ts` holds a stream's writes. Cover a late -group, a mid-read group, a reset group, a missing group resolved by the -grace, and both publishers draining. - -Additive on `@moq/net`, so it targets `main`. The wire fix to the publishers -and to stream_count follows the published drafts, which already required it. - -## Closes - -- [#2318](https://github.com/moq-dev/moq/issues/2318) - close this issue when the quest finishes - -## Related - -- [Rust track tail](/quest/m1/rust-track-tail.md) - the same rule in moq-net, so local and remote readers match -- [Session death error](/quest/m1/session-death-error.md) - the other way a JS track ends wrong: cleanly instead of with the error -- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - removes the grace once a reset group stream keeps its header diff --git a/quest/m1/quic/reliable-reset.md b/quest/m1/quic/reliable-reset.md index aebca66342..ed01ee6620 100644 --- a/quest/m1/quic/reliable-reset.md +++ b/quest/m1/quic/reliable-reset.md @@ -66,8 +66,8 @@ provisional codepoints if the document changes before release. ## Related -- [JS track tail](/quest/m1/js-track-tail.md) and [Rust track tail](/quest/m1/rust-track-tail.md) - - wait a grace for a group whose reset lost its header, until this lands +- [Rust track tail](/quest/m1/rust-track-tail.md) - waits a grace for a group + whose reset lost its header until this lands, as `@moq/net` already does - [qmux on the QUIC stream state machine](/quest/m1/quic/qmux.md) - consumes the same reset state without a parallel implementation - The removed quiche backend was the one stack that had this, so it is the diff --git a/quest/m1/rust-track-tail.md b/quest/m1/rust-track-tail.md index 5af1ff663d..2da144893a 100644 --- a/quest/m1/rust-track-tail.md +++ b/quest/m1/rust-track-tail.md @@ -22,24 +22,27 @@ remaining gap is the subscriber's bookkeeping: `Error::Cancel` (`ietf/subscriber.rs`, `Alias::Retired`). Retire it only once the streams are accounted for or the grace expires. - Grace: a group reset before its header arrived can never be accounted for, - so give up after the same grace as the JS quest and end cleanly, skipping - the missing group as stale: the effective `max_age` as a wall-clock stopgap - on moq-lite (with a fallback when none is set), and a bounded wall-clock - wait on IETF. The reliable-reset quest removes both. + so give up after the same grace as `@moq/net` (`js/net/src/tail.ts`) and end + cleanly, skipping the missing group as stale: the effective `max_age` as a + wall-clock stopgap on moq-lite, 1s when it is zero, and 1s on IETF. The + reliable-reset quest removes both. - Only stream-delivered groups are waited for; datagrams are never. - IETF publisher: send the real number of data streams opened in PublishDone instead of `stream_count: 0`. On receipt, treat the count as a hint: stop waiting once that many are accounted for, but accept a late stream below the boundary within the grace, so a published peer's 0 keeps working. -- IETF publisher on drafts 14-22: write the END_OF_TRACK object at the - boundary, which moq-net does not send today, and assert the - boundary end to end. - -Confirm Stream Count's meaning for the implemented IETF drafts first, and -where each draft carries the track's end (draft-07's SUBSCRIBE_DONE Final -Group and Object, or the END_OF_TRACK object on drafts 14-22), as the JS quest -does; both must agree. A PublishDone with an error status aborts the track -rather than ending it cleanly. +- IETF on drafts 14-22: write the END_OF_TRACK object at the boundary, and + decode it. `@moq/net` already sends it on its own stream at object 0 of + the group `final`, after the group streams drain; moq-net's subscriber + rejects status 0x4 as `Unsupported` today, so it aborts a bogus group + `final` at the end of every JS-published IETF track until this lands. + +`@moq/net` settled the draft reading: on 14-22 Stream Count counts every data +stream opened, fill streams included (20+), with a 2^62-1 or 2^64-1 unknown +sentinel. PUBLISH_DONE carries no end location on any of them, so the end is +the END_OF_TRACK object: at object 0 of group G the track ends at G, +otherwise at G+1. Only TRACK_ENDED (and SUBSCRIPTION_ENDED before 20) ends a +track cleanly; an error status aborts it. Reproduce each case before fixing it: a group header decoded after the subscribe stream's FIN, and a late stream after PublishDone, over the mock @@ -53,6 +56,5 @@ in flight. ## Related -- [JS track tail](/quest/m1/js-track-tail.md) - the same rule in `@moq/net` - [Session death error](/quest/m1/session-death-error.md) - tracks ending wrong when the session dies - [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - removes the grace diff --git a/quest/m1/session-death-error.md b/quest/m1/session-death-error.md index 11df708a9a..223bfbaab6 100644 --- a/quest/m1/session-death-error.md +++ b/quest/m1/session-death-error.md @@ -56,5 +56,4 @@ controls: a finished track still ends clean while its session lives. ## Related -- [JS track tail](/quest/m1/js-track-tail.md) - the clean-end half: a track that did end is delivered whole -- [Rust track tail](/quest/m1/rust-track-tail.md) - the same in moq-net +- [Rust track tail](/quest/m1/rust-track-tail.md) - the clean-end half: a track that did end is delivered whole From fbbcb25cc2ad4675331e348cfdae4658a100d13a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 07:18:23 -0700 Subject: [PATCH 07/21] test(js): assert retention on reactions and listeners, not a GC heap count (#4142) Co-authored-by: Claude Opus 5.5 --- js/binary/src/stream/stream.test.ts | 37 +++++++----- js/json/src/stream/stream.test.ts | 37 +++++++----- js/watch/src/retention.test.ts | 93 ++++++++++++++++++++++++----- js/watch/src/sync.test.ts | 20 ------- 4 files changed, 125 insertions(+), 62 deletions(-) diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index b8745147fb..4b21ace239 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -1,5 +1,4 @@ -import { heapStats } from "bun:jsc"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { DEFAULT_MAX_FRAME_SIZE } from "@moq/flate"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -148,6 +147,24 @@ test("an undecodable payload ends the log for a reader already inside the group" await expect(consumer.next()).rejects.toThrow("limit"); }); +// Counts the reactions `run` attaches to promises still pending once it returns. A promise holds each +// reaction until it settles, so one left per iteration on a promise that outlives the loop is a leak. +// Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + // A blocked read races the frame against the track's next group, which stays pending for the whole // log. Racing it per payload must not leave a reaction behind on it each time. test("blocked reads leave nothing behind on the pending group read", async () => { @@ -155,23 +172,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => const producer = new Producer({ track }); const subscriber = track.subscribe(); const consumer = new Consumer({ track: subscriber }); - const promises = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.Promise ?? 0; - }; - const read = async (from: number, count: number) => { - for (let n = from; n < from + count; n++) { + const reactions = await pendingReactions(async () => { + for (let n = 0; n < 1000; n++) { const next = consumer.next(); producer.append(new Uint8Array([n & 0xff])); expect((await next)?.[0]).toBe(n & 0xff); } - }; - - await read(0, 50); - const before = promises(); - await read(50, 1000); - expect(promises() - before).toBeLessThan(100); + }); + expect(reactions).toBeLessThan(10); subscriber.close(); producer.finish(); diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 35512bac88..1e342998c2 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -1,5 +1,4 @@ -import { heapStats } from "bun:jsc"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -112,6 +111,24 @@ test("a second concurrent read is refused rather than served the first one's gro expect(await first).toEqual({ n: 0 }); }); +// Counts the reactions `run` attaches to promises still pending once it returns. A promise holds each +// reaction until it settles, so one left per iteration on a promise that outlives the loop is a leak. +// Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + // A blocked read races the frame against the track's next group, which stays pending for the whole // log. Racing it per record must not leave a reaction behind on it each time. test("blocked reads leave nothing behind on the pending group read", async () => { @@ -119,23 +136,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => const producer = new Producer({ track }); const subscriber = track.subscribe(); const consumer = new Consumer({ track: subscriber }); - const promises = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.Promise ?? 0; - }; - const read = async (from: number, count: number) => { - for (let n = from; n < from + count; n++) { + const reactions = await pendingReactions(async () => { + for (let n = 0; n < 1000; n++) { const next = consumer.next(); producer.append({ n }); expect((await next)?.n).toBe(n); } - }; - - await read(0, 50); - const before = promises(); - await read(50, 1000); - expect(promises() - before).toBeLessThan(100); + }); + expect(reactions).toBeLessThan(10); subscriber.close(); producer.finish(); diff --git a/js/watch/src/retention.test.ts b/js/watch/src/retention.test.ts index af0af0860e..3afeee39ea 100644 --- a/js/watch/src/retention.test.ts +++ b/js/watch/src/retention.test.ts @@ -1,16 +1,83 @@ -import { heapStats } from "bun:jsc"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { Container } from "@moq/hang"; import * as Moq from "@moq/net"; import { Time } from "@moq/net"; -import { Effect } from "@moq/signals"; +import { Effect, Signal } from "@moq/signals"; import { nextMedia, subscribeMedia } from "./media"; import { Sync } from "./sync"; +// These count what is still attached rather than the heap: `Bun.gc` scans the stack conservatively, +// so a stale pointer can pin thousands of dead cells and fail a heap count under load. + +// Reactions `run` attaches to promises still pending once it returns, which each hold until they +// settle. Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + +// Signal listeners `run` registers that neither fired nor were disposed by the time it returns. +async function pendingListeners(run: () => Promise): Promise { + const listening = new Set(); + const changed = Signal.prototype.changed; + const spy = spyOn(Signal.prototype, "changed").mockImplementation(function ( + this: Signal, + fn?: (value: unknown) => void, + ) { + if (!fn) return (changed as () => Promise).call(this); + const token = {}; + listening.add(token); + const dispose = changed.call(this, (value) => { + listening.delete(token); + fn(value); + }); + return () => { + listening.delete(token); + dispose(); + }; + } as typeof changed); + try { + await run(); + } finally { + spy.mockRestore(); + } + return listening.size; +} + +// Frames sleep on the clock once each, so a sleep that keeps anything on a clock that never changes +// piles it up for the life of the player. +test("waits on a stable clock leave nothing behind", async () => { + const sync = new Sync({ delay: Time.Milli(10) }); + // Let the delay effect run before anchoring. + await new Promise((resolve) => setTimeout(resolve, 0)); + sync.received(Time.Milli.now()); + + const wait = async () => { + for (let round = 0; round < 10; round++) { + const now = Time.Milli.now(); + await Promise.all(Array.from({ length: 100 }, () => sync.wait(now))); + } + }; + expect(await pendingReactions(wait)).toBe(0); + expect(await pendingListeners(wait)).toBe(0); + + sync.close(); +}); + // The player path a decoder drives, one frame per group like AAC audio: the container consumer // reads each frame, the shared clock anchors on it, and presentation waits on the clock against -// the effect's teardown. Retention anywhere along it grows the heap with the frame count. -test("a long subscription through the player path keeps a flat heap", async () => { +// the effect's teardown. Retention anywhere along it grows with the frame count. +test("a long subscription through the player path leaves nothing behind", async () => { const broadcast = new Moq.Broadcast.Producer(); // A tiny publisher window, so the track's own replay cache stays flat too. const track = broadcast.createTrack("audio", { maxAge: Time.Milli(1) }); @@ -47,15 +114,13 @@ test("a long subscription through the player path keeps a flat heap", async () = await Promise.all(presenting); }; - const heap = () => { - Bun.gc(true); - return heapStats().objectCount; - }; - - await play(200); - const before = heap(); - await play(2000); - expect(heap() - before).toBeLessThan(1000); + // What stays is bounded by the track's window of open groups, not the frame count. + let reactions = 0; + const listeners = await pendingListeners(async () => { + reactions = await pendingReactions(() => play(2000)); + }); + expect(reactions).toBeLessThan(100); + expect(listeners).toBeLessThan(100); consumer.close(); effect.close(); diff --git a/js/watch/src/sync.test.ts b/js/watch/src/sync.test.ts index 2376f625e9..d8d18764b8 100644 --- a/js/watch/src/sync.test.ts +++ b/js/watch/src/sync.test.ts @@ -1,4 +1,3 @@ -import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; @@ -99,25 +98,6 @@ describe("delay and buffer", () => { }); describe("wait", () => { - const promises = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.Promise ?? 0; - }; - - it("leaves nothing behind on a stable clock", async () => { - const sync = new Sync({ delay: 10 as Time.Milli }); - await flush(); - sync.received(Time.Milli.now()); - - const before = promises(); - for (let round = 0; round < 10; round++) { - const now = Time.Milli.now(); - await Promise.all(Array.from({ length: 100 }, () => sync.wait(now))); - } - expect(promises() - before).toBeLessThan(100); - sync.close(); - }); - it("wakes a sleeping wait when the delay switches to instant", async () => { const delay = new Signal(10_000 as Time.Milli); const sync = new Sync({ delay }); From 5217cb881b41a274109f29533a77047a4552e13b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 07:53:23 -0700 Subject: [PATCH 08/21] feat(moq-mux): catalog delay measures cross-track encoder lateness (#4123) Co-authored-by: Claude Opus 5.5 --- quest/m1/jitter-flush-clock.md | 97 ++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/quest/m1/jitter-flush-clock.md b/quest/m1/jitter-flush-clock.md index 8c30505e14..e22454e1c3 100644 --- a/quest/m1/jitter-flush-clock.md +++ b/quest/m1/jitter-flush-clock.md @@ -38,21 +38,47 @@ media span of each emitted batch and advertise no `delay`. `flush` but no discontinuity, so a binding publisher that pauses and resumes on a re-anchored PTS within the window would count the pause; add one when such a caller appears. -- **Measurement.** `catalog::Estimator` gains an additive `flush(timestamp, - now)` observation next to the clock-free `write`. Lateness is - `now - timestamp`. Each rendition keeps its own baseline, the minimum - lateness over a sliding window (about 10 s) rather than the lifetime - minimum, so a media clock that drifts slower than wall time does not ratchet - forever. The broadcast baseline, on `catalog::Producer` and shared by every - rendition, is the minimum of those. `delay` is the rendition baseline minus - the broadcast baseline; `jitter` is `lateness - rendition baseline`. Each is - reported as its lifetime maximum. +- **Measurement.** Lateness is `now - timestamp`, observed by the existing + `flush` calls, so no call site changes. Each rendition keeps its own + baseline, the minimum lateness over a sliding window (about 10 s), so a media + clock that drifts slower than wall time does not ratchet forever. The + broadcast baseline, on `catalog::Producer` and shared by every rendition + that flushes, is the minimum of those; the per-rendition `Baseline` epochs + must become one shared epoch for the subtraction to mean anything. `delay` is + the rendition baseline minus the broadcast baseline, reported as its lifetime + maximum. Renditions that never flush advertise no `delay`. - **Open:** lifetime maxima taken against a sliding baseline stop sharing an origin when the earliest rendition changes. If A starts at 0 and B at 200 ms, B keeps `delay: 200`; if A then drifts to 500 ms, A advertises 300 and `Sync` computes `300 - 200 = 100` while the tracks are 300 ms apart. - Settle a fixed common origin, coordinated rebasing, or no subtraction - before implementing. + Options: + - *No subtraction (recommended).* Keep the sliding baselines and never-lower, + and change the player rule to `max(delay + jitter)` over the subscribed + renditions, dropping `- min(delay)`. The subscribed renditions' true spread + is measured from an earliest subscribed baseline no earlier than the + broadcast baseline, so it never exceeds the largest advertised `delay`, + and the catalog alone can never under-buffer. The cost is over-buffering by + `min(delay)` when the broadcast's earliest rendition is not subscribed + (a video-only viewer of a broadcast whose audio leads by 200 ms pays + 200 ms). Dropping a slow track still lowers latency. The draft says a + consumer MUST NOT subtract `delay` values across renditions. + - *Fixed common origin.* Measure every `delay` from the broadcast's first + lateness. Exact subtraction, but common drift raises every rendition + together, so values grow without bound and the catalog republishes for the + life of the broadcast; the problem the sliding window exists to avoid. + - *Coordinated rebasing.* Advertise each `delay` as its current value against + the current broadcast baseline and let it fall. Exact, but `delay` gives up + never-lower, the catalog churns as baselines move, and the player must + shrink safely. + - *No `delay` field.* The player measures each subscribed track's own + arrival baseline and sizes by their spread. No wire change, and it also + covers gateway and ingest offsets, but a track's offset is unknown until its + first frames arrive, so subscribing to a slower rendition glitches once. + Every option also needs the player's reference to follow the earliest + subscribed track's current arrival rather than its lifetime minimum, since + `Sync.received` only ever lowers it; that is receiver-side and belongs with + the arrival minimum the [audio jitter target](/quest/m0/audio-jitter-target/README.md) + already expires. - A faster-than-real-time source flushes early; each frame becomes the new minimum and both stay at zero, which is correct for something that is not live. @@ -60,45 +86,24 @@ media span of each emitted batch and advertise no `delay`. renditions in `rs/hang`, `js/hang`, and `drafts/draft-lcurley-moq-hang.md`, serialized with `MillisCeil` and zero-as-absent like `jitter`. Additive: today's `jitter` never included a cross-track offset. Extend the draft's - never-lower rule to `delay`, and define both in terms of lateness. -- **Call sites:** the `moq-video` and `moq-audio` encode producers, the capture - path in `moq import capture`, `moq-gst`, and `libmoq` (so OBS). `js/publish` - mirrors the measurement in the video and audio encoders and replaces its - fixed `ceil(1000 / framerate)` and frame-duration hints. `container::Producer` - does not call it on its own. -- Replace the provisional PTS-gap floor so a decode-order sequence such as - `0, 120, 40, 80` ms does not permanently advertise its first 120 ms gap - when the reorder delay is only 80 ms. Preserve the never-lower rule for - measurements already advertised. -- **Enforce never-lower at the publisher**, not only by convention. - `js/publish/src/catalog.ts:25-29` already refuses a decrease and a zero for - audio and video jitter; Rust does not. Add a `moq_mux::Error` for a - decreased estimate (covering both fields) and return it from - `Rendition::set`, `Rendition::replace`, and `Rendition::estimate` - (`rs/moq-mux/src/catalog/tracks.rs`). Extend the `MillisCeil` serialization - (`rs/hang/src/catalog/millis.rs`) to `TextConfig` - (`rs/hang/src/catalog/text/mod.rs:124`), and mirror the zero-as-absent - normalization in `js/hang/src/catalog/text.ts` and the text section of - `js/publish/src/catalog.ts`. The `js/publish` check covers `delay` as well - as `jitter`, in every section that carries them. + never-lower rule to `delay` (unless rebasing wins), along with + `moq_mux::Error::JitterDecreased` and the `js/publish/src/catalog.ts` check. - **Player.** `Sync` registers each subscribed rendition's `delay` and - `jitter` and computes `max(delay + jitter) - min(delay)`; it recomputes when - a rendition registers, unregisters, or its catalog entry rises. When the - earliest subscribed rendition's `delay` rises, `Sync` re-anchors its - reference later by that amount instead of leaving it at the old earliest - arrival, or every other track under-buffers. Rename `Sync`'s own `delay` - output (the resolved playout total) so it does not collide with the field. + `jitter` and recomputes when a rendition registers, unregisters, or its + catalog entry rises. Rename `Sync`'s own `delay` output (the resolved + playout total) so it does not collide with the field. #3954 on the + [audio jitter target](/quest/m0/audio-jitter-target/README.md) line changes + what `register()` takes, so build on whichever lands first. - **Docs.** Update `doc/concept/audio-jitter.md`, the normative playout page, with the cross-track rule and the catalog floor it reads. -- **Tests** inject the clock. Cover: a batch flushed at its end reports the - batch as jitter, a constant offset reports as `delay` on the slower track - and nothing on the faster, a slow drift stays bounded, a decrease is refused - on every site, and `Sync` resizes on a subscription change and re-anchors on - a rising earliest `delay`. +- **Tests** inject the clock. Cover: a constant offset reports as `delay` on + the slower track and nothing on the faster, a slow common drift stays + bounded, the earliest rendition changing does not under-buffer, a decrease is + refused on every site, and `Sync` resizes on a subscription change. -Public API: additive `delay` field in `hang` and `@moq/hang`, a new -`moq_mux::Error` variant, `Sync` output rename in `@moq/watch`. Wire: one -optional catalog field. +Public API: additive `delay` field in `hang` and `@moq/hang`, the +decreased-estimate error extended to `delay`, `Sync` output rename in +`@moq/watch`. Wire: one optional catalog field. ## Related From 051930d357d4adb0d967e40c2c7974746431a0b0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 08:29:05 -0700 Subject: [PATCH 09/21] feat(moq-mux): publish data tracks into an application's own catalog section (#4089) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/concept/hang.md | 9 +- doc/lib/rs/moq-mux.md | 33 ++++ drafts/draft-lcurley-moq-hang.md | 11 ++ js/hang/src/catalog/binary.ts | 13 ++ js/hang/src/catalog/json.ts | 13 ++ js/publish/src/catalog.test.ts | 42 ++++ js/publish/src/catalog.ts | 26 ++- quest/m1/README.md | 1 - quest/m1/data-jitter.md | 10 +- quest/m1/data-sections.md | 66 ------- quest/m2/teleop/robot.md | 6 +- rs/hang/src/catalog/binary.rs | 20 ++ rs/hang/src/catalog/json.rs | 20 ++ rs/hang/src/catalog/root.rs | 62 ++++++ rs/moq-mux/src/binary.rs | 299 +++++++++++++++++++++++++---- rs/moq-mux/src/catalog/data.rs | 128 ++++++++++++ rs/moq-mux/src/catalog/mod.rs | 3 + rs/moq-mux/src/catalog/producer.rs | 43 +++-- rs/moq-mux/src/catalog/tracks.rs | 96 ++++++--- rs/moq-mux/src/error.rs | 4 + rs/moq-mux/src/json.rs | 191 ++++++++++++++---- 21 files changed, 901 insertions(+), 195 deletions(-) delete mode 100644 quest/m1/data-sections.md create mode 100644 rs/moq-mux/src/catalog/data.rs diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 22699656f8..c95fa3f2e4 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -104,7 +104,8 @@ document would silently discard everything but the last payload: The rest is descriptive: `compression` (`deflate`, the same group-scoped `deflate-raw` the catalog uses), `schema` on a JSON track, `mime` on a binary -one, plus the optional `broadcast` reference. A +one, `bitrate` and `jitter` with the same meaning as for media, plus the +optional `broadcast` reference. A consumer that doesn't recognize a `mode` or `compression` ignores that track and round-trips it verbatim. @@ -116,6 +117,12 @@ or `catalog.binary.tracks`, then pair its name and config with `moq_publish_binary_*` do the same, retracting on `_finish`. In the browser, read the same map, subscribe by name, and hand the track to `@moq/json` or `@moq/binary`. +An application with its own per-track fields can list a data track in its own +root section instead, flattening the JSON or binary entry beside those fields +so there is one entry per track. Name the section with a namespaced key such as +`com.example.mavlink`. A generic consumer only finds tracks in `json` and +`binary`. + ## Container The `container.kind` on each rendition says how frames are framed: diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 613f9e2b52..a3fd8d42e8 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -47,6 +47,39 @@ before the edit is retained, including while the initial catalog is reserved. Codec importers propagate catalog and media errors through their configuration and frame-writing methods. +Data tracks go through the catalog too. `catalog.json_stream(track, config)` +(or `json_snapshot`, `binary_snapshot`, `binary_stream`) writes the track's +`json` or `binary` entry, measures an absent `bitrate` from the writes, and +retires the entry when the producer drops. To list the track in your own +section beside application fields, pass that section's entry instead of a +`json::Config` or `binary::Config`: any `RenditionConfig` that embeds the data +config through `AsMut`. + +```rust +#[derive(Serialize, Deserialize, Clone)] +struct Mavlink { + #[serde(flatten)] + binary: hang::catalog::BinaryConfig, // mode, compression, bitrate, ... + sysid: u8, +} + +impl AsMut for Mavlink { + fn as_mut(&mut self) -> &mut hang::catalog::BinaryConfig { + &mut self.binary + } +} + +// Plus `RenditionConfig` writing to `catalog.ext.mavlink`, a map +// serialized under the `com.example.mavlink` root key. +let binary = hang::catalog::BinaryConfig::new(hang::catalog::Mode::Stream); +let mut telemetry = catalog.binary_stream(track, Mavlink { binary, sysid: 1 })?; +telemetry.append(packet)?; +``` + +The producer sets the entry's `mode` and encodes the track with its +`compression`. Read it back from `Catalog` and subscribe with +`catalog::Entry::new(name, &entry.binary)`. + ```bash cargo add moq-mux ``` diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index ed250cf3fa..30d711e3c9 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -134,6 +134,7 @@ type Catalog = { ~~~ Additional fields MAY be added based on the application. +An application SHOULD name its own root sections with a namespaced key, such as a reverse-DNS name (`com.example.telemetry`), so they cannot collide with a section a later version of this specification defines. The catalog SHOULD be mostly static, delegating any dynamic content to other tracks. For example, a chat entry should name a chat track, not carry individual chat messages. @@ -388,6 +389,8 @@ type JsonSchema = { "compression": Compression | undefined, "schema": string | undefined, "broadcast": string | undefined, + "bitrate": number | undefined, + "jitter": number | undefined, } ~~~ @@ -401,6 +404,8 @@ type BinarySchema = { "compression": Compression | undefined, "mime": string | undefined, "broadcast": string | undefined, + "bitrate": number | undefined, + "jitter": number | undefined, } ~~~ @@ -456,6 +461,10 @@ A `snapshot` group covers a single value (plus any deltas), so its window spans ### broadcast {#data-shared} The `broadcast` field carries the same meaning here as it does for a media rendition ({{field-broadcast}}). +### bitrate and jitter {#data-estimates} +The optional `bitrate` field is the track's maximum bitrate in bits per second. +The optional `jitter` field carries the same meaning and rules as it does for a media rendition ({{field-jitter}}), with a payload in place of a frame. + ## Binary Fields {#binary} A decoder config field carrying raw bytes, notably `description` (an `AllowSharedBufferSource` in WebCodecs), is carried in the catalog as a hex string ({{!RFC4648, Section 8}}). A publisher SHOULD emit lowercase hexadecimal characters and MUST NOT emit a `0x` prefix or any separators. @@ -1075,6 +1084,8 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - A publisher that stops producing and may resume on the same track SHOULD publish a discontinuity marker when it stops. - An audio endpoint bounds only the terminal packets that follow it in its own group. - Replaced the archive timeline `wall` field with a root `clock` section (`wall` plus `timescale`): one fixed broadcast mapping every track and the archive index convert into, independent of any archive. Zero timescales and walls past the JSON-safe integer range are refused. +- Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. +- Recommended namespaced keys for application root sections. # Acknowledgments {:numbered="false"} diff --git a/js/hang/src/catalog/binary.ts b/js/hang/src/catalog/binary.ts index 2c209e1413..593eaf24d5 100644 --- a/js/hang/src/catalog/binary.ts +++ b/js/hang/src/catalog/binary.ts @@ -1,5 +1,6 @@ import * as z from "zod/mini"; import { CompressionSchema } from "./compression"; +import { u53Schema } from "./integers"; import { ModeSchema } from "./mode"; import { RelativeBroadcastSchema } from "./path"; @@ -28,6 +29,18 @@ export const BinaryConfigSchema = z.looseObject({ // An optional media type for each payload (e.g. "image/jpeg"). Purely descriptive: // a consumer that doesn't recognize it can still read the track. mime: z.optional(z.string()), + + // The maximum bitrate of the track in bits per second, if known. + bitrate: z.optional(u53Schema), + + // The maximum delay between a payload being ready and the publisher flushing it, in whole + // milliseconds rounded up, with the same meaning as a video rendition's `jitter`. + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/json.ts b/js/hang/src/catalog/json.ts index 6ee22e78a0..c5900559d7 100644 --- a/js/hang/src/catalog/json.ts +++ b/js/hang/src/catalog/json.ts @@ -1,5 +1,6 @@ import * as z from "zod/mini"; import { CompressionSchema } from "./compression"; +import { u53Schema } from "./integers"; import { ModeSchema } from "./mode"; import { RelativeBroadcastSchema } from "./path"; @@ -27,6 +28,18 @@ export const JsonConfigSchema = z.looseObject({ // An optional identifier for the shape of each value, typically a JSON Schema URL. // Purely descriptive: a consumer that doesn't recognize it can still read the track. schema: z.optional(z.string()), + + // The maximum bitrate of the track in bits per second, if known. + bitrate: z.optional(u53Schema), + + // The maximum delay between a payload being ready and the publisher flushing it, in whole + // milliseconds rounded up, with the same meaning as a video rendition's `jitter`. + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 58873544ec..cd76a88781 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -195,3 +195,45 @@ for (const section of ["audio", "video", "text"] as const) { }); }); } + +for (const section of ["json", "binary"] as const) { + test(`catalog refuses zero or decreasing ${section} jitter without retaining it`, () => { + const catalog = new CatalogProducer(); + const tracks = (value: Catalog.Root) => { + const sectionValue = value[section]; + if (!sectionValue) throw new Error(`expected a retained ${section} section`); + return sectionValue.tracks; + }; + + expect(() => + catalog.mutate((value) => { + value[section] = { tracks: { data: { mode: "stream", jitter: Catalog.u53(0) } } }; + }), + ).toThrow("omit jitter"); + catalog.mutate((value) => { + expect(value[section]).toBeUndefined(); + }); + + catalog.mutate((value) => { + value[section] = { tracks: { data: { mode: "stream", jitter: Catalog.u53(100) } } }; + }); + for (const jitter of [Catalog.u53(50), undefined]) { + expect(() => + catalog.mutate((value) => { + tracks(value).data.jitter = jitter; + }), + ).toThrow("jitter cannot decrease"); + catalog.mutate((value) => { + expect(tracks(value).data.jitter).toBe(Catalog.u53(100)); + }); + } + + // A new track under the same name, after the old one is gone, starts over. + catalog.mutate((value) => { + delete tracks(value).data; + }); + catalog.mutate((value) => { + tracks(value).data = { mode: "stream", jitter: Catalog.u53(50) }; + }); + }); +} diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 7c45ec7962..3a40e3d1ae 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -24,12 +24,13 @@ export class CatalogProducer { mutate(fn: (catalog: Catalog.Root) => void): void { const value = structuredClone(this.#value); fn(value); - for (const section of ["audio", "video", "text"] as const) { - for (const [name, config] of Object.entries(value[section]?.renditions ?? {})) { - if (config.jitter === 0) throw new Error("omit jitter for a track flushed immediately"); - const previous = this.#value[section]?.renditions[name]?.jitter; - if (previous !== undefined && (config.jitter === undefined || config.jitter < previous)) { - throw new Error("jitter cannot decrease for an existing rendition"); + for (const [section, next] of Object.entries(jitters(value))) { + const previous = jitters(this.#value)[section]; + for (const [name, jitter] of Object.entries(next)) { + if (jitter === 0) throw new Error("omit jitter for a track flushed immediately"); + const before = previous?.[name]; + if (before !== undefined && (jitter === undefined || jitter < before)) { + throw new Error("jitter cannot decrease for an existing track"); } } } @@ -59,6 +60,19 @@ export class CatalogProducer { } } +/** Every track's advertised jitter, by section and then track name. */ +function jitters(catalog: Catalog.Root): Record> { + const pick = (tracks: Record | undefined) => + Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config.jitter])); + return { + audio: pick(catalog.audio?.renditions), + video: pick(catalog.video?.renditions), + text: pick(catalog.text?.renditions), + json: pick(catalog.json?.tracks), + binary: pick(catalog.binary?.tracks), + }; +} + // The wall time of `performance.now() === 0`, the zero every js/publish timestamp counts from. function pageClock(): Catalog.Clock { const wall = Math.round((performance.timeOrigin - Catalog.MOQ_EPOCH_UNIX_MILLIS) * 1000); diff --git a/quest/m1/README.md b/quest/m1/README.md index 3aff7303c9..f2c3be7a91 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -30,7 +30,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [CLI import clock](/quest/m1/cli-import-clock.md) - fMP4, TS, and FLV imports publish on the shared broadcast clock across restarts diff --git a/quest/m1/data-jitter.md b/quest/m1/data-jitter.md index 76cee93321..e21e11ca3b 100644 --- a/quest/m1/data-jitter.md +++ b/quest/m1/data-jitter.md @@ -27,14 +27,17 @@ meaningless zero. broadcast-wide baseline, distort every other track; it stays in the payload. Reject a timestamp ahead of the clock's `now` rather than clamp it. - Add optional `delay` to `JsonConfig` and `BinaryConfig` in `rs/hang`, - `js/hang`, and the draft, beside the `jitter` - [data sections](/quest/m1/data-sections.md) adds. + `js/hang`, and the draft, beside `jitter`. - Feed the catalog's flush clock from the `moq-mux` data producers when a capture time is present and a frame was actually emitted. A `moq-json` snapshot `update` with an unchanged value succeeds without writing one, so the lower producer reports whether it emitted, and a repeated value must not move the baseline (cover it in tests). Publish the result through the embedded config's - `Estimate`, as [data sections](/quest/m1/data-sections.md) does for bitrate. + `Estimate`, as the data producers already do for bitrate. +- Have the lower producers also report each emitted frame's encoded size, and + measure bitrate from that instead of the pre-compression payload or + serialized value: today an unchanged snapshot `update` still counts, and + DEFLATE can slightly expand an incompressible payload. - Mirror the capture timestamp in the published `js/binary` and `js/json` producers, so browser publishers can produce the same timed tracks. @@ -45,4 +48,3 @@ entries. ## Required - [Jitter clock](/quest/m1/jitter-flush-clock.md) - defines the flush-lateness measurement and `delay` -- [Data sections](/quest/m1/data-sections.md) - adds the `jitter` field and the producers this feeds diff --git a/quest/m1/data-sections.md b/quest/m1/data-sections.md deleted file mode 100644 index df8802602b..0000000000 --- a/quest/m1/data-sections.md +++ /dev/null @@ -1,66 +0,0 @@ -# [M] moq-mux: publish data tracks into an application's own catalog section - -## Goal - -An application lists a JSON or binary track in its own root section, with -its own per-track fields next to the reading rules, and publishes it with one -`moq-mux` call that handles framing, compression, the entry's lifecycle, and -a detected `bitrate`. JSON and binary entries gain optional `bitrate` and -`jitter`, matching video and audio. - -The motivating case is MAVLink telemetry: the application's -`catalog["com.example.mavlink"].tracks[name]` flattens a `BinaryConfig` -(mode, compression, broadcast) beside `sysid`, `compids`, and `dialect`. One -entry per track, so nothing has to be kept in sync with a second listing. - -Non-goals: hang defines no MAVLink (or other application) section, and -`BinaryConfig`/`JsonConfig` gain no generic or opaque extension field. A -single `tracks` map holds every data track, so one type parameter would force -every application track kind into one enum. Generic data-track tooling does -not list tracks in an application section; that is the trade for one source of -truth. - -## Plan - -- **Producers.** `Producer::binary_snapshot`/`binary_stream` and - `json_snapshot`/`json_stream` accept any entry that implements - `RenditionConfig` and embeds a `BinaryConfig`/`JsonConfig`, exposed - through a small trait (a `&mut` accessor to the embedded config). The - producer fixes `mode`, applies `compression`, and writes the entry into - whichever section the `RenditionConfig` names; dropping the handle removes - it. The existing `binary::Config`/`json::Config` builders keep working - unchanged, so this generalizes a concrete parameter (RFC 1105 minor) and - lands on `main`. Name the trait by role; `catalog::Entry` is already the - consumer-side pairing of a name and config. -- **Consumers** need nothing new: `Entry::new(name, &mavlink.binary)` already - subscribes through `binary::Consumer`. -- **Bitrate/jitter.** Add optional `bitrate` (bits per second) and `jitter` - (`MillisCeil`, same meaning as video and audio) to `BinaryConfig` and - `JsonConfig` in `rs/hang`, `js/hang`, and the Binary and JSON sections of - `drafts/draft-lcurley-moq-hang.md`. Opt the data producers into bitrate - detection through the embedded config's `Estimate`. Extend `js/publish`'s - `CatalogProducer.mutate` check (nonzero, never lowered; - `js/publish/src/catalog.ts`), today audio and video only, to the `json` and - `binary` sections, with tests. `jitter` is set by the - publisher only here; detection is [data jitter](/quest/m1/data-jitter.md), - because flush lateness needs a source timestamp the producers do not take - yet. -- **Draft.** One line: application root sections SHOULD use a namespaced key - (e.g. reverse-DNS), since `text`, `json`, and `binary` already needed - lenient decoding for keys applications used first. -- **Docs.** A custom-section example in `doc/lib/rs/moq-mux.md` beside the - existing data-track one, and the `RenditionConfig` doc example switched to a - section that embeds a `BinaryConfig`. No new page. -- **Tests.** A custom section round-trips through a producer and a - `Catalog` consumer, drop removes the entry, a duplicate name is refused, - and bitrate is detected. The existing `binary::Config` path stays covered. - -Public API: additive in `moq-mux` (generalized data producer parameter, one -new trait) and `hang`/`@moq/hang` (two optional fields). Wire: two optional -catalog fields, additive. - -## Related - -- [Data jitter](/quest/m1/data-jitter.md) - detects the `jitter` this quest adds -- [MAVLink bridge](/quest/m2/teleop/mavlink.md) - the in-repo consumer of the same shape -- [Robot teleoperation primitive](/quest/m2/teleop/robot.md) - its telemetry section embeds data-track configs this way diff --git a/quest/m2/teleop/robot.md b/quest/m2/teleop/robot.md index 6cd731e4c5..42614ceb11 100644 --- a/quest/m2/teleop/robot.md +++ b/quest/m2/teleop/robot.md @@ -64,7 +64,7 @@ The framing is where the guarantee lives, not the subscription flags: - The catalog section, through `moq-mux`'s `CatalogExt` and `RenditionConfig`: a namespaced root section whose entries embed a `JsonConfig` or `BinaryConfig` beside the robot's own fields, published - through the data producers ([data sections](/quest/m1/data-sections.md)). + through the `moq-mux` data producers. No hang schema change. - Announce-prefix fan-in, generalised from `rs/moq-boy/src/input.rs`. - The two delivery classes, as `moq-json`'s snapshot and stream modes with @@ -81,10 +81,6 @@ Port `moq-boy` onto the crate in the same change, as the no-arbitration case. It is the only existing consumer, and if the abstraction cannot express crowd control then it is the wrong abstraction. -## Required - -- [Data sections](/quest/m1/data-sections.md) - publishes the telemetry section's data tracks - ## Related - [arbitration](/quest/m2/teleop/arbitration.md) - which controller is obeyed diff --git a/rs/hang/src/catalog/binary.rs b/rs/hang/src/catalog/binary.rs index 7a89406785..7c4ec811eb 100644 --- a/rs/hang/src/catalog/binary.rs +++ b/rs/hang/src/catalog/binary.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, btree_map}; use serde::{Deserialize, Serialize}; +use crate::catalog::millis::MillisCeil; use crate::catalog::{Compression, Mode}; /// The binary tracks a broadcast publishes, keyed by track name. @@ -86,6 +87,16 @@ pub struct BinaryConfig { #[serde(default)] pub mime: Option, + /// The maximum bitrate of the track in bits per second, if known. + #[serde(default)] + pub bitrate: Option, + + /// The maximum delay between a payload being ready and the publisher flushing it, with the same + /// meaning and whole-millisecond encoding as [`VideoConfig::jitter`](crate::catalog::VideoConfig::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub jitter: Option, + /// Fields this build doesn't recognize, kept so the entry round-trips. /// /// A future [`Mode`] or [`Compression`] almost certainly comes with fields describing it, and @@ -104,7 +115,16 @@ impl BinaryConfig { mode, compression: None, mime: None, + bitrate: None, + jitter: None, extra: Default::default(), } } } + +/// The config itself, so a data producer takes it wherever it takes an entry embedding one. +impl AsMut for BinaryConfig { + fn as_mut(&mut self) -> &mut Self { + self + } +} diff --git a/rs/hang/src/catalog/json.rs b/rs/hang/src/catalog/json.rs index cdc85a0397..9fa0d355c2 100644 --- a/rs/hang/src/catalog/json.rs +++ b/rs/hang/src/catalog/json.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, btree_map}; use serde::{Deserialize, Serialize}; +use crate::catalog::millis::MillisCeil; use crate::catalog::{Compression, Mode}; /// The JSON tracks a broadcast publishes, keyed by track name. @@ -84,6 +85,16 @@ pub struct JsonConfig { #[serde(default)] pub schema: Option, + /// The maximum bitrate of the track in bits per second, if known. + #[serde(default)] + pub bitrate: Option, + + /// The maximum delay between a payload being ready and the publisher flushing it, with the same + /// meaning and whole-millisecond encoding as [`VideoConfig::jitter`](crate::catalog::VideoConfig::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub jitter: Option, + /// Fields this build doesn't recognize, kept so the entry round-trips. /// /// A future [`Mode`] or [`Compression`] almost certainly comes with fields describing it, and @@ -102,7 +113,16 @@ impl JsonConfig { mode, compression: None, schema: None, + bitrate: None, + jitter: None, extra: Default::default(), } } } + +/// The config itself, so a data producer takes it wherever it takes an entry embedding one. +impl AsMut for JsonConfig { + fn as_mut(&mut self) -> &mut Self { + self + } +} diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index baad3caae8..21c1e11e85 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -780,6 +780,68 @@ mod test { assert_eq!(output, encoded, "encode mismatch"); } + /// Data tracks carry the same optional `bitrate` and whole-millisecond `jitter` as media. + #[test] + fn data_track_bitrate_and_jitter() { + let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"json":{"tracks":{"gps":{"mode":"stream","bitrate":8000,"jitter":100}}},"binary":{"tracks":{"frames":{"mode":"snapshot","bitrate":64000,"jitter":34}}}}"#; + + let mut gps = JsonConfig::new(Mode::Stream); + gps.bitrate = Some(8_000); + gps.jitter = Some(std::time::Duration::from_millis(100)); + + let mut frames = BinaryConfig::new(Mode::Snapshot); + frames.bitrate = Some(64_000); + frames.jitter = Some(std::time::Duration::from_micros(33_334)); + + let mut catalog = Catalog::<()>::default(); + catalog.json.insert("gps", gps).unwrap(); + catalog.binary.insert("frames", frames).unwrap(); + + assert_eq!( + catalog.to_json().unwrap(), + encoded, + "jitter rounds up to whole milliseconds" + ); + + let decoded = Catalog::<()>::from_str(encoded).unwrap(); + assert_eq!( + decoded.binary.tracks["frames"].jitter, + Some(std::time::Duration::from_millis(34)) + ); + assert_eq!(decoded.json.tracks["gps"].bitrate, Some(8_000)); + } + + /// An application lists a data track in its own section by flattening a data config beside its + /// own fields, so the reading rules and the application's fields share one entry. + #[test] + fn a_data_config_flattens_into_an_application_entry() { + #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] + struct Mavlink { + #[serde(flatten)] + binary: BinaryConfig, + sysid: u8, + } + + #[derive(Serialize, Deserialize, PartialEq, Debug, Default, Clone)] + struct Ext { + #[serde(rename = "com.example.mavlink", default)] + mavlink: BTreeMap, + } + + let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"com.example.mavlink":{"telemetry":{"mode":"stream","compression":"deflate","sysid":1}}}"#; + + let catalog = Catalog::::from_str(encoded).unwrap(); + let entry = &catalog.ext.mavlink["telemetry"]; + assert_eq!(entry.sysid, 1); + assert_eq!(entry.binary.mode, Mode::Stream); + assert_eq!(entry.binary.compression, Some(Compression::Deflate)); + assert!( + entry.binary.extra.is_empty(), + "the application's own fields are not unknown data-track fields" + ); + assert_eq!(catalog.to_json().unwrap(), encoded); + } + /// A track using a future mode or compression must survive a reparse-and-republish intact, so a /// relay doesn't corrupt what it can't read. Its siblings stay readable. #[test] diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index 4f5ab5f4dc..d0ad13a996 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -47,17 +47,20 @@ //! # } //! ``` +use std::marker::PhantomData; + use bytes::Bytes; use hang::catalog::{BinaryConfig, Compression, Mode}; -use crate::catalog::Rendition; use crate::catalog::hang::CatalogExt; +use crate::catalog::{IntoRendition, Listing, RenditionConfig}; /// Everything a binary track declares about itself, beyond its mode and name. /// /// Start from [`default`](Default::default) and chain the setters. The mode is not in here: it is -/// fixed by which producer you create. +/// fixed by which producer you create. To list the track in an application's own catalog section +/// instead of `binary`, pass that section's entry (see [`IntoRendition`]). #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct Config { @@ -83,16 +86,33 @@ impl Config { self.mime = Some(mime.into()); self } +} - /// The catalog entry describing a track published under this config in `mode`. - pub(crate) fn entry(&self, mode: Mode) -> BinaryConfig { - let mut entry = BinaryConfig::new(mode); +impl IntoRendition for Config { + type Config = BinaryConfig; + + fn into_rendition(self) -> BinaryConfig { + // The producer overwrites the mode with the one it publishes in. + let mut entry = BinaryConfig::new(Mode::Snapshot); entry.compression = self.compression.then_some(Compression::Deflate); - entry.mime = self.mime.clone(); + entry.mime = self.mime; entry } } +/// Fix `config`'s mode and return whether its frames are compressed. +/// +/// Errors on a compression this build can't write, rather than advertising one the frames don't use, +/// and on a `broadcast` reference, which would point consumers away from the track this publishes. +fn prepare(config: &mut impl AsMut, mode: Mode) -> crate::Result { + let binary = config.as_mut(); + if binary.broadcast.is_some() { + return Err(crate::Error::ForeignBroadcast); + } + binary.mode = mode; + crate::compression(binary.compression.as_ref()) +} + /// Publishes a latest-value binary track, advertised in the catalog for as long as this handle /// lives. /// @@ -100,27 +120,33 @@ impl Config { /// For a log where every payload survives, use [`Stream`]. pub struct Snapshot { inner: moq_binary::snapshot::Producer, - rendition: Rendition, + listing: Listing, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Snapshot { - pub(crate) fn new( + pub(crate) fn new + AsMut>( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, + rendition: crate::catalog::Rendition, + mut config: C, ) -> crate::Result { let mut binary = moq_binary::snapshot::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Snapshot)? { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::snapshot::Producer::new(track, binary); - rendition.set(config.entry(Mode::Snapshot))?; - Ok(Self { inner, rendition }) + let listing = Listing::new(rendition, config)?; + Ok(Self { + inner, + listing, + _catalog: PhantomData, + }) } /// The track name, which is also the catalog key. pub fn name(&self) -> &str { - self.rendition.name() + self.listing.name() } /// Create a subscriber for the underlying track. @@ -130,7 +156,10 @@ impl Snapshot { /// Publish a new payload, superseding the previous one. pub fn update(&mut self, payload: impl Into) -> crate::Result<()> { - Ok(self.inner.update(payload)?) + let payload = payload.into(); + let len = payload.len(); + self.inner.update(payload)?; + self.listing.record(|| len) } /// Finish the track and retire its catalog entry. @@ -154,25 +183,28 @@ pub struct Stream { /// Cleared when a terminal failure ends the track, which retires the catalog entry with it. An /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. - rendition: Option>, + listing: Option, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Stream { - pub(crate) fn new( + pub(crate) fn new + AsMut>( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, + rendition: crate::catalog::Rendition, + mut config: C, ) -> crate::Result { let mut binary = moq_binary::stream::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Stream)? { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::stream::Producer::new(track, binary); - rendition.set(config.entry(Mode::Stream))?; + let listing = Listing::new(rendition, config)?; Ok(Self { inner, - name: rendition.name().to_string(), - rendition: Some(rendition), + name: listing.name().to_string(), + listing: Some(listing), + _catalog: PhantomData, }) } @@ -192,18 +224,25 @@ impl Stream { /// Append one payload to the log. /// /// A payload that cannot be written ends the track (see - /// [`moq_binary::stream::Producer::append`]) and retires the catalog entry with it. + /// [`moq_binary::stream::Producer::append`]) and retires the catalog entry with it. A catalog + /// error publishing the measured bitrate is returned after the payload was written, so the track + /// stays open and a retry would duplicate it. pub fn append(&mut self, payload: impl Into) -> crate::Result<()> { - let Err(err) = self.inner.append(payload) else { - return Ok(()); - }; - - // The inner producer has already closed the track. Dropping the rendition retires the catalog - // entry too: waiting for the handle to drop would keep advertising a track that can no longer - // accept records, so a consumer discovering it now would subscribe to an already-ended log. - self.rendition = None; + let payload = payload.into(); + let len = payload.len(); + if let Err(err) = self.inner.append(payload) { + // The inner producer has already closed the track. Dropping the listing retires the + // catalog entry too: waiting for the handle to drop would keep advertising a track that + // can no longer accept records, so a consumer discovering it now would subscribe to an + // already-ended log. + self.listing = None; + return Err(err.into()); + } - Err(err.into()) + match &mut self.listing { + Some(listing) => listing.record(|| len), + None => Ok(()), + } } /// Finish the track and retire its catalog entry. @@ -425,4 +464,198 @@ mod test { assert!(broadcast.create_track("data", None).is_err()); } + + /// A data track listed in an application's own section, beside its own per-track fields. + mod section { + use std::collections::BTreeMap; + + use serde::{Deserialize, Serialize}; + + use super::*; + use crate::catalog::Estimate; + use crate::catalog::hang::Catalog; + + #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] + struct Ext { + #[serde(rename = "com.example.mavlink", default, skip_serializing_if = "BTreeMap::is_empty")] + mavlink: BTreeMap, + } + + impl CatalogExt for Ext {} + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct Mavlink { + #[serde(flatten)] + binary: BinaryConfig, + sysid: u8, + } + + impl AsMut for Mavlink { + fn as_mut(&mut self) -> &mut BinaryConfig { + &mut self.binary + } + } + + impl RenditionConfig for Mavlink { + fn insert(self, catalog: &mut Catalog, name: &str) { + catalog.ext.mavlink.insert(name.to_string(), self); + } + fn get_mut<'a>(catalog: &'a mut Catalog, name: &str) -> Option<&'a mut Self> { + catalog.ext.mavlink.get_mut(name) + } + fn remove(catalog: &mut Catalog, name: &str) { + catalog.ext.mavlink.remove(name); + } + + fn detects() -> bool { + true + } + fn estimate(&self) -> Estimate { + Estimate::default() + .with_bitrate(self.binary.bitrate) + .with_jitter(self.binary.jitter) + } + fn set_estimate(&mut self, estimate: Estimate) { + self.binary.bitrate = estimate.bitrate; + self.binary.jitter = estimate.jitter; + } + } + + fn mavlink(sysid: u8) -> Mavlink { + let mut binary = BinaryConfig::new(Mode::Snapshot); + binary.compression = Some(Compression::Deflate); + Mavlink { binary, sysid } + } + + fn catalog() -> (moq_net::broadcast::Producer, crate::catalog::Producer) { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let config = crate::catalog::Config::default().with_catalog(Catalog::::default()); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + (broadcast, catalog) + } + + /// The producer fixes the mode and keeps the application's fields; a `Catalog` consumer + /// reads the entry back and subscribes through its embedded config alone. + #[tokio::test] + async fn roundtrips_through_a_catalog_consumer() { + let (mut broadcast, catalog) = catalog(); + let source = crate::source::announced(&broadcast.consume()); + let mut consumer = catalog.consume().unwrap(); + + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), mavlink(7)) + .unwrap(); + telemetry.append(&b"heartbeat"[..]).unwrap(); + + let published = consumer.next().await.unwrap().expect("catalog published"); + let entry = published.ext.mavlink.get("telemetry").expect("missing entry"); + assert_eq!(entry.sysid, 7, "the application's fields survive"); + assert_eq!(entry.binary.mode, Mode::Stream, "the producer fixes the mode"); + assert_eq!(entry.binary.compression, Some(Compression::Deflate)); + assert!(published.binary.tracks.is_empty(), "not listed in the binary section"); + + let mut reader = crate::catalog::Entry::new("telemetry", &entry.binary) + .subscribe(&source) + .await + .unwrap(); + telemetry.finish().unwrap(); + assert_eq!(reader.next().await.unwrap(), Some(Bytes::from_static(b"heartbeat"))); + assert_eq!(reader.next().await.unwrap(), None); + } + + #[test] + fn dropping_the_producer_retires_the_entry() { + let (mut broadcast, catalog) = catalog(); + let telemetry = catalog + .binary_snapshot(track(&mut broadcast, "telemetry"), mavlink(1)) + .unwrap(); + assert!(catalog.snapshot().ext.mavlink.contains_key("telemetry")); + + drop(telemetry); + assert!(!catalog.snapshot().ext.mavlink.contains_key("telemetry")); + } + + /// The name is owned per section, so an entry already in the application's section refuses + /// a second producer without touching the first. + #[test] + fn a_duplicate_name_is_refused() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let mut seed = Catalog::::default(); + seed.ext.mavlink.insert("telemetry".to_string(), mavlink(1)); + let config = crate::catalog::Config::default().with_catalog(seed); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + + assert!(matches!( + catalog.binary_stream(track(&mut broadcast, "telemetry"), mavlink(2)), + Err(crate::Error::Hang(hang::Error::Duplicate(_))) + )); + assert_eq!(catalog.snapshot().ext.mavlink["telemetry"].sysid, 1); + } + + /// A compression this build can't write is refused rather than advertised over plain frames. + #[test] + fn an_unknown_compression_is_refused() { + let (mut broadcast, catalog) = catalog(); + let mut entry = mavlink(1); + entry.binary.compression = Some(Compression::Unknown("zstd".to_string())); + + assert!(matches!( + catalog.binary_stream(track(&mut broadcast, "telemetry"), entry), + Err(crate::Error::UnsupportedCompression(_)) + )); + assert!(catalog.snapshot().ext.mavlink.is_empty()); + } + + /// The producer publishes locally, so an entry pointing at another broadcast is refused rather + /// than advertised: consumers would resolve it there and never reach the published payloads. + #[test] + fn a_broadcast_reference_is_refused() { + let (mut broadcast, catalog) = catalog(); + let mut entry = mavlink(1); + entry.binary.broadcast = Some(moq_net::path::RelativeOwned::new("source")); + + assert!(matches!( + catalog.binary_stream(track(&mut broadcast, "telemetry"), entry), + Err(crate::Error::ForeignBroadcast) + )); + assert!(catalog.snapshot().ext.mavlink.is_empty()); + } + + /// Writes fill an absent bitrate, through the entry's embedded config. + #[test] + fn detects_bitrate() { + let (mut broadcast, catalog) = catalog(); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), mavlink(1)) + .unwrap(); + + // 40ms payloads of 5 kB: 1 Mbps, over more than the bitrate window. + for i in 0..60u64 { + let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); + telemetry.listing.as_mut().unwrap().record_at(now, 5_000).unwrap(); + } + + let entry = &catalog.snapshot().ext.mavlink["telemetry"]; + assert_eq!(entry.binary.bitrate, Some(1_000_000)); + assert_eq!(entry.binary.jitter, None, "write spacing is not a flush delay"); + } + + /// A supplied bitrate is authoritative, so writes aren't measured at all: for JSON that would + /// be a second serialization per write, for nothing. + #[test] + fn a_supplied_bitrate_skips_measurement() { + let (mut broadcast, catalog) = catalog(); + let mut entry = mavlink(1); + entry.binary.bitrate = Some(64_000); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), entry) + .unwrap(); + + let listing = telemetry.listing.as_mut().unwrap(); + listing + .record(|| panic!("measured a write despite a supplied bitrate")) + .unwrap(); + assert_eq!(catalog.snapshot().ext.mavlink["telemetry"].binary.bitrate, Some(64_000)); + } + } } diff --git a/rs/moq-mux/src/catalog/data.rs b/rs/moq-mux/src/catalog/data.rs new file mode 100644 index 0000000000..10f73b0a0b --- /dev/null +++ b/rs/moq-mux/src/catalog/data.rs @@ -0,0 +1,128 @@ +use super::hang::CatalogExt; +use super::{Estimator, Rendition, RenditionConfig}; + +/// The config a data producer ([`Producer::json_stream`](super::Producer::json_stream) and the +/// like) publishes as its catalog entry. +/// +/// Implemented for the [`json::Config`](crate::json::Config) and +/// [`binary::Config`](crate::binary::Config) builders, which list the track in the `json` or +/// `binary` section. Also implemented for any [`RenditionConfig`] that embeds the data config `D` +/// ([`JsonConfig`](hang::catalog::JsonConfig) or [`BinaryConfig`](hang::catalog::BinaryConfig)) +/// through [`AsMut`], which is how an application lists a data track in its own section beside its +/// own per-track fields; see the [`RenditionConfig`] example. +/// +/// The producer sets the embedded config's `mode`, encodes the track with its `compression`, and +/// owns the entry: it is written when the producer is created and removed when it drops. +pub trait IntoRendition { + /// The catalog entry, embedding `D`. + type Config: RenditionConfig + AsMut; + + /// Build the catalog entry. + fn into_rendition(self) -> Self::Config; +} + +impl + AsMut> IntoRendition for C { + type Config = C; + + fn into_rendition(self) -> C { + self + } +} + +/// A data track's catalog entry, owned for the life of its producer and kept current with the +/// bitrate its writes measure. +/// +/// Erases the entry's type, so a data producer's own type doesn't depend on which section lists it. +pub(crate) struct Listing { + rendition: Box, + estimator: Estimator, + /// Whether writes are measured: only when the entry detects its estimate and the publisher + /// didn't supply a bitrate, which detection never overrides. + measures: bool, +} + +/// The parts of a [`Rendition`] a [`Listing`] uses, without its config type. +trait Owned: Send + Sync { + fn name(&self) -> &str; + fn timestamp(&self) -> crate::Result; + fn estimate(&mut self, estimate: super::Estimate) -> crate::Result<()>; +} + +impl> Owned for Rendition { + fn name(&self) -> &str { + Rendition::name(self) + } + fn timestamp(&self) -> crate::Result { + Rendition::timestamp(self, None) + } + fn estimate(&mut self, estimate: super::Estimate) -> crate::Result<()> { + Rendition::estimate(self, estimate) + } +} + +impl Listing { + /// Publish `config` as the entry `rendition` reserved. + pub(crate) fn new>( + mut rendition: Rendition, + config: C, + ) -> crate::Result { + let measures = C::detects() && config.estimate().bitrate.is_none(); + rendition.set(config)?; + Ok(Self { + rendition: Box::new(rendition), + estimator: Estimator::new(), + measures, + }) + } + + /// The track name, which is also the catalog key. + pub(crate) fn name(&self) -> &str { + self.rendition.name() + } + + /// Measure a write of `bytes`, stamped on the broadcast clock. + /// + /// `bytes` is only evaluated for an entry that measures its bitrate, since measuring can cost a + /// second serialization. + pub(crate) fn record(&mut self, bytes: impl FnOnce() -> usize) -> crate::Result<()> { + if !self.measures { + return Ok(()); + } + let now = self.rendition.timestamp()?; + self.record_at(now, bytes()) + } + + /// [`record`](Self::record) at a chosen time, which a test needs since the broadcast clock only + /// moves in real time. + pub(crate) fn record_at(&mut self, now: moq_net::Timestamp, bytes: usize) -> crate::Result<()> { + // Each write is its own span, closed by the next one. + self.estimator.cut(Some(now)); + self.estimator.write(now, bytes); + + // The spacing between writes is the application's cadence, not a flush delay, so only the + // bitrate is measured. A publisher that knows its jitter sets it on the entry. + let estimate = self.estimator.estimate().with_jitter(None); + self.rendition.estimate(estimate) + } +} + +/// The serialized size of `value`, as an upper bound on what a JSON write puts on the wire: +/// compression and deltas only shrink it. +pub(crate) fn json_len(value: &T) -> usize { + struct Count(usize); + + impl std::io::Write for Count { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 += buf.len(); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let mut count = Count(0); + // Only reached after the producer serialized the same value, so this cannot fail. + let _ = serde_json::to_writer(&mut count, value); + count.0 +} diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index c18738f8b2..88ef14f672 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -25,6 +25,7 @@ pub mod msf; mod claim; mod consumer; +mod data; mod entry; mod estimate; mod format; @@ -35,6 +36,8 @@ pub(crate) mod tracks; pub(crate) use claim::Claim; pub use consumer::Consumer; +pub use data::IntoRendition; +pub(crate) use data::{Listing, json_len}; pub use entry::Entry; pub use estimate::{Estimate, Estimator}; pub use format::*; diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 9931f815d5..0d0958a123 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -653,20 +653,28 @@ impl Producer { /// Publish `track` as a latest-value JSON track, advertising it in the catalog. /// /// The caller creates the track on the broadcast, as it does for a media track; this writes its - /// catalog entry and removes the - /// entry when the returned handle drops. The catalog key is [`track.name()`](moq_net::track::Producer::name) - /// verbatim, with no `.z` suffix even when compressed, since the entry's compression flag is - /// what a consumer reads. + /// catalog entry and removes the entry when the returned handle drops. The catalog key is + /// [`track.name()`](moq_net::track::Producer::name) verbatim, with no `.z` suffix even when + /// compressed, since the entry's compression flag is what a consumer reads. /// - /// Errors if the catalog already carries an entry under that name, for example one seeded - /// through [`Config::with_catalog`] or one pointing at a sibling broadcast. + /// `config` is a [`json::Config`](crate::json::Config) for the `json` section, or an + /// application's own entry embedding a [`JsonConfig`](hang::catalog::JsonConfig) (see + /// [`IntoRendition`](super::IntoRendition)). The producer sets its `mode`, encodes the track + /// with its `compression`, and fills an absent `bitrate` from what it writes. A + /// [`delta_ratio`](crate::json::Config::delta_ratio) on `json::Config` selects the snapshot + /// encoder; it is not written into the catalog entry. The config is `'static` so that ratio + /// can be read off the builder. + /// + /// Errors if the entry's section already carries that name, for example an entry seeded + /// through [`Config::with_catalog`] or one pointing at a sibling broadcast, or if the entry + /// declares a compression this build can't write or references another broadcast. pub fn json_snapshot( &self, track: moq_net::track::Producer, - config: crate::json::Config, + config: impl super::IntoRendition + 'static, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::json::Snapshot::new(track, rendition, &config) + crate::json::Snapshot::new(track, rendition, config) } /// Publish `track` as an append-log JSON track, advertising it in the catalog. @@ -676,36 +684,37 @@ impl Producer { pub fn json_stream( &self, track: moq_net::track::Producer, - config: crate::json::Config, + config: impl super::IntoRendition, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::json::Stream::new(track, rendition, &config) + crate::json::Stream::new(track, rendition, config.into_rendition()) } /// Publish `track` as a latest-value binary track, advertising it in the catalog. /// /// See [`json_snapshot`](Self::json_snapshot) for the lifecycle; this differs only in that the - /// payloads are opaque bytes. + /// payloads are opaque bytes, and `config` is a [`binary::Config`](crate::binary::Config) or an + /// entry embedding a [`BinaryConfig`](hang::catalog::BinaryConfig). pub fn binary_snapshot( &self, track: moq_net::track::Producer, - config: crate::binary::Config, + config: impl super::IntoRendition, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::binary::Snapshot::new(track, rendition, &config) + crate::binary::Snapshot::new(track, rendition, config.into_rendition()) } /// Publish `track` as an append-log binary track, advertising it in the catalog. /// - /// See [`json_snapshot`](Self::json_snapshot) for the lifecycle; this differs only in that the - /// payloads are opaque bytes and every one is preserved rather than superseded. + /// See [`binary_snapshot`](Self::binary_snapshot); this differs only in that every payload is + /// preserved rather than superseded. pub fn binary_stream( &self, track: moq_net::track::Producer, - config: crate::binary::Config, + config: impl super::IntoRendition, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::binary::Stream::new(track, rendition, &config) + crate::binary::Stream::new(track, rendition, config.into_rendition()) } /// Reserve the catalog entry a data producer owns, keyed by its track name. diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 95f9f78196..6c2c349fdb 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -7,52 +7,74 @@ use super::hang::{Catalog, CatalogExt}; /// A catalog config that can be published as a named rendition. /// -/// Implement it on your own config type to get the full catalog lifecycle through -/// [`Reserved::track`]: reservation gating, removal on drop, and optional jitter/bitrate detection. -/// [`VideoConfig`](hang::catalog::VideoConfig) and [`AudioConfig`](hang::catalog::AudioConfig) -/// implement it for every extension; a custom config implements it for the one [`CatalogExt`] that -/// holds it: +/// Implement it on your own config type to get the full catalog lifecycle: reservation gating, +/// removal on drop, and optional jitter/bitrate detection. [`VideoConfig`](hang::catalog::VideoConfig) +/// and [`AudioConfig`](hang::catalog::AudioConfig) implement it for every extension; a custom +/// config implements it for the one [`CatalogExt`] that holds it. Publish a media track under it +/// with [`Reserved::track`], or a data track with [`Producer::binary_stream`] and the like when it +/// embeds a data config (see [`IntoRendition`](super::IntoRendition)): /// /// ``` +/// # use std::collections::BTreeMap; +/// # use hang::catalog::{BinaryConfig, Mode}; /// # use moq_mux::catalog::{Estimate, RenditionConfig}; /// # use moq_mux::catalog::hang::{Catalog, CatalogExt}; /// # use serde::{Deserialize, Serialize}; -/// # use std::collections::BTreeMap; /// #[derive(Serialize, Deserialize, Clone, Default)] -/// struct MyExt { -/// telemetry: BTreeMap, +/// struct Ext { +/// #[serde(rename = "com.example.mavlink", default)] +/// mavlink: BTreeMap, /// } -/// impl CatalogExt for MyExt {} +/// impl CatalogExt for Ext {} /// -/// #[derive(Serialize, Deserialize, Clone, Default)] -/// struct Telemetry { -/// schema: String, -/// bitrate: Option, +/// #[derive(Serialize, Deserialize, Clone)] +/// struct Mavlink { +/// #[serde(flatten)] +/// binary: BinaryConfig, +/// sysid: u8, /// } /// -/// impl RenditionConfig for Telemetry { -/// fn detects() -> bool { -/// true +/// impl AsMut for Mavlink { +/// fn as_mut(&mut self) -> &mut BinaryConfig { +/// &mut self.binary /// } +/// } /// -/// fn insert(self, catalog: &mut Catalog, name: &str) { -/// catalog.ext.telemetry.insert(name.to_string(), self); +/// impl RenditionConfig for Mavlink { +/// fn insert(self, catalog: &mut Catalog, name: &str) { +/// catalog.ext.mavlink.insert(name.to_string(), self); /// } -/// fn get_mut<'a>(catalog: &'a mut Catalog, name: &str) -> Option<&'a mut Self> { -/// catalog.ext.telemetry.get_mut(name) +/// fn get_mut<'a>(catalog: &'a mut Catalog, name: &str) -> Option<&'a mut Self> { +/// catalog.ext.mavlink.get_mut(name) /// } -/// fn remove(catalog: &mut Catalog, name: &str) { -/// catalog.ext.telemetry.remove(name); +/// fn remove(catalog: &mut Catalog, name: &str) { +/// catalog.ext.mavlink.remove(name); /// } /// -/// // Opt into bitrate detection; jitter is left undetected. +/// // Opt into bitrate detection through the embedded config. +/// fn detects() -> bool { +/// true +/// } /// fn estimate(&self) -> Estimate { -/// Estimate::default().with_bitrate(self.bitrate) +/// Estimate::default().with_bitrate(self.binary.bitrate).with_jitter(self.binary.jitter) /// } /// fn set_estimate(&mut self, estimate: Estimate) { -/// self.bitrate = estimate.bitrate; +/// self.binary.bitrate = estimate.bitrate; +/// self.binary.jitter = estimate.jitter; /// } /// } +/// +/// # fn example( +/// # broadcast: &mut moq_net::broadcast::Producer, +/// # catalog: &moq_mux::catalog::Producer, +/// # ) -> moq_mux::Result<()> { +/// let track = broadcast.create_track("telemetry", None)?; +/// // The producer fixes the mode, so the one passed here is only a placeholder. +/// let entry = Mavlink { binary: BinaryConfig::new(Mode::Stream), sysid: 1 }; +/// let mut telemetry = catalog.binary_stream(track, entry)?; +/// telemetry.append(&b"\xfd..."[..])?; +/// # Ok(()) +/// # } /// ``` /// /// Note that `insert` takes the whole [`Catalog`], not just the extension, so the built-in media @@ -62,7 +84,7 @@ use super::hang::{Catalog, CatalogExt}; /// [`Reserved::track`] and [`Producer::track`](super::Producer::track) enroll the track in the /// broadcast timeline, measure it, and keep its estimate current automatically. pub trait RenditionConfig: Clone + Send + 'static { - /// Whether container writes should update this config's estimate fields. + /// Whether container or data-track writes should update this config's estimate fields. fn detects() -> bool { false } @@ -95,6 +117,17 @@ impl RenditionConfig for hang::catalog::JsonConfig { fn remove(catalog: &mut Catalog, name: &str) { catalog.json.tracks.remove(name); } + + fn detects() -> bool { + true + } + fn estimate(&self) -> Estimate { + Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + } + fn set_estimate(&mut self, estimate: Estimate) { + self.jitter = estimate.jitter; + self.bitrate = estimate.bitrate; + } } impl RenditionConfig for hang::catalog::BinaryConfig { @@ -107,6 +140,17 @@ impl RenditionConfig for hang::catalog::BinaryConfig { fn remove(catalog: &mut Catalog, name: &str) { catalog.binary.tracks.remove(name); } + + fn detects() -> bool { + true + } + fn estimate(&self) -> Estimate { + Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + } + fn set_estimate(&mut self, estimate: Estimate) { + self.jitter = estimate.jitter; + self.bitrate = estimate.bitrate; + } } /// Caller-provided catalog fields for a video track: a starting point for what the importer detects. diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 5a1637f7a0..a0a50989da 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -48,6 +48,10 @@ pub enum Error { #[error("unsupported track compression: {0}")] UnsupportedCompression(String), + /// A locally published track's catalog entry points at another broadcast. + #[error("a locally published track can't reference another broadcast")] + ForeignBroadcast, + /// Error parsing or building CMAF moof+mdat fragments. #[error("cmaf: {0}")] Cmaf(#[from] crate::container::fmp4::Error), diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 26692fa745..e08e610acd 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -51,18 +51,21 @@ //! # } //! ``` +use std::marker::PhantomData; + use serde::Serialize; use serde::de::DeserializeOwned; use hang::catalog::{Compression, JsonConfig, Mode}; -use crate::catalog::Rendition; use crate::catalog::hang::CatalogExt; +use crate::catalog::{IntoRendition, Listing, RenditionConfig}; /// Everything a JSON track declares about itself, beyond its mode and name. /// /// Start from [`default`](Default::default) and chain the setters. The mode is not in here: it is -/// fixed by which producer you create. +/// fixed by which producer you create. To list the track in an application's own catalog section +/// instead of `json`, pass that section's entry (see [`IntoRendition`]). #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct Config { @@ -101,46 +104,86 @@ impl Config { self.delta_ratio = Some(delta_ratio); self } +} + +impl IntoRendition for Config { + type Config = JsonConfig; - /// The catalog entry describing a track published under this config in `mode`. - pub(crate) fn entry(&self, mode: Mode) -> JsonConfig { - let mut entry = JsonConfig::new(mode); + fn into_rendition(self) -> JsonConfig { + // The producer overwrites the mode with the one it publishes in. + let mut entry = JsonConfig::new(Mode::Snapshot); entry.compression = self.compression.then_some(Compression::Deflate); - entry.schema = self.schema.clone(); + entry.schema = self.schema; entry } } +/// Fix `config`'s mode and return whether its frames are compressed. +/// +/// Errors on a compression this build can't write, rather than advertising one the frames don't use, +/// and on a `broadcast` reference, which would point consumers away from the track this publishes. +fn prepare(config: &mut impl AsMut, mode: Mode) -> crate::Result { + let json = config.as_mut(); + if json.broadcast.is_some() { + return Err(crate::Error::ForeignBroadcast); + } + json.mode = mode; + crate::compression(json.compression.as_ref()) +} + +/// The snapshot encoder ratio on a [`Config`] builder, if `config` is one. +/// +/// [`IntoRendition`] only returns the catalog entry, and this ratio is not a catalog field. +/// Downcast keeps it on the existing builder instead of a new trait method. +fn delta_ratio_of(config: &C) -> Option { + (config as &dyn std::any::Any) + .downcast_ref::() + .and_then(|config| config.delta_ratio) +} + /// Publishes a latest-value JSON track, advertised in the catalog for as long as this handle lives. /// /// Every [`update`](Self::update) supersedes the last, so a consumer reads only the newest value. /// For a log where every record survives, use [`Stream`]. pub struct Snapshot { inner: moq_json::snapshot::Producer, - rendition: Rendition, + listing: Listing, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Snapshot { - pub(crate) fn new( + pub(crate) fn new( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, - ) -> crate::Result { + rendition: crate::catalog::Rendition, + config: C, + ) -> crate::Result + where + C: IntoRendition + std::any::Any, + { + // Read before `into_rendition` consumes the builder. Only [`Config`] carries a ratio; + // a custom section entry has none, and the default encoder ratio applies. + let delta_ratio = delta_ratio_of(&config); + let mut config = config.into_rendition(); let mut json = moq_json::snapshot::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Snapshot)? { json.compression = moq_json::Compression::Deflate; } - if let Some(delta_ratio) = config.delta_ratio { + if let Some(delta_ratio) = delta_ratio { json.delta_ratio = delta_ratio; } let inner = moq_json::snapshot::Producer::new(track, json); - rendition.set(config.entry(Mode::Snapshot))?; - Ok(Self { inner, rendition }) + let listing = Listing::new(rendition, config)?; + Ok(Self { + inner, + listing, + _catalog: PhantomData, + }) } /// The track name, which is also the catalog key. pub fn name(&self) -> &str { - self.rendition.name() + self.listing.name() } /// Create a subscriber for the underlying track. @@ -150,7 +193,8 @@ impl Snapshot { /// Publish a new value, superseding the previous one. pub fn update(&mut self, value: &T) -> crate::Result<()> { - Ok(self.inner.update(value)?) + self.inner.update(value)?; + self.listing.record(|| crate::catalog::json_len(value)) } /// Finish the track and retire its catalog entry. @@ -174,25 +218,28 @@ pub struct Stream { /// Cleared when a terminal failure ends the track, which retires the catalog entry with it. An /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. - rendition: Option>, + listing: Option, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Stream { - pub(crate) fn new( + pub(crate) fn new + AsMut>( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, + rendition: crate::catalog::Rendition, + mut config: C, ) -> crate::Result { let mut json = moq_json::stream::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Stream)? { json.compression = moq_json::Compression::Deflate; } let inner = moq_json::stream::Producer::new(track, json); - rendition.set(config.entry(Mode::Stream))?; + let listing = Listing::new(rendition, config)?; Ok(Self { inner, - name: rendition.name().to_string(), - rendition: Some(rendition), + name: listing.name().to_string(), + listing: Some(listing), + _catalog: PhantomData, }) } @@ -211,19 +258,22 @@ impl Stream { /// Append one record to the log. /// - /// Any failure ends the track (see [`moq_json::stream::Producer::append`]) and retires the - /// catalog entry with it. + /// A record that cannot be written ends the track (see [`moq_json::stream::Producer::append`]) + /// and retires the catalog entry with it. A catalog error publishing the measured bitrate is + /// returned after the record was written, so the track stays open and a retry would duplicate it. pub fn append(&mut self, value: &T) -> crate::Result<()> { - let Err(err) = self.inner.append(value) else { - return Ok(()); - }; - - // The inner producer has already ended the track. Dropping the rendition retires the catalog - // entry: waiting for the handle to drop would keep advertising a track that can no longer - // accept records, so a consumer discovering it now would subscribe to an already-ended log. - self.rendition = None; + if let Err(err) = self.inner.append(value) { + // The inner producer has already ended the track. Dropping the listing retires the catalog + // entry: waiting for the handle to drop would keep advertising a track that can no longer + // accept records, so a consumer discovering it now would subscribe to an already-ended log. + self.listing = None; + return Err(err.into()); + } - Err(err.into()) + match &mut self.listing { + Some(listing) => listing.record(|| crate::catalog::json_len(value)), + None => Ok(()), + } } /// Finish the track and retire its catalog entry. @@ -414,6 +464,46 @@ mod test { assert_eq!(drain(consumer), vec![json!({ "live": true })]); } + /// `delta_ratio` is an encoder setting on [`Config`], not a catalog field. A ratio of 0 + /// publishes each value as its own group; a positive ratio keeps the next value in that group. + #[test] + fn a_config_delta_ratio_reaches_the_encoder() { + let (mut broadcast, catalog) = catalog(); + let mut full = catalog + .json_snapshot::(track(&mut broadcast, "full"), Config::default().with_delta_ratio(0)) + .unwrap(); + let mut delta = catalog + .json_snapshot::(track(&mut broadcast, "delta"), Config::default().with_delta_ratio(100)) + .unwrap(); + + let mut full_track = full.consume(); + let mut delta_track = delta.consume(); + for value in [json!({ "n": 1 }), json!({ "n": 2 })] { + full.update(&value).unwrap(); + delta.update(&value).unwrap(); + } + full.finish().unwrap(); + delta.finish().unwrap(); + + // The default subscription budget keeps only the latest group. Ratio 0 rolled a new + // group for the second value, so that group holds one frame. A positive ratio appends + // the second value to the same group. + assert_eq!(ready_groups(&mut full_track), vec![1]); + assert_eq!(ready_groups(&mut delta_track), vec![2]); + } + + fn ready_groups(subscriber: &mut moq_net::track::Subscriber) -> Vec { + let waiter = kio::Waiter::noop(); + let mut counts = Vec::new(); + loop { + match subscriber.poll_recv_group(&waiter) { + Poll::Ready(Ok(Some(group))) => counts.push(group.frame_count()), + Poll::Ready(Ok(None)) | Poll::Pending => return counts, + Poll::Ready(Err(err)) => panic!("group ended in error: {err}"), + } + } + } + #[test] fn the_entry_describes_how_to_read_the_track() { let (mut broadcast, catalog) = catalog(); @@ -486,6 +576,35 @@ mod test { assert_eq!(catalog.snapshot().json.tracks.get("chat"), Some(&existing)); } + /// Writes fill an absent bitrate; one the publisher supplied is left alone. + #[test] + fn writes_fill_an_absent_bitrate() { + let (mut broadcast, catalog) = catalog(); + let mut gps = catalog + .json_stream::(track(&mut broadcast, "gps"), Config::default()) + .unwrap(); + let mut supplied = JsonConfig::new(Mode::Stream); + supplied.bitrate = Some(4_200); + let mut status = catalog + .json_snapshot::(track(&mut broadcast, "status"), supplied) + .unwrap(); + + // 40ms records of 500 bytes: 100 kbps, over more than the bitrate window. + for i in 0..60u64 { + let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); + gps.listing.as_mut().unwrap().record_at(now, 500).unwrap(); + status.listing.record_at(now, 500).unwrap(); + } + + assert_eq!(entry(&catalog, "gps").bitrate, Some(100_000)); + assert_eq!(entry(&catalog, "status").bitrate, Some(4_200)); + assert_eq!( + entry(&catalog, "gps").jitter, + None, + "write spacing is not a flush delay" + ); + } + /// The catalog is the only thing that announces a data track, so walking it is the discovery /// path. Each entry carries its own name, so nothing has to be threaded alongside it. #[test] From 68c73349a3e50dd34425367e538b9af9285c80ef Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 08:48:50 -0700 Subject: [PATCH 10/21] fix(net): judge spliced staleness against the logical live edge (#4103) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- rs/moq-net/src/model/origin.rs | 74 +++++- rs/moq-net/src/model/resume.rs | 374 +++++++++++++++++--------- rs/moq-net/src/model/track.rs | 466 ++++++++++++++++++++++++++++----- 3 files changed, 719 insertions(+), 195 deletions(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 2f4c0af840..52c06cc5f6 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -6291,6 +6291,77 @@ mod tests { assert_eq!(&group.read_frame().await.unwrap().unwrap().payload[..], b"live"); } + /// A returning reader judges the warm cache against the logical track's live + /// edge, not the parked segment's own frozen one: groups the fresh source + /// has left behind by more than the budget are skipped, exactly as they would + /// be on one unspliced track. + /// + /// A local source is released rather than parked (it keeps its own cache), so + /// this goes through a served front, which is what actually holds the warm copy. + /// The copy resolves at the cached edge, not past it: resolving past it drops + /// the cache outright, which is a different case. + #[tokio::test] + async fn resumed_reader_skips_warm_groups_behind_the_new_edge() { + let ms = |v: u64| crate::Timestamp::from_millis(v).unwrap(); + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + let budget = track::Subscription::default().with_max_age(Duration::from_millis(100)); + let write = |source: &track::Producer, sequence: u64, millis: u64| { + let mut group = source.create_group(sequence.into()).unwrap(); + group.write_frame(ms(millis), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + }; + let drain = |subscription: &mut track::Subscriber| { + let mut sequences = Vec::new(); + while let Poll::Ready(group) = subscription.poll_recv_group(&kio::Waiter::noop()) { + sequences.push(group.unwrap().expect("track ended").sequence); + } + sequences + }; + + let track = resolved.track("video").unwrap(); + let first = budget.clone(); + let subscribing = tokio::spawn(async move { track.subscribe(first).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source") + .expect("request"); + let source = request.resolving_start().accept(None); + for (sequence, millis) in [(0, 0), (1, 20), (2, 40), (3, 60)] { + write(&source, sequence, millis); + } + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + next_group(&mut subscription).await.unwrap().expect("a cached group"); + drain(&mut subscription); + drop(subscription); + + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(source); + + let track = resolved.track("video").unwrap(); + let second = budget.clone(); + let subscribing = tokio::spawn(async move { track.subscribe(second).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source again") + .expect("request"); + let mut source = request.resolving_start().accept(None); + for (sequence, millis) in [(20, 400), (21, 420), (22, 440)] { + write(&source, sequence, millis); + } + // At the cached edge, not past it, so the warm copy stays and the budget + // decides. Past it, the copy has already judged the cache stale. + source.start_at(3).unwrap(); + let mut subscription = subscribing.await.unwrap().expect("resubscribe"); + settle(|| subscription.latest() == Some(22)).await; + + // Groups 0..=2 reach at most 60ms against an edge at 440ms. Group 3 reaches + // where group 20 starts, 40ms behind that edge, inside the 100ms budget. + assert_eq!(drain(&mut subscription), [3, 20, 21, 22]); + } + /// A front serving from another front's spliced copy has no snapshot to keep: /// it still drops upstream on the unused edge, so the publisher's `unused()` /// resolves far below `TRACK_IDLE_LINGER` through the whole chain. The next @@ -6345,10 +6416,11 @@ mod tests { "every front keeps the delivered groups after releasing its source" ); + // A budget spanning the cache, so the returning reader replays it. let mut subscription = edge_resolved .track("video") .unwrap() - .subscribe(None) + .subscribe(track::Subscription::default().with_max_age(Duration::from_secs(3600))) .await .expect("resubscribe"); tokio::time::timeout(Duration::from_secs(5), track.used()) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 2fa3d0c048..e2227a17f1 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -26,7 +26,10 @@ use std::collections::{BTreeMap, HashSet}; use std::ops::Bound; use std::task::{Poll, ready}; +#[cfg(test)] +use crate::Timestamp; use crate::{Datagram, Error, Result, frame, group, track}; +use track::{Anchor, LiveEdge, Successor}; use super::subscription::{Cap, Position, Subscription, max_some, min_some}; @@ -141,6 +144,18 @@ fn slice(prefs: &Subscription, start: Option, end: Option) - } } +/// The first servable group in `from..cap` across `segments`, with the slot identity a +/// later judgment needs. An unstamped group stops the search: skipping it for a later +/// start would shrink a reach that is not yet proven. +fn served_start(segments: &[Segment], from: u64, cap: Option) -> Option { + segments.iter().find_map(|segment| { + let start = segment.start.map_or(0, |start| start.group).max(from); + segment + .track + .served_start(start, min_some(cap, last_group(segment.end))) + }) +} + /// How many segments a logical track keeps before pruning terminal ones from the /// front: the live segment plus a couple of predecessors still draining to slow /// readers. Without a bound, every failover leaves one dead segment (pinning a @@ -219,6 +234,28 @@ impl ResumeState { } } + /// The newest live edge across the segments below the exclusive `cap`, each clamped + /// to its own range: the edge the logical track measures drift against. + fn live_edge(&self, cap: Option) -> Option { + self.segments + .iter() + .filter_map(|segment| { + let edge = segment.track.live_edge(min_some(cap, last_group(segment.end)))?; + // Out-of-range content below the segment is not its to serve. + let start = segment.start.map_or(0, |start| start.group); + (edge.sequence >= start).then_some(edge) + }) + .max_by_key(|edge| edge.sequence) + } + + /// Where the logical track continues past the exclusive group `boundary` of segment + /// `id`, below the reader's `cap`: the start of the first group the later segments + /// serve there. `None` while none is cached, or it has no frame yet. + fn successor(&self, id: u64, boundary: u64, cap: Option) -> Option { + let index = self.segments.iter().position(|segment| segment.id == id)?; + served_start(&self.segments[index + 1..], boundary, cap) + } + /// Append a segment serving the track from `start` onward, capping (or replacing) /// the previous segments so the ranges stay disjoint and ascending. fn switch(&mut self, track: track::Consumer, start: Option) -> Result<()> { @@ -591,8 +628,8 @@ impl Consumer { next_sequence: 0, min_sequence: 0, end_sequence: None, - stale_cap: None, - drift_cap: kio::Producer::new(None), + outer: Anchor::default(), + drift_anchor: kio::Producer::new(Anchor::default()), } } @@ -659,6 +696,17 @@ impl Consumer { self.state.read().resume_position() } + /// The newest live edge across the segments; see [`track::Consumer::live_edge`]. + pub(crate) fn live_edge(&self, cap: Option) -> Option { + self.state.read().live_edge(cap) + } + + /// Where the first servable group in `from..cap` starts, with the identity to + /// revalidate it; see [`track::Consumer::served_start`]. + pub(crate) fn served_start(&self, from: u64, cap: Option) -> Option { + served_start(&self.state.read().segments, from, cap) + } + /// The newest cached group across every spliced segment; see /// [`track::Consumer::peek_latest`]. pub(crate) fn peek_latest(&self) -> Option { @@ -865,8 +913,8 @@ pub(crate) struct Group { state: kio::Consumer, /// The logical subscription's live max age budget. subscription: kio::Consumer, - /// The logical reader's group cap, used only to bound each route's drift anchor. - cap: kio::Consumer>, + /// The logical reader's drift anchor, used only to judge each route's copy. + anchor: kio::Consumer, /// The logical group being assembled. sequence: u64, @@ -921,7 +969,7 @@ impl Clone for Group { Self { state: self.state.clone(), subscription: self.subscription.clone(), - cap: self.cap.clone(), + anchor: self.anchor.clone(), sequence: self.sequence, index: self.index, end: self.end, @@ -936,14 +984,14 @@ impl Group { fn new( state: kio::Consumer, subscription: kio::Consumer, - cap: kio::Consumer>, + anchor: kio::Consumer, sequence: u64, index: u64, ) -> Self { Self { state, subscription, - cap, + anchor, sequence, index, end: None, @@ -1136,7 +1184,7 @@ impl Group { // `start_at` clamps up to the first frame the copy still holds, so landing // higher than asked means this route can't cover the seam after all. Treat it // like a dead copy and wait for one that can. - let mut group = track.guard_group(group, self.subscription.clone(), self.cap.clone(), bound); + let mut group = track.guard_group(group, self.subscription.clone(), self.anchor.clone(), bound); group.set_stale_meter(self.stale_stats.clone()); group.start_at(self.index); if group.index() != self.index { @@ -1295,7 +1343,7 @@ impl Group { // already includes the frames it skipped. Some(continuation) => { let mut continuation = - track.guard_group(continuation, self.subscription.clone(), self.cap.clone(), bound); + track.guard_group(continuation, self.subscription.clone(), self.anchor.clone(), bound); continuation.set_stale_meter(self.stale_stats.clone()); return continuation.poll_finished(waiter); } @@ -1317,6 +1365,9 @@ struct SegmentSub { /// A completed segment's cursor, retained while parked groups may need their /// max age budget re-evaluated after the outer cap rises. terminal: Option, + /// The drift anchor for this segment's cursor as of the last + /// [`Subscriber::refresh_anchor`], applied when a pending cursor activates. + anchor: Anchor, /// The producer dropped this segment (pruned, or replaced before producing). /// The cursor drains what it already holds, then retires; see /// [`Self::retired`]. @@ -1437,14 +1488,15 @@ pub struct Subscriber { min_sequence: u64, /// Exclusive cap for [`Self::next_group`], set by [`Self::end_at`]. end_sequence: Option, - /// A cap imposed by a reader wrapping this subscriber (a nested splice segment), - /// folded into every drift anchor pushed onto the segments. Never bounds delivery: - /// the outer reader enforces its own window, and an inner cap would hide a - /// segment's completion from it. - stale_cap: Option, - /// Shared copy of the effective cap ([`Self::end_sequence`] and [`Self::stale_cap`] - /// combined) for groups that outlive this cursor poll. - drift_cap: kio::Producer>, + /// The anchor imposed by a reader wrapping this subscriber (a nested splice + /// segment), folded into every drift anchor pushed onto the segments. Its cap never + /// bounds delivery: the outer reader enforces its own window, and an inner cap would + /// hide a segment's completion from it. + outer: Anchor, + /// The logical drift anchor ([`Self::end_sequence`] and [`Self::outer`] combined, + /// with the newest edge across the segments), shared with groups that outlive this + /// cursor poll. Refreshed on every [`Self::poll_sync`]. + drift_anchor: kio::Producer, } impl Subscriber { @@ -1453,6 +1505,7 @@ impl Subscriber { fn poll_sync(&mut self, waiter: &kio::Waiter) { self.sync(waiter); self.reap(); + self.refresh_anchor(); } /// Reap retired cursors, then bound the live stragglers: a pruned segment's @@ -1571,7 +1624,6 @@ impl Subscriber { } self.segments.retain(|s| !s.retired()); - let anchor = self.anchor_end(); let nexts: Vec<_> = segments.iter().skip(1).map(|next| Some(next.track.clone())).collect(); let nexts = nexts.into_iter().chain(std::iter::once(None)); for (segment, next) in segments.into_iter().zip(nexts) { @@ -1581,11 +1633,10 @@ impl Subscriber { warm.next = next; } if existing.end != segment.end { + // The boundary bounds the drift anchor as well as the demand; the + // anchor follows in `refresh_anchor`. existing.end = segment.end; - let cap = Self::stale_cap(existing, anchor); if let Some(sub) = existing.stale_sub_mut() { - // The boundary bounds the drift anchor as well as the demand. - sub.set_stale_cap(cap); // Shrink the demand so the session can cap upstream. The // read bounds stay on this subscriber (see `poll_recv_group`): // an inner `end_at` would park boundary-crossing groups in the @@ -1609,6 +1660,7 @@ impl Subscriber { ask: segment.ask, sub: SubState::Pending(sub), terminal: None, + anchor: Anchor::default(), pruned: false, parked: BTreeMap::new(), warm: segment.warm.then(|| Warm { @@ -1642,7 +1694,7 @@ impl Subscriber { let spliced = Group::new( self.state.clone(), self.prefs.consume(), - self.drift_cap.consume(), + self.drift_anchor.consume(), sequence, 0, ) @@ -1650,41 +1702,75 @@ impl Subscriber { Some(group.into_spliced(spliced)) } - /// The highest sequence a segment could hand its reader: the reader's own cap and - /// the segment's boundary, whichever is lower. + /// The anchor a segment's cursor measures drift against: the logical `anchor`, with + /// its cap lowered to the segment's boundary. /// /// A segment's inner cursor is deliberately left uncapped (an inner `end_at` would /// park boundary-crossing groups where its completion can't be seen), so this is how /// both bounds reach the drift anchor. Without them a segment measures staleness /// against groups it will never surface: the route running past the boundary, or the - /// reader's own cap holding content back. `anchor_end` is [`Self::anchor_end`], so a - /// cap imposed on this subscriber from outside is included. - fn stale_cap(seg: &SegmentSub, anchor_end: Option) -> Option { - min_some(anchor_end, seg.last_group()) + /// reader's own cap holding content back. The edge is left as is: it is the newest + /// content the logical track holds, which a segment's own track never sees. + /// + /// When the boundary lowers the cap, the reader's next group past it lives in a + /// later segment, so `state` supplies where it starts ([`Anchor::successor`]). + /// Otherwise the logical anchor's own successor (a wrapping splice's) still holds. + fn segment_anchor(seg: &SegmentSub, anchor: Anchor, state: &ResumeState) -> Anchor { + let Some(boundary) = seg.last_group() else { + return anchor; + }; + let cap = anchor.cap; + let mut capped = anchor.capped(Some(boundary)); + if capped.cap != cap { + capped.successor = state.successor(seg.id, boundary, cap); + } + capped } - /// The tightest cap any reader of this subscriber imposes: its own [`Self::end_at`] - /// and whatever a wrapping splice pushed down. What every segment's drift anchor is - /// bounded by. - fn anchor_end(&self) -> Option { - min_some(self.stale_cap, self.end_sequence) + /// The logical drift anchor, as of the last [`Self::refresh_anchor`]. + fn anchor(&self) -> Anchor { + self.drift_anchor.read().clone() } - /// Bound every segment's drift anchor from outside; the spliced arm of - /// [`track::Subscriber::set_stale_cap`], for this subscriber nested as a segment of - /// another splice. - pub(crate) fn set_stale_cap(&mut self, cap: Option) { - self.stale_cap = cap; - self.update_drift_cap(); - let anchor = self.anchor_end(); + /// Re-derive the logical drift anchor and push it onto every segment cursor. + /// + /// The cap is the tightest any reader of this subscriber imposes: its own + /// [`Self::end_at`] and whatever a wrapping splice pushed down. The edge is the + /// newest across the producer's segments within that cap, or a wrapping splice's if + /// newer. Resolved on every sync, since each segment is a separate track and the + /// logical edge moves whenever any of them grows. + fn refresh_anchor(&mut self) { + let outer = self.outer.clone().capped(self.end_sequence); + let state = self.state.read(); + let edge = state + .live_edge(outer.cap) + .into_iter() + .chain(outer.edge.clone()) + .max_by_key(|edge| edge.sequence); + let anchor = Anchor { edge, ..outer }; + // Skip a no-op write: every handed-out group's expiry watches this channel. + if self.anchor() != anchor + && let Ok(mut current) = self.drift_anchor.write() + { + *current = anchor.clone(); + } for seg in &mut self.segments { - let cap = Self::stale_cap(seg, anchor); + let anchor = Self::segment_anchor(seg, anchor.clone(), &state); + seg.anchor = anchor.clone(); if let Some(sub) = seg.stale_sub_mut() { - sub.set_stale_cap(cap); + sub.set_anchor(anchor); } } } + /// Bound every segment's drift anchor from outside; the spliced arm of + /// [`track::Subscriber::set_anchor`], for this subscriber nested as a segment of + /// another splice. + pub(crate) fn set_anchor(&mut self, anchor: Anchor) { + self.outer = anchor; + self.refresh_anchor(); + } + /// Count each segment's seek convictions behind the deliverer's `committed` /// watermark; the spliced arm of [`track::Subscriber::commit_seek_stale`]. pub(crate) fn commit_seek_stale(&mut self, committed: u64) { @@ -1723,23 +1809,10 @@ impl Subscriber { Poll::Ready(Ok(false)) } - /// Publish the effective cap for groups that outlive this cursor poll. - fn update_drift_cap(&mut self) { - if let Ok(mut cap) = self.drift_cap.write() { - *cap = self.anchor_end(); - } - } - /// Resolve a segment's pending subscription, if any. Ready once the segment is /// `Active` or `Done`; a rejected or closed track becomes `Done` (stall, not /// error). Never consumes groups, so terminal-state pollers can share it. - fn poll_activate( - seg: &mut SegmentSub, - prefs: &Subscription, - min_sequence: u64, - anchor_end: Option, - waiter: &kio::Waiter, - ) -> Poll<()> { + fn poll_activate(seg: &mut SegmentSub, prefs: &Subscription, min_sequence: u64, waiter: &kio::Waiter) -> Poll<()> { if matches!(seg.sub, SubState::Pending(_)) && let Some(warm) = &seg.warm { @@ -1770,7 +1843,7 @@ impl Subscriber { // assigned: the inner subscription resolved its own start from its // budget and floor, and this must not rewind past it. sub.raise_start_to(seg.first_group().max(min_sequence)); - sub.set_stale_cap(Self::stale_cap(seg, anchor_end)); + sub.set_anchor(seg.anchor.clone()); let _ = sub.update(slice(prefs, seg.ask, seg.end)); seg.sub = SubState::Active(Box::new(sub)); } @@ -1787,13 +1860,12 @@ impl Subscriber { seg: &mut SegmentSub, prefs: &Subscription, min_sequence: u64, - anchor_end: Option, waiter: &kio::Waiter, ) -> Poll> { loop { match &mut seg.sub { SubState::Pending(_) => { - ready!(Self::poll_activate(seg, prefs, min_sequence, anchor_end, waiter)); + ready!(Self::poll_activate(seg, prefs, min_sequence, waiter)); } SubState::Active(sub) => match ready!(sub.poll_recv_group(waiter)) { Ok(Some(group)) => { @@ -1859,7 +1931,6 @@ impl Subscriber { self.commit_seek_stale(committed); } - let anchor = self.anchor_end(); let mut floor = floor; 'retry: loop { let mut all_done = true; @@ -1867,14 +1938,8 @@ impl Subscriber { for index in 0..self.segments.len() { if matches!(self.segments[index].sub, SubState::Pending(_)) - && Self::poll_activate( - &mut self.segments[index], - &self.last_prefs, - self.min_sequence, - anchor, - waiter, - ) - .is_pending() + && Self::poll_activate(&mut self.segments[index], &self.last_prefs, self.min_sequence, waiter) + .is_pending() { all_done = false; continue; @@ -1966,7 +2031,6 @@ impl Subscriber { self.poll_sync(waiter); let end_sequence = self.end_sequence; - let anchor = self.anchor_end(); let min_sequence = self.min_sequence; let beyond_cap = |sequence: u64| !super::subscription::before_end(sequence, end_sequence); @@ -2021,13 +2085,7 @@ impl Subscriber { } loop { - let polled = Self::poll_segment( - &mut self.segments[index], - &self.last_prefs, - min_sequence, - anchor, - waiter, - ); + let polled = Self::poll_segment(&mut self.segments[index], &self.last_prefs, min_sequence, waiter); match polled { Poll::Ready(Some(group)) => { if beyond_cap(group.sequence) { @@ -2142,9 +2200,8 @@ impl Subscriber { // datagrams must still resolve the subscription (registering demand) and // be woken when it activates. let mut pending_activation = false; - let anchor = self.anchor_end(); if let Some(seg) = self.segments.last_mut() { - if Self::poll_activate(seg, &self.last_prefs, self.min_sequence, anchor, waiter).is_pending() { + if Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter).is_pending() { pending_activation = true; } else if let SubState::Active(sub) = &mut seg.sub && let Ok(Some(datagram)) = ready!(sub.poll_recv_datagram(waiter)) @@ -2203,17 +2260,10 @@ impl Subscriber { /// completing the segment, would steal them from a `recv_group` caller on the /// same subscriber. fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll> { - let anchor = min_some(self.stale_cap, self.end_sequence); let Some(seg) = self.segments.last_mut() else { return Poll::Ready(None); }; - ready!(Self::poll_activate( - seg, - &self.last_prefs, - self.min_sequence, - anchor, - waiter - )); + ready!(Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter)); match &mut seg.sub { SubState::Done(count) => Poll::Ready(*count), // Observe only: the cursor may still hold groups, so the read path @@ -2269,16 +2319,9 @@ impl Subscriber { /// re-offers it. pub fn end_at(&mut self, end: impl Into) { self.end_sequence = end.into().exclusive(); - self.update_drift_cap(); // The cap bounds each segment's drift anchor as well as this reader's own // delivery: a segment must not measure against groups this cap hides. - let anchor = self.anchor_end(); - for seg in &mut self.segments { - let cap = Self::stale_cap(seg, anchor); - if let Some(sub) = seg.stale_sub_mut() { - sub.set_stale_cap(cap); - } - } + self.refresh_anchor(); } /// The shared preferences channel, so `track::Control` can wrap it. @@ -2323,7 +2366,7 @@ impl Subscriber { #[cfg(test)] mod test { use super::*; - use crate::{Timestamp, broadcast}; + use crate::broadcast; use futures::FutureExt; use std::sync::Arc; use std::time::Duration; @@ -2693,7 +2736,7 @@ mod test { let mut producer = Producer::new(); producer.switch(&consumer_a, None).unwrap(); - let mut sub = producer.consume().subscribe(None); + let mut sub = producer.consume().subscribe(replay()); write_group(&mut track_a, 0, "a0"); producer.switch(&consumer_b, Position::group(1)).unwrap(); @@ -3126,9 +3169,11 @@ mod test { "the arrival path skips straight to the live edge" ); - // Spliced: the same backlog split across a takeover boundary. - let (mut track_a, consumer_a) = track_pair("a"); - let (mut track_b, consumer_b) = track_pair("b"); + // Spliced: the same backlog split across a takeover boundary. Retained long + // enough that the replay budget below is not clamped to the default window. + let retain = track::Info::default().with_max_age(Duration::from_secs(60)); + let (mut track_a, consumer_a) = track_pair_with("a", retain.clone()); + let (mut track_b, consumer_b) = track_pair_with("b", retain); let mut producer = Producer::new(); producer.switch(&consumer_a, None).unwrap(); producer.switch(&consumer_b, Position::group(2)).unwrap(); @@ -3147,11 +3192,11 @@ mod test { }) .collect(); - // Group 1 survives where the plain cursor drops it: each segment measures drift - // against its own boundary, since a segment cannot serve content past it, and - // group 1 is the newest thing segment A has. The sequence path inherits that - // from the arrival path rather than inventing its own anchor, so the two agree. - assert_eq!(spliced, vec![1, 3], "each segment is judged within its own boundary"); + // Segment A's groups are judged as the plain cursor judges them: against the + // logical edge (group 3), with group 1's reach bounded by its successor in + // segment B. The sequence path inherits that from the arrival path rather than + // inventing its own anchor, so the two agree. + assert_eq!(spliced, baseline, "a splice sheds the backlog like one track"); let mut arrival = producer.consume().subscribe(None); let arrival: Vec = std::iter::from_fn(|| { @@ -3177,38 +3222,101 @@ mod test { assert_eq!(replayed, vec![0, 1, 2, 3], "a backlog inside the budget crosses whole"); } + /// A segment's track never sees the groups of the segments after it: its own edge + /// freezes once a takeover caps it, and its last group has no successor there. + /// Both spliced cursors judge its backlog as one plain track holding the same groups + /// would: against the logical edge, with the last group's reach bounded by where the + /// next segment picks up. + #[tokio::test] + async fn a_capped_segment_is_judged_like_one_track() { + let budget = Subscription::default().with_max_age(Duration::from_millis(100)); + let old = [(0, 0), (1, 20), (2, 40), (3, 60)]; + let new = [(20, 400), (21, 420), (22, 440), (23, 460), (24, 480), (25, 500)]; + + let (mut plain, _plain_consumer) = track_pair("plain"); + for (sequence, millis) in old.into_iter().chain(new) { + write_group_at(&mut plain, sequence, "p", Duration::from_millis(millis)); + } + let mut baseline = plain.subscribe(budget.clone()); + let baseline: Vec = std::iter::from_fn(|| { + baseline + .recv_group() + .now_or_never()? + .expect("should not error") + .map(|group| group.sequence) + }) + .collect(); + // Group 3 reaches group 20's start at 400ms, 100ms behind the edge at 500ms. + assert_eq!(baseline, vec![20, 21, 22, 23, 24, 25]); + + let (mut track_a, consumer_a) = track_pair("a"); + let (mut track_b, consumer_b) = track_pair("b"); + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + producer.switch(&consumer_b, Position::group(4)).unwrap(); + for (sequence, millis) in old { + write_group_at(&mut track_a, sequence, "a", Duration::from_millis(millis)); + } + for (sequence, millis) in new { + write_group_at(&mut track_b, sequence, "b", Duration::from_millis(millis)); + } + + let mut arrival = producer.consume().subscribe(budget.clone()); + let arrival: Vec = std::iter::from_fn(|| { + kio::wait(|waiter| arrival.poll_recv_group(waiter)) + .now_or_never()? + .expect("should not error") + .map(|group| group.sequence) + }) + .collect(); + assert_eq!(arrival, baseline); + + let mut ordered = producer.consume().subscribe(budget); + let ordered: Vec = std::iter::from_fn(|| { + kio::wait(|waiter| ordered.poll_next_group(waiter)) + .now_or_never()? + .expect("should not error") + .map(|group| group.sequence) + }) + .collect(); + assert_eq!(ordered, baseline); + } + /// A nested splice's leaves are judged within the *outer* boundary. The outer /// window reaches them two ways, through the seek's own `end` and through - /// [`track::Subscriber::set_stale_cap`] recursing into the spliced segment; without + /// [`track::Subscriber::set_anchor`] recursing into the spliced segment; without /// either, a leaf anchors drift on a group past the outer boundary and convicts /// everything the outer segment still owes its reader. #[tokio::test] async fn nested_splice_judges_within_the_outer_boundary() { let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + let retain = track::Info::default().with_max_age(Duration::from_secs(60)); + let budget = Subscription::default().with_max_age(Duration::from_secs(15)); // Inner splice: one segment carrying groups 0..=2. Group 2 sits past the - // outer boundary below, so it is exactly the anchor the outer window must - // hide from the inner leaves. - let (mut track_a, consumer_a) = track_pair("a"); + // outer boundary below and rewinds to 0s, so it is exactly the successor the + // outer window must hide from the inner leaves: taken as group 1's, it would + // collapse group 1's reach to 0s, 30s behind the edge. + let (mut track_a, consumer_a) = track_pair_with("a", retain.clone()); let mut inner = Producer::new(); inner.switch(&consumer_a, None).unwrap(); write_group_at(&mut track_a, 0, "a0", stamp(0)); write_group_at(&mut track_a, 1, "a1", stamp(1)); - write_group_at(&mut track_a, 2, "a2", stamp(2)); + write_group_at(&mut track_a, 2, "a2", stamp(0)); // Outer splice: the inner spliced track up to group 2, then a plain track. let inner_track = track::Consumer::spliced("inner".into(), Arc::new(broadcast::Info::default()), inner.consume()); - let (mut track_b, consumer_b) = track_pair("b"); + let (mut track_b, consumer_b) = track_pair_with("b", retain); let mut outer = Producer::new(); outer.switch(&inner_track, None).unwrap(); outer.switch(&consumer_b, Position::group(2)).unwrap(); write_group_at(&mut track_b, 2, "b2", stamp(2)); write_group_at(&mut track_b, 3, "b3", stamp(3)); - // Group 1 is the newest group the outer window can serve from the nested - // segment, so it is its own live edge there and survives a zero budget. - let mut sub = outer.consume().subscribe(None); + // Group 0 is 20s behind the edge, past the 15s budget. Group 1 reaches B's + // group 2 at 20s, only 10s behind, so it survives. + let mut sub = outer.consume().subscribe(budget.clone()); let sequences: Vec = std::iter::from_fn(|| { kio::wait(|waiter| sub.poll_next_group(waiter)) .now_or_never()? @@ -3218,11 +3326,11 @@ mod test { .collect(); assert_eq!( sequences, - vec![1, 3], + vec![1, 2, 3], "the nested segment is judged within the outer boundary" ); - let mut arrival = outer.consume().subscribe(None); + let mut arrival = outer.consume().subscribe(budget); let arrival: Vec = std::iter::from_fn(|| { kio::wait(|waiter| arrival.poll_recv_group(waiter)) .now_or_never()? @@ -3239,7 +3347,9 @@ mod test { /// the group after all, and delivered content never counts. #[tokio::test] async fn seek_conviction_counts_only_once_committed() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 2 starts after group 3 (a rewind), so group 1's reach runs past the live + // edge and it survives a zero budget, while groups 0 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 10, 40, 30][sequence as usize]); let (mut track_a, consumer_a) = track_pair("a"); let (mut track_b, consumer_b) = track_pair("b"); @@ -3284,7 +3394,9 @@ mod test { /// jumped past it, and still be deliverable (uncounted) once the budget widens. #[tokio::test] async fn a_reversible_floor_does_not_commit_a_conviction() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 2 starts after group 3 (a rewind), so group 1's reach runs past the live + // edge and it survives a zero budget, while groups 0 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 10, 40, 30][sequence as usize]); let (mut track_a, consumer_a) = track_pair("a"); let (mut track_b, consumer_b) = track_pair("b"); @@ -3333,7 +3445,9 @@ mod test { /// that content, so the conviction must be discarded rather than counted. #[tokio::test] async fn a_delivered_continuation_is_not_counted_stale() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 2 starts after group 3 (a rewind), so group 1's reach runs past the live + // edge and it survives a zero budget, while groups 0 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 10, 40, 30][sequence as usize]); let (mut track_a, consumer_a) = track_pair("a"); let (mut track_b, consumer_b) = track_pair("b"); @@ -3357,9 +3471,9 @@ mod test { .sequence }; - // Group 1 wins from segment A (it holds the head copy) while segment B has - // convicted both its continuation copy of 1 and group 2. Delivering 1 must - // discard B's copy, not count it. + // Group 1 wins from segment A (it holds the head copy) while segment B convicts + // group 2. B's continuation copy of 1 is delivered through the head, so it + // must not be counted. assert_eq!(next(&mut sub), 1); assert_eq!(next(&mut sub), 3); assert_eq!( @@ -3376,12 +3490,16 @@ mod test { /// commit may count; a conviction never committed is dropped with the cursor. #[tokio::test] async fn a_finalized_segment_flushes_nothing_without_a_delivery() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 1 starts after group 3 (a rewind), so group 0's reach runs past the live + // edge and it survives a zero budget, while groups 1 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 40, 20, 30][sequence as usize]); // C owns group 0, A owns group 1's head, finalized B owns its tail onward. - let (mut track_c, consumer_c) = track_pair("c"); - let (mut track_a, consumer_a) = track_pair("a"); - let (mut track_b, consumer_b) = track_pair("b"); + // Retained long enough that the widened budget below is not clamped. + let retain = track::Info::default().with_max_age(Duration::from_secs(60)); + let (mut track_c, consumer_c) = track_pair_with("c", retain.clone()); + let (mut track_a, consumer_a) = track_pair_with("a", retain.clone()); + let (mut track_b, consumer_b) = track_pair_with("b", retain); let mut producer = Producer::new(); producer.switch(&consumer_c, None).unwrap(); producer.switch(&consumer_a, Position::group(1)).unwrap(); @@ -4273,7 +4391,7 @@ mod test { #[tokio::test] async fn capped_subscriber_bounds_parked_segments() { let mut producer = Producer::new(); - let mut sub = producer.consume().subscribe(None); + let mut sub = producer.consume().subscribe(replay()); sub.end_at(..1); // Every round parks one group beyond the cap, then fails over to a live @@ -5082,8 +5200,8 @@ mod test { let mut producer = Producer::new(); producer.switch(&consumer_a, None).unwrap(); let consumer = producer.consume(); - let mut sub1 = consumer.subscribe(None); - let mut sub2 = consumer.subscribe(None); + let mut sub1 = consumer.subscribe(replay()); + let mut sub2 = consumer.subscribe(replay()); recv_pending(&mut sub1); recv_pending(&mut sub2); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 17bec9149b..ba83c78728 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -477,9 +477,8 @@ impl TrackState { /// it, so the groups it still wants aren't stale just because the route ran on. /// Fetched backfill is absent from `arrival`, so it cannot age subscription content /// as though it were a live replacement. - fn live_edge(&self, cap: Option) -> Option { - let presentation = self - .lookup + fn live_edge(&self, cap: Option) -> Option { + self.lookup .range(..) .rev() .filter(|(seq, _)| super::subscription::before_end(**seq, cap)) @@ -495,9 +494,26 @@ impl TrackState { stamp: slot.stamp, timestamp: slot.group.latest().unwrap_or(timestamp), }) - }); + }) + } + + /// This track's own edge under the exclusive `cap`, for measuring drift. An outer + /// edge and a successor live on other tracks; the caller revalidates those before + /// taking this lock and passes them in, so the locks never nest. + fn drift_edge(&self, cap: Option, outer: Option<(u64, Timestamp)>, successor: Option) -> Edge { + Edge { + presentation: self.live_edge(cap), + outer, + cap, + successor, + } + } - presentation.map(|presentation| Edge { presentation, cap }) + /// Whether `sequence` still holds the servable incarnation `stamp`. + fn holds(&self, sequence: u64, stamp: u32) -> bool { + self.lookup + .get(&sequence) + .is_some_and(|slot| slot.stamp == stamp && !slot.group.is_aborted()) } /// The furthest presentation time the group at `sequence` could still reach: where @@ -510,14 +526,45 @@ impl TrackState { /// so a later stamped group proves nothing about where an unstamped successor will /// begin, and shrinking the bound is the unsafe direction. An unstamped successor /// therefore leaves the reach unbounded until it presents its first frame. - fn reach(&self, sequence: u64, cap: Option) -> Option { - let successor = self + /// + /// With no servable successor below `cap`, the reader's next group is past the cap, + /// and `beyond` is where it starts when another track serves it (a splice's next + /// segment; see [`Anchor::successor`]). + fn reach(&self, sequence: u64, cap: Option, beyond: Option) -> Option { + match self.first_start(sequence.saturating_add(1), cap) { + Some(start) => start, + None => beyond, + } + } + + /// Where the first servable group in `from..cap` starts presenting: `None` when no + /// such group is cached, `Some(None)` while it has no frame yet. + fn first_start(&self, from: u64, cap: Option) -> Option> { + let slot = self + .lookup + .range(from..) + .map(|(_, slot)| slot) + .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) + .find(|slot| slot.visible && !slot.group.is_aborted())?; + Some(slot.group.timestamp()) + } + + /// The first servable group's start in `from..cap`, with the slot identity a later + /// judgment needs to tell that group from whatever replaces it. `None` when no such + /// group is cached or it has no frame yet: an unstamped successor leaves reach + /// unbounded, and this does not skip past it to a later group. + fn served_start(&self, from: u64, cap: Option) -> Option { + let slot = self .lookup - .range(sequence.saturating_add(1)..) + .range(from..) .map(|(_, slot)| slot) .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) .find(|slot| slot.visible && !slot.group.is_aborted())?; - successor.group.timestamp() + Some(ServedStart { + sequence: slot.group.sequence, + stamp: slot.stamp, + timestamp: slot.group.timestamp()?, + }) } /// Whether the group at `sequence` has drifted further behind `edge` than `budget` @@ -543,11 +590,10 @@ impl TrackState { /// /// The edge must sit strictly above the candidate. The live edge is never late /// against itself, and backfill or the tail of a rewound timeline can carry a high - /// timestamp on a low sequence without being an edge at all. - fn is_stale(&self, sequence: u64, edge: Option<&Edge>, budget: Duration) -> bool { - let Some(edge) = edge else { - return false; - }; + /// timestamp on a low sequence without being an edge at all. Of the edges that + /// qualify, the highest sequence is the newest content, whichever track holds it: + /// this one's, or the `outer` edge a splice pushed from another segment. + fn is_stale(&self, sequence: u64, edge: &Edge, budget: Duration) -> bool { if !self.lookup.contains_key(&sequence) { return false; } @@ -555,16 +601,23 @@ impl TrackState { // The anchor was resolved under an earlier lock, so confirm it still names // the same servable incarnation before it convicts a candidate. Failing safe // (delivering) is right, since the next poll resolves fresh anchors. - let live_edge = &edge.presentation; - let reach = self.reach(sequence, edge.cap); - live_edge.sequence > sequence - && self - .lookup - .get(&live_edge.sequence) - .is_some_and(|live| live.stamp == live_edge.stamp && !live.group.is_aborted()) - && reach.is_some_and( - |reach| matches!(live_edge.timestamp.checked_sub(reach), Ok(age) if Duration::from(age) >= budget), - ) + let local = edge + .presentation + .filter(|live| self.holds(live.sequence, live.stamp)) + .map(|live| (live.sequence, live.timestamp)); + // `outer` and `successor` were revalidated on their own tracks before this lock + // was taken ([`LiveEdge::is_live`], [`Successor::start`]). There is no slot for + // them here, and taking their locks here would nest. + let Some((_, timestamp)) = local + .into_iter() + .chain(edge.outer) + .filter(|(live, _)| *live > sequence) + .max_by_key(|(live, _)| *live) + else { + return false; + }; + self.reach(sequence, edge.cap, edge.successor) + .is_some_and(|reach| matches!(timestamp.checked_sub(reach), Ok(age) if Duration::from(age) >= budget)) } /// Resolve a one-shot fetch from the track side: the cached group, or an [`Error`] @@ -1554,7 +1607,7 @@ impl Producer { let min_sequence = floor_of(&preferences); let subscription = kio::Producer::new(preferences); register_subscription(self.state.read(), &subscription); - let drift_cap = kio::Producer::new(None); + let drift_anchor = kio::Producer::new(Anchor::default()); // Hoisted: an inline `read()` guard would live to the end of the struct literal, // deadlocking against the `consume()` below. @@ -1573,7 +1626,7 @@ impl Producer { end_sequence: None, parked: BTreeMap::new(), stale_cap: None, - drift_cap, + drift_anchor, stale: stats::Content::default(), seek_pending: BTreeMap::new(), }), @@ -2369,6 +2422,40 @@ impl Consumer { } } + /// The live edge below the exclusive `cap` that drift is measured against; see + /// [`TrackState::live_edge`]. A splice reports the newest across its segments. + pub(crate) fn live_edge(&self, cap: Option) -> Option { + match &self.inner { + ConsumerKind::Plain(state) => { + let edge = state.read().live_edge(cap)?; + Some(LiveEdge { + sequence: edge.sequence, + timestamp: edge.timestamp, + stamp: edge.stamp, + track: state.weak(), + }) + } + ConsumerKind::Spliced(resume) => resume.live_edge(cap), + } + } + + /// Where the first servable group in `from..cap` starts, with enough identity to + /// revalidate it later. A splice answers from the first segment holding one. + pub(crate) fn served_start(&self, from: u64, cap: Option) -> Option { + match &self.inner { + ConsumerKind::Plain(state) => { + let served = state.read().served_start(from, cap)?; + Some(Successor { + sequence: served.sequence, + timestamp: served.timestamp, + stamp: served.stamp, + track: state.weak(), + }) + } + ConsumerKind::Spliced(resume) => resume.served_start(from, cap), + } + } + /// The nearest cached group below `sequence`, under the same terms as /// [`Self::peek_group`]. Walks the cache's own order, so gaps in the group numbering /// are crossed and aborted (evicted) entries are skipped. @@ -2411,7 +2498,7 @@ impl Consumer { &self, group: group::Consumer, subscription: kio::Consumer, - cap: kio::Consumer>, + anchor: kio::Consumer, bound: Option, ) -> group::Consumer { let ConsumerKind::Plain(state) = &self.inner else { @@ -2421,7 +2508,7 @@ impl Consumer { group.with_expiry(Arc::new(GroupExpiry { state: state.weak(), subscription, - cap, + anchor, bound, sequence, })) @@ -2672,7 +2759,7 @@ impl Subscribing { let info = ready!(state.poll(waiter, |state| state.poll_info())) .map_err(|e| e.abort.clone().unwrap_or(Error::Dropped))??; - let drift_cap = kio::Producer::new(None); + let drift_anchor = kio::Producer::new(Anchor::default()); let min_sequence = floor_of(&self.subscription.read()); Poll::Ready(Ok(Subscriber { name: self.name.clone(), @@ -2688,7 +2775,7 @@ impl Subscribing { end_sequence: None, parked: BTreeMap::new(), stale_cap: None, - drift_cap, + drift_anchor, stale: stats::Content::default(), seek_pending: BTreeMap::new(), }), @@ -2978,11 +3065,14 @@ enum SubscriberKind { /// One poll's view of how far this subscription may drift: the clamped budget and the /// live edge to measure a candidate group against. Resolved once, then applied to every -/// group that poll considers. +/// group that poll considers. `outer` and `successor` are revalidated per candidate, +/// outside this track's lock. #[derive(Clone)] struct Drift { budget: Duration, - edge: Option, + edge: Edge, + outer: Option, + successor: Option, } /// Keeps one handed-out group tied to the subscription whose cursor selected it. @@ -2994,7 +3084,7 @@ struct GroupExpiry { /// the last real subscriber. state: kio::ConsumerWeak, subscription: kio::Consumer, - cap: kio::Consumer>, + anchor: kio::Consumer, bound: Option, sequence: u64, } @@ -3007,19 +3097,26 @@ impl group::Expiry for GroupExpiry { Poll::<()>::Pending }); - let mut cap = None; - let _ = self.cap.poll(waiter, |current| { - cap = **current; + let mut anchor = Anchor::default(); + let _ = self.anchor.poll(waiter, |current| { + anchor = (**current).clone(); Poll::<()>::Pending }); - let cap = super::subscription::min_some(cap, self.bound); + let anchor = anchor.capped(self.bound); + let cap = anchor.cap; + // Before this track's lock: both may name another track, and nesting deadlocks. + let outer = anchor + .edge + .filter(LiveEdge::is_live) + .map(|live| (live.sequence, live.timestamp)); + let successor = anchor.successor.as_ref().and_then(Successor::start); let mut expired = false; let _ = self.state.poll(waiter, |state| { let budget = clamp_max_age(max_age, state.max_age_bound()); loop { - let edge = state.live_edge(cap); - expired = state.is_stale(self.sequence, edge.as_ref(), budget); + let edge = state.drift_edge(cap, outer, successor); + expired = state.is_stale(self.sequence, &edge, budget); if expired { break; } @@ -3073,12 +3170,121 @@ impl group::Expiry for GroupExpiry { /// The group a poll's drift is measured against, identified well enough to tell it apart /// from whatever may occupy its sequence by the time a candidate is judged. -#[derive(Clone)] +#[derive(Clone, Copy)] struct Edge { - presentation: PresentationEdge, + /// This track's own edge, revalidated before it convicts anything. + presentation: Option, + /// A newer edge on another track, already revalidated: its sequence and the newest + /// frame it had presented. See [`Anchor::edge`]. + outer: Option<(u64, Timestamp)>, /// The cap the edge was resolved under, so per-candidate reach lookups measure /// against the same servable window. cap: Option, + /// Where the next group past `cap` starts, already revalidated. See [`Anchor::successor`]. + successor: Option, +} + +/// How a reader wrapping a cursor bounds its drift anchor from outside: pushed by a +/// splice onto each segment's cursor, and shared with the groups a cursor hands out. +#[derive(Clone, Default, PartialEq)] +pub(crate) struct Anchor { + /// The exclusive sequence cap on what the reader could be handed; see [`servable_cap`]. + pub cap: Option, + /// The newest edge across a splice's segments. Each segment is a separate track + /// that only sees its own groups, so without this a parked segment measures against + /// its own frozen edge while the logical track has moved on. Revalidated on its own + /// track before it convicts anything, since it may be judged long after it was pushed. + pub edge: Option, + /// Where the reader's next group past `cap` starts presenting, when another track + /// serves it (a splice's next segment). The last group below the cap has no + /// successor in its own track, so without this nothing bounds its reach and it is + /// never judged stale. `None` while unknown or unstamped. Revalidated like `edge`: + /// a cached start must not convict once that group is gone. + pub successor: Option, +} + +impl Anchor { + /// This anchor under a further `cap`. A lower cap drops the successor: it named + /// where the reader continues past the old cap, which is no longer served. + pub fn capped(mut self, cap: Option) -> Self { + let capped = servable_cap(cap, self.cap); + if capped != self.cap { + self.cap = capped; + self.successor = None; + } + self + } +} + +/// The newest stamped group of a track: its sequence and the newest frame it presented, +/// plus enough identity for a reader on another track to revalidate it. +#[derive(Clone)] +pub(crate) struct LiveEdge { + pub sequence: u64, + pub timestamp: Timestamp, + stamp: u32, + track: kio::ConsumerWeak, +} + +impl LiveEdge { + /// Whether the edge still names the same servable group on its own track, the + /// check [`TrackState::is_stale`] runs on a local edge. An eviction or abort since + /// the splice resolved it must not convict anything. Takes that track's lock, so + /// never call it under another's. + fn is_live(&self) -> bool { + self.track.read().holds(self.sequence, self.stamp) + } +} + +impl PartialEq for LiveEdge { + fn eq(&self, other: &Self) -> bool { + self.sequence == other.sequence + && self.timestamp == other.timestamp + && self.stamp == other.stamp + && self.track.same_channel(&other.track) + } +} + +/// The first servable group past a segment boundary: where it starts, and which slot +/// that start was read from. +struct ServedStart { + sequence: u64, + stamp: u32, + timestamp: Timestamp, +} + +/// A successor pushed onto another track's cursor. The timestamp alone is not enough: +/// once the group is evicted, a later group can keep the outer edge valid while this +/// start is no longer where the track continues. +#[derive(Clone)] +pub(crate) struct Successor { + sequence: u64, + timestamp: Timestamp, + stamp: u32, + track: kio::ConsumerWeak, +} + +impl Successor { + /// The start this still names, re-read from its own track, or `None` once that + /// group is gone or no longer stamped. Takes that track's lock, so never call it + /// under another's. + fn start(&self) -> Option { + let state = self.track.read(); + let slot = state.lookup.get(&self.sequence)?; + if slot.stamp != self.stamp || slot.group.is_aborted() { + return None; + } + slot.group.timestamp() + } +} + +impl PartialEq for Successor { + fn eq(&self, other: &Self) -> bool { + self.sequence == other.sequence + && self.timestamp == other.timestamp + && self.stamp == other.stamp + && self.track.same_channel(&other.track) + } } /// The newest servable group that has presented at least one frame. @@ -3124,8 +3330,9 @@ struct PlainSubscriber { /// segment), folded into the drift anchor only. Delivery is still bounded by /// `end_sequence`, which stays unset on a segment so its completion is visible. stale_cap: Option, - /// Shared effective cap used by groups after this cursor hands them out. - drift_cap: kio::Producer>, + /// Shared effective anchor used by groups after this cursor hands them out. The + /// only copy of the outer edge a wrapping reader pushed (see [`Anchor::edge`]). + drift_anchor: kio::Producer, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the handle that owns the stats scope /// is the outer [`Subscriber`], which may be reading this cursor through a @@ -3138,9 +3345,20 @@ struct PlainSubscriber { } impl PlainSubscriber { - fn update_drift_cap(&mut self) { - if let Ok(mut cap) = self.drift_cap.write() { - *cap = servable_cap(self.end_sequence, self.stale_cap); + /// The drift anchor for a read bounded by `end`. Every read folds `end_sequence` + /// into `end`, so capping the shared anchor (which already holds it) is exact. + fn anchor(&self, end: Option) -> Anchor { + self.drift_anchor.read().clone().capped(end) + } + + /// Publish the `outer` anchor under this cursor's own cap. + fn update_drift_anchor(&mut self, outer: Anchor) { + let anchor = outer.capped(self.end_sequence); + // Skip a no-op write: every handed-out group's expiry watches this channel. + if *self.drift_anchor.read() != anchor + && let Ok(mut current) = self.drift_anchor.write() + { + *current = anchor; } } @@ -3177,16 +3395,23 @@ impl PlainSubscriber { /// discarding a backlog of N groups costs one scan rather than N. Only ever /// [`Poll::Ready`]; the track ending surfaces as the error the caller was going to /// get anyway. - fn poll_drift(&self, cap: Option, waiter: &kio::Waiter) -> Poll> { + fn poll_drift(&self, anchor: Anchor, waiter: &kio::Waiter) -> Poll> { let mut max_age = Duration::default(); let _ = self.subscription.poll(waiter, |subscription| { max_age = subscription.max_age; Poll::<()>::Pending }); - self.poll(waiter, move |state| { + let cap = anchor.cap; + let outer = anchor.edge; + let successor = anchor.successor; + self.poll(waiter, |state| { + // Local edge only. The pushed edge and successor are revalidated in + // [`Self::poll_stale`], outside this lock. Poll::Ready(Ok(Drift { budget: clamp_max_age(max_age, state.max_age_bound()), - edge: state.live_edge(cap), + edge: state.drift_edge(cap, None, None), + outer: outer.clone(), + successor: successor.clone(), })) }) } @@ -3194,8 +3419,25 @@ impl PlainSubscriber { /// Whether the drift budget says to skip `group`, against a [`Drift`] already resolved /// for this poll. fn poll_stale(&self, group: &group::Consumer, drift: &Drift, waiter: &kio::Waiter) -> Poll> { + // Revalidate before this track's lock. Both can name another track, including + // one whose own judgment is waiting on this one. + let outer = drift + .outer + .as_ref() + .filter(|live| live.is_live()) + .map(|live| (live.sequence, live.timestamp)); + let successor = drift.successor.as_ref().and_then(Successor::start); + let presentation = drift.edge.presentation; + let cap = drift.edge.cap; + let budget = drift.budget; self.poll(waiter, move |state| { - Poll::Ready(Ok(state.is_stale(group.sequence, drift.edge.as_ref(), drift.budget))) + let edge = Edge { + presentation, + outer, + cap, + successor, + }; + Poll::Ready(Ok(state.is_stale(group.sequence, &edge, budget))) }) } @@ -3204,7 +3446,7 @@ impl PlainSubscriber { group.with_expiry(Arc::new(GroupExpiry { state: self.state.weak(), subscription: self.subscription.consume(), - cap: self.drift_cap.consume(), + anchor: self.drift_anchor.consume(), bound: None, sequence, })) @@ -3232,7 +3474,7 @@ impl PlainSubscriber { .retain(|sequence, group| *sequence >= min_sequence && watch(group)); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(servable_cap(self.end_sequence, self.stale_cap), waiter))?; + let drift = ready!(self.poll_drift(self.anchor(self.end_sequence), waiter))?; loop { // Re-offer the lowest parked group back inside the cap once it rises, @@ -3331,7 +3573,7 @@ impl PlainSubscriber { let mut floor = floor.max(self.min_sequence); let end = super::subscription::min_some(end, self.end_sequence); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(servable_cap(end, self.stale_cap), waiter))?; + let drift = ready!(self.poll_drift(self.anchor(end), waiter))?; loop { let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end))?) else { @@ -3462,21 +3704,22 @@ impl Subscriber { } /// Bound the drift anchor from outside, for a reader that caps this subscriber - /// without capping its cursor. + /// without capping its cursor, and splices it with other tracks. /// /// A [`super::resume::Subscriber`] segment is deliberately left uncapped /// ([`Self::set_groups`] would park boundary-crossing groups where its completion can't /// be seen), so its own cap has to reach the anchor this way or the segment measures - /// drift against groups its reader will never be served. A spliced segment folds the - /// cap into what it pushes onto its own segments, so the bound reaches the plain - /// cursors at the leaves however deep the splices nest. - pub(crate) fn set_stale_cap(&mut self, cap: Option) { + /// drift against groups its reader will never be served. The same goes for the edge: + /// a segment's track never sees the groups of the segments after it. A spliced + /// segment folds the anchor into what it pushes onto its own segments, so it reaches + /// the plain cursors at the leaves however deep the splices nest. + pub(crate) fn set_anchor(&mut self, anchor: Anchor) { match &mut self.inner { SubscriberKind::Plain(plain) => { - plain.stale_cap = cap; - plain.update_drift_cap(); + plain.stale_cap = anchor.cap; + plain.update_drift_anchor(anchor); } - SubscriberKind::Spliced(spliced) => spliced.set_stale_cap(cap), + SubscriberKind::Spliced(spliced) => spliced.set_anchor(anchor), } } @@ -3514,7 +3757,7 @@ impl Subscriber { SubscriberKind::Plain(plain) => plain, SubscriberKind::Spliced(spliced) => return spliced.poll_stale(group, waiter), }; - let drift = ready!(plain.poll_drift(servable_cap(plain.end_sequence, plain.stale_cap), waiter))?; + let drift = ready!(plain.poll_drift(plain.anchor(plain.end_sequence), waiter))?; let stale = ready!(plain.poll_stale(group, &drift, waiter))?; if stale { plain.note_stale(group); @@ -3741,7 +3984,13 @@ impl Subscriber { match &mut self.inner { SubscriberKind::Plain(plain) => { plain.end_sequence = end.exclusive(); - plain.update_drift_cap(); + // A successor dropped by a lower cap stays dropped until the wrapping + // reader pushes its anchor again, which it does on every poll. + let outer = Anchor { + cap: plain.stale_cap, + ..plain.drift_anchor.read().clone() + }; + plain.update_drift_anchor(outer); } SubscriberKind::Spliced(spliced) => spliced.end_at(end), } @@ -6018,10 +6267,12 @@ mod test { let state = producer.state.read(); let drift = Drift { budget: Duration::ZERO, - edge: state.live_edge(None), + edge: state.drift_edge(None, None, None), + outer: None, + successor: None, }; assert!( - state.is_stale(0, drift.edge.as_ref(), drift.budget), + state.is_stale(0, &drift.edge, drift.budget), "stale against a live edge" ); drop(state); @@ -6032,11 +6283,94 @@ mod test { let state = producer.state.read(); assert!( - !state.is_stale(0, drift.edge.as_ref(), drift.budget), + !state.is_stale(0, &drift.edge, drift.budget), "a vanished edge is no reason to drop what is left" ); } + /// The same holds for an edge a splice pushed from another segment: a handed-out + /// group judges it long after the splice resolved it, so it is revalidated on its own + /// track before it convicts anything. + #[tokio::test] + async fn an_evicted_outer_edge_convicts_nothing() { + let mut producer = track_producer("a", None); + let mut subscriber = producer.subscribe(Subscription::default().with_max_age(Duration::from_secs(1))); + let mut open = producer.append_group().unwrap(); + open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"a")) + .unwrap(); + let mut group = subscriber.recv_group().await.unwrap().expect("group"); + assert!(group.read_frame().await.unwrap().is_some()); + // A successor bounds the open group's reach, without being late against it. + append_at(&mut producer, 10); + + let mut next = track_producer("b", None); + append_at(&mut next, 30_000); + let edge = append_at(&mut next, 30_010); + subscriber.set_anchor(Anchor { + cap: None, + edge: next.consume().live_edge(None), + successor: None, + }); + + let mut control = group.clone(); + assert!( + matches!(control.read_frame().now_or_never(), Some(Ok(None))), + "the open group ends against the outer edge" + ); + + // The outer edge dies before the group is judged again. + let slot = next.modify().unwrap().lookup.remove(&edge).unwrap(); + let _ = slot.group.abort(Error::Evicted); + + assert!( + group.read_frame().now_or_never().is_none(), + "a vanished outer edge is no reason to drop what is left" + ); + assert!(!group.latency_expired()); + open.finish().unwrap(); + } + + /// A pushed successor is the same kind of cached fact. Evicting it must not keep + /// bounding the previous segment's last group while a later group still anchors the + /// outer edge. + #[tokio::test] + async fn an_evicted_successor_convicts_nothing() { + let producer = track_producer("a", None); + let mut subscriber = producer.subscribe(Subscription::default().with_max_age(Duration::from_secs(1))); + let mut open = producer.append_group().unwrap(); + open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"a")) + .unwrap(); + let mut group = subscriber.recv_group().await.unwrap().expect("group"); + assert!(group.read_frame().await.unwrap().is_some()); + + let mut next = track_producer("b", None); + // Early enough that group 0 is already past the budget, and not itself the edge. + let successor = append_at(&mut next, 10); + append_at(&mut next, 30_000); + let consumer = next.consume(); + subscriber.set_anchor(Anchor { + cap: None, + edge: consumer.live_edge(None), + successor: consumer.served_start(successor, None), + }); + + let mut control = group.clone(); + assert!( + matches!(control.read_frame().now_or_never(), Some(Ok(None))), + "the open group ends against the successor" + ); + + let slot = next.modify().unwrap().lookup.remove(&successor).unwrap(); + let _ = slot.group.abort(Error::Evicted); + + assert!( + group.read_frame().now_or_never().is_none(), + "a vanished successor is no reason to drop what is left" + ); + assert!(!group.latency_expired()); + open.finish().unwrap(); + } + #[tokio::test] async fn a_lower_sequence_is_never_the_live_edge() { let producer = track_producer("test", None); From 94df86c1e6f391ceb09f68f82e550b3981d87c0a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 08:54:21 -0700 Subject: [PATCH 11/21] docs(quest): plan CI benchmark regressions and new benches (#4149) Co-authored-by: Claude Opus 5.5 --- CLAUDE.md | 2 +- quest/m1/README.md | 4 +++ quest/m1/bench-ci.md | 54 +++++++++++++++++++++++++++++ quest/m1/bench-coverage.md | 28 +++++++++++++++ quest/m1/bench-relay.md | 24 +++++++++++++ quest/m1/bench-session.md | 31 +++++++++++++++++ quest/m1/browser-benchmarks.md | 2 +- quest/m1/performance-comparisons.md | 4 +-- quest/m1/performance-profiles.md | 2 +- 9 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 quest/m1/bench-ci.md create mode 100644 quest/m1/bench-coverage.md create mode 100644 quest/m1/bench-relay.md create mode 100644 quest/m1/bench-session.md diff --git a/CLAUDE.md b/CLAUDE.md index 504b71a14b..ba39b50d64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ This file is split into nested `CLAUDE.md` files based on the language/situation - Try to do stuff asynchronously. ex. ask about follow-ups while tests run. - Try to recognize when you're stuck, or making minimal progress, and stop early. - If the core problem is addressed, ship it instead of spinning your wheels on meaningless revisions. -- Benchmark any performance optimizations instead of relying on intuition. +- Add or extend a benchmark for any performance-sensitive change so later regressions show up, and measure optimizations instead of relying on intuition. # Public API diff --git a/quest/m1/README.md b/quest/m1/README.md index f2c3be7a91..bb651de765 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -75,8 +75,12 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every listener on both runtimes shares one reloadable served identity, so rotation is atomic and generate works with workers - [#2964](/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md) - integrate the dev worker owner with hardened socket-group formation - [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - PRs get a non-blocking comparison of the Criterion benches they affect, and a nightly trend on main alerts on regressions - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims - [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state +- [Sans-IO session bench](/quest/m1/bench-session.md) - a moq-net bench drives publisher, relay, and subscribers over the in-memory transport, swept over publishers, subscribers, and frame size +- [Relay session bench](/quest/m1/bench-relay.md) - the same scenario through moq-relay's own connection handling +- [Bench coverage](/quest/m1/bench-coverage.md) - Criterion targets for moq-mux containers, the hang catalog, moq-auth verification, and moq-pattern matching - [Relay profiling](/quest/m1/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads - [Browser benchmarks](/quest/m1/browser-benchmarks.md) - measure JS transport, container, decode, and render costs in an identified browser - [Plan: watch worker](/quest/m1/plan-watch-worker.md) - prototype an invisible page worker against app-spawned workers, and land the jank harness that decides diff --git a/quest/m1/bench-ci.md b/quest/m1/bench-ci.md new file mode 100644 index 0000000000..3514730d8a --- /dev/null +++ b/quest/m1/bench-ci.md @@ -0,0 +1,54 @@ +# [M] Benchmark regressions in CI + +## Goal + +A pull request that touches a benched Rust crate, or anything it depends on, +gets a sticky comment comparing its Criterion benchmarks against the base. The +comment never fails the PR. A nightly run on `main` keeps a history of every +benchmark and fails when one regresses past a loose threshold against the +previous nightly's commit measured on the same runner, so the existing Alert +workflow posts it to Discord. + +Rust Criterion targets only. Browser and JS benchmarks stay with +[Browser benchmarks](/quest/m1/browser-benchmarks.md), and relay load stays +local under `just bench BASE`. + +## Plan + +Researched 2026-09: CodSpeed's instruction counting skips every +`iter_custom` bench (all of `moq-uring`, plus `moq-net` origin and track) and +can't see syscall time. Bencher compares a PR against history recorded on other +VMs, so its thresholds fire on runner noise. The chosen shape, free and without +a GitHub App: + +- PR: one job builds base and head on the same runner and runs only the + selected targets, saving and comparing Criterion baselines. Selection is the + changed crates plus their dependents, from the impact map that + [Thin justfiles](/quest/m1/tooling/justfiles.md) lands. No selected bench + means no job. Extend `bench/run.sh` with a Criterion-only, crate-scoped mode + behind a recipe instead of writing a second runner. Hosted runners vary by + about 3%, so the comment highlights only changes Criterion calls + significant and beyond a noise floor you choose from A/A runs. Fork PRs + have a read-only token, so posting needs a `workflow_run` follow-up. +- Nightly: build the previous nightly's commit beside `main` on the same + runner and fail only on that paired comparison. Each hosted run is a fresh + VM, so the cross-run history is subject to the same noise as Bencher and + stays informational: record it with + [github-action-benchmark](https://github.com/benchmark-action/github-action-benchmark) + on a data branch, with its own alert disabled. The `moq-uring` socket + benches run only here, and skip loudly when the runner's kernel is too old. Add the workflow's name to + `alert.yml` if it isn't `Nightly`. +- Validate with an A/A run (the same commit twice) and a deliberately slowed + bench before trusting either signal. +- Document the comment, the trend, and local reproduction in + `bench/README.md`. + +## Required + +- [Thin justfiles](/quest/m1/tooling/justfiles.md) - owns the diff-to-crate impact map the PR job reuses + +## Related + +- [Benchmark comparisons](/quest/m1/performance-comparisons.md) - extends the same `bench/run.sh` with repeated paired rounds +- [Sans-IO session bench](/quest/m1/bench-session.md) - a low-noise end-to-end bench this job picks up +- [Bench coverage](/quest/m1/bench-coverage.md) - more targets for this job to track diff --git a/quest/m1/bench-coverage.md b/quest/m1/bench-coverage.md new file mode 100644 index 0000000000..05d5b3077a --- /dev/null +++ b/quest/m1/bench-coverage.md @@ -0,0 +1,28 @@ +# [M] Benchmarks for containers, catalog, auth, and path matching + +## Goal + +Hot paths that have no benchmark get Criterion targets, so CI tracks them: +`moq-mux` container import and export per frame, `hang` catalog encode, decode, +and update, `moq-auth` token verification, and `moq-pattern` path matching. + +## Plan + +- `moq-mux`: fMP4/CMAF and Annex-B import and export over checked-in or + generated fixtures, with throughput in frames and bytes. Keep fixture + generation out of the timed region. +- `hang`: catalog encode and decode swept over rendition count, plus the + per-update cost a publisher pays. +- `moq-auth`: JWT verification per connection, swept over algorithm and claim + size. The in-band token path gets its bench with + [In-band token](/quest/m1/auth/token-in-band.md), not here. +- `moq-pattern`: matching swept over pattern count and path depth. Coordinate + with [Path patterns](/quest/m1/path-patterns.md) so the matcher gets one + bench, not two. + +Anything that fans out gets a sweep over both axes. Name each target after +what it measures, so the CI comment reads without opening the file. + +## Related + +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - tracks these targets on PRs and nightly diff --git a/quest/m1/bench-relay.md b/quest/m1/bench-relay.md new file mode 100644 index 0000000000..596cddb10e --- /dev/null +++ b/quest/m1/bench-relay.md @@ -0,0 +1,24 @@ +# [L] Relay session benchmark through moq-relay + +## Goal + +The sans-IO session bench also runs through `moq-relay`'s own connection +handling, including auth, the cluster origin, and stats, over the in-memory +transport. Relay-layer costs then show up in benchmarks, not only the +`moq-net` model. + +## Plan + +`moq_relay::Connection` takes a `moq_tokio::server::Request`, which wraps a +concrete transport, so there is no seam for an in-memory session today. Find +the smallest change that lets the handler run over a generic `moq-net` +transport session without widening the public API. If the seam costs more +than the bench is worth, say so and stop. + +Reuse the scenario, the publisher and subscriber sweeps, and the delivery +accounting from the `moq-net` bench so the two results line up, and the +difference is the relay layer. + +## Required + +- [Sans-IO session bench](/quest/m1/bench-session.md) - supplies the scenario and harness this bench reuses diff --git a/quest/m1/bench-session.md b/quest/m1/bench-session.md new file mode 100644 index 0000000000..82aa479d22 --- /dev/null +++ b/quest/m1/bench-session.md @@ -0,0 +1,31 @@ +# [M] Sans-IO session benchmark in moq-net + +## Goal + +A Criterion bench in `moq-net` measures a full publishers to relay to +subscribers path with no sockets: M publishing clients, relay sessions that +forward through an origin the way `moq-relay` does, and N subscribing +clients, all over the in-memory transport. It sweeps publishers, subscribers, +and frame size independently, over lite and IETF, so a per-publisher, +per-subscriber, or per-frame cost shows as a slope and CI can compare it with +little noise. + +## Plan + +`rs/moq-net/tests/support/{mock,harness}.rs` already pairs sessions over an +in-memory WebTransport mock and runs the full handshake. Reuse it from the +bench, as `moq-uring`'s benches reuse their test support, rather than +exporting a mock from the crate. + +- Keep setup (handshakes, announce, the subscribe round trip) outside the + timed region. Time delivery of groups until every subscriber has received + every frame, and count delivered bytes so a skipped delivery can't look like + a speedup. +- Also time subscriber join, since a relay pays that per viewer. +- Report throughput in bytes and frames. +- Pick sweep points that finish quickly enough to run on every `moq-net` PR. + +## Related + +- [Relay session bench](/quest/m1/bench-relay.md) - the same shape through the real `moq-relay` handler +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - tracks this bench on PRs and nightly diff --git a/quest/m1/browser-benchmarks.md b/quest/m1/browser-benchmarks.md index 60c5dcb212..06043fac8d 100644 --- a/quest/m1/browser-benchmarks.md +++ b/quest/m1/browser-benchmarks.md @@ -7,7 +7,7 @@ Criterion targets and native relay load generator do not exercise. ## Plan -`rs/scripts/bench.sh::criterion_targets` discovers Cargo targets only. Existing JS unit +`bench/run.sh::criterion_targets` discovers Cargo targets only. Existing JS unit tests validate behavior, and `test/wasm` validates browser interop, but neither provides a repeatable JS performance comparison. Reuse the existing relay/browser harness pieces and add a focused recipe with artifacts under the benchmark diff --git a/quest/m1/performance-comparisons.md b/quest/m1/performance-comparisons.md index 0169acbf11..e9fcaf8b3a 100644 --- a/quest/m1/performance-comparisons.md +++ b/quest/m1/performance-comparisons.md @@ -7,7 +7,7 @@ estimate and preserved evidence, so a small reported speedup can be evaluated. ## Plan -`rs/scripts/bench.sh` runs each relay workload once as base then current, +`bench/run.sh` runs each relay workload once as base then current, without repeated rounds or alternating execution order. `cleanup` deletes the run directory, including Criterion estimates, load/host JSONL, relay logs, and summaries. Preserve the existing default command @@ -24,7 +24,7 @@ while extending this harness rather than creating another benchmark runner. hardware/kernel, allocator, affinity, workload, and execution order. Preserve partial evidence on failure while still cleaning up owned processes/worktrees. - Distinguish throughput-window counters from cumulative latency/loss. Today - `rs/scripts/bench.sh::summarize_load` differences bytes over the last five seconds + the load summary in `bench/relay.sh` differences bytes over the last five seconds but reads final lifetime latency and group-loss counters. Label that explicitly; consume windowed data when the existing latency quest supplies it. Never subtract percentiles or call cumulative loss a steady-state sample. diff --git a/quest/m1/performance-profiles.md b/quest/m1/performance-profiles.md index 8fe119b4ee..eb52ef23b1 100644 --- a/quest/m1/performance-profiles.md +++ b/quest/m1/performance-profiles.md @@ -8,7 +8,7 @@ cost. Profiling is opt-in and has no production overhead when disabled. ## Plan -`rs/scripts/bench.sh` already owns the builds, relay PID, workload, and host +`bench/run.sh` already owns the builds, relay PID, workload, and host samples, but has no profiler integration. Reuse that lifecycle instead of adding a second launcher. `Cargo.toml` already has a `profiling` profile and `rs/moq-native/src/jemalloc.rs` already supports on-demand heap dumps. From f29895cb64683c5b8c02b0de46d29d031083c913 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 08:56:20 -0700 Subject: [PATCH 12/21] feat(moq-gst): opt pads fed by a local encoder into flush jitter (#4112) Co-authored-by: Claude Opus 5.5 --- doc/bin/gstreamer.md | 13 +- quest/m1/README.md | 1 - quest/m1/gst-encoder-jitter-provenance.md | 23 --- quest/m1/jitter-flush-clock.md | 7 +- rs/moq-gst/src/sink/imp.rs | 10 +- rs/moq-gst/src/sink/pad.rs | 226 ++++++++++++++++++---- rs/moq-gst/src/sink/request_pad.rs | 24 ++- rs/moq-gst/tests/element.rs | 14 ++ 8 files changed, 246 insertions(+), 72 deletions(-) delete mode 100644 quest/m1/gst-encoder-jitter-provenance.md diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index e60965a359..99bc063da5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -21,7 +21,7 @@ nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -e \ # Publish a test pattern gst-launch-1.0 -e videotestsrc is-live=true ! x264enc tune=zerolatency ! h264parse \ ! video/x-h264,stream-format=byte-stream,alignment=au ! mux.sink_0 \ - moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang + moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang sink_0::encoder=true ``` Install via `apt install gstreamer1.0-moq` or `dnf install gstreamer1-moq` @@ -48,7 +48,8 @@ directly. A cue with no duration is dropped rather than left on screen. Each `sink_%u` request pad is one track. Pad properties: `track` names it (default: after the codec), `container=loc` publishes it as -[LOC](/concept/standard#loc) instead of the legacy hang container, and +[LOC](/concept/standard#loc) instead of the legacy hang container, +`encoder=true` marks it as fed by a local encoder, and `track-status`/`track-error` report its lifecycle. Element properties: `url`, `broadcast`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keep-alive`, and read-only `status`, `connected`, `moq-version`, and @@ -73,6 +74,14 @@ while connected and 0 otherwise, reconnects are `started - 1` once `started` is at least 1, and a rate is the delta over any window you sample. Unlike `status`, a connection that drops before you poll still moves both counters. +Set `encoder=true` on audio and video pads a local encoder feeds +(`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches +the sink behind its running time and raises the catalog `jitter` by the spread, +so players buffer for an encoder that delivers irregularly. Leave it off, the +default, for file, demuxed, and network media: their arrival reflects the disk +or the network, not the original encoder, and a GStreamer segment cannot tell +the two apart. Text and opaque pads refuse it. + ## moqsrc Pads are named by kind and appear as the catalog announces renditions: diff --git a/quest/m1/README.md b/quest/m1/README.md index bb651de765..93cb6d3458 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -40,7 +40,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes -- [GStreamer encoder jitter](/quest/m1/gst-encoder-jitter-provenance.md) - only opted-in local encoder pads feed the flush clock - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers diff --git a/quest/m1/gst-encoder-jitter-provenance.md b/quest/m1/gst-encoder-jitter-provenance.md deleted file mode 100644 index ca6a4c387f..0000000000 --- a/quest/m1/gst-encoder-jitter-provenance.md +++ /dev/null @@ -1,23 +0,0 @@ -# [M] moq-gst: observe flush jitter only for local encoders - -## Goal - -A `moqsink` pad fed by a local encoder measures catalog jitter from the frame's -transport handoff, while file, pipe, demuxed, and network imports remain -clock-free. A GStreamer TIME segment and PTS alone cannot identify provenance: -`multifilesrc ! parsebin ! moqsink` supplies both. - -## Plan - -- Choose an explicit opt-in on the request pad (name and lifecycle to settle - with the maintainer). Default to imports, which retain batch/reorder estimates. -- For an opted-in pad, call the explicit codec importer flush observation after - a successful media write, with the mapped broadcast PTS and `Instant::now()`. - Refuse an invalid opt-in/timestamp combination instead of silently skipping. -- Exercise a local encoder pipeline and the existing looped MP4 import recipe; - prove only the opted-in path raises jitter. Document the opt-in in - `doc/bin/gstreamer.md` and update demo pipelines where they encode locally. - -## Related - -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the flush measurement this pad feeds diff --git a/quest/m1/jitter-flush-clock.md b/quest/m1/jitter-flush-clock.md index e22454e1c3..ad27a5edf6 100644 --- a/quest/m1/jitter-flush-clock.md +++ b/quest/m1/jitter-flush-clock.md @@ -33,11 +33,14 @@ media span of each emitted batch and advertise no `delay`. libmoq (so OBS), moq-ffi and its wrappers, and the `js/publish` encoders call it. The provisional PTS-gap floor is gone, and `moq_mux::Error::JitterDecreased` plus zero-as-absent text jitter enforce never-lower in Rust and JS. - `moq-gst` is split into [GStreamer encoder jitter](/quest/m1/gst-encoder-jitter-provenance.md). + `moq-gst` pads opt in with `encoder=true`; imports stay clock-free. What remains below is `delay` and the player. libmoq and moq-ffi expose `flush` but no discontinuity, so a binding publisher that pauses and resumes on a re-anchored PTS within the window would count the pause; add one when - such a caller appears. + such a caller appears. A `moq-gst` encoder pad has the same gap across a + `PLAYING -> PAUSED -> PLAYING` cycle (running time stops, the wall clock + does not) and a flushing seek, since `import::Track` forwards no + discontinuity. - **Measurement.** Lateness is `now - timestamp`, observed by the existing `flush` calls, so no call site changes. Each rendition keeps its own baseline, the minimum lateness over a sliding window (about 10 s), so a media diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index 9135696fae..e07f6c0178 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -10,7 +10,7 @@ //! pad lifecycle, then an object lock. No path takes the element control while holding a pad lifecycle. use std::sync::{LazyLock, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use bytes::Bytes; @@ -685,7 +685,9 @@ impl MoqSink { if lifecycle.media.is_failed() { return Ok(gst::FlowSuccess::Ok); } - let outcome = lifecycle.media.push_buffer(data, pts, duration, current_running_time); + let outcome = lifecycle + .media + .push_buffer(data, pts, duration, current_running_time, Instant::now()); let changes = match &outcome { Ok(PushOutcome::Failed(reason)) => Some(lifecycle.fail(reason.clone())), _ => None, @@ -772,7 +774,9 @@ impl MoqSink { return false; } let requested = lifecycle.requested().map(str::to_owned); - let mut options = ProducerOptions::new(&caps).with_container(lifecycle.container().into()); + let mut options = ProducerOptions::new(&caps) + .with_container(lifecycle.container().into()) + .with_encoder(lifecycle.encoder()); if let Some(track) = requested.as_deref() { options = options.with_track(track); } diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index cf73a04d36..23ecdb1a33 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -4,6 +4,8 @@ //! that pad's own streaming thread, so this type is touched from one thread and needs no generation //! tagging or cross-thread failure map. +use std::time::Instant; + use anyhow::{Context, Result, ensure}; use bytes::Bytes; @@ -30,23 +32,50 @@ enum PadState { /// Both payloads are large (a codec importer, a container producer), so each is boxed to keep the /// enum small. enum Sink { - /// `audio` decides grouping. `import::Track` draws no audio boundaries of its own (every - /// packet is independently decodable, so there is no keyframe to group on), leaving them to - /// whoever knows the latency target. A live sink wants each packet forwarded without waiting, - /// so it cuts per frame. Video groups at its own keyframes and needs nothing. - Media { - track: Box, - audio: bool, - }, + Media(Box), Text(Box), Opaque(moq_net::track::Producer), } +/// An audio or video pad, published through a codec importer. +struct Media { + track: import::Track, + /// Decides grouping. `import::Track` draws no audio boundaries of its own (every packet is + /// independently decodable, so there is no keyframe to group on), leaving them to whoever knows + /// the latency target. A live sink wants each packet forwarded without waiting, so it cuts per + /// frame. Video groups at its own keyframes and needs nothing. + audio: bool, + /// Records each frame's handoff against the wall clock, raising the catalog jitter by how + /// irregularly a local encoder delivers. Imports leave it off: their arrival times describe the + /// file or network, not the encoder. + encoder: bool, +} + +impl Media { + /// Publish one frame at `micros` on the media clock, handed over at `now`. + fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result<()> { + let ts = hang::container::Timestamp::from_micros(micros).ok(); + self.track.decode(data, ts)?; + // One group (one QUIC stream) per audio packet, so the relay forwards it without waiting for + // the next. + if self.audio { + self.track.cut(None)?; + } + if self.encoder { + // Skipping the observation would publish a frame the jitter never saw. + let ts = ts.context("encoder frame timestamp out of range")?; + self.track.flush(ts, now)?; + } + Ok(()) + } +} + /// Inputs used to build a producer after a pad observes caps. pub(super) struct ProducerOptions<'a> { container: hang::catalog::Container, caps: &'a gst::Caps, requested: Option<&'a str>, + encoder: bool, } impl<'a> ProducerOptions<'a> { @@ -55,6 +84,7 @@ impl<'a> ProducerOptions<'a> { container: hang::catalog::Container::default(), caps, requested: None, + encoder: false, } } @@ -67,6 +97,11 @@ impl<'a> ProducerOptions<'a> { self.requested = Some(track); self } + + pub(super) fn with_encoder(mut self, encoder: bool) -> Self { + self.encoder = encoder; + self + } } /// A subtitle pad. GStreamer hands us one decoded cue per buffer (`text/x-raw`, UTF-8) with the @@ -245,8 +280,15 @@ impl Pad { container, caps, requested, + encoder, } = options; let structure = caps.structure(0).context("empty caps")?; + // Only a codec rendition carries the jitter a local encoder's clock would raise. + ensure!( + !encoder || !matches!(structure.name().as_str(), "application/octet-stream" | "text/x-raw"), + "encoder is only supported on audio and video pads, not {}", + structure.name() + ); // Renegotiation: finalize the previous producer before replacing it (closed once, not abandoned). self.finalize()?; // Opaque data has no codec importer and no catalog entry, so it never reaches the codec match. @@ -422,10 +464,7 @@ impl Pad { } other => anyhow::bail!("unsupported caps: {other}"), }; - self.track = Some(Sink::Media { - track: Box::new(track), - audio, - }); + self.track = Some(Sink::Media(Box::new(Media { track, audio, encoder }))); self.caps = Some(caps.clone()); Ok(name) } @@ -597,8 +636,9 @@ impl Pad { /// Import one buffer into the producer. A failed or producer-less pad drops the buffer; a timeline /// drop is logged. Unstamped opaque data on an active timeline uses the element's current running - /// time. A bad bitstream (or an oversized frame, rejected by moq-net) invalidates only this pad and - /// says so in the returned outcome. Returns an error when an unstamped opaque buffer has no current + /// time. An encoder pad records `now` as the frame's handoff once it is published. A bad bitstream + /// (or an oversized frame, rejected by moq-net) invalidates only this pad and says so in the + /// returned outcome. Returns an error when an unstamped opaque buffer has no current /// running time, so the caller fails the flow instead of silently dropping data. pub fn push_buffer( &mut self, @@ -606,6 +646,7 @@ impl Pad { pts: Option, duration: Option, current_running_time: Option, + now: Instant, ) -> std::result::Result { if self.failed { return Ok(PushOutcome::Dropped); @@ -625,15 +666,7 @@ impl Pad { match timestamp { Ok(micros) => { let result: Result<()> = match self.track.as_mut().expect("track present") { - Sink::Media { track, audio } => { - let ts = hang::container::Timestamp::from_micros(micros).ok(); - track - .decode(&data, ts) - // One group (one QUIC stream) per audio packet, so the relay forwards - // it without waiting for the next. See `Sink::Media`. - .and_then(|()| if *audio { track.cut(None) } else { Ok(()) }) - .map_err(Into::into) - } + Sink::Media(media) => media.write(&data, micros, now), Sink::Text(text) => match std::str::from_utf8(&data) { // A cue with no duration would never be dismissed, so drop it rather than pin it // on screen; the demuxer supplies one for every real subtitle sample. @@ -687,7 +720,7 @@ impl Pad { return Ok(false); }; let closed = match track { - Sink::Media { mut track, .. } => track.finish().map_err(anyhow::Error::from), + Sink::Media(mut media) => media.track.finish().map_err(anyhow::Error::from), Sink::Text(mut text) => text.producer.finish().map_err(anyhow::Error::from), Sink::Opaque(producer) => producer.finish().map_err(anyhow::Error::from), }; @@ -831,8 +864,14 @@ mod tests { "the reserved name is the requested one" ); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let snapshot = catalog.snapshot(); let renditions: Vec = snapshot.video.renditions.keys().map(|name| name.to_string()).collect(); @@ -933,14 +972,26 @@ mod tests { pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), None)); // No observe_segment: the pad stays in NoSegment. assert_eq!( - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(), + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now() + ) + .unwrap(), PushOutcome::NoSegment, "first no-segment buffer is reported" ); assert_eq!( - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(), + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now() + ) + .unwrap(), PushOutcome::Dropped, "subsequent no-segment buffers are not re-reported" ); @@ -995,7 +1046,13 @@ mod tests { assert!(!data.is_failed()); video.observe_segment(time_segment()); video - .push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) + .push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) .unwrap(); let snapshot = catalog.snapshot(); @@ -1028,6 +1085,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(40)), None, None, + Instant::now(), ) .unwrap(); pad.push_buffer( @@ -1035,6 +1093,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(80)), None, None, + Instant::now(), ) .unwrap(); @@ -1086,8 +1145,14 @@ mod tests { CapsOutcome::Active("camera".to_string()) ); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let config = catalog.snapshot().video.renditions.get("camera").cloned().unwrap(); assert_eq!(config.container, hang::catalog::Container::Loc); @@ -1155,6 +1220,7 @@ mod tests { None, None, Some(gst::ClockTime::from_mseconds(25)), + Instant::now(), ) .unwrap(); assert!(!pad.is_failed(), "a missing PTS uses the supplied running time"); @@ -1186,7 +1252,7 @@ mod tests { pad.observe_segment(time_segment()); assert!( - pad.push_buffer(Bytes::from_static(b"no timestamp"), None, None, None) + pad.push_buffer(Bytes::from_static(b"no timestamp"), None, None, None, Instant::now()) .is_err(), "the caller gets a hard error instead of a silent drop" ); @@ -1205,8 +1271,14 @@ mod tests { ); assert!(pad.is_failed()); pad.observe_segment(time_segment()); - pad.push_buffer(Bytes::from_static(b"x"), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + Bytes::from_static(b"x"), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); } // A real IDR AU emits a frame to the published track (not just a rendition off the SPS). @@ -1217,8 +1289,14 @@ mod tests { let mut pad = Pad::new(); pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), None)); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let snapshot = catalog.snapshot(); let track = snapshot.video.renditions.keys().next().expect("a video rendition"); @@ -1419,6 +1497,7 @@ mod tests { Some(gst::ClockTime::ZERO), Some(gst::ClockTime::from_seconds(1)), None, + Instant::now(), ) .unwrap(); assert!(pad.is_failed()); @@ -1445,6 +1524,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(start_ms)), Some(gst::ClockTime::from_mseconds(dur_ms)), None, + Instant::now(), ) .unwrap(); } @@ -1463,8 +1543,14 @@ mod tests { let mut pad = Pad::new(); pad.observe_caps(&broadcast, &catalog, producer_options(&text_caps(), None)); pad.observe_segment(time_segment()); - pad.push_buffer(Bytes::from_static(b"hello"), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + Bytes::from_static(b"hello"), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); assert!(!pad.is_failed(), "a durationless cue drops the buffer, not the pad"); } @@ -1503,4 +1589,64 @@ mod tests { .count(); assert_eq!(emitted, 5, "all five decode-order frames must emit (got {emitted})"); } + + // `multifilesrc ! parsebin` and a live encoder deliver identical segments and PTS, so the opt-in is + // the only thing that tells them apart. The same late frame raises only the encoder pad's jitter. + #[test] + fn only_an_encoder_pad_measures_its_handoff() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + let mut encoder = Pad::new(); + let mut import = Pad::new(); + encoder.observe_caps( + &broadcast, + &catalog, + producer_options(&h264_caps(), Some("encoder")).with_encoder(true), + ); + import.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), Some("import"))); + + // The third frame reaches the sink 100ms later than its running time says it should. + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (33, 33), (66, 166)] { + for pad in [&mut encoder, &mut import] { + pad.observe_segment(time_segment()); + let outcome = pad + .push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival), + ) + .unwrap(); + assert_eq!(outcome, PushOutcome::Published); + } + } + + let snapshot = catalog.snapshot(); + let jitter = |name: &str| snapshot.video.renditions[name].jitter; + assert_eq!(jitter("encoder"), Some(std::time::Duration::from_millis(100))); + assert_eq!(jitter("import"), None, "an import's arrival never reaches the catalog"); + } + + // Text and opaque tracks carry no codec jitter, so asking them to measure one is a mistake to report + // rather than a setting to ignore. + #[test] + fn an_encoder_pad_must_carry_audio_or_video() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + for caps in [text_caps(), opaque_caps()] { + let mut pad = Pad::new(); + let outcome = pad.observe_caps( + &broadcast, + &catalog, + producer_options(&caps, Some("data")).with_encoder(true), + ); + assert!( + matches!(outcome, CapsOutcome::Failed(ref reason) if reason.starts_with("encoder is only supported")), + "{outcome:?}" + ); + assert!(pad.is_failed()); + } + } } diff --git a/rs/moq-gst/src/sink/request_pad.rs b/rs/moq-gst/src/sink/request_pad.rs index d8f8f540c4..9bc31f19ab 100644 --- a/rs/moq-gst/src/sink/request_pad.rs +++ b/rs/moq-gst/src/sink/request_pad.rs @@ -41,6 +41,8 @@ struct Settings { effective: Option, /// The wire container selected for this pad's media producer. container: MediaContainer, + /// Whether a local encoder feeds this pad, so its handoff clock may raise the catalog jitter. + encoder: bool, /// What the track is doing, read back through `status`. status: Status, /// The reason the pad was invalidated, read back through `track-error`. @@ -104,6 +106,11 @@ impl PadLifecycle { self.settings.container } + /// Whether a local encoder feeds this pad. + pub(super) fn encoder(&self) -> bool { + self.settings.encoder + } + /// Record the name reserved by a successful CAPS event. pub(super) fn commit(&mut self, track: String) -> Notifications { let before = self @@ -186,6 +193,19 @@ impl ObjectImpl for MoqSinkPadImp { .default_value(MediaContainer::Legacy) .mutable_playing() .build(), + // Provenance is not in the caps: `multifilesrc ! parsebin` hands over the same TIME + // segment and PTS a live encoder does, so only the application can say which it is. + glib::ParamSpecBoolean::builder("encoder") + .nick("Local encoder") + .blurb( + "A local encoder feeds this pad, so the catalog jitter includes how late each \ + frame reaches the sink behind its running time. Leave it off for file, demuxed, \ + and network media, whose arrival says nothing about the original encoder. Audio \ + and video only. Writable in any state until the CAPS event reserves the track", + ) + .default_value(false) + .mutable_playing() + .build(), glib::ParamSpecEnum::builder::("track-status") .nick("Status") .blurb( @@ -219,7 +239,7 @@ impl ObjectImpl for MoqSinkPadImp { ); return; } - // A producer keeps its reserved name and wire container for its whole life, so a later write + // A producer keeps its reserved name, wire container, and provenance for its whole life, so a later write // would read back without ever reaching the broadcast or the catalog. if lifecycle.settings.effective.is_some() { gst::warning!( @@ -236,6 +256,7 @@ impl ObjectImpl for MoqSinkPadImp { lifecycle.settings.requested = value.get::>().unwrap().filter(|name| !name.is_empty()) } "container" => lifecycle.settings.container = value.get().unwrap(), + "encoder" => lifecycle.settings.encoder = value.get().unwrap(), _ => unreachable!(), } } @@ -250,6 +271,7 @@ impl ObjectImpl for MoqSinkPadImp { .or_else(|| lifecycle.settings.requested.clone()) .to_value(), "container" => lifecycle.settings.container.to_value(), + "encoder" => lifecycle.settings.encoder.to_value(), "track-status" => lifecycle.settings.status.to_value(), "track-error" => lifecycle.settings.error.clone().to_value(), _ => unreachable!(), diff --git a/rs/moq-gst/tests/element.rs b/rs/moq-gst/tests/element.rs index 114b6166ad..833590c89a 100644 --- a/rs/moq-gst/tests/element.rs +++ b/rs/moq-gst/tests/element.rs @@ -572,6 +572,20 @@ fn a_pipeline_description_selects_loc() { ); } +#[test] +fn a_pipeline_description_marks_an_encoder_pad() { + init(); + let sink = gst::parse::launch("moqsink name=publisher url=https://127.0.0.1:1 broadcast=test sink_0::encoder=true") + .expect("parse the description"); + let _pad = sink.request_pad_simple("sink_0").expect("request sink_0"); + assert!(child_of(&sink, "sink_0").property::("encoder")); + let _other = sink.request_pad_simple("sink_1").expect("request sink_1"); + assert!( + !child_of(&sink, "sink_1").property::("encoder"), + "a pad is an import unless it says otherwise" + ); +} + // The acceptance criterion: once CAPS reserves the track, its name and container are fixed; stopping // the element makes both configurable again. #[test] From 2171c280febec6355b73d43a95cafcdab1cc06de Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 08:59:53 -0700 Subject: [PATCH 13/21] docs(stats): document the stats broadcasts as a wire contract (#3951) Co-authored-by: Claude Opus 5.5 --- doc/.vitepress/config.ts | 1 + doc/bin/relay/config.md | 19 +--- doc/concept/index.md | 1 + doc/concept/stats.md | 161 +++++++++++++++++++++++++++ quest/m0/README.md | 1 - quest/m0/stats-binary/README.md | 52 --------- quest/m0/stats-binary/docs.md | 20 ---- quest/m0/stats-binary/flatbuffers.md | 39 ------- quest/m1/qos/stats/schema.md | 4 - quest/m1/relay-peer-set.md | 4 - quest/m2/stats-delta.md | 4 +- rs/moq-stats/src/lib.rs | 43 ++----- 12 files changed, 179 insertions(+), 170 deletions(-) create mode 100644 doc/concept/stats.md delete mode 100644 quest/m0/stats-binary/README.md delete mode 100644 quest/m0/stats-binary/docs.md delete mode 100644 quest/m0/stats-binary/flatbuffers.md diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 2174f1dcdc..5b6eb122e7 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -92,6 +92,7 @@ export default defineConfig({ { text: "moq-lite", link: "/concept/moq-lite" }, { text: "hang", link: "/concept/hang" }, { text: "Audio jitter", link: "/concept/audio-jitter" }, + { text: "Stats", link: "/concept/stats" }, { text: "Standards", link: "/concept/standard" }, { text: "Use cases", diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 75d792c36f..6f8677ac36 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -201,19 +201,12 @@ node = "sjc/1" # Disambiguates relays sharing a cluster. depth = 1 # Also bucket by the first N path segments (per tenant). ``` -Each stats broadcast carries `publisher.json`, `subscriber.json`, and -`sessions.json` tracks (plus compressed `.z` twins) with cumulative counters -per broadcast. Every counter pair is `*_started` / `*_ended`: -`announces_started` / `announces_ended`, `broadcasts_started` / -`broadcasts_ended`, `subscriptions_started` / `subscriptions_ended`, and -`sessions_started` / `sessions_ended`. A live count is started minus ended. -This release also writes the previous `announced` / `*_closed` spellings beside -the new names so an older consumer still reads a new relay; a new consumer -accepts either spelling, with the canonical name winning. Payload counters -(bytes, frames, groups, datagrams) are unchanged. Traffic is split by an -arbitrary **tier** label chosen by the auth server's grant or `--cluster-tier`, -which is what makes billing per customer or per region possible. Read them with -the [`moq-stats`](https://docs.rs/moq-stats) crate. +Each node publishes `publisher.json`, `subscriber.json`, and `sessions.json` +tracks (plus compressed `.json.z` twins) of cumulative counters per broadcast +and auth root, split by a **tier** label chosen by the auth server's grant or +`--cluster-tier`, which is what makes billing per customer or per region +possible. [Stats](/concept/stats) describes the paths, tracks, and encodings; +read them with the [`moq-stats`](https://docs.rs/moq-stats) crate. ## \[iroh] diff --git a/doc/concept/index.md b/doc/concept/index.md index 5dbe3880bf..fae0751a11 100644 --- a/doc/concept/index.md +++ b/doc/concept/index.md @@ -37,5 +37,6 @@ watched at 100 ms by one viewer and 10 s by another. - [moq-lite](/concept/moq-lite): the pub/sub protocol, discovery, path patterns, subscriptions, and congestion behavior. - [hang](/concept/hang): the media catalog, containers, and how to extend both. - [Audio jitter](/concept/audio-jitter): how a receiver sizes its audio playout target from arrival timing. +- [Stats](/concept/stats): the traffic counters a relay publishes as broadcasts, and how to read them. - [Standards](/concept/standard): how this relates to the IETF moq-transport, MSF, LOC, and this project's own drafts, including [e2ee](/draft/moq-e2ee). - [Use cases](/concept/use-case/): MoQ compared with HLS/DASH, RTMP/SRT, WebRTC, and used for AI. diff --git a/doc/concept/stats.md b/doc/concept/stats.md new file mode 100644 index 0000000000..7b6cb321ca --- /dev/null +++ b/doc/concept/stats.md @@ -0,0 +1,161 @@ +--- +title: Stats +description: The stats broadcasts a relay publishes, their tracks, and both JSON encodings +--- + +# Stats + +A relay publishes its traffic counters as ordinary MoQ broadcasts, so any +subscriber can read them: a dashboard, a billing meter, an aggregator in +another region. This page is the wire contract for those broadcasts, enough to +read them in any language. [`moq-stats`](https://docs.rs/moq-stats) is the Rust +producer and consumer, and the relay's [`[stats]`](/bin/relay/config#stats) +section turns it on. + +## Broadcasts + +Each node publishes under a prefix, `.stats` by default, which +[moq-lite](/concept/moq-lite) hides from announce listings that don't ask for +it. Traffic under the prefix is never counted, so serving stats doesn't +generate more stats. + +```text +/node/ depth 0: one broadcast per node +//node/ depth N: one broadcast per group per node +``` + +- `` tells relays sharing a cluster apart. It may span several segments + (`sjc/1`), and is omitted along with its slash when unset: `/node`. +- `` is the first `depth` segments of each broadcast path (for traffic) + or auth root (for sessions), so a consumer can scope an announce to one + tenant. A path shorter than `depth` groups under all of its segments. +- The literal `node` segment leaves room for sibling categories under the same + prefix, so a consumer skips any path without `node` where it expects one. A + group segment literally named `node` is ambiguous; don't use one. + +At depth 0 the broadcast stays announced for the producer's life. At depth +1 or more, a group's broadcast is announced while that group has entries and +unannounced once it has none. + +## Tracks + +Traffic is split by **tier**, an arbitrary label (a billing class, a region) +the relay takes from the auth grant or `--cluster-tier`. Each tier has three +tracks, each in two encodings: + +| Track | Frame keyed by | Entry | +| --- | --- | --- | +| `publisher.json` | broadcast path | [Traffic](#traffic) this node sent (egress) | +| `subscriber.json` | broadcast path | [Traffic](#traffic) this node received (ingress) | +| `sessions.json` | auth root | [Presence](#presence) of connected sessions | + +The default tier is unprefixed. A named tier prefixes each name with its +label and a slash: tier `region/sjc` publishes `region/sjc/publisher.json`. +Appending `.z` selects the [compressed](#compressed) encoding of the same +track: `publisher.json.z`. + +The default tier's six tracks always exist. A named tier's are created on its +first recorded traffic, but a subscriber may ask for them earlier: any name of +the shape `[/]{publisher,subscriber,sessions}.json[.z]` is accepted and +held open with `{}` until the tier records. Any other name is refused. + +## Frames + +Every frame is a JSON object mapping a key (broadcast path or auth root) to an +entry. An entry appears while it is **live**, meaning some started counter +still exceeds its ended counterpart so traffic could resume at any moment, and +on any tick its counters changed. Once fully closed it appears one last time +with its final counters and is then dropped. A track with no entries holds `{}`. + +The producer drains its counters every interval (one second by default) and +writes only when a track's frame changed, so silence means nothing moved, not +that the producer is gone. The track ends when the producer does, or at +depth 1 or more when its group's broadcast is unannounced; the group may +return later as a new broadcast. + +### Traffic + +```json +{ + "acme/live": { + "announces_started": 1, "announces_ended": 0, "announced_bytes": 9, + "broadcasts_started": 3, "broadcasts_ended": 1, + "subscriptions_started": 6, "subscriptions_ended": 2, + "fetches": 0, + "bytes": 1048576, "frames": 900, "groups": 30, "datagrams": 0, + "stale": { "bytes": 0, "frames": 0, "groups": 0, "datagrams": 0 }, + "announced": 1, "announced_closed": 0, + "broadcasts": 3, "broadcasts_closed": 1, + "subscriptions": 6, "subscriptions_closed": 2 + } +} +``` + +| Field | Counts | +| --- | --- | +| `announces_started` / `announces_ended` | Announces and unannounces of the broadcast. | +| `announced_bytes` | The broadcast name's length, summed over each announce and unannounce. Not part of `bytes`. | +| `broadcasts_started` / `broadcasts_ended` | On `publisher.json`, a session's first subscription to the broadcast and its last one closing. Started minus ended is the viewer count. `subscriber.json` leaves both at zero: ingress does not count viewers. | +| `subscriptions_started` / `subscriptions_ended` | Track subscriptions opened and closed. | +| `fetches` | One-shot group fetches requested, including ones that found nothing. Their payload counts in `bytes`, `frames`, and `groups`. | +| `bytes` / `frames` / `groups` | Payload delivered. | +| `datagrams` | Groups delivered as an unreliable datagram. A subset of `groups`. | +| `stale` | Payload skipped because it aged past a subscriber's latency budget, with the same four fields. Disjoint from the top-level payload counters. | + +The last six fields are legacy spellings of the `*_started` and `*_ended` +counters, still written so an older consumer reads a newer relay. A reader +should prefer the canonical name and fall back to the legacy one. + +### Presence + +```json +{ "acme": { "sessions_started": 12, "sessions_ended": 10, "sessions": 12, "sessions_closed": 10 } } +``` + +`sessions_started` and `sessions_ended` count connects and disconnects under an +auth root on the tier, whether or not any data flows. `sessions` and +`sessions_closed` are their legacy spellings. A session moved to a new tier +ends on the old one and starts on the new. + +### Counters + +Every counter is a cumulative, monotonic unsigned integer. A rate is the +difference between two frames divided by the time between them, and a live +count is started minus ended. A frame never shows ended above started. + +A counter going **down** means the relay restarted or the entry was dropped +and re-created. Treat it as the start of a fresh segment rather than a +negative rate. + +A reader ignores unknown fields, so a newer relay can add counters, and +defaults a missing field to zero, so it can read an older relay. + +## Encodings + +Both encodings carry identical frames; pick by bandwidth. Only the track name +says which one a track uses: the payload has no marker. + +### Plain + +On a `.json` track each changed frame is its own group holding one frame, the +full object as UTF-8 JSON. A reader takes the newest group. + +### Compressed + +A `.json.z` track is a [moq-json](/lib/rs/moq-json) snapshot track with +compression on. Stats frames change little between ticks, so it costs a +fraction of the plain track's bytes. + +- **Groups.** A group's first frame is the full object. Each later frame is an + [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) merge patch against the + value so far: it carries only the changed counters, and `null` removes a + dropped entry. The producer starts a new group once the patches outgrow + eight times the snapshot's compressed size, or after 256 frames. +- **DEFLATE.** Each group's frames form one raw DEFLATE stream, sync flushed + per frame with the trailing `00 00 ff ff` stripped, as + [moq-flate](/draft/moq-flate) specifies. The window starts cold at every + group and never spans two. + +To read one, jump to the newest group, inflate and parse its first frame, +then inflate and apply each following frame as a merge patch, in order. A +reader missing a frame abandons the group and waits for the next. diff --git a/quest/m0/README.md b/quest/m0/README.md index eb67865d6d..83dd630eed 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -94,7 +94,6 @@ do not add another media abstraction or a renderer crate during stabilization. ## Quests - [Release](/quest/m0/release.md) - the release moq.pro adopts: binding docs, an upgrade page, and a staging soak gate it rather than the merge -- [Binary stats](/quest/m0/stats-binary/README.md) - an allocation-free stats tick and an on-demand FlatBuffers `.fb.z` flavor with a checked-in schema - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess - [A/V clock](/quest/m0/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles diff --git a/quest/m0/stats-binary/README.md b/quest/m0/stats-binary/README.md deleted file mode 100644 index 80fd327359..0000000000 --- a/quest/m0/stats-binary/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Allocation-free binary stats - -## Goal - -Draining the registry into stats frames allocates nothing per entry, and -producing a `.fb.z` frame keeps that property. Any consumer can request a -FlatBuffers flavor of every stats track (`publisher.fb.z`, `subscriber.fb.z`, -`sessions.fb.z`, per tier) that is smaller and cheaper to produce and read than -`.json.z`, backed by a checked-in schema that other languages can generate -readers from and that later fields extend without breaking old readers. The -JSON tracks stay, unchanged on the wire, for dashboards and debugging. - -Not here: a JS reader (the demo dashboard stays on JSON), replacing or -deprecating the JSON flavors, and any relay config switch. Both flavors are -served on demand, so an unrequested one costs nothing. - -## Plan - -Settled while planning: - -- **Why:** a typed contract for non-Rust consumers, fewer bytes, less CPU, and - less memory churn at scale. JSON's cost is mostly our pipeline: per-tick - `BTreeMap` frames, path clones, and merge-patch diffs through - `serde_json::Value`. The format change alone would not fix that. -- **FlatBuffers via planus:** the builder resets without freeing, strings live - in its buffer, reads are zero-copy views, and tables extend by appending - fields. We chose it over protobuf because generated protobuf types own their - strings and maps; this was decided over a hand-written protobuf codec. -- **One flavor, `.fb.z`:** a full snapshot per frame, with each group sharing - one DEFLATE window, so repeated bytes compress away without deltas. There is - no uncompressed `.fb`. -- **Extension-ready:** the schema leaves room for the client-stats extension - (a nested table on each entry), which - [schema](/quest/m1/qos/stats/schema.md) fills in when it lands. -- **The line lands on main:** the maintainer approved `Registry::report(&mut - Report)` as a published API break before the pending moq-net release. Merge - this line before #3928, the last breaking change before that release. - -The line owns the end-to-end check: a relay test that subscribes to -`.json.z` and `.fb.z`, pairs frames from the same tick (deterministically, -for example by driving one tick under paused time), and asserts that the -counters agree. - -## Quests - -- [FlatBuffers flavor](/quest/m0/stats-binary/flatbuffers.md) - moq-stats serves and reads `.fb.z` from a checked-in schema -- [Stats format page](/quest/m0/stats-binary/docs.md) - a doc/concept page for every stats track and both encodings - -## Related - -- [Client stats](/quest/m1/qos/stats/README.md) - the extension the schema must leave room for -- [Flate track wrapper](/quest/m2/flate/track.md) - the group-window discipline the `.fb.z` producer repeats diff --git a/quest/m0/stats-binary/docs.md b/quest/m0/stats-binary/docs.md deleted file mode 100644 index 499395cd88..0000000000 --- a/quest/m0/stats-binary/docs.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Stats format page - -## Goal - -A `doc/concept` page describes every stats broadcast and track: the path -layout, tiers, the three track kinds, both encodings (`.json.z` merge-patch -and `.fb.z` FlatBuffers), the counter semantics (cumulative, a decrease -starts a fresh segment), and how a consumer in another language generates a -reader from the `.fbs`. The relay config page and the moq-stats crate docs -link to it rather than repeating it. - -## Plan - -Start from the moq-stats crate docs and the `[stats]` section of -`doc/bin/relay/config.md`, and move the wire description there. Add the page -to the VitePress sidebar. - -## Required - -- [FlatBuffers flavor](/quest/m0/stats-binary/flatbuffers.md) - the encoding the page documents diff --git a/quest/m0/stats-binary/flatbuffers.md b/quest/m0/stats-binary/flatbuffers.md deleted file mode 100644 index 636b095740..0000000000 --- a/quest/m0/stats-binary/flatbuffers.md +++ /dev/null @@ -1,39 +0,0 @@ -# [L] FlatBuffers stats flavor - -## Goal - -`moq_stats::Producer` serves `[/]{publisher,subscriber,sessions}.fb.z` -on request next to the JSON tracks, and `moq_stats::Consumer` and the -aggregate read it, all from a checked-in `.fbs` schema. A benchmark shows -`.fb.z` beating `.json.z` on bytes, CPU, and allocations, for both producer -and consumer. If it -does not clearly win, abandon the quest and report the numbers. - -## Plan - -- The schema in `rs/moq-stats` is the contract: one table per `Traffic` and - `Presence`, keyed entries per frame, and room for the client-stats - extension as an optional nested table. Fields are only ever appended. - Generate with planus. Prefer generating in `build.rs` if planus supports - it cleanly; otherwise check in the output with a just recipe and a CI diff - check. -- The producer already collects each drain into per-track buffers kept - across drains, and `steady_drain_collects_without_allocating` holds that - path at zero allocations. Encode from those buffers into one planus - builder that resets every frame, and extend the test to cover it. Each group is one DEFLATE window, as - `.json.z` does today, with a full snapshot per frame and no deltas. -- `.fb.z` is a flavor suffix: it applies to every track name that takes - `.json.z` (default and named tiers, sessions, and the per-broadcast tracks - if [schema](/quest/m1/qos/stats/schema.md) lands first) and nothing - else. Extend `requested_track_shape` and the track-name helpers for it. Decide whether the helpers take a flavor enum instead of - `compressed: bool`, and weigh that break while the release is still pending. -- `Consumer` reads either flavor into the same frame types; decoding views - the inflated buffer without copying strings until a caller keeps one. -- Benchmark `.json.z` against `.fb.z` over broadcasts x tiers: bytes after - flate, encode and decode ns, and allocations on both sides. Put the numbers - in the PR. -- Update the stats section of `doc/bin/relay/config.md` and the moq-stats - crate docs (wire format) inline. - -Public API impact: additive on moq-stats unless the helper signatures change -(before the pending release). Wire impact: new on-demand tracks; the existing tracks are unchanged. diff --git a/quest/m1/qos/stats/schema.md b/quest/m1/qos/stats/schema.md index 8e4d21f181..2a60d265ec 100644 --- a/quest/m1/qos/stats/schema.md +++ b/quest/m1/qos/stats/schema.md @@ -49,10 +49,6 @@ what a subscriber received and played, per audio and video. The relay is connection carries. `Merge` sums counters, leaves gauges out of the sum, and keeps the newest liveness pair. -- The FlatBuffers flavor ([binary stats](/quest/m0/stats-binary/README.md)) - leaves an optional nested table for `E`. Either give `Ext` a FlatBuffers - encoding, or have a non-`()` producer refuse `.fb.z` requests and document - that. Whichever you choose, the contract must be explicit. - Naming: `.stats` as a broadcast suffix, documented in the `moq-stats` crate docs and `doc/lib/rs/index.md` beside the relay's prefix convention, with `moq_stats::is_stats(path)` so a dashboard filters telemetry from diff --git a/quest/m1/relay-peer-set.md b/quest/m1/relay-peer-set.md index 7ca9dcddbf..14a2e46b24 100644 --- a/quest/m1/relay-peer-set.md +++ b/quest/m1/relay-peer-set.md @@ -26,10 +26,6 @@ hop-id bit prefix (`agent/moq_agent/matching.py`). Public API: additive on moq-stats and moq-auth. Wire: the stats broadcast gains a peer-set frame; the JWT claims may gain a field. -## Required - -- [Binary stats](/quest/m0/stats-binary/README.md) - settles the stats track layout the frame joins - ## Related - [Route cost](/quest/m1/route-cost.md) - the other route fact JS lacks diff --git a/quest/m2/stats-delta.md b/quest/m2/stats-delta.md index 19bd074bb2..b60e1b9afa 100644 --- a/quest/m2/stats-delta.md +++ b/quest/m2/stats-delta.md @@ -100,7 +100,7 @@ has landed by then. Share fixtures between Rust and JS so the two stay wire identical, and keep the benchmark in-tree, wired into CI at least nightly. **Docs and spec.** No IETF draft covers stats today. Write the format into -the stats format page ([stats-binary docs](/quest/m0/stats-binary/docs.md)) +the stats format page ([doc/concept/stats.md](/doc/concept/stats.md)) and the stats section of `doc/bin/relay/config.md`, plus the moq-stats crate docs. Whether stats needs its own `draft-lcurley-moq-stats.md` is the maintainer's call; ask before writing one. @@ -110,6 +110,6 @@ impact: new on-demand tracks; existing tracks unchanged. ## Related -- [Stats format page](/quest/m0/stats-binary/docs.md) - where the new flavor is documented +- [Stats format page](/doc/concept/stats.md) - where the new flavor is documented - [Client stats](/quest/m1/qos/stats/README.md) - the extension and gauges the format must carry or refuse - [Compressed tracks](/quest/m2/flate/README.md) - the group-window discipline this flavor repeats diff --git a/rs/moq-stats/src/lib.rs b/rs/moq-stats/src/lib.rs index d1613d7217..99b2411232 100644 --- a/rs/moq-stats/src/lib.rs +++ b/rs/moq-stats/src/lib.rs @@ -15,41 +15,14 @@ //! # Wire format //! //! A [`Producer`] publishes one broadcast per node at `/node/` -//! (default prefix `.stats`; the node suffix disambiguates relays sharing a -//! cluster origin and may be multi-segment, e.g. `sjc/1`). A grouping `depth` -//! splits that into one broadcast per leading broadcast-path segments at -//! `//node/`, so a consumer can announce-scope to a -//! single group. Parse announce paths back with [`parse_node_path`]. -//! -//! Traffic is bucketed by [`Tier`] (an arbitrary label chosen by business -//! logic: billing class, region, ...). The default tier is unprefixed; a named -//! tier prefixes its track names with its label. Each broadcast carries, per -//! tier, a publisher (egress) and a subscriber (ingress) traffic track plus a -//! sessions track, each in a plain and a compressed flavor: -//! -//! * `publisher.json` / `subscriber.json`: each frame is a JSON object mapping -//! broadcast path to a cumulative [`Traffic`] snapshot ([`TrafficFrame`]), -//! one full snapshot per frame. -//! * `sessions.json`: each frame maps auth root to a cumulative [`Presence`] -//! gauge ([`SessionsFrame`]), counting connected sessions regardless of data -//! flow. -//! * `.json.z`: a compressed sibling of each of the above, encoded with -//! [`moq_json::snapshot`] (group-scoped DEFLATE plus RFC 7396 merge-patch -//! deltas). Since successive stats frames are nearly identical, this is a -//! fraction of the plain track's bytes; read it with [`Consumer`] (or -//! `moq_json` directly), not as raw JSON frames. -//! -//! Named-tier tracks (`/publisher.json`, ...) are created the first time -//! traffic records under that label; default-tier tracks always exist and hold -//! `{}` while idle. Compute names with [`traffic_track`] / [`sessions_track`]. -//! -//! An entry appears in a frame while it is live (a started counter still exceeds -//! its `*_ended` counterpart, so traffic could resume at any moment) or on -//! the tick its snapshot changed, then is dropped once fully closed. Counters -//! are cumulative and monotonic: a downstream aggregator computes rates from -//! successive snapshots, and a counter going backwards means the relay -//! restarted or the entry was garbage collected and re-created, so consumers -//! should treat a decrease as a fresh segment. +//! (default prefix `.stats`), or one per group of leading broadcast-path +//! segments at `//node/`; parse announce paths back with +//! [`parse_node_path`]. Each [`Tier`] carries `publisher.json`, +//! `subscriber.json`, and `sessions.json` tracks of cumulative [`Traffic`] and +//! [`Presence`] counters, plus `.json.z` siblings encoded with +//! [`moq_json::snapshot`]; compute names with [`traffic_track`] / +//! [`sessions_track`]. The full contract (paths, tracks, both encodings, and +//! counter semantics) is at . pub mod aggregate; pub mod consume; From abd91e141a478c27f206b74a41e84b41976b3f1b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 09:02:26 -0700 Subject: [PATCH 14/21] fix(net)!: ship lite-07 as moq-lite-07-wip, off by default (#4148) Co-authored-by: Claude Opus 5.5 --- doc/concept/moq-lite.md | 9 ++++-- drafts/draft-lcurley-moq-lite.md | 4 +-- js/net/src/connection/accept.ts | 2 +- js/net/src/connection/connect.test.ts | 23 +++++++++++--- js/net/src/connection/connect.ts | 7 +++-- js/net/src/integration.test.ts | 2 +- js/net/src/lite/version.ts | 13 +++++--- quest/m1/lite-stream-count.md | 4 +-- quest/m2/hidden-exemption.md | 2 +- rs/libmoq/src/api.rs | 3 ++ rs/moq-net/src/client.rs | 8 ++--- rs/moq-net/src/lite/version.rs | 9 +++--- rs/moq-net/src/server.rs | 10 +++--- rs/moq-net/src/version.rs | 45 +++++++++++++++++++++------ rs/moq-relay/src/connection.rs | 2 +- rs/moq-relay/src/websocket.rs | 2 +- rs/moq-relay/tests/smoke.rs | 21 ++++++++----- rs/moq-tokio/src/connect.rs | 4 +-- rs/moq-tokio/src/listen.rs | 4 +-- rs/moq-tokio/tests/broadcast.rs | 14 +++++---- test/wasm/README.md | 4 +-- test/wasm/run.sh | 4 +-- 22 files changed, 127 insertions(+), 69 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 2235506c52..33930649a2 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -29,8 +29,10 @@ implement in an afternoon. The wire spec is A dedicated ALPN selects the wire version for moq-lite 03 and newer. The legacy `moql` ALPN negotiates moq-lite 01 or 02 via `SETUP`. In moq-lite 05 and newer, each side also sends a `SETUP` message with its capabilities. -Rust and TypeScript speak moq-lite 01 through 07 and moq-transport drafts -14 through 22. Clients offer `moq-lite-07` first by default. +Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts +14 through 22. Clients offer `moq-lite-06` first by default. moq-lite 07 is +still in progress: it negotiates as `moq-lite-07-wip`, and only when both +sides explicitly enable it. ## Discovery @@ -85,7 +87,8 @@ let announced = origin.consume().with_hidden(true).announced(); const announced = connection.announced(Path.Pattern.all(), { hidden: true }); ``` -On the wire, moq-lite 07 carries the opt-in on each announce request, and +On the wire, moq-lite 07 (`moq-lite-07-wip`, opt-in only) carries the opt-in +on each announce request, and moq-transport carries it as a `SUBSCRIBE_NAMESPACE` parameter once the peer's `SETUP` says it understands one ([hidden](/draft/moq-hidden)). An older peer never opts in, so it never discovers hidden routes. Rust sessions always opt in diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 8c5c464c08..455a587917 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -94,7 +94,7 @@ A Session consists of a connection between a client and a server. There is currently no P2P support within QUIC so it's out of scope for moq-lite. The moq-lite version identifier is `moq-lite-xx` where `xx` is the two-digit draft version. -The identifier for this draft is `moq-lite-07`. +The identifier for this draft is `moq-lite-07-wip` while it is a work in progress, and becomes `moq-lite-07` once finalized. For bare QUIC, this is negotiated as an ALPN token during the QUIC handshake. For WebTransport over HTTP/3, the QUIC ALPN remains `h3` and the moq-lite version is advertised via the `WT-Available-Protocols` and `WT-Protocol` CONNECT headers. @@ -1330,7 +1330,7 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-07 -- Assigned `moq-lite-07` as this draft's protocol identifier. +- Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. - Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. ## moq-lite-06 diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 0430892187..d0f4f2c297 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -87,7 +87,7 @@ async function acceptInner( return acceptSetup(transport, url, Ietf.Version.DRAFT_16, wiring); } else if (protocol === Ietf.ALPN.DRAFT_15) { return acceptSetup(transport, url, Ietf.Version.DRAFT_15, wiring); - } else if (protocol === Lite.ALPN_07) { + } else if (protocol === Lite.ALPN_07_WIP) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_06, ...wiring }); diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 02b8e6d175..c3cedd6607 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { ALPN_05, ALPN_07 } from "../lite/version.ts"; +import { ALPN_05, ALPN_06, ALPN_07_WIP } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; import { type ConnectProps, connect as connectSession } from "./connect.ts"; @@ -54,8 +54,10 @@ function stubWebTransport(transport: WebTransport): () => void { }; } -test("WebTransport offers lite-07 first by default", async () => { - const pair = createMockTransportPair(ALPN_07); +// Connect through a stubbed `new WebTransport(...)`, returning the offered protocols and +// the negotiated version. +async function offered(alpn: string, props: Omit = {}) { + const pair = createMockTransportPair(alpn); const original = globalThis.WebTransport; let protocols: string[] | undefined; @@ -66,13 +68,24 @@ test("WebTransport offers lite-07 first by default", async () => { globalThis.WebTransport = StubWebTransport as unknown as typeof WebTransport; try { - const connection = await connect(url, { websocket: { enabled: false } }); + const connection = await connect(url, { websocket: { enabled: false }, ...props }); connection.close(); + return { protocols, version: connection.version }; } finally { globalThis.WebTransport = original; } +} + +test("WebTransport offers lite-06 first by default", async () => { + const { protocols } = await offered(ALPN_06); + expect(protocols?.[0]).toBe("moq-lite-06"); + expect(protocols).not.toContain(ALPN_07_WIP); +}); - expect(protocols?.[0]).toBe("moq-lite-07"); +test("WebTransport negotiates lite-07-wip only when explicitly offered", async () => { + const { protocols, version } = await offered(ALPN_07_WIP, { webtransport: { protocols: [ALPN_07_WIP] } }); + expect(protocols).toEqual(["moq-lite-07-wip"]); + expect(version).toBe("moq-lite-07-wip"); }); test("connect logs the relay URL without its credentials", async () => { diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index b0fcbc62b1..759c3cceb0 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -281,7 +281,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): setupVersion = Ietf.Version.DRAFT_16; } else if (protocol === Ietf.ALPN.DRAFT_15) { setupVersion = Ietf.Version.DRAFT_15; - } else if (protocol === Lite.ALPN_07) { + } else if (protocol === Lite.ALPN_07_WIP) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_06, ...wiring }); @@ -438,7 +438,8 @@ async function connectWebTransport( allowPooling: false, congestionControl: "low-latency", protocols: [ - Lite.ALPN_07, + // Lite.ALPN_07_WIP is intentionally omitted: lite-07 is work-in-progress and + // not advertised by default (negotiate still accepts it if a server selects it). Lite.ALPN_06, Lite.ALPN_05, Lite.ALPN_04, @@ -525,7 +526,7 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): // advertises every QMux draft it knows about and the server picks one. // Insertion order is the negotiation preference on the wire. const versions = { - [Lite.ALPN_07]: null, + // Lite.ALPN_07_WIP omitted on purpose: lite-07 is work-in-progress, not advertised by default. [Lite.ALPN_06]: null, [Lite.ALPN_05]: null, [Lite.ALPN_04]: null, diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 2a98a9a8bc..3107cb2b14 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -421,7 +421,7 @@ async function announcedUntil(announced: { next(): Promise<{ prefix: Path.Valid // A `.`-named broadcast is left out of discovery unless the request opts in or names the // dot segment. lite-06 cannot carry the opt-in, so its peer never lists the hidden path. for (const [protocol, carriesOptIn] of [ - [Lite.ALPN_07, true], + [Lite.ALPN_07_WIP, true], [Lite.ALPN_06, false], [Ietf.ALPN.DRAFT_19, true], [Ietf.ALPN.DRAFT_16, true], diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index fbaa1e871e..4fda174243 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -4,11 +4,12 @@ export const Version = { DRAFT_03: 0xff0dad03, DRAFT_04: 0xff0dad04, DRAFT_05: 0xff0dad05, - /// Lite-06. Adds announce ids: each active ANNOUNCE_BROADCAST implicitly assigns the next + /// Lite-06, advertised as the preferred WebTransport subprotocol. + /// Adds announce ids: each active ANNOUNCE_BROADCAST implicitly assigns the next /// ordinal, and ended/restart reference that id instead of repeating the path. /// Also adds frame-precise subscribe/fetch bounds and a GROUP frame offset. DRAFT_06: 0xff0dad06, - /// Lite-07, advertised as the preferred WebTransport subprotocol. + /// Work-in-progress lite-07, only negotiated when explicitly offered. /// Adds the ANNOUNCE_REQUEST hidden opt-in. DRAFT_07: 0xff0dad07, } as const; @@ -245,8 +246,10 @@ export const ALPN_05 = "moq-lite-05"; /// The ALPN string for Draft06. export const ALPN_06 = "moq-lite-06"; -/// The ALPN string for Draft07. -export const ALPN_07 = "moq-lite-07"; +/// The ALPN string for the work-in-progress Draft07. It is NOT in the default +/// WebTransport `protocols` list, so lite-07 is never advertised or negotiated by +/// default; a peer only reaches it when both sides explicitly offer this ALPN. +export const ALPN_07_WIP = "moq-lite-07-wip"; const VERSION_NAMES: Record = { [Version.DRAFT_01]: "moq-lite-01", @@ -255,7 +258,7 @@ const VERSION_NAMES: Record = { [Version.DRAFT_04]: "moq-lite-04", [Version.DRAFT_05]: "moq-lite-05", [Version.DRAFT_06]: "moq-lite-06", - [Version.DRAFT_07]: "moq-lite-07", + [Version.DRAFT_07]: "moq-lite-07-wip", }; export function versionName(v: Version): string { diff --git a/quest/m1/lite-stream-count.md b/quest/m1/lite-stream-count.md index 30f8a05363..b866d94686 100644 --- a/quest/m1/lite-stream-count.md +++ b/quest/m1/lite-stream-count.md @@ -21,7 +21,7 @@ before its header arrived is still invisible, so the track-tail grace stays. PUBLISH_DONE, so the boundary arrives slightly later than today. Remove SUBSCRIBE_DROP and its type from lite-07 and reword the Subscribe Stream section: the FIN follows once every counted stream has finished or been - reset. lite-07 is unpublished, so this changes it in place; update + reset. lite-07 is still work-in-progress (`moq-lite-07-wip`), so this changes it in place; update `drafts/draft-lcurley-moq-lite.md` and its changelog. - Rust and JS publishers count the streams they open per subscription and send the count; a relay counts its own downstream streams, never forwarding @@ -33,7 +33,7 @@ before its header arrived is still invisible, so the track-tail grace stays. that is never counted, a reset stream, and a count of zero. Add a Rust-JS interop case. -This lands before lite-07 is published. Rust has never sent or acted on +This lands before lite-07 is finalized. Rust has never sent or acted on SUBSCRIBE_DROP, so [Rust track tail](/quest/m1/rust-track-tail.md) builds its lite accounting on the count rather than on drops. diff --git a/quest/m2/hidden-exemption.md b/quest/m2/hidden-exemption.md index f39212684e..96ecf2afea 100644 --- a/quest/m2/hidden-exemption.md +++ b/quest/m2/hidden-exemption.md @@ -17,4 +17,4 @@ for peers that predate it. ## Required -- Every deployed relay in the moq.pro mesh speaks moq-lite-07 or MoQ Hidden. +- Every deployed relay in the moq.pro mesh speaks a finalized moq-lite-07 or MoQ Hidden; `moq-lite-07-wip` is opt-in only. diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 42bfcecbe7..66e353a052 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -890,6 +890,9 @@ static VERSION_NAMES: std::sync::LazyLock> = /// first. Each name borrows a static string valid for the life of the process, so a /// caller building a menu can hold them indefinitely. /// +/// Work-in-progress versions are omitted, since they are not advertised unless pinned; +/// a dial still accepts them by name. +/// /// Returns the total count on success, or a negative code on failure. /// /// # Safety diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index a4e17b65e5..75abdb5e3b 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -4,7 +4,7 @@ use crate::runtime::Timers; use crate::time::{Clock, Instant}; use crate::{ ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_19, ALPN_20, ALPN_21, ALPN_22, ALPN_LITE, ALPN_LITE_03, - ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07, Consume, Error, NEGOTIATED, Session, Version, Versions, + ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, Error, NEGOTIATED, Session, Version, Versions, coding::{self, Decode, Encode, Stream}, ietf, lite, setup, stats, }; @@ -216,7 +216,7 @@ impl Client { { let runtime = Clock::new(now); let version = match session.protocol() { - Some(ALPN_LITE_07) => lite::Version::Lite07, + Some(ALPN_LITE_07_WIP) => lite::Version::Lite07, Some(ALPN_LITE_06) => lite::Version::Lite06, Some(ALPN_LITE_05) => lite::Version::Lite05, Some(ALPN_LITE_04) => lite::Version::Lite04, @@ -301,9 +301,9 @@ impl Client { .ok_or(Error::Version)?; (v, v.into()) } - Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07)) => { + Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07_WIP)) => { let version = match alpn { - ALPN_LITE_07 => lite::Version::Lite07, + ALPN_LITE_07_WIP => lite::Version::Lite07, ALPN_LITE_06 => lite::Version::Lite06, _ => lite::Version::Lite05, }; diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index ad57336503..dfd78d0c57 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -15,11 +15,12 @@ pub enum Version { /// implicitly assigns the next ordinal, and `ended`/`restart` reference that id /// instead of repeating the path. Also adds the route cost carried alongside the /// hop chain, ranking above hop count in route selection. Advertised over ALPN - /// as `moq-lite-06`. + /// as `moq-lite-06` and preferred by the default version sets. Lite06, /// Lite-07. Adds the hidden opt-in to ANNOUNCE_REQUEST: without it, a route with - /// a `.`-prefixed segment below the requested prefix is left out. Advertised over - /// ALPN as `moq-lite-07` and preferred by the default version sets. + /// a `.`-prefixed segment below the requested prefix is left out. The wire format is + /// still work-in-progress, so it is advertised over ALPN as `moq-lite-07-wip` and + /// only when explicitly requested; the default version sets leave it out. Lite07, } @@ -203,7 +204,7 @@ impl fmt::Display for Version { Self::Lite04 => write!(f, "moq-lite-04"), Self::Lite05 => write!(f, "moq-lite-05"), Self::Lite06 => write!(f, "moq-lite-06"), - Self::Lite07 => write!(f, "moq-lite-07"), + Self::Lite07 => write!(f, "moq-lite-07-wip"), } } } diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 0d8ffed032..e26f5e632d 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -7,8 +7,8 @@ use crate::origin; use crate::time::{Clock, Instant}; use crate::{ ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_19, ALPN_20, ALPN_21, ALPN_22, ALPN_LITE, ALPN_LITE_03, - ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07, Consume, Error, NEGOTIATED, Role, Session, SessionError, - Version, Versions, + ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, Error, NEGOTIATED, Role, Session, + SessionError, Version, Versions, coding::{Decode, Encode, Stream}, ietf, lite, setup, stats, }; @@ -157,9 +157,9 @@ impl Server { { let runtime = Clock::new(now); let (path, role, origin, handshake) = match session.protocol() { - Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07)) => { + Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07_WIP)) => { let version = match alpn { - ALPN_LITE_07 => lite::Version::Lite07, + ALPN_LITE_07_WIP => lite::Version::Lite07, ALPN_LITE_06 => lite::Version::Lite06, _ => lite::Version::Lite05, }; @@ -294,7 +294,7 @@ impl Server { } // Every lite ALPN goes through the same entry point, which is also // what a `!Send` transport calls directly. - Some(ALPN_LITE_07 | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { + Some(ALPN_LITE_07_WIP | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { return self.accept_request_lite(now, session).await; } Some(ALPN_LITE) | None => { diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index a0ab24b216..e84ab91b38 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -16,8 +16,11 @@ pub(crate) const NEGOTIATED: [Version; 3] = [ /// ALPN strings for supported versions, most-preferred first. `ALPNS[0]` is the /// newest moq-lite ALPN that both sides converge on. +/// +/// `ALPN_LITE_07_WIP` is deliberately absent: lite-07's wire format is still +/// work-in-progress, so it is never advertised or negotiated by default. It is only +/// reachable when both peers explicitly opt in (e.g. `--version moq-lite-07-wip`). pub const ALPNS: &[&str] = &[ - ALPN_LITE_07, ALPN_LITE_06, ALPN_LITE_05, ALPN_LITE_04, @@ -40,7 +43,7 @@ pub(crate) const ALPN_LITE_03: &str = "moq-lite-03"; pub(crate) const ALPN_LITE_04: &str = "moq-lite-04"; pub(crate) const ALPN_LITE_05: &str = "moq-lite-05"; pub(crate) const ALPN_LITE_06: &str = "moq-lite-06"; -pub(crate) const ALPN_LITE_07: &str = "moq-lite-07"; +pub(crate) const ALPN_LITE_07_WIP: &str = "moq-lite-07-wip"; pub(crate) const ALPN_14: &str = "moq-00"; pub(crate) const ALPN_15: &str = "moqt-15"; pub(crate) const ALPN_16: &str = "moqt-16"; @@ -95,7 +98,7 @@ impl Version { Self::Lite(lite::Version::Lite04) => "moq-lite-04", Self::Lite(lite::Version::Lite05) => "moq-lite-05", Self::Lite(lite::Version::Lite06) => "moq-lite-06", - Self::Lite(lite::Version::Lite07) => "moq-lite-07", + Self::Lite(lite::Version::Lite07) => "moq-lite-07-wip", Self::Ietf(ietf::Version::Draft14) => "moq-transport-14", Self::Ietf(ietf::Version::Draft15) => "moq-transport-15", Self::Ietf(ietf::Version::Draft16) => "moq-transport-16", @@ -164,7 +167,7 @@ impl Version { ALPN_LITE_04 => Some(Self::Lite(lite::Version::Lite04)), ALPN_LITE_05 => Some(Self::Lite(lite::Version::Lite05)), ALPN_LITE_06 => Some(Self::Lite(lite::Version::Lite06)), - ALPN_LITE_07 => Some(Self::Lite(lite::Version::Lite07)), + ALPN_LITE_07_WIP => Some(Self::Lite(lite::Version::Lite07)), ALPN_14 => Some(Self::Ietf(ietf::Version::Draft14)), ALPN_15 => Some(Self::Ietf(ietf::Version::Draft15)), ALPN_16 => Some(Self::Ietf(ietf::Version::Draft16)), @@ -181,7 +184,7 @@ impl Version { /// Returns the ALPN string for this version. pub fn alpn(&self) -> &'static str { match self { - Self::Lite(lite::Version::Lite07) => ALPN_LITE_07, + Self::Lite(lite::Version::Lite07) => ALPN_LITE_07_WIP, Self::Lite(lite::Version::Lite06) => ALPN_LITE_06, Self::Lite(lite::Version::Lite05) => ALPN_LITE_05, Self::Lite(lite::Version::Lite04) => ALPN_LITE_04, @@ -294,8 +297,17 @@ pub struct Versions(Vec); impl Versions { /// All versions exposed by default. + /// + /// `Lite07` is intentionally excluded: its wire format is still work-in-progress, + /// so it is not advertised until a caller opts in explicitly (e.g. a pinned + /// `version = ["moq-lite-07-wip"]`). An opt-in set that includes it negotiates normally. pub fn all() -> Self { - Self(ALL.to_vec()) + Self( + ALL.iter() + .filter(|version| !matches!(version, Version::Lite(lite::Version::Lite07))) + .copied() + .collect(), + ) } /// Compute the unique ALPN strings needed for these versions. @@ -372,13 +384,28 @@ mod tests { use super::*; #[test] - fn default_versions_prefer_lite_07() { - let newest = Version::Lite(lite::Version::Lite07); - assert_eq!(newest.alpn(), "moq-lite-07"); + fn default_versions_prefer_lite_06() { + let newest = Version::Lite(lite::Version::Lite06); + assert_eq!(newest.alpn(), "moq-lite-06"); assert_eq!(Version::from_alpn("moq-lite-06-wip"), None); assert!("moq-lite-06-wip".parse::().is_err()); assert_eq!(Versions::all().iter().next(), Some(&newest)); assert_eq!(Versions::all().alpns().first(), Some(&newest.alpn())); assert_eq!(ALPNS.first(), Some(&newest.alpn())); } + + #[test] + fn lite_07_wip_is_opt_in_only() { + let wip = Version::Lite(lite::Version::Lite07); + assert_eq!(wip.alpn(), "moq-lite-07-wip"); + assert_eq!(wip.to_string(), "moq-lite-07-wip"); + assert_eq!("moq-lite-07-wip".parse::(), Ok(wip)); + assert_eq!(Version::from_alpn("moq-lite-07-wip"), Some(wip)); + assert!("moq-lite-07".parse::().is_err()); + assert_eq!(Version::from_alpn("moq-lite-07"), None); + + assert!(!Versions::all().contains(&wip)); + assert!(!ALPNS.contains(&wip.alpn())); + assert_eq!(Versions::from(wip).alpns(), [wip.alpn()]); + } } diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 9b1da86f9b..8d5e460c84 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -200,7 +200,7 @@ pub(crate) struct Grants { /// /// `cluster_peer` marks an authenticated cluster peer (a verified client /// certificate or the LAN credential), which discovers hidden routes whether -/// or not it asks. A peer that predates the hidden opt-in (below moq-lite-07, +/// or not it asks. A peer that predates the hidden opt-in (below moq-lite-07-wip, /// or moq-transport without MoQ Hidden) would otherwise lose `.internal/origins` /// and every other dot path during a rolling upgrade. // TODO: drop the exemption once deployed peers all opt in. diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 231ab9327c..6093d0ba95 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -241,7 +241,7 @@ where /// /// We advertise the configured qmux × moq-net subprotocol matrix, with bare /// qmux fallbacks last. axum picks the first entry that the client also offered, so -/// a modern client lands on `qmux-01.moq-lite-07`; old clients still match +/// a modern client lands on `qmux-01.moq-lite-06`; old clients still match /// `webtransport` or `qmux-00.moql` and negotiate via SETUP. /// /// When the client offered subprotocols and none of them are ours, the diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 9475aa040d..63b47a00c4 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -262,7 +262,10 @@ async fn announced_until(announcements: &mut moq_net::announce::Consumer, until: /// by exact path needs no opt-in. #[tokio::test] async fn hidden_broadcasts_need_a_lite07_opt_in() { - let (port, web_handle) = spawn_relay().await; + // lite-07 is work-in-progress and off by default, so the relay must enable it. + let lite07: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + let lite06: moq_net::Version = "moq-lite-06".parse().unwrap(); + let (port, web_handle) = spawn_versioned_relay(vec![lite07, lite06]).await; let url: url::Url = format!("ws://127.0.0.1:{port}/hidden").parse().expect("parse url"); let pub_origin = moq_tokio::origin::spawn(); @@ -274,14 +277,17 @@ async fn hidden_broadcasts_need_a_lite07_opt_in() { .expect("append group") .write_frame(moq_net::Timestamp::ZERO, b"hidden".as_ref()) .expect("write frame"); - let (_pub_client, pub_connection) = - tokio::time::timeout(TIMEOUT, connect_once(client().with_publisher(&pub_origin), url.clone())) - .await - .expect("publisher connect timeout") - .expect("publisher connect failed"); + // The relay's announce request carries the opt-in only on lite-07, so the publisher + // must speak it too for the hidden route to reach the relay. + let (_pub_client, pub_connection) = tokio::time::timeout( + TIMEOUT, + connect_once(client_version(Some(lite07)).with_publisher(&pub_origin), url.clone()), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); // An opted-in lite-07 client discovers the hidden broadcast. - let lite07: moq_net::Version = "moq-lite-07".parse().unwrap(); let opted_origin = moq_tokio::origin::spawn(); let mut opted = opted_origin.consume().with_hidden(true).announced(); let (_opted_client, opted_connection) = tokio::time::timeout( @@ -301,7 +307,6 @@ async fn hidden_broadcasts_need_a_lite07_opt_in() { // A lite-07 client that did not opt in, and a lite-06 client that cannot even // when its local reader asks, see only the visible broadcast. - let lite06: moq_net::Version = "moq-lite-06".parse().unwrap(); for (version, local_hidden) in [(lite07, false), (lite06, true)] { let origin = moq_tokio::origin::spawn(); let consumer = origin.consume().with_hidden(local_hidden); diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 7eb7b89c74..3dbbe6a889 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -205,7 +205,7 @@ pub(crate) struct Legacy { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", @@ -595,7 +595,7 @@ pub struct Config { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index d3e4d1422a..2d2cea2e92 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -129,7 +129,7 @@ pub struct Config { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", @@ -253,7 +253,7 @@ pub(crate) struct Legacy { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 4c2953a72d..d6e56e1723 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -1350,18 +1350,18 @@ async fn route_reannounce_test(version: Option<&str>) { handle.await.expect("server panicked").expect("server failed"); } -/// Route re-advertisement on the default version (lite-07: ANNOUNCE_RESTART by id). +/// Route re-advertisement on the default version (lite-06: ANNOUNCE_RESTART by id). #[tracing_test::traced_test] #[tokio::test] async fn broadcast_route_reannounce() { route_reannounce_test(None).await; } -/// Route re-advertisement on lite-06 (an explicit ANNOUNCE_RESTART by id). +/// Route re-advertisement on the opt-in lite-07-wip (an explicit ANNOUNCE_RESTART by id). #[tracing_test::traced_test] #[tokio::test] -async fn broadcast_route_reannounce_lite_06() { - route_reannounce_test(Some("moq-lite-06")).await; +async fn broadcast_route_reannounce_lite_07() { + route_reannounce_test(Some("moq-lite-07-wip")).await; } // ── Raw QUIC (moqt://) – same version on both sides ───────────────── @@ -1393,7 +1393,7 @@ async fn broadcast_moq_lite_06() { #[tracing_test::traced_test] #[tokio::test] async fn broadcast_moq_lite_07() { - broadcast_test("moqt", Some("moq-lite-07"), Some("moq-lite-07")).await; + broadcast_test("moqt", Some("moq-lite-07-wip"), Some("moq-lite-07-wip")).await; } #[tracing_test::traced_test] @@ -2117,7 +2117,9 @@ async fn broadcast_websocket_fallback() { /// /// Bump this whenever [`moq_net::Versions::all`] gains a newer Lite variant /// so the regression tests below keep tracking "the newest", not a frozen value. -const NEWEST_LITE: &str = "moq-lite-07"; +/// Work-in-progress versions (e.g. `moq-lite-07-wip`) are excluded from the default +/// set, so they don't count as "the newest" here until promoted. +const NEWEST_LITE: &str = "moq-lite-06"; /// Regression guard for the WebSocket ALPN path. Lite02 over WebSocket means /// the qmux subprotocol negotiation produced a bare `moql` (or no match) diff --git a/test/wasm/README.md b/test/wasm/README.md index 9ee699c24d..a1eb599b49 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -56,7 +56,7 @@ One per protocol flavour, since negotiation is the part that broke: | name | relay | negotiates | | ------- | -------------------------------- | -------------------------------------- | -| `lite` | defaults | `moq-lite-07`, over its own ALPN | +| `lite` | defaults | `moq-lite-06`, over its own ALPN | | `ietf` | `--listen-version moq-transport-19` | `moq-transport-19`, over its own ALPN | | `setup` | `--listen-version moq-lite-02` | the `moql` ALPN, version chosen by SETUP | @@ -99,7 +99,7 @@ here becomes a second wasm session and the interop runs both ways. Firefox. Playwright can launch it and it opens WebTransport sessions here, but it ships no `WebTransport.prototype.protocol`, so it cannot request or read a subprotocol. Run against Firefox 153, the `lite` relay negotiates `moq-lite-02` -over SETUP instead of `moq-lite-07` and the `ietf` relay rejects the connection +over SETUP instead of `moq-lite-06` and the `ietf` relay rejects the connection outright: four of the nine cases fail, including the version-negotiation case this harness exists for. There is nothing left to assert about negotiation, so Firefox joins the matrix when Gecko implements the subprotocol, not before. diff --git a/test/wasm/run.sh b/test/wasm/run.sh index 4b33db7af9..e2e649743e 100755 --- a/test/wasm/run.sh +++ b/test/wasm/run.sh @@ -9,7 +9,7 @@ # generated `js/wasm/dist` loaded by a real browser. # # One relay per protocol flavour, because negotiation is the part that broke: -# lite default versions -> moq-lite-07 over its own ALPN +# lite default versions -> moq-lite-06 over its own ALPN # ietf --listen-version 19 -> moq-transport-19 over its own ALPN # setup --listen-version lite-02 -> the "moql" ALPN, version chosen by SETUP # @@ -64,7 +64,7 @@ fi # name:version-flag:expected-version. An empty flag leaves the relay at its # defaults, which is the ALPN both sides prefer. FLAVOURS=( - "lite::moq-lite-07" + "lite::moq-lite-06" "ietf:moq-transport-19:moq-transport-19" "setup:moq-lite-02:moq-lite-02" ) From 63e5c5fd8a30a4a350c68bb3b24c90ddac2a2a1c Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:03:05 -0700 Subject: [PATCH 15/21] chore: release (#4091) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 54 +++++++++++++++++------------------ Cargo.toml | 34 +++++++++++----------- rs/hang/CHANGELOG.md | 6 ++++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 7 +++++ rs/libmoq/Cargo.toml | 2 +- rs/moq-archive/CHANGELOG.md | 6 ++++ rs/moq-archive/Cargo.toml | 2 +- rs/moq-audio/CHANGELOG.md | 6 ++++ rs/moq-audio/Cargo.toml | 2 +- rs/moq-binary/CHANGELOG.md | 6 ++++ rs/moq-binary/Cargo.toml | 2 +- rs/moq-boy/CHANGELOG.md | 6 ++++ rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 6 ++++ rs/moq-cli/Cargo.toml | 2 +- rs/moq-e2ee/CHANGELOG.md | 6 ++++ rs/moq-e2ee/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 7 +++++ rs/moq-ffi/Cargo.toml | 2 +- rs/moq-gst/CHANGELOG.md | 6 ++++ rs/moq-gst/Cargo.toml | 2 +- rs/moq-hls/CHANGELOG.md | 6 ++++ rs/moq-hls/Cargo.toml | 2 +- rs/moq-json/CHANGELOG.md | 6 ++++ rs/moq-json/Cargo.toml | 2 +- rs/moq-loc/CHANGELOG.md | 6 ++++ rs/moq-loc/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 11 +++++++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 7 +++++ rs/moq-net/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 6 ++++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-room/CHANGELOG.md | 6 ++++ rs/moq-room/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 ++++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-rtmp/CHANGELOG.md | 6 ++++ rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-srt/CHANGELOG.md | 6 ++++ rs/moq-srt/Cargo.toml | 2 +- rs/moq-stats/CHANGELOG.md | 6 ++++ rs/moq-stats/Cargo.toml | 2 +- rs/moq-tokio/CHANGELOG.md | 10 +++++++ rs/moq-tokio/Cargo.toml | 2 +- rs/moq-transcode/CHANGELOG.md | 6 ++++ rs/moq-transcode/Cargo.toml | 2 +- rs/moq-uring/CHANGELOG.md | 6 ++++ rs/moq-uring/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 6 ++++ rs/moq-video/Cargo.toml | 2 +- 52 files changed, 231 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d2368190d..08d5838edf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.3" +version = "0.21.4" dependencies = [ "anyhow", "bytes", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "bytes", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.3" +version = "0.0.4" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.2" +version = "0.1.3" dependencies = [ "block2 0.6.2", "bytes", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.2" +version = "0.1.3" dependencies = [ "bytes", "kio 0.6.0", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.3" +version = "0.12.4" dependencies = [ "anyhow", "axum", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.3" +version = "0.0.4" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.3" +version = "0.4.4" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.3" +version = "0.4.4" dependencies = [ "anyhow", "bytes", @@ -4393,7 +4393,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.3" +version = "0.5.4" dependencies = [ "axum", "bytes", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.0" +version = "0.5.1" dependencies = [ "bytes", "criterion", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.11" +version = "0.2.12" dependencies = [ "bytes", "moq-net", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.3" +version = "0.10.4" dependencies = [ "anyhow", "base64 0.23.1", @@ -4489,7 +4489,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.2" +version = "0.3.3" dependencies = [ "arrayvec", "bytes", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.3" +version = "0.15.4" dependencies = [ "anyhow", "axum", @@ -4649,7 +4649,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.3" +version = "0.2.4" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.3" +version = "0.3.4" dependencies = [ "aws-lc-rs", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.3" +version = "0.3.4" dependencies = [ "anyhow", "byteorder", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.3" +version = "0.3.4" dependencies = [ "bytes", "futures", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.3" +version = "0.2.4" dependencies = [ "futures", "moq-json", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.14" +version = "0.19.15" dependencies = [ "anyhow", "bytes", @@ -4815,7 +4815,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "bytes", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.4" +version = "0.0.5" dependencies = [ "anyhow", "bytes", @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "ash", @@ -8041,9 +8041,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smawk" diff --git a/Cargo.toml b/Cargo.toml index 3e25cae5ee..e523d0943d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,7 @@ dispatch2 = "0.3.1" flate2 = { version = "1.1", default-features = false } futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.3", path = "rs/hang" } +hang = { version = "0.21.4", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -139,16 +139,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.2", path = "rs/moq-audio", default-features = false } +moq-audio = { version = "0.1.3", path = "rs/moq-audio", default-features = false } moq-auth = { version = "0.1.1", path = "rs/moq-auth" } -moq-binary = { version = "0.1.2", path = "rs/moq-binary" } +moq-binary = { version = "0.1.3", path = "rs/moq-binary" } moq-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.3", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.0", path = "rs/moq-json" } -moq-loc = { version = "0.2.11", path = "rs/moq-loc" } +moq-hls = { version = "0.5.4", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.1", path = "rs/moq-json" } +moq-loc = { version = "0.2.12", path = "rs/moq-loc" } moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.3", path = "rs/moq-mux" } -moq-net = { version = "0.3.2", path = "rs/moq-net" } +moq-mux = { version = "0.10.4", path = "rs/moq-mux" } +moq-net = { version = "0.3.3", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "1.3", default-features = false } @@ -158,23 +158,23 @@ moq-noq-udp = "1.3" # used by moq-video on Linux. moq-nvenc = { version = "0.1.0", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.3", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.3", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.3", path = "rs/moq-rtmp" } +moq-relay = { version = "0.15.4", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.4", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.4", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.3", path = "rs/moq-srt" } -moq-stats = { version = "0.2.3", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.14", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.4", path = "rs/moq-srt" } +moq-stats = { version = "0.2.4", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.15", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.2", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.3", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.4", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.5", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -187,7 +187,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.1.2", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.3", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 50a8bc4ca9..981b6e83bd 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.4](https://github.com/moq-dev/moq/compare/hang-v0.21.3...hang-v0.21.4) - 2026-09-25 + +### Added + +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.21.3](https://github.com/moq-dev/moq/compare/hang-v0.21.2...hang-v0.21.3) - 2026-09-25 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 7d413b6b87..6411e9c8b0 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.21.3" +version = "0.21.4" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index b0494602f6..b1f29d0485 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.4](https://github.com/moq-dev/moq/compare/libmoq-v0.6.3...libmoq-v0.6.4) - 2026-09-25 + +### Added + +- *(libmoq)* advertise JSON tracks in the catalog, add binary data tracks ([#4073](https://github.com/moq-dev/moq/pull/4073)) +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.6.3](https://github.com/moq-dev/moq/compare/libmoq-v0.6.2...libmoq-v0.6.3) - 2026-09-25 ### Added diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 110459dd6b..c0f7f1140e 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.3" +version = "0.6.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index dbc51f9fd9..e2d7380444 100644 --- a/rs/moq-archive/CHANGELOG.md +++ b/rs/moq-archive/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.4](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.3...moq-archive-v0.0.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.0.3](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.2...moq-archive-v0.0.3) - 2026-09-25 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 53500dd89c..df94dc4d37 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.3" +version = "0.0.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index aec7067d3d..9ca3925685 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.2...moq-audio-v0.1.3) - 2026-09-25 + +### Added + +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.1...moq-audio-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 40cd259d15..a270e8dbde 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index f3d3799e9c..2bb81ea2c3 100644 --- a/rs/moq-binary/CHANGELOG.md +++ b/rs/moq-binary/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.2...moq-binary-v0.1.3) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.1...moq-binary-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index 7e1ea74f2f..e33ebf7d0c 100644 --- a/rs/moq-binary/Cargo.toml +++ b/rs/moq-binary/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 9cd4be3f72..e9ad1b0e60 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.4](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.3...moq-boy-v0.5.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json, hang, moq-mux, moq-tokio, moq-audio, moq-video + ## [0.5.3](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.2...moq-boy-v0.5.3) - 2026-09-25 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index e8f265bb0d..c4c7ea0b31 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.5.3" +version = "0.5.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 29815ec3cf..a2a3cfae6f 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.4](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.3...moq-cli-v0.12.4) - 2026-09-25 + +### Other + +- updated the following local packages: hang, moq-mux, moq-tokio, moq-audio, moq-video, moq-hls, moq-relay, moq-rtc, moq-rtmp, moq-srt, moq-transcode + ## [0.12.3](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.2...moq-cli-v0.12.3) - 2026-09-25 ### Added diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index de9b25aa66..84846ca97e 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.12.3" +version = "0.12.4" edition = "2024" # Depends on moq-relay, whose sysinfo 0.39 needs 1.95, above the 1.91 workspace # floor. This binary is an application, so the bump stays here rather than diff --git a/rs/moq-e2ee/CHANGELOG.md b/rs/moq-e2ee/CHANGELOG.md index 258ecfb1ff..4b498a5e6d 100644 --- a/rs/moq-e2ee/CHANGELOG.md +++ b/rs/moq-e2ee/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.4](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.3...moq-e2ee-v0.0.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.0.3](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.2...moq-e2ee-v0.0.3) - 2026-09-25 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index 36f8d1e75c..0baaeee410 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.3" +version = "0.0.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index d03958a62e..49b66d3187 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.4](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.3...moq-ffi-v0.4.4) - 2026-09-25 + +### Added + +- *(ffi)* name the track an encoded audio or video publish writes ([#4097](https://github.com/moq-dev/moq/pull/4097)) +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.4.3](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.2...moq-ffi-v0.4.3) - 2026-09-25 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 141409f0b3..fd860ef969 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.3" +version = "0.4.4" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 2b4eb93f89..9298a1ce41 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.4](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.3...moq-gst-v0.4.4) - 2026-09-25 + +### Fixed + +- *(moq-gst)* wait for the sink's reconnect loop to end on stop ([#4074](https://github.com/moq-dev/moq/pull/4074)) + ## [0.4.3](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.2...moq-gst-v0.4.3) - 2026-09-25 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 327a77d7c2..6270468961 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.3" +version = "0.4.4" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index 78f8db5af7..49babfa787 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.4](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.3...moq-hls-v0.5.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.5.3](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.2...moq-hls-v0.5.3) - 2026-09-25 ### Added diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index e759559a79..ece64dbde1 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.3" +version = "0.5.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index b4a1ac63a2..5fe2b82810 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.1](https://github.com/moq-dev/moq/compare/moq-json-v0.5.0...moq-json-v0.5.1) - 2026-09-25 + +### Other + +- *(json)* skip unchanged root entries in the snapshot diff ([#4020](https://github.com/moq-dev/moq/pull/4020)) + ## [0.5.0](https://github.com/moq-dev/moq/compare/moq-json-v0.4.2...moq-json-v0.5.0) - 2026-09-25 ### Fixed diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 1b1307a120..9562dc8017 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index 29fcae56c0..8f5176c657 100644 --- a/rs/moq-loc/CHANGELOG.md +++ b/rs/moq-loc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.12](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.11...moq-loc-v0.2.12) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.2.11](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.10...moq-loc-v0.2.11) - 2026-09-25 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 4d9a79adfe..9cab0d3bad 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.11" +version = "0.2.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 533b50c673..2b787a26c5 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.4](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.3...moq-mux-v0.10.4) - 2026-09-25 + +### Added + +- *(libmoq)* advertise JSON tracks in the catalog, add binary data tracks ([#4073](https://github.com/moq-dev/moq/pull/4073)) +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + +### Fixed + +- *(moq-mux)* order TS export by media time, not arrival ([#4001](https://github.com/moq-dev/moq/pull/4001)) + ## [0.10.3](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.2...moq-mux-v0.10.3) - 2026-09-25 ### Fixed diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 5ff45656de..7529d71ea4 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.3" +version = "0.10.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 7f29dd53b8..a5e792ca23 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.3](https://github.com/moq-dev/moq/compare/moq-net-v0.3.2...moq-net-v0.3.3) - 2026-09-25 + +### Fixed + +- *(net)* end an IETF subscription from its PUBLISH_DONE ([#4083](https://github.com/moq-dev/moq/pull/4083)) +- *(net)* use the registered IETF priority property and TRACK_STATUS/FETCH refusal codes ([#3937](https://github.com/moq-dev/moq/pull/3937)) + ## [0.3.2](https://github.com/moq-dev/moq/compare/moq-net-v0.3.1...moq-net-v0.3.2) - 2026-09-25 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 66b697784e..b65a6d5402 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.2" +version = "0.3.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 9f3d36d2c0..bd19f08a09 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.4](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.3...moq-relay-v0.15.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-tokio, moq-uring, moq-stats + ## [0.15.3](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.2...moq-relay-v0.15.3) - 2026-09-25 ### Added diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 02446cf4b6..31d5636ca4 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.15.3" +version = "0.15.4" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target diff --git a/rs/moq-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index d3fdfcb6f0..760338d6d8 100644 --- a/rs/moq-room/CHANGELOG.md +++ b/rs/moq-room/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.4](https://github.com/moq-dev/moq/compare/moq-room-v0.2.3...moq-room-v0.2.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.3](https://github.com/moq-dev/moq/compare/moq-room-v0.2.2...moq-room-v0.2.3) - 2026-09-25 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index f4c1777a28..7049f9a914 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.3" +version = "0.2.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index db905dbedd..1a333e3ed6 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.3...moq-rtc-v0.3.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.3](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.2...moq-rtc-v0.3.3) - 2026-09-25 ### Added diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 001c43a41b..980bbf76c6 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.3" +version = "0.3.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index 5550af5e3e..e5ea9c69ab 100644 --- a/rs/moq-rtmp/CHANGELOG.md +++ b/rs/moq-rtmp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.3...moq-rtmp-v0.3.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.3](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.2...moq-rtmp-v0.3.3) - 2026-09-25 ### Added diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index ffaf2634a0..57193f32dc 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/moq-dev/moq" # src/rml/LICENSE and applies to that module regardless of which option you pick. license = "MIT OR Apache-2.0" -version = "0.3.3" +version = "0.3.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index 9a4e10ebb9..acc2a9f83f 100644 --- a/rs/moq-srt/CHANGELOG.md +++ b/rs/moq-srt/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.3...moq-srt-v0.3.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux + ## [0.3.3](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.2...moq-srt-v0.3.3) - 2026-09-25 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 20cb6da8d5..2bc4a28d9f 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.3" +version = "0.3.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index ebc2ff3024..6d23cac52c 100644 --- a/rs/moq-stats/CHANGELOG.md +++ b/rs/moq-stats/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.4](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.3...moq-stats-v0.2.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.3](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.2...moq-stats-v0.2.3) - 2026-09-25 ### Added diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 295ca18336..2c130390ca 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.3" +version = "0.2.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index 6f3171a37e..ba23b19ab9 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.15](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.14...moq-tokio-v0.19.15) - 2026-09-25 + +### Fixed + +- *(moq-tokio)* release the QUIC socket before Listener::close returns ([#4087](https://github.com/moq-dev/moq/pull/4087)) + +### Other + +- *(moq-tokio)* dial the WebSocket fallback on its own ephemeral port ([#4084](https://github.com/moq-dev/moq/pull/4084)) + ## [0.19.14](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.13...moq-tokio-v0.19.14) - 2026-09-25 ### Added diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index f07b31921c..c8aab23026 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.14" +version = "0.19.15" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 8dc782f3de..466a209d5e 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.2...moq-transcode-v0.1.3) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-video + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.1...moq-transcode-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index c85edef892..0a642a0040 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index 5275d2dfa5..2baec4294e 100644 --- a/rs/moq-uring/CHANGELOG.md +++ b/rs/moq-uring/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.5](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.4...moq-uring-v0.0.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.0.4](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.3...moq-uring-v0.0.4) - 2026-09-25 ### Added diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 5f74c6dfdd..7755f2228e 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.4" +version = "0.0.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 8525c9ca02..ef7f9b6dcc 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-video-v0.1.2...moq-video-v0.1.3) - 2026-09-25 + +### Added + +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-video-v0.1.1...moq-video-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 9810c3c929..862d0af9d7 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version.workspace = true From a2cb46c991ccb1c7151185a850334006ceec7514 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 09:13:29 -0700 Subject: [PATCH 16/21] ae --- .claude/skills/spawn-merge/SKILL.md | 2 +- .claude/skills/start-quest/SKILL.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/skills/spawn-merge/SKILL.md b/.claude/skills/spawn-merge/SKILL.md index 6549ea57d3..94e4ede39b 100644 --- a/.claude/skills/spawn-merge/SKILL.md +++ b/.claude/skills/spawn-merge/SKILL.md @@ -8,7 +8,7 @@ Read the /merge, /takeover, and /close skills before starting. The goal is to evaluate the open PRs in the repository and decide which ones to merge. Each merge is performed in parallel by a sub-agent. -Start by listing all open PRs. +Start by listing all open PRs that are ready for review (skip drafts). An argument can be used to filter the PRs in scope. One at a time, for each PR, interactively prompt the user if we should /merge, skip, or /close. diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md index 35d5078967..4ec47379e5 100644 --- a/.claude/skills/start-quest/SKILL.md +++ b/.claude/skills/start-quest/SKILL.md @@ -5,14 +5,18 @@ description: Start work on a quest. Before you begin, read `quest/CLAUDE.md` completely. -Your goal is to implement the quest, or as much of it as possible, and create a PR. +Your goal is to implement the quest, or as much of it as possible, and create a draft PR. The argument is the quest to work on. If you are unsure on the best course of action, ask the user for direction. Confirm the quest is ready and unclaimed. Claim it as `quest/CLAUDE.md` describes: `quest branch` names the branch and its bases, missing line branches get a draft PR, and the quest branch gets an empty commit. + Implement the quest until it is complete, or some blocker is hit, then create a PR against the base. Keep scratch files (PR body, logs, notes) in the worktree's gitignored `.scratch/`. Never write to or clean up a directory other agents share, such as a session scratchpad. -Summarize the notable changes for the user. + +When done, summarize any issues encounted, and suggest potential follow-up. +If you're happy with the outcome, switch the draft PR to ready for review. +If you want another set of eyes on it, keep it a draft. From e173ddd83c83b5088f3fc5bfc71ac42aaa01b2e4 Mon Sep 17 00:00:00 2001 From: bgreenway Date: Fri, 25 Sep 2026 11:30:05 -0500 Subject: [PATCH 17/21] feat(ffi): advertise JSON tracks in the catalog, add binary data tracks (#4137) Co-authored-by: Brad Greenway --- dart/moq_ffi/lib/src/moq.dart | 404 +++++++++++++++++++++++++++++++++- py/moq-rs/moq/publish.py | 8 +- rs/moq-ffi/src/binary.rs | 125 +++++++++++ rs/moq-ffi/src/json.rs | 35 +-- rs/moq-ffi/src/lib.rs | 1 + rs/moq-ffi/src/test.rs | 123 +++++++++++ rs/moq-mux/src/json.rs | 10 + 7 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 rs/moq-ffi/src/binary.rs diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index d4635f31c0..4ada2c2a1c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -14,6 +14,65 @@ import "package:ffi/ffi.dart"; import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; +class MoqBinaryConfig { + final bool compression; + final String? mime; + MoqBinaryConfig({this.compression = false, this.mime = null}); +} + +class FfiConverterMoqBinaryConfig { + static MoqBinaryConfig lift(RustBuffer buf) { + return FfiConverterMoqBinaryConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final compression_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final compression = compression_lifted.value; + new_offset += compression_lifted.bytesRead; + final mime_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final mime = mime_lifted.value; + new_offset += mime_lifted.bytesRead; + return LiftRetVal( + MoqBinaryConfig(compression: compression, mime: mime), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqBinaryConfig value) { + final total_length = + FfiConverterBool.allocationSize(value.compression) + + FfiConverterOptionalString.allocationSize(value.mime) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqBinaryConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterBool.write( + value.compression, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalString.write( + value.mime, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqBinaryConfig value) { + return FfiConverterBool.allocationSize(value.compression) + + FfiConverterOptionalString.allocationSize(value.mime) + + 0; + } +} + class MoqFetchGroupOptions { final int priority; MoqFetchGroupOptions({this.priority = 0}); @@ -4002,6 +4061,164 @@ class FfiConverterMoqReservation { } } +abstract class MoqBinarySnapshotProducerInterface { + void finish(); + void update({required Uint8List payload}); +} + +final _MoqBinarySnapshotProducerFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer(ptr, status), + ); +}); + +class MoqBinarySnapshotProducer implements MoqBinarySnapshotProducerInterface { + late final Pointer _ptr; + MoqBinarySnapshotProducer._(this._ptr) { + _MoqBinarySnapshotProducerFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBinarySnapshotProducer.lift(Pointer ptr) { + return MoqBinarySnapshotProducer._(ptr); + } + Pointer uniffiClonePointer() { + return rustCall( + (status) => + uniffi_moq_ffi_fn_clone_moqbinarysnapshotproducer(_ptr, status), + ); + } + + void dispose() { + _MoqBinarySnapshotProducerFinalizer.detach(this); + rustCall( + (status) => + uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer(_ptr, status), + ); + } + + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_finish( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + + void update({required Uint8List payload}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_update( + uniffiClonePointer(), + FfiConverterUint8List.lower(payload), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqBinarySnapshotProducer { + static MoqBinarySnapshotProducer lift(Pointer ptr) { + return MoqBinarySnapshotProducer.lift(ptr); + } + + static Pointer lower(MoqBinarySnapshotProducer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBinarySnapshotProducer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBinarySnapshotProducer.lift(pointer), 8); + } + + static int write(MoqBinarySnapshotProducer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqBinaryStreamProducerInterface { + void append({required Uint8List payload}); + void finish(); +} + +final _MoqBinaryStreamProducerFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarystreamproducer(ptr, status), + ); +}); + +class MoqBinaryStreamProducer implements MoqBinaryStreamProducerInterface { + late final Pointer _ptr; + MoqBinaryStreamProducer._(this._ptr) { + _MoqBinaryStreamProducerFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBinaryStreamProducer.lift(Pointer ptr) { + return MoqBinaryStreamProducer._(ptr); + } + Pointer uniffiClonePointer() { + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqbinarystreamproducer(_ptr, status), + ); + } + + void dispose() { + _MoqBinaryStreamProducerFinalizer.detach(this); + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarystreamproducer(_ptr, status), + ); + } + + void append({required Uint8List payload}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarystreamproducer_append( + uniffiClonePointer(), + FfiConverterUint8List.lower(payload), + status, + ); + }, moqExceptionErrorHandler); + } + + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarystreamproducer_finish( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqBinaryStreamProducer { + static MoqBinaryStreamProducer lift(Pointer ptr) { + return MoqBinaryStreamProducer.lift(ptr); + } + + static Pointer lower(MoqBinaryStreamProducer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBinaryStreamProducer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBinaryStreamProducer.lift(pointer), 8); + } + + static int write(MoqBinaryStreamProducer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqBroadcastConsumerInterface { Future fetchGroup({ required String name, @@ -5891,6 +6108,14 @@ class FfiConverterMoqBroadcastDynamic { } abstract class MoqBroadcastProducerInterface { + MoqBinarySnapshotProducer publishBinarySnapshot({ + required String name, + required MoqBinaryConfig config, + }); + MoqBinaryStreamProducer publishBinaryStream({ + required String name, + required MoqBinaryConfig config, + }); MoqJsonSnapshotProducer publishJsonSnapshot({ required String name, required MoqJsonSnapshotConfig config, @@ -5963,6 +6188,40 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { ); } + MoqBinarySnapshotProducer publishBinarySnapshot({ + required String name, + required MoqBinaryConfig config, + }) { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_snapshot( + uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterMoqBinaryConfig.lower(config), + status, + ), + FfiConverterMoqBinarySnapshotProducer.lift, + moqExceptionErrorHandler, + ); + } + + MoqBinaryStreamProducer publishBinaryStream({ + required String name, + required MoqBinaryConfig config, + }) { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_stream( + uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterMoqBinaryConfig.lower(config), + status, + ), + FfiConverterMoqBinaryStreamProducer.lift, + moqExceptionErrorHandler, + ); + } + MoqJsonSnapshotProducer publishJsonSnapshot({ required String name, required MoqJsonSnapshotConfig config, @@ -9318,6 +9577,72 @@ external void uniffi_moq_ffi_fn_method_moqreservation_update( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbinarysnapshotproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_finish( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_update( + Pointer ptr, + RustBuffer payload, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbinarystreamproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbinarystreamproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarystreamproducer_append( + Pointer ptr, + RustBuffer payload, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarystreamproducer_finish( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10132,6 +10457,38 @@ external Pointer uniffi_moq_ffi_fn_constructor_moqbroadcastproducer_new( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_snapshot( + Pointer ptr, + RustBuffer name, + RustBuffer config, + Pointer uniffiStatus, +); + +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_stream( + Pointer ptr, + RustBuffer name, + RustBuffer config, + Pointer uniffiStatus, +); + @Native< Pointer Function( Pointer, @@ -11620,6 +11977,18 @@ external int uniffi_moq_ffi_checksum_method_moqreservation_grant(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqreservation_update(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_finish(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_update(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_append(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_finish(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group(); @@ -11814,6 +12183,14 @@ external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel(); external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track(); +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_snapshot(); + +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_stream(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot(); @@ -12186,6 +12563,21 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqreservation_update() != 9626) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_finish() != + 10338) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_update() != + 56077) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_append() != 1645) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_finish() != + 60630) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group() != 18633) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); @@ -12388,12 +12780,20 @@ void _checkApiChecksums() { 24118) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_snapshot() != + 6748) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_stream() != + 58418) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot() != - 51036) { + 64276) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream() != - 47317) { + 54975) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 2a5929954a..cbaf29ba13 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -792,8 +792,8 @@ def publish_json_snapshot( ``delta_ratio`` controls how aggressively deltas are emitted instead of full snapshots (0 disables deltas); ``None`` uses the binding's default. Set ``compression`` to DEFLATE-compress each group; the consumer must pass the same - flag. Advertise the track with :meth:`set_catalog_section` if consumers should - discover it. + flag. The track is advertised in the broadcast's catalog (``json.tracks.``) + until it finishes; a name the catalog already carries is refused. """ # Let the record supply delta_ratio's default rather than restating it here. config = ( @@ -807,7 +807,9 @@ def publish_json_stream(self, name: str, *, compression: bool = False) -> JsonSt """Publish a JSON stream track (lossless append-log). Every appended record is preserved and delivered in order. Set ``compression`` to - DEFLATE-compress the group; the consumer must pass the same flag. + DEFLATE-compress the group; the consumer must pass the same flag. The track is + advertised in the broadcast's catalog (``json.tracks.``) until it finishes; a + name the catalog already carries is refused. """ config = MoqJsonStreamConfig(compression=compression) return JsonStreamProducer(self._inner.publish_json_stream(name, config)) diff --git a/rs/moq-ffi/src/binary.rs b/rs/moq-ffi/src/binary.rs new file mode 100644 index 0000000000..8e343bd2ad --- /dev/null +++ b/rs/moq-ffi/src/binary.rs @@ -0,0 +1,125 @@ +//! Binary data tracks over the FFI boundary, advertised in the catalog. +//! +//! The binary counterpart of [`crate::json`]: opaque payloads (for example a camera's latest JPEG +//! thumbnail) on a named track, in either mode — `snapshot` (each payload supersedes the last) or +//! `stream` (every payload preserved in order). The broadcast's catalog carries +//! `binary.tracks.` (mode, plus `mime` and `compression` when set) for as long as the track +//! lives, so a consumer discovers it without knowing the application. + +use std::sync::Arc; + +use moq_mux::catalog::hang::Extra; + +use crate::error::MoqError; +use crate::producer::MoqBroadcastProducer; + +/// Options for a binary data track, in either mode (the mode is fixed by the constructor). +#[derive(Clone, uniffi::Record)] +pub struct MoqBinaryConfig { + /// DEFLATE-compress each payload, advertised in the catalog entry. + #[uniffi(default = false)] + pub compression: bool, + + /// The payloads' media type (e.g. `image/jpeg`), or `None` to leave it unstated. + #[uniffi(default = None)] + pub mime: Option, +} + +impl From for moq_mux::binary::Config { + fn from(config: MoqBinaryConfig) -> Self { + let mut out = moq_mux::binary::Config::default().with_compression(config.compression); + if let Some(mime) = config.mime { + out = out.with_mime(mime); + } + out + } +} + +#[uniffi::export] +impl MoqBroadcastProducer { + /// Publish a binary snapshot track (lossy latest-value) by name, advertised in the catalog. + /// + /// Errors if the catalog already carries an entry under `name`. + pub fn publish_binary_snapshot( + &self, + name: String, + config: MoqBinaryConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + self.with_state(|state| { + let track = state.broadcast.create_track(name, None)?; + let producer = state.catalog.binary_snapshot(track, config.into())?; + Ok(Arc::new(MoqBinarySnapshotProducer { + inner: std::sync::Mutex::new(Some(producer)), + })) + }) + } + + /// Publish a binary stream track (lossless append-log) by name, advertised in the catalog. + /// + /// Errors if the catalog already carries an entry under `name`. + pub fn publish_binary_stream( + &self, + name: String, + config: MoqBinaryConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + self.with_state(|state| { + let track = state.broadcast.create_track(name, None)?; + let producer = state.catalog.binary_stream(track, config.into())?; + Ok(Arc::new(MoqBinaryStreamProducer { + inner: std::sync::Mutex::new(Some(producer)), + })) + }) + } +} + +/// Publishes opaque payloads that consumers see as a single latest value. +#[derive(uniffi::Object)] +pub struct MoqBinarySnapshotProducer { + inner: std::sync::Mutex>>, +} + +#[uniffi::export] +impl MoqBinarySnapshotProducer { + /// Publish a new payload, superseding the last. + pub fn update(&self, payload: Vec) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let mut guard = self.inner.lock().unwrap(); + guard.as_mut().ok_or(MoqError::Closed)?.update(payload)?; + Ok(()) + } + + /// Finish the track and retire its catalog entry. + pub fn finish(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + producer.finish()?; + Ok(()) + } +} + +/// Publishes an ordered log of opaque payloads, one per append. +#[derive(uniffi::Object)] +pub struct MoqBinaryStreamProducer { + inner: std::sync::Mutex>>, +} + +#[uniffi::export] +impl MoqBinaryStreamProducer { + /// Append one payload to the log. + pub fn append(&self, payload: Vec) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let mut guard = self.inner.lock().unwrap(); + guard.as_mut().ok_or(MoqError::Closed)?.append(payload)?; + Ok(()) + } + + /// Finish the track and retire its catalog entry. + pub fn finish(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + producer.finish()?; + Ok(()) + } +} diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 243067d655..6b10c40e10 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -14,6 +14,7 @@ use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; use crate::producer::MoqBroadcastProducer; +use moq_mux::catalog::hang::Extra; /// Options for a JSON snapshot track (lossy latest-value mode). /// @@ -101,10 +102,11 @@ mod tests { #[uniffi::export] impl MoqBroadcastProducer { - /// Publish a JSON snapshot track (lossy latest-value) by name. + /// Publish a JSON snapshot track (lossy latest-value) by name, advertised in the catalog. /// - /// Advertise it in the catalog yourself with - /// [`set_catalog_section`](Self::set_catalog_section) if consumers should discover it. + /// The broadcast's catalog carries `json.tracks.` (`mode: snapshot`, and + /// `compression: deflate` when set) for as long as the track lives; finishing or dropping the + /// producer retires it. Errors if the catalog already carries an entry under `name`. pub fn publish_json_snapshot( &self, name: String, @@ -112,16 +114,21 @@ impl MoqBroadcastProducer { ) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { - let broadcast = state.broadcast.clone(); - let track = broadcast.create_track(name, None)?; - let producer = moq_json::snapshot::Producer::::new(track, config.into()); + let track = state.broadcast.create_track(name, None)?; + let config = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_delta_ratio(config.delta_ratio); + let producer = state.catalog.json_snapshot::(track, config)?; Ok(Arc::new(MoqJsonSnapshotProducer { inner: std::sync::Mutex::new(Some(producer)), })) }) } - /// Publish a JSON stream track (lossless append-log) by name. + /// Publish a JSON stream track (lossless append-log) by name, advertised in the catalog. + /// + /// The broadcast's catalog carries `json.tracks.` (`mode: stream`) for as long as the + /// track lives. Errors if the catalog already carries an entry under `name`. pub fn publish_json_stream( &self, name: String, @@ -129,9 +136,9 @@ impl MoqBroadcastProducer { ) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { - let broadcast = state.broadcast.clone(); - let track = broadcast.create_track(name, None)?; - let producer = moq_json::stream::Producer::::new(track, config.into()); + let track = state.broadcast.create_track(name, None)?; + let config = moq_mux::json::Config::default().with_compression(config.compression); + let producer = state.catalog.json_stream::(track, config)?; Ok(Arc::new(MoqJsonStreamProducer { inner: std::sync::Mutex::new(Some(producer)), })) @@ -175,7 +182,7 @@ impl MoqBroadcastConsumer { /// Publishes a JSON value that consumers see as a single latest state. #[derive(uniffi::Object)] pub struct MoqJsonSnapshotProducer { - inner: std::sync::Mutex>>, + inner: std::sync::Mutex>>, } #[uniffi::export] @@ -200,7 +207,7 @@ impl MoqJsonSnapshotProducer { /// Finish the track, closing any open group. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let mut producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; producer.finish()?; Ok(()) } @@ -247,7 +254,7 @@ impl MoqJsonSnapshotConsumer { /// Publishes an ordered log of JSON records, one record per append. #[derive(uniffi::Object)] pub struct MoqJsonStreamProducer { - inner: std::sync::Mutex>>, + inner: std::sync::Mutex>>, } #[uniffi::export] @@ -271,7 +278,7 @@ impl MoqJsonStreamProducer { /// Finish the track, closing the group. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let mut producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; producer.finish()?; Ok(()) } diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 1916f1282b..8b2743fdef 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -17,6 +17,7 @@ mod android; #[cfg(all(feature = "audio", not(target_arch = "wasm32")))] pub mod audio; pub mod bandwidth; +pub mod binary; pub mod consumer; pub mod demand; pub mod error; diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 4963dff98f..265dee6162 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2,6 +2,7 @@ use super::origin::*; use super::producer::*; use super::server::MoqServer; use super::session::{MoqClient, MoqSession}; +use crate::binary::MoqBinaryConfig; use crate::consumer::MoqBroadcastConsumer; use crate::consumer::MoqFetchGroupOptions; use crate::consumer::MoqSubscription; @@ -4495,3 +4496,125 @@ async fn shutdown_cancels_and_drops_cleanly() { drop(client_origin); drop(server_origin); } + +/// The broadcast's current catalog, read on the publish side. +fn published_catalog( + broadcast: &MoqBroadcastProducer, +) -> moq_mux::catalog::hang::Catalog { + broadcast.with_state(|state| Ok(state.catalog.snapshot())).unwrap() +} + +/// JSON tracks are advertised in the catalog (no `set_catalog_section` needed) and retired on finish. +#[tokio::test] +async fn json_tracks_are_advertised_in_the_catalog() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let snapshot = broadcast + .publish_json_snapshot( + "status".into(), + MoqJsonSnapshotConfig { + delta_ratio: 4, + compression: true, + }, + ) + .unwrap(); + let stream = broadcast + .publish_json_stream("events".into(), MoqJsonStreamConfig { compression: false }) + .unwrap(); + + let catalog = published_catalog(&broadcast); + let entry = catalog.json.tracks.get("status").expect("snapshot track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.compression, Some(hang::catalog::Compression::Deflate)); + let entry = catalog.json.tracks.get("events").expect("stream track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.compression, None); + + snapshot.finish().unwrap(); + let catalog = published_catalog(&broadcast); + assert!( + !catalog.json.tracks.contains_key("status"), + "finished track still advertised" + ); + assert!(catalog.json.tracks.contains_key("events")); + stream.finish().unwrap(); + assert!(published_catalog(&broadcast).json.tracks.is_empty()); +} + +/// Binary tracks carry their mode and (optional) media type in the catalog. +#[tokio::test] +async fn binary_tracks_are_advertised_in_the_catalog() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let thumb = broadcast + .publish_binary_snapshot( + "thumbnail".into(), + MoqBinaryConfig { + compression: false, + mime: Some("image/jpeg".into()), + }, + ) + .unwrap(); + let log = broadcast + .publish_binary_stream( + "log".into(), + MoqBinaryConfig { + compression: false, + mime: None, + }, + ) + .unwrap(); + thumb.update(vec![0xff, 0xd8, 0xff]).unwrap(); + log.append(vec![1, 2, 3]).unwrap(); + + let catalog = published_catalog(&broadcast); + let entry = catalog + .binary + .tracks + .get("thumbnail") + .expect("binary snapshot advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.mime.as_deref(), Some("image/jpeg")); + let entry = catalog.binary.tracks.get("log").expect("binary stream advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.mime, None); + + thumb.finish().unwrap(); + assert!(matches!(thumb.update(vec![0]), Err(MoqError::Closed))); + log.finish().unwrap(); + assert!(published_catalog(&broadcast).binary.tracks.is_empty()); +} + +/// A second data track under a name the catalog already carries is refused, leaving the first. +#[tokio::test] +async fn data_track_names_cannot_collide() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let first = broadcast + .publish_json_snapshot( + "state".into(), + MoqJsonSnapshotConfig { + delta_ratio: 0, + compression: false, + }, + ) + .unwrap(); + assert!( + broadcast + .publish_binary_stream( + "state".into(), + MoqBinaryConfig { + compression: false, + mime: None, + }, + ) + .is_err(), + "a duplicate data track name should fail" + ); + assert_eq!( + published_catalog(&broadcast) + .json + .tracks + .get("state") + .map(|e| e.mode.clone()), + Some(hang::catalog::Mode::Snapshot) + ); + first.finish().unwrap(); +} diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index e08e610acd..9d6572eb20 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -191,6 +191,11 @@ impl Snapshot { self.inner.consume() } + /// A watch-only handle to whether this track has subscribers. + pub fn demand(&self) -> moq_net::track::Demand { + self.inner.demand() + } + /// Publish a new value, superseding the previous one. pub fn update(&mut self, value: &T) -> crate::Result<()> { self.inner.update(value)?; @@ -256,6 +261,11 @@ impl Stream { self.inner.consume() } + /// A watch-only handle to whether this track has subscribers. + pub fn demand(&self) -> moq_net::track::Demand { + self.inner.demand() + } + /// Append one record to the log. /// /// A record that cannot be written ends the track (see [`moq_json::stream::Producer::append`]) From 69a914dc14ff4c0e01f07744a9d568e22105c3b7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 12:23:45 -0700 Subject: [PATCH 18/21] quest: open the transport-upgrade line Co-Authored-By: Claude Opus 5.5 From 1a4f8e26a9e6211cbd07b06ce217552b886ae5bb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 12:52:19 -0700 Subject: [PATCH 19/21] fix(ffi): name the binary config conversion so moq-ffi compiles (#4157) Co-authored-by: Claude Opus 5.5 --- rs/moq-ffi/src/binary.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rs/moq-ffi/src/binary.rs b/rs/moq-ffi/src/binary.rs index 8e343bd2ad..3c231757f7 100644 --- a/rs/moq-ffi/src/binary.rs +++ b/rs/moq-ffi/src/binary.rs @@ -48,7 +48,9 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let track = state.broadcast.create_track(name, None)?; - let producer = state.catalog.binary_snapshot(track, config.into())?; + let producer = state + .catalog + .binary_snapshot(track, moq_mux::binary::Config::from(config))?; Ok(Arc::new(MoqBinarySnapshotProducer { inner: std::sync::Mutex::new(Some(producer)), })) @@ -66,7 +68,9 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let track = state.broadcast.create_track(name, None)?; - let producer = state.catalog.binary_stream(track, config.into())?; + let producer = state + .catalog + .binary_stream(track, moq_mux::binary::Config::from(config))?; Ok(Arc::new(MoqBinaryStreamProducer { inner: std::sync::Mutex::new(Some(producer)), })) From cf3bb19cf5376e06f1b580c0084796ea1f94f603 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 16:10:34 -0700 Subject: [PATCH 20/21] feat(tokio)!: upgrade a WebSocket fallback session to WebTransport (#4189) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- dart/moq_ffi/lib/src/moq.dart | 4 +- doc/concept/transport.md | 3 +- doc/lib/rs/index.md | 4 +- go/wrapper/server.go | 2 + quest/m1/transport-upgrade/README.md | 6 +- quest/m1/transport-upgrade/rust.md | 49 --- rs/moq-cli/src/main.rs | 12 +- rs/moq-ffi/src/server.rs | 25 +- rs/moq-relay/src/auth.rs | 11 +- rs/moq-relay/src/uring.rs | 2 +- rs/moq-tokio/src/client.rs | 288 +++++++++++++--- rs/moq-tokio/src/connection.rs | 470 +++++++++++++++++++++++++-- rs/moq-tokio/src/lib.rs | 1 + rs/moq-tokio/src/server.rs | 42 +-- rs/moq-tokio/src/transport.rs | 40 +++ rs/moq-tokio/src/websocket.rs | 38 ++- rs/moq-tokio/tests/backend.rs | 2 +- rs/moq-tokio/tests/broadcast.rs | 8 +- 18 files changed, 810 insertions(+), 197 deletions(-) delete mode 100644 quest/m1/transport-upgrade/rust.md diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 4ada2c2a1c..d0ffb4a7eb 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -3832,7 +3832,7 @@ class FfiConverterMoqVideoFormat { } } -enum MoqTransport { quic, iroh, webSocket, tcp, unix } +enum MoqTransport { quic, iroh, webSocket, tcp, unix, webTransport } class FfiConverterMoqTransport { static LiftRetVal read(Uint8List buf) { @@ -3848,6 +3848,8 @@ class FfiConverterMoqTransport { return LiftRetVal(MoqTransport.tcp, 4); case 5: return LiftRetVal(MoqTransport.unix, 4); + case 6: + return LiftRetVal(MoqTransport.webTransport, 4); default: throw UniffiInternalError( UniffiInternalError.unexpectedEnumCase, diff --git a/doc/concept/transport.md b/doc/concept/transport.md index 14c74e7005..8bb8e802f4 100644 --- a/doc/concept/transport.md +++ b/doc/concept/transport.md @@ -42,7 +42,8 @@ connection (`wss://`) and keep whichever wins. A small multiplexer, [qmux](/draft/qmux-websocket), carries MoQ streams over the socket. It works everywhere but cannot escape TCP head-of-line blocking, so priority and resets only help once bytes leave the TCP queue. The fallback is automatic in every -client; the relay enables it with `[web.https]`. +client; the relay enables it with `[web.https]`. A native Rust client that lands on +WebSocket keeps dialing QUIC and moves the session onto it if that dial completes. ## Raw QUIC (native) diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 2a02438809..3dfbb96ef9 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -83,7 +83,9 @@ URLs may be `https://` (WebTransport, with raw QUIC preferred for native), `moql://`/`moqt://` (raw QUIC), or `iroh://`. A `?jwt=` query carries the token. `http://` is for a relay on localhost only: it fetches the certificate fingerprint unauthenticated before upgrading, so never send a token over it. Connections race -QUIC against WebSocket and remember which won. +QUIC against WebSocket and remember which won. When WebSocket wins, a `Connection` keeps +dialing QUIC and moves onto it once it lands, handing live tracks over at a group boundary +and draining the WebSocket session; `Connection::transport()` reports which is live. The `Default::default()` above is the QUIC transport section, and the same value serves a dial and a listener: diff --git a/go/wrapper/server.go b/go/wrapper/server.go index 02f668f116..108e51037d 100644 --- a/go/wrapper/server.go +++ b/go/wrapper/server.go @@ -22,6 +22,8 @@ const ( TransportTCP = ffi.MoqTransportTcp // TransportUnix is a session that arrived over a Unix domain socket. TransportUnix = ffi.MoqTransportUnix + // TransportWebTransport is a session that arrived over WebTransport (HTTP/3). + TransportWebTransport = ffi.MoqTransportWebTransport ) // Request is an incoming session that can be accepted (Accept) or rejected (Reject). diff --git a/quest/m1/transport-upgrade/README.md b/quest/m1/transport-upgrade/README.md index 7e88b12e6f..833fa9cd13 100644 --- a/quest/m1/transport-upgrade/README.md +++ b/quest/m1/transport-upgrade/README.md @@ -40,12 +40,13 @@ requires it. Shared decisions: - The race returns the winner plus the still-pending QUIC dial when WebSocket - wins. The QUIC handshake timeout bounds that dial; no extra deadline. + wins. The deadline that already bounds the race attempt (the connect + timeout in Rust) bounds that dial too; no extra deadline. - On a successful upgrade the "WebSocket won" memo (`WEBSOCKET_WON` in `moq-tokio`, `websocketWon` in `js/net`) forgets the URL: QUIC works on this network, so the head start comes back. Otherwise a network where WebSocket narrowly beats QUIC would open two connections on every reconnect. -- The old session gets `Goaway::same()` with the configured handover cap before +- The old session gets `Goaway::new()` with the configured handover cap before it enters draining. The relay refuses new requests on it from then on; the splice ends its subscriptions at the boundary. - One-shot `connect()` returns one session and never upgrades; every @@ -57,7 +58,6 @@ Shared decisions: ## Quests -- [Rust](/quest/m1/transport-upgrade/rust.md) - moq-tokio keeps the QUIC dial after WebSocket wins and migrates through the existing Draining path - [JavaScript](/quest/m1/transport-upgrade/js.md) - js/net keeps the WebTransport dial after WebSocket wins and migrates through the client-goaway handover ## Related diff --git a/quest/m1/transport-upgrade/rust.md b/quest/m1/transport-upgrade/rust.md deleted file mode 100644 index e0d05e29b5..0000000000 --- a/quest/m1/transport-upgrade/rust.md +++ /dev/null @@ -1,49 +0,0 @@ -# [M] Rust WebSocket to QUIC upgrade - -## Goal - -A `moq_tokio::Connection` that came up over the WebSocket fallback migrates to -QUIC once the QUIC dial completes: live tracks hand over at a group boundary, -the WebSocket session receives a GOAWAY and drains within the handover cap, -`Status::Migrating` is observable across the swap, and the "WebSocket won" -memo forgets the URL. When QUIC wins the race nothing changes. - -## Plan - -Lands in `rs/moq-tokio`. See the -[questline](/quest/m1/transport-upgrade/README.md) for the shared decisions. - -- `race_moq_connect` (`rs/moq-tokio/src/client.rs`) currently drops the losing - arm. When WebSocket wins, return the handshaken WebSocket session together - with the pending QUIC future; when QUIC wins or the QUIC arm has already - failed, return the session alone. `Client::dial` keeps its one-session shape - for the one-shot `connect()` path; give `Connection` the variant that carries - the pending upgrade. -- `Connection::run` (`rs/moq-tokio/src/connection.rs`) polls the pending QUIC - dial alongside `run_session`, the way it already polls `draining`. When it - completes, run the MoQ handshake on it with the same `moq_net::Client` (the - origins attach and the front reselects to the newest route), call - `shared.migrating()` then `shared.connected(&new)`, send - `old.drain().send(Goaway::same().timeout(handover))` with the configured - `connection::Goaway` cap, and move the old session into `Draining`. A QUIC dial - that fails after WebSocket won is logged at debug, leaves the memo untouched, - and the session carries on over WebSocket. The pending dial is dropped when the WebSocket session ends - first; the reconnect races again. -- On a successful upgrade remove the URL from `WEBSOCKET_WON` - (`rs/moq-tokio/src/websocket.rs`). -- Add `moq_tokio::Transport` (QUIC, WebTransport, WebSocket, TCP, Unix) and - `Connection::transport()` returning it for the live session, so telemetry can - see the swap. It lives in `moq-tokio`, not `moq_net`: the session is generic - over the transport trait and cannot know what it runs on, only the dial - does. Mirrors `js/net`'s `transportOf`. -- Regression test against the in-tree relay: the QUIC path goes through an - in-process UDP forwarder that delays packets past the WebSocket head start so - WebSocket wins deterministically; a subscribed track keeps every group across - the swap, `Status::Migrating` is observed, the WebSocket session closes - within the handover cap, and a second connect to the same URL gives QUIC the - head start again. A second test drops the delay to zero and asserts QUIC wins - and no second session is ever opened. -- Public API: additive (the transport accessor). Wire: none; the client GOAWAY - is already specified for either endpoint on moq-lite 04+ and an empty-URI - GOAWAY is legal for a moq-transport client. Update `doc/lib/rs` where the - fallback race is described. diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 27ee260451..5b771d9c5f 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -251,9 +251,9 @@ async fn serve_client( } /// Whether ordinary clients may use this transport on the shared LAN server. -fn is_public_transport(transport: moq_tokio::server::Transport, public_quic: bool) -> bool { +fn is_public_transport(transport: moq_tokio::Transport, public_quic: bool) -> bool { match transport { - moq_tokio::server::Transport::Tcp | moq_tokio::server::Transport::Unix => true, + moq_tokio::Transport::Tcp | moq_tokio::Transport::Unix => true, _ => public_quic, } } @@ -949,9 +949,9 @@ mod tests { #[test] fn explicit_stream_listeners_are_public_without_exposing_mesh_quic() { - assert!(is_public_transport(moq_tokio::server::Transport::Tcp, false)); - assert!(is_public_transport(moq_tokio::server::Transport::Unix, false)); - assert!(!is_public_transport(moq_tokio::server::Transport::Quic, false)); - assert!(is_public_transport(moq_tokio::server::Transport::Quic, true)); + assert!(is_public_transport(moq_tokio::Transport::Tcp, false)); + assert!(is_public_transport(moq_tokio::Transport::Unix, false)); + assert!(!is_public_transport(moq_tokio::Transport::Quic, false)); + assert!(is_public_transport(moq_tokio::Transport::Quic, true)); } } diff --git a/rs/moq-ffi/src/server.rs b/rs/moq-ffi/src/server.rs index daae44f026..c42937fb7a 100644 --- a/rs/moq-ffi/src/server.rs +++ b/rs/moq-ffi/src/server.rs @@ -202,7 +202,7 @@ struct RequestState { /// The network transport carrying an incoming session. #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MoqTransport { - /// QUIC, either directly or through WebTransport over HTTP/3. + /// Raw QUIC, negotiating a MoQ ALPN directly. Quic, /// An Iroh QUIC connection. Iroh, @@ -212,18 +212,21 @@ pub enum MoqTransport { Tcp, /// A Unix domain socket using qmux framing. Unix, + /// WebTransport over HTTP/3 on QUIC. + WebTransport, } -impl TryFrom for MoqTransport { +impl TryFrom for MoqTransport { type Error = MoqError; - fn try_from(value: moq_tokio::server::Transport) -> Result { + fn try_from(value: moq_tokio::Transport) -> Result { Ok(match value { - moq_tokio::server::Transport::Quic => Self::Quic, - moq_tokio::server::Transport::Iroh => Self::Iroh, - moq_tokio::server::Transport::WebSocket => Self::WebSocket, - moq_tokio::server::Transport::Tcp => Self::Tcp, - moq_tokio::server::Transport::Unix => Self::Unix, + moq_tokio::Transport::Quic => Self::Quic, + moq_tokio::Transport::Iroh => Self::Iroh, + moq_tokio::Transport::WebSocket => Self::WebSocket, + moq_tokio::Transport::Tcp => Self::Tcp, + moq_tokio::Transport::Unix => Self::Unix, + moq_tokio::Transport::WebTransport => Self::WebTransport, _ => return Err(MoqError::Unsupported), }) } @@ -232,7 +235,7 @@ impl TryFrom for MoqTransport { #[cfg(test)] mod transport_tests { use super::MoqTransport; - use moq_tokio::server::Transport; + use moq_tokio::Transport; #[test] fn converts_supported_transports() { @@ -244,6 +247,10 @@ mod transport_tests { ); assert_eq!(MoqTransport::try_from(Transport::Tcp).unwrap(), MoqTransport::Tcp); assert_eq!(MoqTransport::try_from(Transport::Unix).unwrap(), MoqTransport::Unix); + assert_eq!( + MoqTransport::try_from(Transport::WebTransport).unwrap(), + MoqTransport::WebTransport + ); } } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 22ff0d7a5d..ff47f5e1db 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -488,11 +488,12 @@ impl Admission { /// transport knows, nothing parsed on the server's behalf. pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request { let transport = match request.transport() { - moq_tokio::server::Transport::Quic => moq_auth::Transport::Quic, - moq_tokio::server::Transport::Iroh => moq_auth::Transport::Iroh, - moq_tokio::server::Transport::WebSocket => moq_auth::Transport::WebSocket, - moq_tokio::server::Transport::Tcp => moq_auth::Transport::Tcp, - moq_tokio::server::Transport::Unix => moq_auth::Transport::Unix, + // The auth contract names QUIC either way; WebTransport is QUIC underneath. + moq_tokio::Transport::Quic | moq_tokio::Transport::WebTransport => moq_auth::Transport::Quic, + moq_tokio::Transport::Iroh => moq_auth::Transport::Iroh, + moq_tokio::Transport::WebSocket => moq_auth::Transport::WebSocket, + moq_tokio::Transport::Tcp => moq_auth::Transport::Tcp, + moq_tokio::Transport::Unix => moq_auth::Transport::Unix, // A transport this build does not know is still a session on the wire; the // server sees the same facts either way. other => unreachable!("unknown transport {other}"), diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index bb1d3e2559..0f12dfce4c 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -776,7 +776,7 @@ async fn serve_connection( }); let node_connection = peer_hop.map(|origin| serve.cluster.nodes.connect_inbound(id, origin)); - tracing::info!(id, version = %session.version(), transport = %moq_tokio::server::Transport::Quic, "negotiated"); + tracing::info!(id, version = %session.version(), transport = %moq_tokio::Transport::Quic, "negotiated"); // The session handle is Send + Sync however its transport is driven, so // its lifecycle (credential expiry, GOAWAY drain) lives with the timers diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index e9133a8e89..300765d61b 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -6,8 +6,10 @@ use crate::connection::Goaway; use crate::{Addrs, Backoff, Connection, Error}; +use futures::future::BoxFuture; #[cfg(all(feature = "websocket", feature = "noq"))] use std::future::Future; +use std::task::{Poll, ready}; use url::Url; /// Everything a [`Client`] is built from. @@ -270,7 +272,7 @@ impl Client { feature = "tcp", feature = "uds" )))] - pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { Err(Error::NoBackend( "no backend compiled; enable noq, iroh, websocket, tcp, or uds feature", )) @@ -279,7 +281,8 @@ impl Client { /// Dial the given URL and complete the MoQ handshake. /// /// The scheme picks the transport, and `https://` races QUIC against the - /// WebSocket fallback so a blocked UDP path still connects. The session's + /// WebSocket fallback so a blocked UDP path still connects. When the fallback + /// wins, the QUIC dial keeps going as [`Dialed::upgrade`]. The session's /// protocol driver is spawned on the current tokio runtime; the session /// closes once the last returned handle drops. #[cfg(any( @@ -289,7 +292,7 @@ impl Client { feature = "tcp", feature = "uds" ))] - pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. let attempt = Box::pin(self.connect_inner(addr)); @@ -297,16 +300,23 @@ impl Client { // The deadline covers the dial AND the handshake, for every transport: it is the // only bound some of them have. Dropping `attempt` on expiry cancels whichever // arm was still pending. - let session = match self.timeout.is_zero() { + let dialed = match self.timeout.is_zero() { true => attempt.await?, - false => match tokio::time::timeout(self.timeout, attempt).await { - Ok(res) => res?, - Err(_) => return Err(Error::ConnectTimeout(self.timeout)), - }, + false => { + let deadline = tokio::time::Instant::now() + self.timeout; + let mut dialed = match tokio::time::timeout_at(deadline, attempt).await { + Ok(res) => res?, + Err(_) => return Err(Error::ConnectTimeout(self.timeout)), + }; + // The QUIC arm that lost to WebSocket is still part of this attempt, so the + // same deadline bounds it. + dialed.upgrade = dialed.upgrade.map(|upgrade| upgrade.until(deadline, self.timeout)); + dialed + } }; - tracing::info!(version = %session.version(), "connected"); - Ok(session) + tracing::info!(version = %dialed.session.version(), transport = %dialed.transport, "connected"); + Ok(dialed) } /// The moq client builder, advertising `path` in the SETUP when there is one. @@ -331,7 +341,7 @@ impl Client { feature = "tcp", feature = "uds" ))] - async fn connect_inner(&self, addr: crate::connect::Addr) -> crate::Result { + async fn connect_inner(&self, addr: crate::connect::Addr) -> crate::Result { let url = addr.url().clone(); // Transports with no request URI of their own advertise the request target in the // SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where @@ -346,7 +356,8 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + return Ok(Dialed::new(session, crate::Transport::Tcp)); } // Unix domain socket (qmux, no TLS). Same-host only; the server can @@ -354,7 +365,8 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + return Ok(Dialed::new(session, crate::Transport::Unix)); } // A WebSocket URL names its transport. No QUIC backend can dial it, so there is @@ -379,19 +391,23 @@ impl Client { crate::iroh::Binding::H3 => self.moq.clone(), }; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + return Ok(Dialed::new(session, crate::Transport::Iroh)); } #[cfg(feature = "noq")] - if let Some(noq) = self.noq.as_ref() { + if let Some(noq) = self.noq.clone() { + // Owned rather than borrowed from `self`: when WebSocket wins the race, this dial + // outlives the attempt as the pending upgrade. let tls = self.tls.clone(); + let versions = self.versions.clone(); let quic_addr = addr.clone(); - let quic_handle = async { - noq.connect(&tls, quic_addr, &self.versions) + let quic_handle = Box::pin(async move { + noq.connect(&tls, quic_addr, &versions) .await .map(crate::transport::Session::new) .map_err(Error::from) - }; + }); #[cfg(feature = "websocket")] { @@ -401,7 +417,8 @@ impl Client { #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(connect_session(&moq, session).await?); + let session = connect_session(&moq, session).await?; + return Ok(Dialed::new(session, quic_transport(&url))); } } @@ -415,15 +432,19 @@ impl Client { /// Connect over WebSocket alone. qmux over WebSocket carries the path in its request /// URI, so the plain builder is used: repeating it in the SETUP is a protocol violation. #[cfg(feature = "websocket")] - async fn connect_websocket(&self, addr: crate::connect::Addr) -> crate::Result { + async fn connect_websocket(&self, addr: crate::connect::Addr) -> crate::Result { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - Ok(connect_session(&self.moq, crate::transport::Session::new(session)).await?) + let session = connect_session(&self.moq, crate::transport::Session::new(session)).await?; + Ok(Dialed::new(session, crate::Transport::WebSocket)) } /// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins. /// + /// When WebSocket wins while QUIC is still dialing, the QUIC dial carries on as + /// [`Dialed::upgrade`] rather than being dropped. + /// /// `moq` is the QUIC-side builder, which carries the SETUP path for a raw QUIC dial. /// The WebSocket fallback uses the plain builder: qmux over WebSocket carries the /// path in its request URI, so repeating it in the SETUP is a protocol violation. @@ -436,11 +457,12 @@ impl Client { moq: &moq_net::Client, addr: crate::connect::Addr, quic: Q, - ) -> crate::Result + ) -> crate::Result where - Q: Future>, + Q: Future> + Unpin + Send + 'static, S: moq_net::transport::poll::Boxable, { + let transport = quic_transport(addr.url()); let alpns = self.versions.alpns(); let ws_config = self.websocket.clone(); let ws_tls = self.tls.clone(); @@ -452,9 +474,19 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(connect_session(moq, quic).await?), - TransportRace::WebSocket(websocket) => { - Ok(connect_session(&self.moq, crate::transport::Session::new(websocket)).await?) + TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic).await?, transport)), + TransportRace::WebSocket { session, quic } => { + let session = connect_session(&self.moq, crate::transport::Session::new(session)).await?; + let mut dialed = Dialed::new(session, crate::Transport::WebSocket); + dialed.upgrade = quic.map(|quic| { + let moq = moq.clone(); + let dial = Box::pin(async move { + let quic = quic.await?; + Ok(Box::pin(async move { Ok(connect_session(&moq, quic).await?) }) as Handshake) + }); + Upgrade::new(dial, transport) + }); + Ok(dialed) } } } @@ -516,20 +548,134 @@ fn setup_path(url: &Url) -> Option { } } +/// What a noq dial to `url` runs on: WebTransport for `https://` (and the `http://` +/// bootstrap), raw QUIC for `moqt://` and `moql://`. +#[cfg(feature = "noq")] +fn quic_transport(url: &Url) -> crate::Transport { + match url.scheme() { + "moqt" | "moql" => crate::Transport::Quic, + _ => crate::Transport::WebTransport, + } +} + +/// A session [`Client::dial`] brought up. +pub(crate) struct Dialed { + pub session: moq_net::Session, + /// What `session` runs on. + pub transport: crate::Transport, + /// The QUIC dial still in flight when the WebSocket fallback won the race. + pub upgrade: Option, +} + +impl Dialed { + #[cfg_attr( + not(any( + feature = "noq", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" + )), + allow(dead_code) + )] + fn new(session: moq_net::Session, transport: crate::Transport) -> Self { + Self { + session, + transport, + upgrade: None, + } + } +} + +/// The MoQ handshake running on an upgrade's QUIC session. +pub(crate) type Handshake = BoxFuture<'static, crate::Result>; + +/// A QUIC dial that lost the race to the WebSocket fallback but is still going. +/// +/// Polled by [`Connection`] alongside the WebSocket session, which it replaces +/// once both stages complete. Dropping it cancels the dial. +pub(crate) struct Upgrade { + stage: Stage, + /// What the upgraded session runs on. + transport: crate::Transport, + /// The connect deadline of the attempt this dial belongs to, and its length for + /// the error. + deadline: Option<(std::pin::Pin>, std::time::Duration)>, +} + +#[cfg_attr(not(all(feature = "websocket", feature = "noq")), allow(dead_code))] +enum Stage { + /// Waiting on the QUIC transport, and the WebTransport CONNECT for `https://`. + Dialing(BoxFuture<'static, crate::Result>), + /// The QUIC transport is up and the MoQ handshake is running on it. + Handshaking(Handshake), +} + +/// How far an [`Upgrade`] got on one poll. +pub(crate) enum Step { + /// The QUIC transport is up; the MoQ handshake has started on it. + Handshaking, + /// The MoQ session over QUIC is ready to take over, running on this transport. + Done(moq_net::Session, crate::Transport), +} + +#[cfg_attr(not(all(feature = "websocket", feature = "noq")), allow(dead_code))] +impl Upgrade { + pub(crate) fn new(dial: BoxFuture<'static, crate::Result>, transport: crate::Transport) -> Self { + Self { + stage: Stage::Dialing(dial), + transport, + deadline: None, + } + } + + /// Fail with [`Error::ConnectTimeout`] if still pending at `deadline`. + fn until(mut self, deadline: tokio::time::Instant, timeout: std::time::Duration) -> Self { + self.deadline = Some((Box::pin(tokio::time::sleep_until(deadline)), timeout)); + self + } + + /// Drive the dial: `Ready(Ok(Step::Handshaking))` once when the QUIC transport + /// comes up, then `Ready(Ok(Step::Done))` when the handshake on it completes. Not + /// polled again after `Done` or an error. + pub(crate) fn poll(&mut self, waiter: &moq_net::kio::Waiter) -> Poll> { + if let Some((sleep, timeout)) = &mut self.deadline + && waiter.poll_future(sleep.as_mut()).is_ready() + { + return Poll::Ready(Err(Error::ConnectTimeout(*timeout))); + } + + match &mut self.stage { + Stage::Dialing(dial) => { + let handshake = ready!(waiter.poll_future(dial.as_mut()))?; + self.stage = Stage::Handshaking(handshake); + Poll::Ready(Ok(Step::Handshaking)) + } + Stage::Handshaking(handshake) => { + let session = ready!(waiter.poll_future(handshake.as_mut()))?; + Poll::Ready(Ok(Step::Done(session, self.transport))) + } + } + } +} + #[cfg(all(feature = "websocket", feature = "noq"))] -#[derive(Debug, PartialEq, Eq)] -enum TransportRace { - Quic(Q), - WebSocket(W), +enum TransportRace { + Quic(QT), + /// The fallback won. `quic` is the QUIC dial if it was still pending, and `None` + /// when it had already failed. + WebSocket { + session: WT, + quic: Option, + }, } #[cfg(all(feature = "websocket", feature = "noq"))] -async fn race_transport_connect(quic: Q, websocket: W) -> crate::Result> +async fn race_transport_connect(mut quic: Q, websocket: W) -> crate::Result> where - Q: Future>, + Q: Future> + Unpin, W: Future>>, { - tokio::pin!(quic); tokio::pin!(websocket); let mut quic_err = None; @@ -551,7 +697,10 @@ where } res = &mut websocket, if !websocket_done => { match res { - Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)), + Some(Ok(session)) => { + let quic = (!quic_done).then_some(quic); + return Ok(TransportRace::WebSocket { session, quic }); + } Some(Err(err)) => { tracing::warn!(%err, "WebSocket connection failed"); websocket_err = Some(err); @@ -1141,8 +1290,11 @@ mod tests { Some(Ok(1usize)) }; - let value = super::race_transport_connect(quic, websocket).await.unwrap(); - assert_eq!(value, super::TransportRace::WebSocket(1)); + let value = super::race_transport_connect(Box::pin(quic), websocket).await.unwrap(); + assert!(matches!( + value, + super::TransportRace::WebSocket { session: 1, quic: None } + )); } #[cfg(all(feature = "websocket", feature = "noq"))] @@ -1154,8 +1306,8 @@ mod tests { }; let websocket = async { Some(Err::(crate::ConnectError::Forbidden.into())) }; - let value = super::race_transport_connect(quic, websocket).await.unwrap(); - assert_eq!(value, super::TransportRace::Quic(3)); + let value = super::race_transport_connect(Box::pin(quic), websocket).await.unwrap(); + assert!(matches!(value, super::TransportRace::Quic(3))); } /// A WebTransport-only endpoint answers the WebSocket fallback with 403 while the @@ -1172,7 +1324,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("[::]:0").await.unwrap(); let ws_port = listener.local_addr().unwrap().port(); - let mut forbid = tokio::spawn(async move { + let forbid = tokio::spawn(async move { let (mut stream, _) = listener.accept().await?; let mut buf = [0; 1024]; let _ = stream.read(&mut buf).await?; @@ -1207,19 +1359,20 @@ mod tests { // The same race `connect_inner` runs, except the fallback dials its own port, // as plain ws:// so the listener above can answer without TLS. - let noq = client.noq.as_ref().unwrap(); + let noq = client.noq.clone().unwrap(); + let (tls, versions) = (client.tls.clone(), client.versions.clone()); let quic_addr: crate::connect::Addr = Url::parse(&format!("https://localhost:{quic_port}")).unwrap().into(); let ws_addr: crate::connect::Addr = Url::parse(&format!("http://localhost:{ws_port}")).unwrap().into(); // Hold QUIC until the fallback has been refused, so the 403 is always exercised. - let quic = async { - (&mut forbid).await.unwrap().expect("fallback listener failed"); - noq.connect(&client.tls, quic_addr, &client.versions) + let quic = Box::pin(async move { + forbid.await.unwrap().expect("fallback listener failed"); + noq.connect(&tls, quic_addr, &versions) .await .map(crate::transport::Session::new) .map_err(Error::from) - }; + }); - let session = tokio::time::timeout( + let dialed = tokio::time::timeout( std::time::Duration::from_secs(10), client.race_moq_connect(&client.moq, ws_addr, quic), ) @@ -1227,7 +1380,8 @@ mod tests { .expect("client connect timed out") .expect("a fallback refused on auth must not end a connect whose QUIC arm succeeds"); - drop(session); + assert_eq!(dialed.transport, crate::Transport::WebTransport); + drop(dialed); accepted.await.unwrap().expect("server handshake failed"); } @@ -1237,7 +1391,9 @@ mod tests { let quic = async { Err::(crate::ConnectError::Unauthorized.into()) }; let websocket = async { Some(Err::(crate::ConnectError::Forbidden.into())) }; - let err = super::race_transport_connect(quic, websocket).await.unwrap_err(); + let Err(err) = super::race_transport_connect(Box::pin(quic), websocket).await else { + panic!("the race must fail"); + }; assert!(err.is_auth(), "unexpected error: {err}"); } @@ -1250,7 +1406,9 @@ mod tests { }; let websocket = async { Some(Err::(crate::ConnectError::Forbidden.into())) }; - let err = super::race_transport_connect(quic, websocket).await.unwrap_err(); + let Err(err) = super::race_transport_connect(Box::pin(quic), websocket).await else { + panic!("the race must fail"); + }; assert!(matches!(err, Error::ConnectFailed), "unexpected error: {err}"); assert!(!err.is_auth(), "mixed auth/non-auth must stay retryable: {err}"); } @@ -1261,8 +1419,34 @@ mod tests { let quic = async { Err::(Error::ConnectFailed) }; let websocket = async { Some(Ok(7usize)) }; - let value = super::race_transport_connect(quic, websocket).await.unwrap(); - assert_eq!(value, super::TransportRace::WebSocket(7)); + let value = super::race_transport_connect(Box::pin(quic), websocket).await.unwrap(); + // `select!` may poll either arm first, so the failed QUIC dial can come back unpolled. + assert!(matches!(value, super::TransportRace::WebSocket { session: 7, .. })); + } + + /// WebSocket winning hands back the QUIC dial it beat, still running, so the + /// session can move onto QUIC once it lands. + #[cfg(all(feature = "websocket", feature = "noq"))] + #[tokio::test] + async fn race_transport_connect_keeps_a_pending_quic_dial() { + let (land, landed) = tokio::sync::oneshot::channel::<()>(); + let quic = async move { + landed.await.unwrap(); + Ok("quic") + }; + let websocket = async { Some(Ok("websocket")) }; + + let race = super::race_transport_connect(Box::pin(quic), websocket).await; + let Ok(super::TransportRace::WebSocket { + session: "websocket", + quic: Some(quic), + }) = race + else { + panic!("WebSocket won, and the QUIC dial it beat must come back with it"); + }; + + land.send(()).unwrap(); + assert_eq!(quic.await.unwrap(), "quic"); } #[cfg(all(feature = "websocket", feature = "noq"))] @@ -1273,12 +1457,12 @@ mod tests { let value = tokio::time::timeout( std::time::Duration::from_secs(1), - super::race_transport_connect(quic, websocket), + super::race_transport_connect(Box::pin(quic), websocket), ) .await .expect("race waited for WebSocket after QUIC transport connected") .unwrap(); - assert_eq!(value, super::TransportRace::Quic("quic")); + assert!(matches!(value, super::TransportRace::Quic("quic"))); } /// The resolved default has to exist in every build, including ones that compile diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 13f3fb4e2a..3e49d87628 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -175,8 +175,8 @@ pub enum Status { Connected, /// An established session dropped; a reconnect attempt follows. Disconnected, - /// The peer sent a GOAWAY; the replacement is being dialed while the old - /// session keeps serving. + /// A replacement session is coming up while the old one keeps serving: the + /// peer sent a GOAWAY, or a QUIC dial landed after the WebSocket fallback won. Migrating, } @@ -456,6 +456,8 @@ struct State { presence: moq_net::stats::Presence, /// The negotiated MoQ version of the live session, or `None` when disconnected. version: Option, + /// What the live session runs on, or `None` when disconnected. + transport: Option, /// Set when the reconnect loop permanently gives up (reconnect timeout exceeded). error: Option, /// The currently-connected session, or `None` while reconnecting. Read by @@ -479,7 +481,7 @@ struct Shared { impl Shared { /// A session is live and serving. - fn connected(&self, session: &moq_net::Session) { + fn connected(&self, session: &moq_net::Session, transport: crate::Transport) { // Held across the publish: see [`CloseGuard`]. A close that already ran is // honored here rather than leaving this session parked in the final state. let closed = self.closed.lock().unwrap(); @@ -488,19 +490,35 @@ impl Shared { return; } if let Ok(mut state) = self.state.write() { + // A migration replaces a live session without a disconnect in between, so + // its end is counted here, keeping `started - ended` at 1 while connected. + if state.session.is_some() { + state.presence.sessions_ended += 1; + } state.status = Some(Status::Connected); state.epoch += 1; state.presence.sessions_started += 1; state.version = Some(session.version()); + state.transport = Some(transport); state.session = Some(session.clone()); } } - /// The peer sent a GOAWAY: the session in [`State`] keeps serving while its - /// replacement is dialed, so only the status moves. + /// The session in [`State`] keeps serving while its replacement comes up, so + /// only the status moves. fn migrating(&self) { + self.status(Status::Migrating); + } + + /// The replacement did not come up after all: the session in [`State`] was + /// serving throughout, so only the status moves back. + fn stayed(&self) { + self.status(Status::Connected); + } + + fn status(&self, status: Status) { if let Ok(mut state) = self.state.write() { - state.status = Some(Status::Migrating); + state.status = Some(status); } } @@ -516,6 +534,7 @@ impl Shared { } state.status = Some(Status::Disconnected); state.version = None; + state.transport = None; state.session = None; } let _ = self.send_bw.set(None); @@ -747,16 +766,46 @@ impl Connection { let budget = retry_budget(client.reconnect, retry_start, timeout); match Self::dial_any(shared, &client, &addrs, &mut draining, budget).await { - Ok((addr, session)) => { + Ok((addr, dialed)) => { let url = addr.url().clone(); - tracing::info!(peer = %Endpoint(&url), "connected"); - shared.connected(&session); + tracing::info!(peer = %Endpoint(&url), transport = %dialed.transport, "connected"); + shared.connected(&dialed.session, dialed.transport); + let mut session = dialed.session; + let mut upgrade = dialed.upgrade; - let connected = tokio::time::Instant::now(); + let mut connected = tokio::time::Instant::now(); // 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; + // connection, and draining any predecessor left over from a migration. A QUIC + // dial that lands after WebSocket won takes over here without a redial. + let ended = loop { + match run_session(shared, &session, &mut draining, &mut upgrade).await { + Next::Ended(ended) => break ended, + Next::Upgraded(next, transport) => { + tracing::info!(peer = %Endpoint(&url), %transport, "upgraded from WebSocket"); + // UDP gets through after all, so the next dial gives QUIC its head start. + #[cfg(feature = "websocket")] + crate::websocket::forget(&url); + + // The same handover as a peer's GOAWAY, initiated by us: the new session + // is live, and the old one serves until its routes splice over at a group + // boundary or the cap closes it. + let old = std::mem::replace(&mut session, next); + shared.connected(&session, transport); + // An empty URI is legal from either endpoint on every version; the old + // session's driver closes it at the deadline if the peer lingers. + let msg = moq_net::goaway::Goaway::new().with_timeout(goaway.handover); + if let Err(err) = old.drain().send(msg) { + tracing::debug!(%err, "failed to send GOAWAY on the WebSocket session"); + } + if let Some(mut old) = draining.take() { + old.retire(); + } + draining = Some(Draining::new(old, goaway.handover)); + connected = tokio::time::Instant::now(); + } + } + }; // A session that stayed up past the initial backoff is healthy; one that // ended sooner counts as a failed attempt however it ended. @@ -933,7 +982,7 @@ impl Connection { addrs: &Addrs, draining: &mut Option, budget: Option, - ) -> crate::Result<(crate::connect::Addr, moq_net::Session)> { + ) -> crate::Result<(crate::connect::Addr, crate::client::Dialed)> { let candidates = addrs.as_slice(); let mut last = None; @@ -965,7 +1014,7 @@ impl Connection { }; match dialed { - Ok(session) => return Ok((addr.clone(), session)), + Ok(dialed) => return Ok((addr.clone(), dialed)), // A status the peer actually sent is its answer, not this address's, so // unless it invites another attempt it settles the whole walk. Carrying // on would offer the same rejected credentials at the peer's other @@ -1076,6 +1125,14 @@ impl Connection { self.state.read().version } + /// What the live session runs on, or `None` while disconnected. + /// + /// Changes without a disconnect when a session that came up over the WebSocket + /// fallback moves onto QUIC. + pub fn transport(&self) -> Option { + self.state.read().transport + } + /// Observe a GOAWAY from the peer, or `None` while between sessions. /// /// The reconnect loop reads the same GOAWAY to drive migration, reported as @@ -1154,6 +1211,15 @@ fn retry_wait(delay: Duration, retry_start: tokio::time::Instant, timeout: Durat Some(wait.min(timeout.checked_sub(retry_start.elapsed())?)) } +/// What [`run_session`] returns on. +enum Next { + /// The session stopped being the live one. + Ended(Ended), + /// The pending QUIC dial finished its handshake; this session, on this + /// transport, replaces the WebSocket one. + Upgraded(moq_net::Session, crate::Transport), +} + /// Why a session stopped being the live one. enum Ended { /// The transport closed. @@ -1252,9 +1318,18 @@ async fn sleep_draining(delay: Duration, draining: &mut Option, shared /// /// One `poll_*` step drives it all: [`poll_forward`] mirrors each kio bandwidth estimate, the /// 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 { +/// polled through the waiter's own waker. +/// +/// `upgrade` is a QUIC dial still in flight after the WebSocket fallback won. It reports +/// [`Status::Migrating`] once the QUIC transport is up and returns [`Next::Upgraded`] once +/// the handshake on it completes. A failure leaves this session serving. It is dropped with +/// the session otherwise: the redial races QUIC again. +async fn run_session( + shared: &Shared, + session: &moq_net::Session, + draining: &mut Option, + upgrade: &mut Option, +) -> Next { let mut send = session.send_bandwidth(); let mut recv = session.recv_bandwidth(); let goaway = session.draining(); @@ -1278,14 +1353,43 @@ async fn run_session(shared: &Shared, session: &moq_net::Session, draining: &mut // 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)); + return Poll::Ready(Next::Ended(Ended::Goaway(msg))); + } + + if let Poll::Ready(err) = waiter.poll_future(closed.as_mut()) { + return Poll::Ready(Next::Ended(Ended::Closed(Err(err)))); } - waiter.poll_future(closed.as_mut()).map(|err| Ended::Closed(Err(err))) + poll_upgrade(shared, upgrade, waiter).map(|(session, transport)| Next::Upgraded(session, transport)) }) .await } +/// Drive a pending upgrade, `Ready` with the QUIC session once its handshake completes. +fn poll_upgrade( + shared: &Shared, + upgrade: &mut Option, + waiter: &kio::Waiter, +) -> Poll<(moq_net::Session, crate::Transport)> { + while let Some(pending) = upgrade.as_mut() { + match ready!(pending.poll(waiter)) { + Ok(crate::client::Step::Handshaking) => shared.migrating(), + Ok(crate::client::Step::Done(session, transport)) => { + *upgrade = None; + return Poll::Ready((session, transport)); + } + Err(err) => { + // Only the Handshaking stage moved the status, but restoring it is harmless + // either way: this session was serving throughout. + tracing::debug!(%err, "QUIC upgrade failed; staying on WebSocket"); + shared.stayed(); + *upgrade = None; + } + } + } + Poll::Pending +} + /// Mirror `bw`'s live estimate into `out` for as long as it changes, dropping the source handle once /// the session's producer is gone so we don't keep polling a dead arm. A `poll_*` step: on return, /// `waiter` is registered for the next change (unless the source is gone). Seeding is implicit @@ -1950,4 +2054,332 @@ mod tests { assert_eq!(attempt_timeout(1, 3), Some(CONNECT_ATTEMPT), "still one more"); assert_eq!(attempt_timeout(2, 3), None, "the last of three"); } + + /// A QUIC server with the WebSocket fallback on the same port number, the QUIC + /// half reached through a [`Forwarder`], publishing `origin`. + /// + /// Yields each accepted session with the transport it arrived on. + #[cfg(all(feature = "websocket", feature = "noq"))] + struct Fallback { + url: Url, + forwarder: Forwarder, + accepted: tokio::sync::mpsc::UnboundedReceiver<(crate::Transport, moq_net::Session)>, + } + + #[cfg(all(feature = "websocket", feature = "noq"))] + impl Fallback { + async fn start(origin: &moq_net::origin::Producer, open: bool) -> Self { + let mut listen = crate::listen::Config { + bind: Some("127.0.0.1:0".parse().unwrap()), + ..Default::default() + }; + listen.tls.generate = vec!["localhost".into()]; + + // The fallback dials the URL's port over TCP, so the forwarder takes the same + // number over UDP. Nothing reserves the pair, so retry on a collision. + let (websocket, forwarder) = 'bind: { + for _ in 0..20 { + let websocket = crate::websocket::Listener::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let port = websocket.local_addr().unwrap().port(); + if let Ok(front) = tokio::net::UdpSocket::bind(("127.0.0.1", port)).await { + break 'bind (websocket, front); + } + } + panic!("could not bind a matching TCP and UDP port after 20 attempts"); + }; + + let config = crate::server::Config { + listen, + websocket: Some(websocket), + publisher: Some(origin.consume()), + ..Default::default() + }; + let mut server = config.init().unwrap().listen().await.unwrap(); + let quic = server.local_addr().unwrap(); + let port = forwarder.local_addr().unwrap().port(); + let forwarder = Forwarder::start(forwarder, quic, open).await; + + let (tx, accepted) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + while let Some(request) = server.accept().await { + let transport = request.transport(); + if let Ok(session) = request.ok().await { + let _ = tx.send((transport, session)); + } + } + }); + + Self { + url: url(&format!("http://127.0.0.1:{port}/")), + forwarder, + accepted, + } + } + + async fn accept(&mut self) -> (crate::Transport, moq_net::Session) { + tokio::time::timeout(UPGRADE_WAIT, self.accepted.recv()) + .await + .expect("the server never accepted a session") + .expect("the server stopped") + } + } + + /// Relays QUIC datagrams to `server`, holding them all until [`Forwarder::open`]. + /// + /// Holding is what makes WebSocket win the race without depending on timing: the + /// QUIC dial cannot finish until the test says so. + #[cfg(all(feature = "websocket", feature = "noq"))] + struct Forwarder { + gate: tokio::sync::watch::Sender, + } + + #[cfg(all(feature = "websocket", feature = "noq"))] + impl Forwarder { + /// `open` is whether to forward from the start rather than hold. + async fn start(front: tokio::net::UdpSocket, server: std::net::SocketAddr, open: bool) -> Self { + use std::sync::Arc; + + let back = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + back.connect(server).await.unwrap(); + let (front, back) = (Arc::new(front), Arc::new(back)); + let (tx, rx) = tokio::sync::watch::channel(open); + let (client_tx, client_rx) = tokio::sync::watch::channel(None); + + // Each datagram waits for the gate on its own task. + fn relay(gate: &tokio::sync::watch::Receiver, send: impl Future + Send + 'static) { + let mut gate = gate.clone(); + tokio::spawn(async move { + if gate.wait_for(|open| *open).await.is_ok() { + send.await; + } + }); + } + + { + let (front, back, gate) = (front.clone(), back.clone(), rx.clone()); + tokio::spawn(async move { + let mut buf = vec![0; 65536]; + while let Ok((len, from)) = front.recv_from(&mut buf).await { + client_tx.send_replace(Some(from)); + let packet = buf[..len].to_vec(); + let back = back.clone(); + relay(&gate, async move { + let _ = back.send(&packet).await; + }); + } + }); + } + + tokio::spawn(async move { + let mut buf = vec![0; 65536]; + while let Ok(len) = back.recv(&mut buf).await { + let Some(client) = *client_rx.borrow() else { continue }; + let packet = buf[..len].to_vec(); + let front = front.clone(); + relay(&rx, async move { + let _ = front.send_to(&packet, client).await; + }); + } + }); + + Self { gate: tx } + } + + /// Start forwarding, releasing whatever was held. + fn open(&self) { + self.gate.send_replace(true); + } + } + + #[cfg(all(feature = "websocket", feature = "noq"))] + const UPGRADE_WAIT: Duration = Duration::from_secs(10); + + /// The sequence of the next group `sub` receives. + #[cfg(all(feature = "websocket", feature = "noq"))] + async fn next_group(sub: &mut moq_net::track::Subscriber) -> u64 { + let group = tokio::time::timeout(UPGRADE_WAIT, sub.recv_group()) + .await + .expect("no group arrived") + .unwrap() + .expect("the track ended"); + group.sequence + } + + /// The upgrade reports [`Status::Migrating`] while the MoQ handshake runs on the + /// QUIC session, and a failure there puts the status back: the WebSocket session + /// served throughout. + /// + /// Pinned here rather than end to end, since a moq-lite client completes its + /// handshake without waiting on the server, leaving no window to observe. + #[test] + fn a_failed_upgrade_stays_connected() { + let shared = shared(); + shared.status(Status::Connected); + let waiter = kio::Waiter::noop(); + + let (fail, failed) = tokio::sync::oneshot::channel::<()>(); + let handshake: crate::client::Handshake = Box::pin(async move { + let _ = failed.await; + Err(Error::ConnectFailed) + }); + let mut upgrade = Some(crate::client::Upgrade::new( + Box::pin(async move { Ok(handshake) }), + crate::Transport::WebTransport, + )); + + assert!(poll_upgrade(&shared, &mut upgrade, &waiter).is_pending()); + assert_eq!(shared.state.consume().read().status, Some(Status::Migrating)); + assert!(upgrade.is_some()); + + fail.send(()).unwrap(); + assert!(poll_upgrade(&shared, &mut upgrade, &waiter).is_pending()); + assert_eq!(shared.state.consume().read().status, Some(Status::Connected)); + assert!(upgrade.is_none(), "a failed upgrade is not retried"); + } + + /// A session that came up over the WebSocket fallback moves onto QUIC once the + /// QUIC dial lands, without dropping a group, and forgets that WebSocket won. + #[cfg(all(feature = "websocket", feature = "noq"))] + #[tokio::test] + async fn websocket_upgrades_to_quic() { + const HANDOVER: Duration = Duration::from_millis(500); + + let origin = crate::origin::spawn(); + let broadcast = origin.create_broadcast("cam").unwrap(); + broadcast.announce(Default::default()).unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + let write = |payload: &'static [u8]| { + let mut group = track.append_group().unwrap(); + group.write_frame(moq_net::Timestamp::ZERO, payload).unwrap(); + group.finish().unwrap(); + }; + + let mut fallback = Fallback::start(&origin, false).await; + + let subscriber = crate::origin::spawn(); + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + config.goaway.handover = HANDOVER; + let client = config + .init(Default::default()) + .unwrap() + .with_subscriber(subscriber.clone()); + let connection = tokio::time::timeout(UPGRADE_WAIT, client.connect(fallback.url.clone()).established()) + .await + .expect("never connected") + .unwrap(); + + // QUIC is held, so the fallback won. + assert_eq!(connection.transport(), Some(crate::Transport::WebSocket)); + assert!( + crate::websocket::won(&fallback.url), + "the fallback's win was not remembered" + ); + let mut monitor = connection.monitor(); + let (transport, websocket) = fallback.accept().await; + assert_eq!(transport, crate::Transport::WebSocket); + + let cam = tokio::time::timeout(UPGRADE_WAIT, subscriber.consume().routed_broadcast("cam")) + .await + .unwrap() + .unwrap(); + let mut sub = cam.track("video").unwrap().subscribe(None).await.unwrap(); + write(b"g0"); + assert_eq!(next_group(&mut sub).await, 0); + + // Let QUIC through, and publish while the replacement comes up. + fallback.forwarder.open(); + write(b"g1"); + + // The swap is a new session on the same handle, with no disconnect between. + let presence = tokio::time::timeout(UPGRADE_WAIT, async { + loop { + let presence = monitor.presence_changed().await.unwrap(); + if presence.sessions_started == 2 { + return presence; + } + } + }) + .await + .expect("never upgraded"); + assert_eq!( + presence.sessions_ended, 1, + "the WebSocket session was not counted as ended" + ); + assert!(connection.connected()); + assert_eq!(connection.transport(), Some(crate::Transport::WebTransport)); + assert_eq!(connection.epoch(), 2); + let (transport, _quic) = fallback.accept().await; + assert_eq!(transport, crate::Transport::WebTransport); + // QUIC works on this network, so the next dial gives it the head start again. + assert!( + !crate::websocket::won(&fallback.url), + "the upgrade kept WebSocket's win" + ); + + // Every group arrives exactly once across the swap: the one written while QUIC + // came up and the one after it. + write(b"g2"); + assert_eq!(next_group(&mut sub).await, 1); + assert_eq!(next_group(&mut sub).await, 2); + + // The WebSocket session is told to leave, and closes within the handover cap + // rather than lingering alongside QUIC. + let goaway = websocket.draining(); + let goaway = tokio::time::timeout(UPGRADE_WAIT, kio::wait(|waiter| goaway.poll(waiter))) + .await + .expect("the WebSocket session never received a GOAWAY") + .unwrap(); + assert_eq!(goaway.uri(), "", "a client may not redirect its server"); + tokio::time::timeout(UPGRADE_WAIT, websocket.closed()) + .await + .expect("the WebSocket session outlived the handover cap"); + assert_eq!(connection.transport(), Some(crate::Transport::WebTransport)); + } + + /// When QUIC wins the race nothing changes: one session, over QUIC, and no + /// WebSocket session is ever opened. + #[cfg(all(feature = "websocket", feature = "noq"))] + #[tokio::test] + async fn quic_winning_opens_one_session() { + let origin = crate::origin::spawn(); + let broadcast = origin.create_broadcast("cam").unwrap(); + broadcast.announce(Default::default()).unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + + let mut fallback = Fallback::start(&origin, true).await; + + let subscriber = crate::origin::spawn(); + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + let client = config + .init(Default::default()) + .unwrap() + .with_subscriber(subscriber.clone()); + let connection = tokio::time::timeout(UPGRADE_WAIT, client.connect(fallback.url.clone()).established()) + .await + .expect("never connected") + .unwrap(); + assert_eq!(connection.transport(), Some(crate::Transport::WebTransport)); + let (transport, _session) = fallback.accept().await; + assert_eq!(transport, crate::Transport::WebTransport); + + let cam = tokio::time::timeout(UPGRADE_WAIT, subscriber.consume().routed_broadcast("cam")) + .await + .unwrap() + .unwrap(); + let mut sub = cam.track("video").unwrap().subscribe(None).await.unwrap(); + let mut group = track.append_group().unwrap(); + group.write_frame(moq_net::Timestamp::ZERO, b"g0".as_ref()).unwrap(); + group.finish().unwrap(); + assert_eq!(next_group(&mut sub).await, 0); + + // QUIC's win dropped the fallback before it dialed, so there is nothing else to + // accept and nothing to upgrade from. + assert!(fallback.accepted.try_recv().is_err(), "a second session was opened"); + assert_eq!(connection.epoch(), 1); + assert!(!crate::websocket::won(&fallback.url)); + } } diff --git a/rs/moq-tokio/src/lib.rs b/rs/moq-tokio/src/lib.rs index f520b86565..7850224e75 100644 --- a/rs/moq-tokio/src/lib.rs +++ b/rs/moq-tokio/src/lib.rs @@ -75,6 +75,7 @@ pub use error::{Error, Result}; pub use log::{Log, RedactedUrl}; #[cfg(feature = "_transport")] pub use server::{Listener, Server}; +pub use transport::Transport; // Re-export these crates. pub use moq_net; diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index d71a05f658..b6967a38d8 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -8,9 +8,9 @@ use std::net; #[cfg(any(test, all(feature = "uds", unix)))] use std::path::PathBuf; -use crate::Error; #[cfg(feature = "iroh")] use crate::iroh; +use crate::{Error, Transport}; use moq_net::Session; use url::Url; @@ -587,7 +587,9 @@ impl Server { let Accepted { session, url, identity, authority, mut link } = super::noq::accept(_conn, alpns).await?; link.local = local; let request = server.accept_request(tokio::time::Instant::now().into_std(), crate::transport::Session::new(session)).await?; - Ok(Request { transport: Transport::Quic, url, identity, authority, link, kind: RequestKind::Noq(Box::new(request)) }) + // Only WebTransport carries a request URL; raw QUIC puts the path in the SETUP. + let transport = match url { Some(_) => Transport::WebTransport, None => Transport::Quic }; + Ok(Request { transport, url, identity, authority, link, kind: RequestKind::Noq(Box::new(request)) }) }.boxed()); } } @@ -1098,41 +1100,6 @@ pub struct Link { pub alpn: Option, } -/// The network transport carrying an incoming MoQ session. -#[non_exhaustive] -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum Transport { - /// QUIC, either directly or through WebTransport over HTTP/3. - Quic, - /// An Iroh QUIC connection. - Iroh, - /// A WebSocket connection using qmux framing. - WebSocket, - /// A plaintext TCP connection using qmux framing. - Tcp, - /// A Unix domain socket using qmux framing. - Unix, -} - -impl Transport { - /// Returns the stable lowercase name used in logs and external metadata. - pub const fn as_str(self) -> &'static str { - match self { - Self::Quic => "quic", - Self::Iroh => "iroh", - Self::WebSocket => "websocket", - Self::Tcp => "tcp", - Self::Unix => "unix", - } - } -} - -impl std::fmt::Display for Transport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - /// An incoming MoQ session that can be accepted or rejected. /// /// The transport connection and the MoQ SETUP are already complete, so [`path`](Self::path), @@ -1723,6 +1690,7 @@ mod tests { assert_eq!(Transport::WebSocket.as_str(), "websocket"); assert_eq!(Transport::Tcp.as_str(), "tcp"); assert_eq!(Transport::Unix.as_str(), "unix"); + assert_eq!(Transport::WebTransport.as_str(), "webtransport"); } /// Building the endpoint needs a runtime, and `certificates()` must stay diff --git a/rs/moq-tokio/src/transport.rs b/rs/moq-tokio/src/transport.rs index ffc046c658..0331ff6322 100644 --- a/rs/moq-tokio/src/transport.rs +++ b/rs/moq-tokio/src/transport.rs @@ -7,6 +7,8 @@ //! closed-watch emulation is weaker than a native implementation (see //! [`Session`]), so a backend that implements the poll interface itself is //! handed to moq-net directly. +//! +//! It also names the [`Transport`] a session runs on, for the accept and dial sides alike. use std::task::{Context, Poll, ready}; @@ -14,6 +16,44 @@ use bytes::Bytes; use futures::FutureExt; use web_transport_trait::poll as wt_poll; +/// The network transport carrying a MoQ session, on either side of it. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Transport { + /// Raw QUIC, negotiating a MoQ ALPN directly. + Quic, + /// An Iroh QUIC connection. + Iroh, + /// A WebSocket connection using qmux framing. + WebSocket, + /// A plaintext TCP connection using qmux framing. + Tcp, + /// A Unix domain socket using qmux framing. + Unix, + /// WebTransport over HTTP/3 on QUIC. + WebTransport, +} + +impl Transport { + /// Returns the stable lowercase name used in logs and external metadata. + pub const fn as_str(self) -> &'static str { + match self { + Self::Quic => "quic", + Self::Iroh => "iroh", + Self::WebSocket => "websocket", + Self::Tcp => "tcp", + Self::Unix => "unix", + Self::WebTransport => "webtransport", + } + } +} + +impl std::fmt::Display for Transport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + /// A stored in-flight operation future. Native transports are Send, so the /// plain boxed flavor suffices. type OpBox = futures::future::BoxFuture<'static, T>; diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index ab6f911c78..2d0b2652d4 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -3,7 +3,8 @@ //! Used when QUIC is unreachable: UDP blocked by a firewall, a proxy in the way, a //! network that only passes TCP/443. The client races this against QUIC and gives QUIC //! a small head start ([`Config::delay`]), so WebSocket only wins when QUIC can't get -//! through. Servers accept it on a separate TCP port via [`Listener`]. +//! through. A [`crate::Connection`] that lands on WebSocket keeps the QUIC dial going and +//! moves onto it if it completes. Servers accept it on a separate TCP port via [`Listener`]. use qmux::ws::tokio_tungstenite; use qmux::ws::tokio_tungstenite::tungstenite::{self, client::IntoClientRequest, http}; @@ -106,6 +107,32 @@ type Result = std::result::Result; // Track servers (hostname:port) where WebSocket won the race, so we won't give QUIC a headstart next time static WEBSOCKET_WON: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); +/// The [`WEBSOCKET_WON`] key for a dial URL: the host and port the fallback dials. +fn won_key(url: &Url) -> Result<(String, u16)> { + let host = url.host_str().ok_or(Error::MissingHostname)?.to_string(); + let port = url.port().unwrap_or_else(|| match url.scheme() { + "https" | "wss" | "moql" | "moqt" => 443, + "http" | "ws" => 80, + _ => 443, + }); + Ok((host, port)) +} + +/// Forget that WebSocket won for `url`, giving QUIC its head start again. +/// +/// Called once a QUIC dial to `url` lands after all, which proves UDP gets through. +pub(crate) fn forget(url: &Url) { + if let Ok(key) = won_key(url) { + WEBSOCKET_WON.lock().unwrap().remove(&key); + } +} + +/// Whether the next dial to `url` skips QUIC's head start. +#[cfg(all(test, feature = "noq"))] +pub(crate) fn won(url: &Url) -> bool { + won_key(url).is_ok_and(|key| WEBSOCKET_WON.lock().unwrap().contains(&key)) +} + /// WebSocket configuration for the client. #[derive(Clone, Debug, usage::Args, serde::Serialize, serde::Deserialize)] #[usage(unknown_flags = "error", args_override_self = false)] @@ -278,13 +305,8 @@ pub(crate) async fn connect( return Err(Error::Disabled); } - let host = url.host_str().ok_or(Error::MissingHostname)?.to_string(); - let port = url.port().unwrap_or_else(|| match url.scheme() { - "https" | "wss" | "moql" | "moqt" => 443, - "http" | "ws" => 80, - _ => 443, - }); - let key = (host.clone(), port); + let key = won_key(&url)?; + let (host, port) = key.clone(); // Apply a small penalty to WebSocket to improve odds for QUIC to connect first, // unless we've already had to fall back to WebSockets for this server. diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index 15d850c24b..15b1dce1b9 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -610,7 +610,7 @@ async fn iroh_connect() { assert_eq!(request.role(), Some(moq_tokio::moq_net::Role::Subscriber)); // iroh offers the moq ALPNs ahead of H3, so this lands on raw QUIC: no request // URL, leaving the SETUP as the only place for the request target. - assert_eq!(request.transport(), moq_tokio::server::Transport::Iroh); + assert_eq!(request.transport(), moq_tokio::Transport::Iroh); assert_eq!(request.url(), None); assert_eq!(request.path(), "/room"); assert_eq!(request.query(), Some("jwt=abc")); diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index d6e56e1723..221aa008d3 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -1928,7 +1928,7 @@ async fn broadcast_websocket() { // ── run server and client concurrently ────────────────────────── let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - assert_eq!(request.transport(), moq_tokio::server::Transport::WebSocket); + assert_eq!(request.transport(), moq_tokio::Transport::WebSocket); assert_eq!(request.path(), ""); // The dialed host reaches the server as the authority, like the QUIC transports. assert_eq!(request.authority(), Some("localhost")); @@ -2050,7 +2050,7 @@ async fn broadcast_websocket_fallback() { // ── run server and client concurrently ────────────────────────── let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - assert_eq!(request.transport(), moq_tokio::server::Transport::WebSocket); + assert_eq!(request.transport(), moq_tokio::Transport::WebSocket); assert_eq!(request.path(), "/admin"); assert_eq!(request.query(), Some("jwt=test")); assert_eq!(request.url().and_then(url::Url::query), Some("jwt=test")); @@ -2167,7 +2167,7 @@ async fn broadcast_websocket_uses_newest_version() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - assert_eq!(request.transport(), moq_tokio::server::Transport::WebSocket); + assert_eq!(request.transport(), moq_tokio::Transport::WebSocket); let session = request.with_publisher(&pub_origin).ok().await?; assert_eq!(session.version(), expected_version, "server negotiated stale version"); let _broadcast = broadcast; @@ -2245,7 +2245,7 @@ async fn broadcast_race_quic_wins() { let request = server.accept().await.expect("no incoming connection"); assert_eq!( request.transport(), - moq_tokio::server::Transport::Quic, + moq_tokio::Transport::WebTransport, "QUIC lost the race to WebSocket with both reachable", ); let session = request.with_publisher(&pub_origin).ok().await?; From 8b8519afcbeaa3181eebd7b800f91d088c5c04e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:12:13 -0700 Subject: [PATCH 21/21] quest(transport-upgrade): WebSocket upgrade edge cases (#4204) Co-authored-by: Claude Opus 5.5 --- quest/m1/transport-upgrade/README.md | 1 + quest/m1/transport-upgrade/polish.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 quest/m1/transport-upgrade/polish.md diff --git a/quest/m1/transport-upgrade/README.md b/quest/m1/transport-upgrade/README.md index 833fa9cd13..f3bd6c0174 100644 --- a/quest/m1/transport-upgrade/README.md +++ b/quest/m1/transport-upgrade/README.md @@ -58,6 +58,7 @@ Shared decisions: ## Quests +- [Rust edge cases](/quest/m1/transport-upgrade/polish.md) - no false GOAWAY warning on upgrade, and a failed WebSocket handshake falls back to the pending QUIC dial - [JavaScript](/quest/m1/transport-upgrade/js.md) - js/net keeps the WebTransport dial after WebSocket wins and migrates through the client-goaway handover ## Related diff --git a/quest/m1/transport-upgrade/polish.md b/quest/m1/transport-upgrade/polish.md new file mode 100644 index 0000000000..9a3c1b602f --- /dev/null +++ b/quest/m1/transport-upgrade/polish.md @@ -0,0 +1,24 @@ +# [S] WebSocket upgrade edge cases + +## Goal + +The Rust WebSocket-to-QUIC upgrade leaves no false warnings and no avoidable +failures: a GOAWAY the peer received is not logged as a send failure, and a +WebSocket handshake that fails after WebSocket won the dial race falls back to +the still-pending QUIC dial instead of failing the connection attempt. + +## Plan + +- Every upgrade logs `failed to send goaway: transport: connection closed` + although the server receives it. Find why the send reports failure (likely + the close racing the flush) and fix it at the source. +- When WebSocket wins the race but its MoQ handshake fails, the pending QUIC + dial is still alive; use it rather than failing, bounded by the existing + connect timeout. +- Tests for both, including the deterministic QUIC-held forwarder from #4189. + +Public API: none. Wire: none. + +## Related + +- [#4189](https://github.com/moq-dev/moq/pull/4189) - the WebSocket-to-QUIC upgrade this polish quest follows