From 601599992c8a8c6fb50de3544850296beb5b3bd1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 20 Sep 2026 13:39:03 -0700 Subject: [PATCH 1/5] refactor(net)!: return session drivers to callers Replace the spawning Runtime trait with caller-owned session drivers and independent timer providers. Update runtime integrations, tests, and documentation. Co-Authored-By: GPT-6 --- doc/lib/rs/moq-net.md | 25 +++- rs/moq-e2ee/tests/support/harness.rs | 57 +++----- rs/moq-e2ee/tests/transport.rs | 2 +- rs/moq-ffi/src/origin.rs | 2 +- rs/moq-ffi/src/runtime.rs | 17 +-- rs/moq-ffi/src/session.rs | 8 +- rs/moq-hls/src/export/mod.rs | 2 +- rs/moq-hls/src/export/upstream.rs | 2 +- rs/moq-hls/src/server/mod.rs | 2 +- rs/moq-mux/src/source.rs | 2 +- rs/moq-net/src/client.rs | 135 ++++++++--------- rs/moq-net/src/driver.rs | 134 +++++++++++++++++ rs/moq-net/src/goaway.rs | 6 +- rs/moq-net/src/ietf/control.rs | 2 +- rs/moq-net/src/ietf/publisher.rs | 8 +- rs/moq-net/src/ietf/session.rs | 14 +- rs/moq-net/src/ietf/subscriber.rs | 16 +- rs/moq-net/src/lib.rs | 24 +-- rs/moq-net/src/lite/publisher.rs | 26 ++-- rs/moq-net/src/lite/session.rs | 16 +- rs/moq-net/src/model/origin.rs | 4 +- rs/moq-net/src/runtime.rs | 210 +++------------------------ rs/moq-net/src/runtime/test.rs | 207 +++----------------------- rs/moq-net/src/server.rs | 151 +++++++++---------- rs/moq-net/src/session.rs | 67 ++++----- rs/moq-net/tests/datagram.rs | 2 +- rs/moq-net/tests/goaway.rs | 8 +- rs/moq-net/tests/support/harness.rs | 57 +++----- rs/moq-relay/src/uring.rs | 7 +- rs/moq-relay/src/websocket.rs | 13 +- rs/moq-rtc/src/egress.rs | 2 +- rs/moq-rtc/src/server/mod.rs | 2 +- rs/moq-srt/src/dial.rs | 2 +- rs/moq-srt/src/server.rs | 2 +- rs/moq-srt/src/ts.rs | 2 +- rs/moq-stats/src/aggregate.rs | 2 +- rs/moq-stats/src/consume.rs | 2 +- rs/moq-stats/src/produce.rs | 2 +- rs/moq-tokio/src/client.rs | 54 ++++--- rs/moq-tokio/src/origin.rs | 2 +- rs/moq-tokio/src/runtime.rs | 143 ++---------------- rs/moq-tokio/src/server.rs | 9 +- rs/moq-uring/README.md | 8 +- rs/moq-uring/benches/session_lite.rs | 8 +- rs/moq-uring/src/lib.rs | 4 +- rs/moq-uring/src/quic/web.rs | 6 +- rs/moq-uring/src/worker.rs | 18 --- rs/moq-uring/tests/session.rs | 16 +- rs/moq-uring/tests/web.rs | 10 +- rs/moq-video/src/decode/consumer.rs | 2 +- rs/moq-wasm/README.md | 6 +- rs/moq-wasm/src/lib.rs | 17 +-- rs/moq-wasm/src/runtime.rs | 17 +-- 53 files changed, 584 insertions(+), 978 deletions(-) create mode 100644 rs/moq-net/src/driver.rs diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 6712767a9c..a54936068f 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -24,7 +24,7 @@ above ([hang](/lib/rs/hang)); relays and CDNs implement only this. - **Routes** record the relay hops and a cost, which is what the relay [cluster](/bin/relay/cluster) routes on. A hop of 0 marks the chain anonymous: `Route::is_anonymous()` is true, and that route ranks below every fully identified one. - **Stats** counters per broadcast and session, drained by [`moq-stats`](https://docs.rs/moq-stats). -It runs over anything implementing `web_transport_trait::Session`: quinn, +It runs over anything implementing `web_transport_trait::poll::Session`: quinn, quiche, noq, the browser, iroh, or qmux over TCP, Unix sockets, and WebSockets. [`moq-tokio`](https://docs.rs/moq-tokio) wires those up. @@ -37,6 +37,29 @@ See the [Rust quick start](/lib/rs/#quick-start) and [`@moq/net`](/lib/js/net). The [path pattern](/concept/moq-lite#path-patterns) grammar lives on the concept page. +## Driving sessions + +`Client::connect(timers, transport)`, `Server::accept(timers, transport)`, and +`server::Handshake::ok()` return `(Session, Driver)`. The lite-only entry points +return the same pair. `moq-net` never spawns tasks: poll or spawn the driver to +run the protocol, process close requests, and update session statistics. + +```rust +let (session, driver) = client + .connect(moq_tokio::runtime::Runtime::new(), transport) + .await?; +tokio::spawn(driver); +``` + +Dropping the last session handle requests closure when the driver next runs. +Dropping the driver cancels the session. Keep both alive while using the +connection. `moq-tokio` and `moq-wasm` spawn the drivers for their callers. + +`Timers` supplies a clock and re-armable timers independently of the transport +and executor. `runtime::Test` (feature `test-runtime`) provides virtual time; +tests advance its clock and poll their drivers explicitly. The lite-only path +also supports native `!Send` transports when driven on their owning thread. + ## Patterns `Pattern` describes a set of paths; `Patterns` is a union reduced by diff --git a/rs/moq-e2ee/tests/support/harness.rs b/rs/moq-e2ee/tests/support/harness.rs index 11b303bc7b..85ead4fd0c 100644 --- a/rs/moq-e2ee/tests/support/harness.rs +++ b/rs/moq-e2ee/tests/support/harness.rs @@ -2,44 +2,28 @@ //! [`moq_net::Session`]s over the in-memory mock transport. //! //! The harness runs the full MoQ handshake (Client::connect + Server::accept) -//! over a [`MockSession`] pair and spawns both protocol drivers, giving tests +//! over a [`super::mock::MockSession`] pair and spawns both protocol drivers, giving tests //! two live sessions ready for pub/sub without any real QUIC or network I/O. #![allow(dead_code)] -use std::{marker::PhantomData, pin::Pin, task::Poll}; +use std::{pin::Pin, task::Poll}; use moq_net::{Client, Server, Session, Version, origin}; -use super::mock::{MockSession, create_mock_session_pair}; +use super::mock::create_mock_session_pair; -/// A tokio-backed [`moq_net::runtime::Runtime`] for these tests: machines are -/// spawned onto tokio and timers are tokio sleeps, so `tokio::time::pause` keeps -/// working. A copy of the crate's own `runtime::tokio_test` module, which is -/// `cfg(test)` and therefore invisible to integration tests. -pub struct TokioRuntime(PhantomData); +/// Tokio's clock and timers for these tests. +#[derive(Clone, Default)] +pub struct TokioRuntime; -impl TokioRuntime { +impl TokioRuntime { pub fn new() -> Self { - Self(PhantomData) + Self } } -impl Clone for TokioRuntime { - fn clone(&self) -> Self { - Self(PhantomData) - } -} - -impl Default for TokioRuntime { - fn default() -> Self { - Self::new() - } -} - -// Unbounded: timers don't involve the transport, so origin drivers can borrow -// a transportless handle (`TokioRuntime::<()>::new()`). -impl moq_net::runtime::Timers for TokioRuntime { +impl moq_net::runtime::Timers for TokioRuntime { type Timer = TokioTimer; fn timer(&self) -> Self::Timer { @@ -51,15 +35,6 @@ impl moq_net::runtime::Timers for TokioRuntime { } } -// Boxable: tokio work-steals, so the machine must be `Send`. -impl moq_net::runtime::Runtime for TokioRuntime { - type Transport = S; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - tokio::spawn(machine); - } -} - /// The [`moq_net::runtime::Timer`] handed out by [`TokioRuntime`]. pub struct TokioTimer { at: Option, @@ -151,21 +126,25 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { server = server.with_subscriber(subscribe); } - // Run both handshakes concurrently; the runtime spawns each side's machine + // Run both handshakes concurrently and spawn each side's driver // the moment its handshake resolves: on draft-17+ the server's accept blocks // on the client's SETUP, which only reaches the wire once the client's // machine is polled (and vice versa for the server's own SETUP). let client_fut = async { - client + let (session, driver) = client .connect(TokioRuntime::new(), client_transport) .await - .expect("client handshake failed") + .expect("client handshake failed"); + tokio::spawn(driver); + session }; let server_fut = async { - server + let (session, driver) = server .accept(TokioRuntime::new(), server_transport) .await - .expect("server handshake failed") + .expect("server handshake failed"); + tokio::spawn(driver); + session }; let (client_session, server_session) = tokio::join!(client_fut, server_fut); diff --git a/rs/moq-e2ee/tests/transport.rs b/rs/moq-e2ee/tests/transport.rs index 9051e6be25..6b9d4a6fc7 100644 --- a/rs/moq-e2ee/tests/transport.rs +++ b/rs/moq-e2ee/tests/transport.rs @@ -12,7 +12,7 @@ const TEST_TIMEOUT: Duration = Duration::from_secs(10); fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); - tokio::spawn(driver.run(TokioRuntime::<()>::new())); + tokio::spawn(driver.run(TokioRuntime::new())); producer } diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index a07fc03c0d..d4a59138dc 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -206,7 +206,7 @@ impl MoqOriginProducer { pub(crate) fn spawn(config: moq_net::origin::Config) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(config); #[cfg(not(target_arch = "wasm32"))] - crate::ffi::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + crate::ffi::spawn(driver.run(moq_tokio::runtime::Runtime::new())); #[cfg(target_arch = "wasm32")] crate::ffi::spawn(driver.run(crate::runtime::Runtime)); producer diff --git a/rs/moq-ffi/src/runtime.rs b/rs/moq-ffi/src/runtime.rs index ee8964181e..58196d7379 100644 --- a/rs/moq-ffi/src/runtime.rs +++ b/rs/moq-ffi/src/runtime.rs @@ -1,9 +1,8 @@ -//! The browser runtime: microtask spawning and `wasmtimer`-backed timers. +//! Browser-backed timers for MoQ drivers. use std::{pin::Pin, task::Poll}; -/// The [`moq_net::Runtime`] for the browser: machines run on the microtask -/// queue via `web_async::spawn`, timers are `setTimeout`-backed sleeps. +/// Supplies the browser clock and timers to MoQ drivers. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct Runtime; @@ -15,18 +14,6 @@ impl moq_net::Timers for Runtime { } } -impl moq_net::Runtime for Runtime { - type Transport = web_transport_wasm::Session; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - web_async::spawn(async move { - // The session surfaces the result through `closed()`; nothing to do - // with it here. - let _ = machine.await; - }); - } -} - /// A `web_async::time::Sleep` driven through the [`moq_net::runtime::Timer`] /// contract. pub(crate) struct Timer { diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 981cb6a86d..6e1a72750d 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -291,15 +291,19 @@ impl Client { } .map_err(|err| MoqError::Connect(format!("{err}")))?; - // The runtime spawns the protocol machine on the microtask queue. The machine + // Run the driver on the microtask queue. The driver // holds no session clone, so dropping the last handle still closes the // transport and ends that task. - let session = moq_net::Client::new() + let (session, driver) = moq_net::Client::new() .with_publisher(&publish) .with_subscriber(subscribe.clone()) .connect(crate::runtime::Runtime, transport) .await?; + crate::ffi::spawn(async move { + let _ = driver.await; + }); + Ok(Arc::new(MoqSession::accepted(session, publish, subscribe))) } } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index a72dba6744..629c1b75fa 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -360,7 +360,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-hls/src/export/upstream.rs b/rs/moq-hls/src/export/upstream.rs index 63ca786921..767b1cb51d 100644 --- a/rs/moq-hls/src/export/upstream.rs +++ b/rs/moq-hls/src/export/upstream.rs @@ -40,7 +40,7 @@ mod tests { #[tokio::test] async fn self_references_keep_the_catalog_broadcast_after_replacement() { let (origin, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); - let driver = tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + let driver = tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); let old = origin.create_broadcast("a/live").unwrap(); let source = moq_mux::Source::new(origin.consume(), "a/live"); let upstream = Upstream { diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index 3065102ee0..1879cbacf2 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -146,7 +146,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index 6eb61e5d10..f475bf2e60 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -288,7 +288,7 @@ impl BroadcastConfig for hang::catalog::BinaryConfig { pub(crate) fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index d8e48f5adf..1e7dcc1eea 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -6,9 +6,7 @@ use crate::{ ietf, lite, setup, stats, }; -// The transport methods are called on the projected `R::Transport`, which needs the -// trait itself in scope (a plain type parameter would not). -use web_transport_trait::{MaybeSend, MaybeSync, poll::Session as _}; +use web_transport_trait::{MaybeSend, MaybeSync}; /// A MoQ client session builder. #[derive(Default, Clone)] @@ -153,10 +151,16 @@ impl Client { } /// Start a lite session on an already-negotiated version: build our SETUP, - /// wire the origins, and hand the machine to the runtime. - fn start_lite(&self, runtime: R, session: R::Transport, version: lite::Version) -> Result + /// wire the origins, and return the session and its driver. + fn start_lite( + &self, + runtime: R, + session: S, + version: lite::Version, + ) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, + R: crate::runtime::Timers + 'static, { let (publish, subscribe) = self.origins(); @@ -190,12 +194,12 @@ impl Client { peer_setup: None, })?; - Ok(Session::spawn( + Ok(Session::new( runtime, session, version.into(), start.recv_bandwidth, - crate::runtime::Protocol::Lite(Box::new(start.driver)), + crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, )) } @@ -209,9 +213,10 @@ impl Client { /// [`Boxable`](crate::transport::poll::Boxable) transport. An ietf ALPN, an /// unknown one, or the legacy no-ALPN SETUP negotiation is refused with /// [`Error::Version`]. - pub async fn connect_lite(&self, runtime: R, session: R::Transport) -> Result + pub async fn connect_lite(&self, runtime: R, session: S) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, + R: crate::runtime::Timers + 'static, { let version = match session.protocol() { Some(ALPN_LITE_06_WIP) => lite::Version::Lite06Wip, @@ -224,15 +229,14 @@ impl Client { self.start_lite(runtime, session, version) } - /// Perform the MoQ handshake, returning the [`Session`]. + /// Perform the MoQ handshake, returning the [`Session`] and its [`Driver`](crate::Driver). /// - /// The session's protocol machine is handed to `runtime` - /// ([`Runtime::spawn`](crate::runtime::Runtime::spawn)), so there is nothing - /// else to drive: the runtime runs the session for as long as it lives. - pub async fn connect(&self, runtime: R, mut session: R::Transport) -> Result + /// Poll or spawn the returned driver to run the session. `runtime` supplies + /// only its clock and timers. + pub async fn connect(&self, runtime: R, mut session: S) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, - R::Transport: crate::transport::poll::Boxable, + S: crate::transport::poll::Boxable, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { let (publish, subscribe) = self.origins(); @@ -270,12 +274,12 @@ impl Client { })?; tracing::debug!(version = ?v, "connected"); - return Ok(Session::spawn( + return Ok(Session::new( runtime, session, v, None, - crate::runtime::Protocol::Ietf(protocol), + crate::driver::Protocol::Ietf(protocol), goaway, )); } @@ -376,7 +380,7 @@ impl Client { ( start.recv_bandwidth, - crate::runtime::Protocol::Lite(Box::new(start.driver)), + crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, ) } @@ -409,11 +413,11 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), })?; - (None, crate::runtime::Protocol::Ietf(protocol), goaway) + (None, crate::driver::Protocol::Ietf(protocol), goaway) } }; - Ok(Session::spawn(runtime, session, version, recv_bw, protocol, goaway)) + Ok(Session::new(runtime, session, version, recv_bw, protocol, goaway)) } } @@ -675,12 +679,12 @@ mod tests { .into(), ); - // `connect` returns as soon as the handshake completes; the protocol machine - // was handed to the runtime, which spawned it onto tokio. - let _session = client + // Start the returned driver after the handshake completes. + let (_session, driver) = client .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) .await .unwrap(); + tokio::spawn(driver); // Verify the client setup was encoded using Draft14 framing (ALPN_LITE fallback path). let mut setup_bytes = Bytes::from(fake.control_writes()); @@ -726,7 +730,7 @@ mod tests { // Paused time auto-advances while every task is idle, so a `connect` that waits // on the silent peer trips this rather than hanging the suite. - tokio::time::timeout( + let (_session, _driver) = tokio::time::timeout( std::time::Duration::from_secs(30), client.connect(crate::runtime::tokio_test::Tokio::new(), transport), ) @@ -745,31 +749,25 @@ mod tests { run_alpn_lite_fallback_case(None).await; } - // This fake reports no send-rate estimate, so it never reaches a timer in the - // bandwidth loop. `connect` hands the machine to the runtime we pass, so with - // the deterministic Test runtime nothing runs on an ambient executor at all. - // - // The machine must hold no Session clone (the #2286 leak), so the transport - // still closes when the caller drops their last session handle. The close is - // relayed through the machine (the Session holds no transport), so it lands - // on the next tick. + // No executor is running: only explicitly polling the driver may process + // a session close, and the driver must not retain a session handle. #[test] - fn machine_is_runtime_polled_and_holds_no_session() { + fn driver_is_caller_polled_and_holds_no_session() { let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let runtime = crate::runtime::Test::::new(); - let session = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); + let runtime = crate::runtime::Test::new(); + let (session, mut driver) = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); assert_eq!(session.version(), Version::Lite(lite::Version::Lite04)); - // The machine sits inside the Test runtime, stepped manually: nothing was - // spawned onto an ambient runtime, and it is still pending. - assert_eq!(runtime.tick(), 1); + // Construction leaves the driver idle until the caller polls it. + assert!(driver.poll(&kio::Waiter::noop()).is_pending()); // The caller drops their only session clone; the machine observes the // last handle going away and closes the transport. drop(session); - runtime.tick(); + assert!(fake.state.close_events.lock().unwrap().is_empty()); + let _ = driver.poll(&kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -784,17 +782,18 @@ mod tests { let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let runtime = crate::runtime::Test::::new(); - let session = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); + let runtime = crate::runtime::Test::new(); + let (session, mut driver) = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); let clone = session.clone(); // One clone dropping does nothing while another is alive. drop(session); - runtime.tick(); + assert!(fake.state.close_events.lock().unwrap().is_empty()); + let _ = driver.poll(&kio::Waiter::noop()); assert!(fake.state.close_events.lock().unwrap().is_empty()); clone.abort(Error::Cancel); - runtime.tick(); + let _ = driver.poll(&kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -802,28 +801,28 @@ mod tests { // And the machine publishes the transport's terminal error, which is // what `closed()` reports. - runtime.tick(); + let _ = driver.poll(&kio::Waiter::noop()); futures::executor::block_on(clone.closed()); // The final drop requests no second close: the handle-side close is once. let closes = fake.state.close_events.lock().unwrap().len(); drop(clone); - runtime.tick(); + let _ = driver.poll(&kio::Waiter::noop()); assert_eq!(fake.state.close_events.lock().unwrap().len(), closes); } - // A runtime that drops the machine instead of running it tears the session + // Dropping the driver instead of running it tears the session // down: the machine was the only transport holder, and `closed()` resolves // rather than parking forever on a machine nobody polls. #[test] - fn dropped_machine_resolves_closed() { + fn dropped_driver_resolves_closed() { let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let runtime = crate::runtime::Test::::new(); - let session = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); + let runtime = crate::runtime::Test::new(); + let (session, driver) = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); - runtime.shutdown(); + drop(driver); assert!(matches!(futures::executor::block_on(session.closed()), Error::Cancel)); } @@ -971,7 +970,7 @@ mod tests { } // The point of the lite-only entry: a `!Send` transport yields a `!Send` - // machine held by a local runtime, while the severed Session handle stays + // driver polled by its caller, while the severed Session handle stays // Send + Sync. Compiling is most of the assertion; the rest checks the // machine still relays the close. #[test] @@ -983,15 +982,15 @@ mod tests { }; let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let runtime = crate::runtime::Test::::new(); - let session = futures::executor::block_on(client.connect_lite(runtime.clone(), local)).unwrap(); - assert_eq!(runtime.tick(), 1); + let runtime = crate::runtime::Test::new(); + let (session, mut driver) = futures::executor::block_on(client.connect_lite(runtime.clone(), local)).unwrap(); + assert!(driver.poll(&kio::Waiter::noop()).is_pending()); fn assert_send_sync(_: &T) {} assert_send_sync(&session); session.abort(Error::Cancel); - runtime.tick(); + let _ = driver.poll(&kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -999,7 +998,7 @@ mod tests { } // The server-side twin: a `!Send` transport accepts a lite session whose - // machine a local runtime drives. + // driver the caller polls directly. #[test] fn accept_lite_over_a_send_less_transport() { let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); @@ -1009,13 +1008,14 @@ mod tests { }; let server = crate::Server::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let runtime = crate::runtime::Test::::new(); - let session = futures::executor::block_on(server.accept_lite(runtime.clone(), local)).unwrap(); + let runtime = crate::runtime::Test::new(); + let (session, mut driver) = futures::executor::block_on(server.accept_lite(runtime.clone(), local)).unwrap(); assert_eq!(session.version(), Version::Lite(lite::Version::Lite04)); - assert_eq!(runtime.tick(), 1); + assert!(driver.poll(&kio::Waiter::noop()).is_pending()); drop(session); - runtime.tick(); + assert!(fake.state.close_events.lock().unwrap().is_empty()); + let _ = driver.poll(&kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -1032,7 +1032,7 @@ mod tests { _local: std::rc::Rc::new(()), }; let client = Client::new(); - let runtime = crate::runtime::Test::::new(); + let runtime = crate::runtime::Test::new(); let result = futures::executor::block_on(client.connect_lite(runtime, local)); assert!(matches!(result, Err(Error::Version))); } @@ -1046,10 +1046,11 @@ mod tests { fake.set_send_rate(Some(1_000_000)); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let session = client + let (session, driver) = client .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) .await .unwrap(); + tokio::spawn(driver); // The construction-time snapshot, before the machine sampled anything. assert_eq!( @@ -1078,10 +1079,11 @@ mod tests { fake.set_bytes_sent(Some(0)); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let session = client + let (session, driver) = client .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) .await .unwrap(); + tokio::spawn(driver); assert!( session.send_bandwidth().is_none(), "no send-rate estimate, so nothing samples on its own" @@ -1108,10 +1110,11 @@ mod tests { fake.set_send_rate(Some(1_000_000)); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let session = client + let (session, driver) = client .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) .await .unwrap(); + tokio::spawn(driver); let mut bandwidth = session.send_bandwidth().expect("backend reports an estimate"); assert_eq!( diff --git a/rs/moq-net/src/driver.rs b/rs/moq-net/src/driver.rs new file mode 100644 index 0000000000..ae4a4d248a --- /dev/null +++ b/rs/moq-net/src/driver.rs @@ -0,0 +1,134 @@ +//! Drive a session on the caller's executor. + +use std::task::Poll; + +use crate::{Error, runtime::Timers}; + +/// The future driving a [`crate::Session`]'s protocol state. +/// +/// Returned by [`crate::Client::connect`] and [`crate::Server::accept`]. Poll it, +/// either as a [`Future`] or by stepping [`poll`](Self::poll) from a +/// [`kio`]-style poll function. It resolves when the session ends, and keeps +/// returning that same result if polled again. +/// +/// It holds no session handle: dropping the last [`crate::Session`] requests +/// closure on the next poll. Dropping the driver cancels the session, and +/// [`crate::Session::closed`] resolves with [`Error::Cancel`]. Its `Send`-ness +/// follows its transport and timer provider. +#[must_use = "the session makes no progress unless its driver is polled"] +pub struct Driver { + state: State, + // Retains the waiter across `Future` polls so its kio registrations stay + // live. Kept out of `State` so the borrow `hold` hands back doesn't + // collide with the `&mut` that polling the state needs. + park: kio::Park, +} + +/// The protocol half of a machine, one variant per negotiated wire protocol. +/// +/// The lite driver is a named machine, so the machine's `Send`-ness follows +/// the transport (a pinned `!Send` transport yields a `!Send` machine that +/// stays on its thread). The ietf driver is still a boxed future; the box +/// demands `Send` on native, which is why the ietf path requires a +/// [`Boxable`](crate::transport::poll::Boxable) transport until it too becomes +/// a named machine. +pub(crate) enum Protocol { + /// Boxed for size only: a concrete box, so `Send` stays inferred. + Lite(Box>), + Ietf(crate::util::MaybeSendBox<'static, Result<(), Error>>), +} + +/// Everything the machine polls, split from the park so the two borrow +/// disjointly. +pub(crate) struct State { + pub(crate) protocol: Protocol, + // The session supervisor, polled alongside the protocol: it executes the + // handles' close requests, publishes the transport's terminal error, and + // samples stats. It finishes once the transport reports closed, and the + // machine is not done until it has: the protocol's terminal transport close + // is what `Session::closed` observes, so resolving before it is published + // would leave waiters parked on a machine nobody polls again. `None` once + // finished, since a completed machine must not be polled again. + pub(crate) supervisor: Option>, + // Cached so a poll after completion doesn't re-poll a finished protocol. + pub(crate) result: Option>, +} + +impl Driver { + pub(crate) fn new(state: State) -> Self { + Self { + state, + park: kio::Park::default(), + } + } + + /// Drive the protocol one step, registering `waiter` for the next wakeup. + /// + /// The `poll_*` counterpart of `.await`ing the machine, for runtimes + /// composing it into their own [`kio`]-style poll loops. + pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + self.state.poll(waiter) + } +} + +impl Protocol { + fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + match self { + Self::Lite(driver) => driver.poll(waiter), + Self::Ietf(driver) => waiter.poll_future(driver.as_mut()), + } + } +} + +impl State { + fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(supervisor) = &mut self.supervisor + && supervisor.poll(waiter).is_ready() + { + self.supervisor = None; + } + + if self.result.is_none() + && let Poll::Ready(result) = self.protocol.poll(waiter) + { + self.result = Some(result); + // The protocol's last act was closing the transport, which wakes the + // supervisor's close watch; poll it now instead of waiting a turn. + if let Some(supervisor) = &mut self.supervisor + && supervisor.poll(waiter).is_ready() + { + self.supervisor = None; + } + } + + match (&self.result, &self.supervisor) { + (Some(result), None) => Poll::Ready(result.clone()), + _ => Poll::Pending, + } + } +} + +// Every part of the machine is a poll machine driven through `&mut` (the one +// pinned piece, the boxed ietf future, is `Unpin` itself), so nothing relies +// on address stability and the machine may move freely between polls. +impl Unpin for Driver {} + +impl Future for Driver { + type Output = Result<(), Error>; + + fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { + let this = &mut *self; + // Disjoint field borrows: `hold` borrows the park for as long as the + // waiter lives, while the state is polled through its own `&mut`. + let waiter = this.park.hold(cx); + this.state.poll(waiter) + } +} + +impl std::fmt::Debug for Driver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Driver") + .field("done", &self.state.result.is_some()) + .finish() + } +} diff --git a/rs/moq-net/src/goaway.rs b/rs/moq-net/src/goaway.rs index cd1773042c..9ab2f651c0 100644 --- a/rs/moq-net/src/goaway.rs +++ b/rs/moq-net/src/goaway.rs @@ -270,14 +270,14 @@ impl Protocol { /// The draft makes the deadline the sender's promise, not the peer's, so the /// driver arms this after the message hits the wire rather than making the /// caller hold a handle and await it. -pub(crate) struct Enforce { +pub(crate) struct Enforce { session: S, /// The armed deadline, or `None` when the GOAWAY carried no timeout (ready /// immediately: there is nothing to enforce). deadline: Option<(crate::runtime::Deadline, Duration)>, } -impl Enforce { +impl Enforce { pub fn new(runtime: &R, session: S, timeout: Option) -> Self { Self { session, @@ -307,7 +307,7 @@ impl Enforce( +pub(crate) async fn enforce( runtime: &R, session: &mut S, timeout: Option, diff --git a/rs/moq-net/src/ietf/control.rs b/rs/moq-net/src/ietf/control.rs index ebca2556bd..30ab511c5e 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -32,7 +32,7 @@ impl Control { /// /// `runtime` arms the give-up timer, so the wait cannot outlive the caller's /// clock. - pub async fn next_request_id(&self, runtime: &R) -> Result { + pub async fn next_request_id(&self, runtime: &R) -> Result { let mut timeout = crate::runtime::Deadline::after(runtime, std::time::Duration::from_secs(10)); kio::wait(|waiter| { diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 96e59fa932..76444c071c 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -220,7 +220,7 @@ enum NamespaceEvent { } #[derive(Clone)] -pub(super) struct Publisher { +pub(super) struct Publisher { // Arms the advertise, retry, and linger timers. runtime: R, session: S, @@ -276,7 +276,7 @@ impl Drop for Join { impl Publisher where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { pub fn new( @@ -2526,7 +2526,7 @@ mod serve_tests { use crate::lite::test_transport::{Log, ScriptedSession, SinkSession}; use crate::model::ProduceTest; - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; fn occurrences(log: &Log, needle: &[u8]) -> usize { let writes = log.writes.lock().unwrap(); @@ -3373,7 +3373,7 @@ mod tests { /// The tokio-backed test runtime. Its transport parameter is phantom, so one /// type serves every fake session in this module. - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; async fn settle() { tokio::time::sleep(Duration::from_millis(1)).await; diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index eb784069cf..1b880dff54 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -15,7 +15,7 @@ use super::{ }; /// Everything one moq-transport session needs to start. -pub struct Config { +pub struct Config { /// The runtime that arms the session's timers. pub runtime: R, @@ -71,7 +71,7 @@ pub fn start( ) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { let Config { @@ -514,7 +514,7 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( +async fn run_setup( runtime: R, mut session: S, version: Version, @@ -600,7 +600,7 @@ async fn run_unis( ) -> Result<(), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { let outer_version = crate::Version::Ietf(version); @@ -711,7 +711,7 @@ async fn run_uni_group( ) -> Result<(), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { let kind: u64 = stream.decode_peek().await?; @@ -742,7 +742,7 @@ async fn run_dispatch( ) -> Result<(), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { // PUBLISH_NAMESPACE decodes differently once the MoQ Cluster extension is @@ -886,7 +886,7 @@ mod tests { /// The tokio-backed test runtime. Its transport parameter is phantom, so one /// type serves every fake session in this module. - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { let writes = log.writes.lock().unwrap(); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index b8985e7c17..f310a91b51 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -382,7 +382,7 @@ struct Advertised { } #[derive(Clone)] -pub(super) struct Subscriber { +pub(super) struct Subscriber { // Arms the track-alias and request-id timeouts. runtime: R, session: S, @@ -421,7 +421,7 @@ pub(super) struct Subscriber( +async fn resolve_track_alias( runtime: &R, aliases: kio::Consumer, alias: u64, @@ -450,7 +450,7 @@ async fn resolve_track_alias( impl Subscriber where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { #[allow(clippy::too_many_arguments)] @@ -2131,7 +2131,7 @@ where impl Subscriber where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { /// The group producer this subgroup stream writes into, and the Object ID it starts at. @@ -2281,7 +2281,7 @@ impl GroupIngest { impl Subscriber where S: crate::transport::poll::Boxable, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { /// Read a fill fetch stream: the head of the group a subscription joins part way through. @@ -2765,7 +2765,7 @@ mod tests { /// The tokio-backed test runtime. Its transport parameter is phantom, so one /// type serves every fake session in this module. - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; #[tokio::test(start_paused = true)] async fn track_alias_waits_for_control_message() { @@ -5354,7 +5354,7 @@ mod stitch_tests { util::{TaskSet, Tasks}, }; - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; const VERSION: Version = Version::Draft20; const ALIAS: u64 = 7; @@ -5989,7 +5989,7 @@ mod joining_fetch_tests { util::{TaskSet, Tasks}, }; - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; const JOINING_DRAFTS: [Version; 6] = [ Version::Draft14, diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index d8a232bc67..65afd8ffae 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -47,15 +47,15 @@ //! last producer signals consumers that no more updates are coming. //! //! ## Async -//! This library is async-first, but it never spawns onto a global executor and -//! never reaches for ambient state. [`Client::connect`] and [`Server::accept`] -//! take a [`Runtime`], which supplies the two things a session cannot do alone: -//! run its protocol [`runtime::Machine`] to completion and arm its timers. The -//! machine holds no session handle, so the transport still closes when the last -//! [`Session`] clone drops (or on [`Session::abort`]), which in turn finishes -//! the machine. Each transport ships its runtime (`moq-tokio`, `moq-wasm`, a -//! thread-per-core io_uring runtime), and `runtime::Test` is the -//! deterministic one for tests; see the [`runtime`] module. +//! This library never spawns tasks. [`Client::connect`] and [`Server::accept`] +//! take [`Timers`] and return a `(Session, Driver)` pair. Poll or spawn the +//! [`Driver`] to run the session. It holds no session handle, so dropping the +//! last [`Session`] clone requests transport closure and lets the driver finish. +//! Dropping the driver cancels the session instead. +//! +//! `moq-tokio` and `moq-wasm` supply timers and spawn drivers for their callers. +//! Direct callers can use any executor, including a thread-local executor for +//! `!Send` transports. `runtime::Test` (feature `test-runtime`) supplies virtual time for tests. //! //! Origins follow the caller-driven pattern: [`origin::Producer::new`] returns a //! `(Producer, Driver)` pair, [`origin::Driver::run`] takes the [`Timers`] the @@ -68,7 +68,7 @@ //! (plain [`std::task::Waker`] plumbing) and `futures`, so any executor can poll //! them, and the `poll_xxx` counterparts can be stepped synchronously with a //! [`kio::Waiter`]. Purely model-layer methods (tracks, groups, frames, -//! origins) never arm a timer and need no [`Runtime`] at all; they read the +//! origins) never arm a timer and need no [`Timers`] at all; they read the //! crate's ambient clock for passive stamps (arrival times, cache ticks). #![warn(missing_docs)] @@ -79,6 +79,7 @@ mod client; mod coding; +mod driver; mod error; pub mod goaway; // Not part of the public API: compiled only for the crate's own tests and for the @@ -103,6 +104,7 @@ pub mod transport; pub use client::*; pub use coding::{BoundsExceeded, DecodeError, EncodeError, VarInt}; +pub use driver::Driver; pub use error::*; /// The session direction a client advertises in its SETUP (moq-lite-05+). pub use lite::Role; @@ -110,7 +112,7 @@ pub use model::*; pub use path::{ AsPath, InvalidPattern, Path, PathOwned, PathPrefixes, PathRelative, PathRelativeOwned, Pattern, Patterns, }; -pub use runtime::{Runtime, Timers}; +pub use runtime::Timers; pub use server::Server; pub use session::Session; pub use version::*; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 61a597efaa..c91eed1373 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -20,7 +20,7 @@ use crate::{ use super::Version; -pub(super) struct PublisherConfig { +pub(super) struct PublisherConfig { /// The runtime that arms the publisher's timers. pub runtime: R, pub session: S, @@ -128,7 +128,7 @@ impl Shared { /// The publisher half: accepts control streams and drives each as a child state /// machine. Resolves only on a transport error; children never end the session. -pub(super) struct Publisher { +pub(super) struct Publisher { shared: Arc>, // Cloned into each control-stream child that arms timers (PROBE, announce linger). runtime: R, @@ -138,7 +138,7 @@ pub(super) struct Publisher>, } -impl Publisher { +impl Publisher { pub fn new(config: PublisherConfig) -> Self { // Identity stamped onto outbound announce hops. Derived from the // origin we're consuming so it matches the local relay identity @@ -167,7 +167,7 @@ impl Publisher Publisher where S: crate::transport::poll::Session, - R: crate::runtime::Runtime, + R: crate::runtime::Timers, { pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let _ = self.children.poll(waiter); @@ -194,7 +194,7 @@ where } #[cfg(test)] -impl Publisher { +impl Publisher { /// Test shim: drive one announce-interest stream like the old `run_announce`. async fn run_announce( stream: &mut Stream, @@ -210,7 +210,7 @@ impl Publisher { +struct Control { shared: Arc>, // Handed to the children that arm timers (PROBE, announce linger). runtime: R, @@ -220,7 +220,7 @@ struct Control { // A state machine's enum is its storage: one transient instance per stream, so the // big variant is the working state, not padding held in bulk. #[allow(clippy::large_enum_variant)] -enum ControlState { +enum ControlState { /// Reading the stream's type. Start { stream: Stream, @@ -237,7 +237,7 @@ enum ControlState kio::Task for Control { +impl kio::Task for Control { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { if let Err(err) = ready!(self.poll_serve(waiter)) { tracing::warn!(%err, "control stream error"); @@ -246,7 +246,7 @@ impl kio::Task f } } -impl Control { +impl Control { fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { loop { match &mut self.state { @@ -315,7 +315,7 @@ impl Control { +struct ProbeServe { shared: Arc>, runtime: R, stream: Option>, @@ -323,7 +323,7 @@ struct ProbeServe, } -impl ProbeServe { +impl ProbeServe { const PROBE_INTERVAL: Duration = Duration::from_millis(100); const PROBE_MAX_AGE: Duration = Duration::from_secs(10); const PROBE_MAX_DELTA: f64 = 0.25; @@ -1641,7 +1641,7 @@ mod announce_test { use std::sync::Mutex; /// The tokio-backed test runtime, matching the fake transport. - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; type TestPublisher = Publisher; const VERSION: Version = Version::Lite06Wip; @@ -3014,7 +3014,7 @@ mod tests { use crate::model::ProduceTest; /// The tokio-backed test runtime, matching the fake transport. - type TestRuntime = crate::runtime::tokio_test::Tokio; + type TestRuntime = crate::runtime::tokio_test::Tokio; /// A peer that declares no origin in its SETUP is split-horizoned by the identity /// the caller assigned it, on the data plane and not just the announce filter. diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 260800ee52..d4b91fec39 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -11,7 +11,7 @@ use super::{ DataType, PeerSetup, Publisher, PublisherConfig, Setup, Subscriber, SubscriberConfig, SubscriberDriver, Version, }; -pub(crate) struct SessionStart { +pub(crate) struct SessionStart { pub recv_bandwidth: Option, /// The session's protocol machine, named so its `Send`-ness stays inferred /// from the transport instead of being fixed by a box. @@ -50,7 +50,7 @@ pub async fn accept_setup( } /// Everything one moq-lite session needs to start. -pub struct Config { +pub struct Config { /// The runtime that arms the session's timers. pub runtime: R, @@ -94,7 +94,7 @@ pub struct Config(config: Config) -> Result, Error> where S: crate::transport::poll::Session, - R: crate::runtime::Runtime, + R: crate::runtime::Timers, { let Config { runtime, @@ -208,7 +208,7 @@ where /// The lite session driver: one poll function racing every protocol arm, in /// place of a task set of boxed futures. -pub(crate) struct Driver { +pub(crate) struct Driver { /// Advertising our capabilities, or `None` once sent (or on a version with no /// Setup Stream). setup: Option>, @@ -227,7 +227,7 @@ pub(crate) struct Driver Driver where S: crate::transport::poll::Session, - R: crate::runtime::Runtime, + R: crate::runtime::Timers, { pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let res = std::task::ready!(self.poll_protocol(waiter)); @@ -371,7 +371,7 @@ impl SendSetup { /// Runs on every version, including those with no GOAWAY message: the deadline is /// the sender's own timer, so a caller draining a lite-03 peer still gets the /// session closed on schedule; the peer just never learns why. -struct SendGoaway { +struct SendGoaway { version: Version, runtime: R, goaway: crate::goaway::Protocol, @@ -382,7 +382,7 @@ struct SendGoaway, } -enum SendGoawayState { +enum SendGoawayState { /// Parked on the send trigger, racing the transport close so a parked trigger /// never blocks the driver. The trigger fires at most once. Waiting, @@ -401,7 +401,7 @@ enum SendGoawayState), } -impl SendGoaway { +impl SendGoaway { fn new(runtime: R, session: S, goaway: crate::goaway::Protocol, version: Version) -> Self { Self { version, diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a6e2ff551e..14f5ee37ec 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -4104,7 +4104,7 @@ impl ProduceTest for Config { fn produce(self) -> Producer { let (producer, driver) = Producer::new(self); if tokio::runtime::Handle::try_current().is_ok() { - web_async::spawn(driver.run(crate::runtime::tokio_test::Tokio::<()>::new())); + web_async::spawn(driver.run(crate::runtime::tokio_test::Tokio::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. @@ -5314,7 +5314,7 @@ mod tests { async fn driver_resolves_with_live_consumers() { let (producer, driver) = Producer::new(Config::new(origin(1))); let consumer = producer.consume(); - let run = driver.run(crate::runtime::tokio_test::Tokio::<()>::new()); + let run = driver.run(crate::runtime::tokio_test::Tokio::new()); drop(producer); tokio::time::timeout(Duration::from_secs(5), run) .await diff --git a/rs/moq-net/src/runtime.rs b/rs/moq-net/src/runtime.rs index a96e5505d0..f7621e5167 100644 --- a/rs/moq-net/src/runtime.rs +++ b/rs/moq-net/src/runtime.rs @@ -1,25 +1,15 @@ -//! Executor integration: who runs session machines and arms their timers. +//! Timer integration for session and origin drivers. //! -//! This crate never spawns onto a global executor and never reaches for ambient -//! state (no thread-locals, no globals). Instead, [`crate::Client::connect`] and -//! [`crate::Server::accept`] take a [`Runtime`], which supplies the two things a -//! session cannot do alone: run its protocol [`Machine`] to completion, and wake -//! a [`Timer`] when a deadline passes. +//! [`crate::Client::connect`] and [`crate::Server::accept`] take [`Timers`] and +//! return a [`crate::Driver`] for the caller to poll or spawn. This crate never +//! spawns tasks. Timer providers supply a clock and re-armable registrations, +//! without choosing a transport or executor. //! -//! Each transport ships its runtime: `moq-tokio` spawns machines onto tokio, -//! `moq-wasm` onto the browser microtask queue, and a thread-per-core runtime -//! keeps them on its own thread. `Test` (behind the `test-runtime` feature) is -//! the deterministic one for tests: nothing runs and no time passes unless the -//! test says so. -//! -//! There is deliberately no `Send` bound anywhere here. A runtime whose -//! [`Runtime::Transport`] is `Send` gets a `Send` [`Machine`] and may move it -//! across threads (tokio does); a pinned `!Send` transport stays on its thread. +//! `Test` (behind `test-runtime`) provides a virtual clock that advances only +//! when the test requests it. use std::task::Poll; -use crate::Error; - /// The instant type used for deadlines and annotations. /// /// [`std::time::Instant`] on native. The browser has no monotonic std clock, so @@ -52,10 +42,8 @@ pub trait Timer { /// The timer half of a runtime: mint [`Timer`]s and read the clock they follow. /// -/// Split from [`Runtime`] so work that only arms deadlines (the origin driver's -/// linger and hold-down machinery) can borrow a runtime's timers without caring -/// which transport it drives. Cloned into whatever arms timers, so clones must -/// be cheap (a ZST or a reference count). +/// Shared by session and origin drivers, independently of their transport or +/// executor. Clones must be cheap (a ZST or a reference count). pub trait Timers: Clone { /// The timer registration this runtime hands out. type Timer: Timer; @@ -76,27 +64,6 @@ pub trait Timers: Clone { } } -/// The executor a session runs on: [`Timers`] plus a transport and a spawn. -/// -/// Passed to [`crate::Client::connect`] / [`crate::Server::accept`], which hand -/// their protocol [`Machine`] to [`spawn`](Self::spawn). -pub trait Runtime: Timers { - /// The transport this runtime can drive. Pinning the transport per runtime - /// is what lets each implementation know the concrete `Machine` type it - /// spawns, including whether it is `Send`. - type Transport: crate::transport::poll::Session; - - /// Take ownership of a session's protocol machine and poll it to completion. - /// - /// The machine holds no [`crate::Session`] clone, so running it never keeps - /// the session alive; once the last session handle drops, the machine closes - /// the transport and finishes on its own. The machine is also the only thing - /// holding the transport, so dropping it instead tears the whole session - /// down: the transport closes and [`crate::Session::closed`] resolves with - /// [`crate::Error::Cancel`]. - fn spawn(&self, machine: Machine); -} - /// A boxed [`Timer`], minted by [`AnyTimers`]. pub(crate) struct BoxTimer(MaybeSendTimer); @@ -284,140 +251,16 @@ impl Deadline { } } -impl std::fmt::Debug for Deadline { +impl std::fmt::Debug for Deadline { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Deadline").field("at", &self.at).finish() } } -/// The future driving a [`crate::Session`]'s protocol state. -/// -/// Created by [`crate::Client::connect`] / [`crate::Server::accept`] and handed -/// straight to [`Runtime::spawn`]; the only thing to do with one is poll it, -/// either as a [`Future`] or by stepping [`poll`](Self::poll) from a -/// [`kio`]-style poll function. It resolves when the session ends, and keeps -/// returning that same result if polled again. -pub struct Machine { - state: MachineState, - // Retains the waiter across `Future` polls so its kio registrations stay - // live. Kept out of `MachineState` so the borrow `hold` hands back doesn't - // collide with the `&mut` that polling the state needs. - park: kio::Park, -} - -/// The protocol half of a machine, one variant per negotiated wire protocol. -/// -/// The lite driver is a named machine, so the machine's `Send`-ness follows -/// the transport (a pinned `!Send` transport yields a `!Send` machine that -/// stays on its thread). The ietf driver is still a boxed future; the box -/// demands `Send` on native, which is why the ietf path requires a -/// [`Boxable`](crate::transport::poll::Boxable) transport until it too becomes -/// a named machine. -pub(crate) enum Protocol { - /// Boxed for size only: a concrete box, so `Send` stays inferred. - Lite(Box>), - Ietf(crate::util::MaybeSendBox<'static, Result<(), Error>>), -} - -/// Everything the machine polls, split from the park so the two borrow -/// disjointly. -pub(crate) struct MachineState { - pub(crate) protocol: Protocol, - // The session supervisor, polled alongside the protocol: it executes the - // handles' close requests, publishes the transport's terminal error, and - // samples stats. It finishes once the transport reports closed, and the - // machine is not done until it has: the protocol's terminal transport close - // is what `Session::closed` observes, so resolving before it is published - // would leave waiters parked on a machine nobody polls again. `None` once - // finished, since a completed machine must not be polled again. - pub(crate) supervisor: Option>, - // Cached so a poll after completion doesn't re-poll a finished protocol. - pub(crate) result: Option>, -} - -impl Machine { - pub(crate) fn new(state: MachineState) -> Self { - Self { - state, - park: kio::Park::default(), - } - } - - /// Drive the protocol one step, registering `waiter` for the next wakeup. - /// - /// The `poll_*` counterpart of `.await`ing the machine, for runtimes - /// composing it into their own [`kio`]-style poll loops. - pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { - self.state.poll(waiter) - } -} - -impl Protocol { - fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { - match self { - Self::Lite(driver) => driver.poll(waiter), - Self::Ietf(driver) => waiter.poll_future(driver.as_mut()), - } - } -} - -impl MachineState { - fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { - if let Some(supervisor) = &mut self.supervisor - && supervisor.poll(waiter).is_ready() - { - self.supervisor = None; - } - - if self.result.is_none() - && let Poll::Ready(result) = self.protocol.poll(waiter) - { - self.result = Some(result); - // The protocol's last act was closing the transport, which wakes the - // supervisor's close watch; poll it now instead of waiting a turn. - if let Some(supervisor) = &mut self.supervisor - && supervisor.poll(waiter).is_ready() - { - self.supervisor = None; - } - } - - match (&self.result, &self.supervisor) { - (Some(result), None) => Poll::Ready(result.clone()), - _ => Poll::Pending, - } - } -} - -// Every part of the machine is a poll machine driven through `&mut` (the one -// pinned piece, the boxed ietf future, is `Unpin` itself), so nothing relies -// on address stability and the machine may move freely between polls. -impl Unpin for Machine {} - -impl Future for Machine { - type Output = Result<(), Error>; - - fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { - let this = &mut *self; - // Disjoint field borrows: `hold` borrows the park for as long as the - // waiter lives, while the state is polled through its own `&mut`. - let waiter = this.park.hold(cx); - this.state.poll(waiter) - } -} - -impl std::fmt::Debug for Machine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Machine") - .field("done", &self.state.result.is_some()) - .finish() - } -} - #[cfg(feature = "test-runtime")] mod test; #[cfg(feature = "test-runtime")] -pub use test::{Never, Test}; +pub use test::Test; /// A tokio-backed runtime for this crate's own unit tests, so the existing /// `tokio::time::pause`/`advance` tests keep their semantics: `now` reads @@ -427,28 +270,20 @@ pub use test::{Never, Test}; /// in `tests/support`). #[cfg(all(test, not(target_family = "wasm")))] pub(crate) mod tokio_test { - use std::{marker::PhantomData, pin::Pin, task::Poll}; + use std::{pin::Pin, task::Poll}; - use super::{Instant, Machine, Runtime, Timer}; + use super::{Instant, Timer}; - // The default `S` makes a bare `Tokio::new()` a transportless timers handle - // for origin drivers, mirroring `moq_tokio::runtime::Runtime`. - pub(crate) struct Tokio(PhantomData); + #[derive(Clone, Default)] + pub(crate) struct Tokio; - impl Tokio { + impl Tokio { pub fn new() -> Self { - Self(PhantomData) - } - } - - impl Clone for Tokio { - fn clone(&self) -> Self { - Self(PhantomData) + Self } } - // Unbounded: timers don't involve the transport. - impl super::Timers for Tokio { + impl super::Timers for Tokio { type Timer = TokioTimer; fn timer(&self) -> Self::Timer { @@ -460,15 +295,6 @@ pub(crate) mod tokio_test { } } - // Boxable: tokio work-steals, so the machine must be `Send`. - impl Runtime for Tokio { - type Transport = S; - - fn spawn(&self, machine: Machine) { - tokio::spawn(machine); - } - } - pub(crate) struct TokioTimer { at: Option, // Allocated on the first poll after arming, then re-armed in place via diff --git a/rs/moq-net/src/runtime/test.rs b/rs/moq-net/src/runtime/test.rs index 63145ac2cb..d010c82eeb 100644 --- a/rs/moq-net/src/runtime/test.rs +++ b/rs/moq-net/src/runtime/test.rs @@ -1,38 +1,25 @@ -//! The deterministic test runtime: virtual time, manual machine stepping. +//! A virtual clock and timer registrations for deterministic tests. use std::{ collections::HashMap, - marker::PhantomData, sync::{Arc, Mutex}, task::Poll, }; -use super::{Instant, Machine, Runtime, Timer, Timers}; +use super::{Instant, Timer, Timers}; -/// A deterministic [`Runtime`] for tests: no thread runs machines and no time -/// passes unless the test says so. +/// A virtual clock for deterministic timer tests. /// -/// - [`tick`](Self::tick) polls every spawned machine once. -/// - [`advance`](Self::advance) moves the virtual clock, firing elapsed timers. -/// - [`advance_to_timer`](Self::advance_to_timer) jumps straight to the -/// earliest armed timer. -/// -/// [`Runtime::now`] reads the virtual clock, so deadlines armed relative to -/// "now" stay coherent with the advances a test performs. Clones share one -/// clock and machine queue. -/// -/// The transport parameter defaults to [`Never`] for tests that only exercise -/// timers. -pub struct Test { - shared: Arc>>, - _transport: PhantomData, +/// Clones share the clock. Advance time explicitly, then poll session or origin +/// drivers yourself to observe elapsed deadlines. +pub struct Test { + shared: Arc>, } -struct Shared { +struct Shared { now: Instant, timers: HashMap, next_id: u64, - machines: Vec>>, } struct Entry { @@ -40,7 +27,7 @@ struct Entry { waiters: kio::WaiterList, } -impl Test { +impl Test { /// A fresh runtime whose virtual clock starts at the real current instant. pub fn new() -> Self { Self { @@ -48,9 +35,7 @@ impl Test { now: Instant::now(), timers: HashMap::new(), next_id: 0, - machines: Vec::new(), })), - _transport: PhantomData, } } @@ -97,38 +82,10 @@ impl Test { None => false, } } - - /// Drop every spawned machine without polling it, like a runtime shutting - /// down mid-session. - /// - /// Machines hold runtime clones (for timers), so dropping every external - /// handle alone never drops them; this is the explicit teardown. - pub fn shutdown(&self) { - let machines = std::mem::take(&mut self.shared.lock().unwrap().machines); - drop(machines); - } - - /// Poll every spawned machine once, dropping the finished ones. - /// - /// Returns how many machines remain. Polling is unconditional (no waker - /// bookkeeping): a test advances state, ticks, and asserts. - pub fn tick(&self) -> usize { - // Take the machines out so their polls can reach the timers without - // deadlocking on the shared lock. - let mut machines = std::mem::take(&mut self.shared.lock().unwrap().machines); - let waiter = kio::Waiter::noop(); - machines.retain_mut(|machine| machine.poll(&waiter).is_pending()); - - let mut shared = self.shared.lock().unwrap(); - // A machine spawned by a machine mid-tick landed in the queue already; - // keep both. - shared.machines.extend(machines); - shared.machines.len() - } } -impl Timers for Test { - type Timer = TestTimer; +impl Timers for Test { + type Timer = TestTimer; fn timer(&self) -> Self::Timer { let id = { @@ -155,48 +112,38 @@ impl Timers for Test { } } -impl Runtime for Test { - type Transport = S; - - fn spawn(&self, machine: Machine) { - self.shared.lock().unwrap().machines.push(machine); - } -} - -impl Clone for Test { +impl Clone for Test { fn clone(&self) -> Self { Self { shared: self.shared.clone(), - _transport: PhantomData, } } } -impl Default for Test { +impl Default for Test { fn default() -> Self { Self::new() } } -impl std::fmt::Debug for Test { +impl std::fmt::Debug for Test { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let shared = self.shared.lock().unwrap(); f.debug_struct("Test") .field("now", &shared.now) .field("timers", &shared.timers.len()) - .field("machines", &shared.machines.len()) .finish() } } /// The [`Timer`] handed out by [`Test`]: elapsed exactly when its armed instant /// is at or before the shared virtual clock. -pub struct TestTimer { - shared: Arc>>, +pub struct TestTimer { + shared: Arc>, id: u64, } -impl Timer for TestTimer { +impl Timer for TestTimer { fn set(&mut self, at: Option) { let mut shared = self.shared.lock().unwrap(); if let Some(entry) = shared.timers.get_mut(&self.id) { @@ -220,128 +167,12 @@ impl Timer for TestTimer { } } -impl Drop for TestTimer { +impl Drop for TestTimer { fn drop(&mut self) { self.shared.lock().unwrap().timers.remove(&self.id); } } -/// An uninhabited transport, for [`Test`] runtimes that never open a session. -#[derive(Debug, Clone, Copy)] -pub enum Never {} - -impl std::fmt::Display for Never { - fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match *self {} - } -} - -impl std::error::Error for Never {} - -impl web_transport_trait::Error for Never { - fn session_error(&self) -> Option<(u32, String)> { - match *self {} - } -} - -impl web_transport_trait::poll::Session for Never { - type SendStream = Never; - type RecvStream = Never; - type Error = Never; - - fn poll_accept_uni(&mut self, _: &mut std::task::Context<'_>) -> Poll> { - match *self {} - } - - fn poll_accept_bi( - &mut self, - _: &mut std::task::Context<'_>, - ) -> Poll, Self::Error>> { - match *self {} - } - - fn poll_open_uni(&mut self, _: &mut std::task::Context<'_>) -> Poll> { - match *self {} - } - - fn poll_open_bi( - &mut self, - _: &mut std::task::Context<'_>, - ) -> Poll, Self::Error>> { - match *self {} - } - - fn poll_send_datagram(&mut self, _: &mut std::task::Context<'_>, _: &[u8]) -> Poll> { - match *self {} - } - - fn poll_recv_datagram(&mut self, _: &mut std::task::Context<'_>) -> Poll> { - match *self {} - } - - fn max_datagram_size(&self) -> usize { - match *self {} - } - - fn protocol(&self) -> Option<&str> { - match *self {} - } - - fn close(&mut self, _: u32, _: &str) { - match *self {} - } - - fn poll_closed(&mut self, _: &mut std::task::Context<'_>) -> Poll { - match *self {} - } - - fn stats(&self) -> impl web_transport_trait::Stats { - // Uninhabited, so this is never called; a concrete type keeps the - // opaque return type nameable. - web_transport_trait::StatsUnavailable - } -} - -impl web_transport_trait::poll::SendStream for Never { - type Error = Never; - - fn poll_write(&mut self, _: &mut std::task::Context<'_>, _: &[u8]) -> Poll> { - match *self {} - } - - fn set_priority(&mut self, _: u8) { - match *self {} - } - - fn finish(&mut self) -> Result<(), Self::Error> { - match *self {} - } - - fn reset(&mut self, _: u32) { - match *self {} - } - - fn poll_closed(&mut self, _: &mut std::task::Context<'_>) -> Poll> { - match *self {} - } -} - -impl web_transport_trait::poll::RecvStream for Never { - type Error = Never; - - fn poll_read(&mut self, _: &mut std::task::Context<'_>, _: &mut [u8]) -> Poll, Self::Error>> { - match *self {} - } - - fn stop(&mut self, _: u32) { - match *self {} - } - - fn poll_closed(&mut self, _: &mut std::task::Context<'_>) -> Poll> { - match *self {} - } -} - #[cfg(all(test, not(loom)))] mod tests { use std::time::Duration; @@ -371,7 +202,7 @@ mod tests { #[test] fn rearm_after_advance_lands_in_the_future() { - // The reason Runtime::now exists: a deadline armed relative to "now" + // The reason Timers::now exists: a deadline armed relative to "now" // after a big advance must not sit in the virtual past. let rt: Test = Test::new(); rt.advance(Duration::from_secs(3600)); diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 230a02ce7d..3cf2aadc3c 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -9,9 +9,7 @@ use crate::{ ietf, lite, setup, stats, }; -// The transport methods are called on the projected `R::Transport`, which needs the -// trait itself in scope (a plain type parameter would not). -use web_transport_trait::{MaybeSend, MaybeSync, poll::Session as _}; +use web_transport_trait::{MaybeSend, MaybeSync}; /// A MoQ server session builder. #[derive(Default, Clone)] @@ -79,17 +77,18 @@ impl Server { } /// Start a lite session on an accepted transport: wire the origins, answer - /// with our SETUP, and hand the machine to the runtime. - fn start_lite( + /// with our SETUP, and return the session and its driver. + fn start_lite( &self, runtime: R, - session: R::Transport, + session: S, version: lite::Version, client_setup: Option, peer_hop: Option, - ) -> Result + ) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, + R: crate::runtime::Timers + 'static, { let (publish, subscribe) = self.stat_tagged_origins(); @@ -121,12 +120,12 @@ impl Server { peer_setup: client_setup, })?; - Ok(Session::spawn( + Ok(Session::new( runtime, session, version.into(), start.recv_bandwidth, - crate::runtime::Protocol::Lite(Box::new(start.driver)), + crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, )) } @@ -139,9 +138,10 @@ impl Server { /// with [`Error::Version`]). Completes the handshake immediately; a caller /// gating on the advertised path uses /// [`accept_request_lite`](Self::accept_request_lite) instead. - pub async fn accept_lite(&self, runtime: R, session: R::Transport) -> Result + pub async fn accept_lite(&self, runtime: R, session: S) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, + R: crate::runtime::Timers + 'static, { self.accept_request_lite(runtime, session).await?.ok().await } @@ -151,13 +151,10 @@ impl Server { /// which is what drops the thread-affinity bounds: a pinned `!Send` /// transport can gate on the advertised path too. Anything but a moq-lite /// ALPN is refused with [`Error::Version`]. - pub async fn accept_request_lite( - &self, - runtime: R, - mut session: R::Transport, - ) -> Result, Error> + pub async fn accept_request_lite(&self, runtime: R, mut session: S) -> Result, Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, + R: crate::runtime::Timers + 'static, { let (path, role, origin, handshake) = match session.protocol() { Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06_WIP)) => { @@ -226,21 +223,20 @@ impl Server { }) } - /// Perform the MoQ handshake as a server, returning the [`Session`]. + /// Perform the MoQ handshake as a server, returning the [`Session`] and its [`Driver`](crate::Driver). /// - /// The session's protocol machine is handed to `runtime` - /// ([`Runtime::spawn`](crate::runtime::Runtime::spawn)), so there is nothing - /// else to drive. + /// Poll or spawn the returned driver to run the session. `runtime` supplies + /// only its clock and timers. /// /// Convenience wrapper over [`accept_request`](Self::accept_request) that /// completes the handshake immediately. Use `accept_request` when you need to /// inspect the client's advertised path before deciding what to serve. - pub async fn accept(&self, runtime: R, session: R::Transport) -> Result + pub async fn accept(&self, runtime: R, session: S) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, - R::Transport: crate::transport::poll::Boxable, - ::SendStream: MaybeSync, - ::RecvStream: MaybeSync, + S: crate::transport::poll::Boxable, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, R::Timer: MaybeSend, { self.accept_request(runtime, session).await?.ok().await @@ -256,16 +252,12 @@ impl Server { /// /// The path is surfaced for moq-lite-05 and every moq-transport draft we speak; /// it's empty on versions with no in-band request path (e.g. lite 01-04). - pub async fn accept_request( - &self, - runtime: R, - mut session: R::Transport, - ) -> Result, Error> + pub async fn accept_request(&self, runtime: R, mut session: S) -> Result, Error> where - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, - R::Transport: crate::transport::poll::Boxable, - ::SendStream: MaybeSync, - ::RecvStream: MaybeSync, + S: crate::transport::poll::Boxable, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, R::Timer: MaybeSend, { let (encoding, supported) = match session.protocol() { @@ -373,17 +365,17 @@ impl Server { /// Read a draft-17/18 client's SETUP (with its request path) off its uni stream, /// then pause. `ok()` starts the session and hands the stream back for GOAWAY. - async fn accept_ietf_modern( + async fn accept_ietf_modern( &self, runtime: R, - mut session: R::Transport, + mut session: S, version: ietf::Version, - ) -> Result, Error> + ) -> Result, Error> where - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, - R::Transport: crate::transport::poll::Boxable, - ::SendStream: MaybeSync, - ::RecvStream: MaybeSync, + S: crate::transport::poll::Boxable, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, R::Timer: MaybeSend, { let peer_setup = ietf::accept_setup(&mut session, version).await?; @@ -414,7 +406,7 @@ impl Server { /// the origins to serve, then call [`ok`](Self::ok) to complete the handshake, or /// [`close`](Self::close) to reject it. Modeled on the WebTransport `Request` in /// moq-tokio. -pub struct Handshake { +pub struct Handshake { path: Option, role: Option, origin: Option, @@ -427,16 +419,16 @@ pub struct Handshake { +struct RequestInner { server: Server, - /// Receives the session's machine once `ok()` completes the handshake. + /// Supplies the clock and timers for the accepted session. runtime: R, handshake: PausedHandshake, } /// The handshake state captured at the pause point. Every variant defers its /// session start to [`Handshake::ok`] so origins set on the handshake still apply. -enum PausedHandshake { +enum PausedHandshake { /// moq-lite 03/04: no Setup Stream. LiteBare { session: S, version: lite::Version }, /// moq-lite 05+: the client's Setup Stream has been read. `ok()` starts the @@ -452,23 +444,20 @@ enum PausedHandshake>), + Boxed(Box>), } +type Accept = crate::util::MaybeSendBox<'static, Result<(Session, crate::Driver), Error>>; + /// A paused non-lite handshake. See [`PausedHandshake::Boxed`] for why this is a /// trait object. /// /// `MaybeSync` is not decoration: a caller holding a [`Handshake`] across an /// await behind `&self` (moq-relay authenticates that way) needs /// `&Handshake: Send`, which is `Handshake: Sync`, which is this. -trait Paused: MaybeSend + MaybeSync { +trait Paused: MaybeSend + MaybeSync { /// Complete the handshake with the final server config. - fn ok( - self: Box, - server: Server, - runtime: R, - peer_hop: Option, - ) -> crate::util::MaybeSendBox<'static, Result>; + fn ok(self: Box, server: Server, runtime: R, peer_hop: Option) -> Accept; /// Reject the handshake, closing the transport with `err`'s wire code. fn close(self: Box, err: Error); @@ -483,20 +472,15 @@ struct PausedIetfModern { peer_setup: ietf::PeerSetup, } -impl Paused for PausedIetfModern +impl Paused for PausedIetfModern where - S: crate::transport::poll::Session + crate::transport::poll::Boxable, + S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { - fn ok( - self: Box, - server: Server, - runtime: R, - peer_hop: Option, - ) -> crate::util::MaybeSendBox<'static, Result> { + fn ok(self: Box, server: Server, runtime: R, peer_hop: Option) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -525,12 +509,12 @@ where peer_declared: Some(peer_setup.declared), })?; tracing::debug!(?version, "connected"); - Ok(Session::spawn( + Ok(Session::new( runtime, session, version.into(), None, - crate::runtime::Protocol::Ietf(protocol), + crate::driver::Protocol::Ietf(protocol), goaway, )) } @@ -555,20 +539,15 @@ struct PausedLegacy { peer_declared: ietf::peer::Peer, } -impl Paused for PausedLegacy +impl Paused for PausedLegacy where - S: crate::transport::poll::Session + crate::transport::poll::Boxable, + S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, + R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, R::Timer: MaybeSend, { - fn ok( - self: Box, - server: Server, - runtime: R, - peer_hop: Option, - ) -> crate::util::MaybeSendBox<'static, Result> { + fn ok(self: Box, server: Server, runtime: R, peer_hop: Option) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -615,7 +594,7 @@ where })?; ( start.recv_bandwidth, - crate::runtime::Protocol::Lite(Box::new(start.driver)), + crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, ) } @@ -637,11 +616,11 @@ where peer_setup_stream: None, peer_declared: Some(peer_declared), })?; - (None, crate::runtime::Protocol::Ietf(protocol), goaway) + (None, crate::driver::Protocol::Ietf(protocol), goaway) } }; - Ok(Session::spawn(runtime, session, version, recv_bw, protocol, goaway)) + Ok(Session::new(runtime, session, version, recv_bw, protocol, goaway)) } .maybe_boxed() } @@ -655,7 +634,7 @@ where impl Handshake where S: crate::transport::poll::Session, - R: crate::runtime::Runtime + 'static, + R: crate::runtime::Timers + 'static, { /// The request path the client advertised in its SETUP. /// @@ -732,11 +711,10 @@ where self.inner.as_mut().expect("request already responded") } - /// Accept the session, returning the [`Session`]. + /// Accept the session, returning the [`Session`] and its [`Driver`](crate::Driver). /// - /// The session's protocol machine is handed to the runtime given to - /// [`Server::accept_request`], so there is nothing else to drive. - pub async fn ok(mut self) -> Result { + /// Poll or spawn the returned driver to run the session. + pub async fn ok(mut self) -> Result<(Session, crate::Driver), Error> { let peer_hop = Some(self.assigned_hop); let RequestInner { server, @@ -764,7 +742,7 @@ where } } -impl RequestInner { +impl RequestInner { fn close(self, err: Error) { let mut session = match self.handshake { PausedHandshake::LiteBare { session, .. } => session, @@ -775,7 +753,7 @@ impl RequestInne } } -impl Drop for Handshake { +impl Drop for Handshake { // A dropped request would otherwise leave the client hanging until its idle // timeout: it already sent SETUP and is waiting on a response. Reject loudly. fn drop(&mut self) { @@ -1141,7 +1119,8 @@ mod tests { .announce("local-route", crate::origin::Route::default().with_hops(local_hops)) .unwrap(); - let session = request.ok().await.unwrap(); + let (session, driver) = request.ok().await.unwrap(); + tokio::spawn(driver); for _ in 0..100 { if occurrences(&log, b"local-route") > 0 { diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 286afce81d..b7bb52b7a6 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -6,16 +6,16 @@ use web_transport_trait::Stats as _; use crate::{Error, SessionError, Version, bandwidth, goaway}; -/// A close requested by a session handle, executed by the machine. +/// A close requested by a session handle, executed by the driver. #[derive(Clone)] struct Close { code: u32, reason: String, } -/// The stats cell shared between the machine's sampler and the handles. +/// The stats cell shared between the driver's sampler and the handles. struct StatsState { - /// The latest sample the machine took (or the construction-time snapshot). + /// The latest sample the driver took (or the construction-time snapshot). sample: Stats, /// A handle read the stats since the last sample: keep sampling. demanded: bool, @@ -62,25 +62,24 @@ pub struct Stats { /// A MoQ transport session, wrapping a WebTransport connection. /// -/// Returned by [`crate::Client::connect`] and [`crate::Server::accept`], which hand -/// the session's protocol [`runtime::Machine`](crate::runtime::Machine) to the -/// [`Runtime`](crate::runtime::Runtime) they were given: that runtime is the only -/// thing driving the session. +/// Returned with a [`Driver`](crate::Driver) by [`crate::Client::connect`] and +/// [`crate::Server::accept`]. The caller must poll or spawn that driver to run +/// the session. /// /// Like every handle in this library, the lifecycle is reference counted: clones /// share the connection, the transport closes when the last clone drops, and /// [`abort`](Self::abort) closes it explicitly with an error. The handle and the -/// machine are severed in both directions: the machine holds no `Session` clone, -/// so the runtime running it never keeps the session alive, and the `Session` +/// driver are severed in both directions: the driver holds no `Session` clone, +/// so running it never keeps the session alive, and the `Session` /// holds no transport, so the handle is `Send + Sync` whatever transport the -/// runtime drives. Everything transport-shaped (the close, the close reason, -/// the stats sample) is relayed through the machine. +/// driver uses. Everything transport-shaped (the close, the close reason, +/// the stats sample) is relayed through the driver. #[derive(Clone)] pub struct Session { - /// Handle side to machine: `Some` once [`abort`](Self::abort) ran; the + /// Handle side to driver: `Some` once [`abort`](Self::abort) ran; the /// channel closing (the last handle dropping) is the implicit Cancel. close: kio::Producer>, - /// Machine to handle side: the transport's terminal error. + /// Driver to handle side: the transport's terminal error. closed: kio::Consumer>, stats: kio::Shared, version: Version, @@ -112,7 +111,7 @@ impl Session { /// Returns a snapshot of the current connection statistics. /// /// Cheap and non-blocking: this reads the latest sample the session's - /// machine took, and schedules a refresh, so periodic polling observes + /// driver took, and schedules a refresh, so periodic polling observes /// fresh counters (100ms cadence). See [`Stats`] for which /// metrics each backend reports. pub fn stats(&self) -> Stats { @@ -132,7 +131,7 @@ impl Session { /// Close the transport with an explicit error, instead of waiting for the last /// clone to drop. Idempotent: the first close wins. /// - /// The close is executed by the session's machine, so it reaches the wire + /// The close is executed by the session's driver, so it reaches the wire /// once the runtime polls it (immediately on a live runtime). pub fn abort(&self, err: Error) { if let Ok(mut close) = self.close.write() @@ -151,7 +150,7 @@ impl Session { /// rejection arrives as `Error::Session(SessionError::Unauthorized)`); every peer code is /// preserved as [`Error::Session`], and a close carrying no application code surfaces as /// [`Error::Transport`]. See [`Error::from_transport`]. If the runtime drops - /// the machine instead of running it to completion, this resolves with + /// the driver instead of running it to completion, this resolves with /// [`Error::Cancel`]. pub async fn closed(&self) -> Error { match self @@ -163,7 +162,7 @@ impl Session { .await { Ok(err) => err, - // The machine was dropped before it could observe the close. + // The driver was dropped before it could observe the close. Err(kio::Closed) => Error::Cancel, } } @@ -200,16 +199,17 @@ impl Session { } impl Session { - pub(super) fn new( + pub(super) fn new( runtime: R, - session: R::Transport, + session: S, version: Version, recv_bandwidth: Option, - protocol: crate::runtime::Protocol, + protocol: crate::driver::Protocol, goaway: goaway::Handle, - ) -> (Self, crate::runtime::Machine) + ) -> (Self, crate::Driver) where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, + R: crate::runtime::Timers + 'static, { let sample = snapshot(&session); @@ -249,34 +249,17 @@ impl Session { recv_bandwidth, goaway: Arc::new(goaway), }; - let machine = crate::runtime::Machine::new(crate::runtime::MachineState { + let driver = crate::Driver::new(crate::driver::State { protocol, supervisor: Some(supervisor), result: None, }); - (session, machine) - } - - /// Build the session, hand its machine to the runtime, and return the handle. - pub(super) fn spawn( - runtime: R, - session: R::Transport, - version: Version, - recv_bandwidth: Option, - protocol: crate::runtime::Protocol, - goaway: goaway::Handle, - ) -> Self - where - R: crate::runtime::Runtime + 'static, - { - let (session, machine) = Self::new(runtime.clone(), session, version, recv_bandwidth, protocol, goaway); - runtime.spawn(machine); - session + (session, driver) } } -/// The machine's transport-facing half of a [`Session`]: it executes the +/// The driver's transport-facing half of a [`Session`]: it executes the /// handles' close requests, publishes the transport's terminal error, and /// samples the connection stats (including the send-bandwidth estimate) while /// anyone is consuming them. diff --git a/rs/moq-net/tests/datagram.rs b/rs/moq-net/tests/datagram.rs index 4c7bec9f2c..2a5681df09 100644 --- a/rs/moq-net/tests/datagram.rs +++ b/rs/moq-net/tests/datagram.rs @@ -21,7 +21,7 @@ const PAYLOAD: &[u8] = b"datagram payload"; /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); - tokio::spawn(driver.run(TokioRuntime::<()>::new())); + tokio::spawn(driver.run(TokioRuntime::new())); producer } diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index 035269e921..26961c5ce4 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -15,7 +15,7 @@ use support::mock::create_mock_session_pair; /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: Hop) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(hop)); - tokio::spawn(driver.run(support::harness::TokioRuntime::<()>::new())); + tokio::spawn(driver.run(support::harness::TokioRuntime::new())); producer } @@ -277,8 +277,10 @@ async fn duplicate_goaway_keeps_first_payload_moq_lite_04() { client.connect(support::harness::TokioRuntime::new(), client_transport), server.accept(support::harness::TokioRuntime::new(), server_transport) ); - let client_session = client_result.expect("client handshake failed"); - let _server_session = server_result.expect("server handshake failed"); + let (client_session, client_driver) = client_result.expect("client handshake failed"); + tokio::spawn(client_driver); + let (_server_session, server_driver) = server_result.expect("server handshake failed"); + tokio::spawn(server_driver); // First GOAWAY: observed with its URI. Waiting for the peer to close the // stream guarantees the control message was fully processed. diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 11b303bc7b..85ead4fd0c 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -2,44 +2,28 @@ //! [`moq_net::Session`]s over the in-memory mock transport. //! //! The harness runs the full MoQ handshake (Client::connect + Server::accept) -//! over a [`MockSession`] pair and spawns both protocol drivers, giving tests +//! over a [`super::mock::MockSession`] pair and spawns both protocol drivers, giving tests //! two live sessions ready for pub/sub without any real QUIC or network I/O. #![allow(dead_code)] -use std::{marker::PhantomData, pin::Pin, task::Poll}; +use std::{pin::Pin, task::Poll}; use moq_net::{Client, Server, Session, Version, origin}; -use super::mock::{MockSession, create_mock_session_pair}; +use super::mock::create_mock_session_pair; -/// A tokio-backed [`moq_net::runtime::Runtime`] for these tests: machines are -/// spawned onto tokio and timers are tokio sleeps, so `tokio::time::pause` keeps -/// working. A copy of the crate's own `runtime::tokio_test` module, which is -/// `cfg(test)` and therefore invisible to integration tests. -pub struct TokioRuntime(PhantomData); +/// Tokio's clock and timers for these tests. +#[derive(Clone, Default)] +pub struct TokioRuntime; -impl TokioRuntime { +impl TokioRuntime { pub fn new() -> Self { - Self(PhantomData) + Self } } -impl Clone for TokioRuntime { - fn clone(&self) -> Self { - Self(PhantomData) - } -} - -impl Default for TokioRuntime { - fn default() -> Self { - Self::new() - } -} - -// Unbounded: timers don't involve the transport, so origin drivers can borrow -// a transportless handle (`TokioRuntime::<()>::new()`). -impl moq_net::runtime::Timers for TokioRuntime { +impl moq_net::runtime::Timers for TokioRuntime { type Timer = TokioTimer; fn timer(&self) -> Self::Timer { @@ -51,15 +35,6 @@ impl moq_net::runtime::Timers for TokioRuntime { } } -// Boxable: tokio work-steals, so the machine must be `Send`. -impl moq_net::runtime::Runtime for TokioRuntime { - type Transport = S; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - tokio::spawn(machine); - } -} - /// The [`moq_net::runtime::Timer`] handed out by [`TokioRuntime`]. pub struct TokioTimer { at: Option, @@ -151,21 +126,25 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { server = server.with_subscriber(subscribe); } - // Run both handshakes concurrently; the runtime spawns each side's machine + // Run both handshakes concurrently and spawn each side's driver // the moment its handshake resolves: on draft-17+ the server's accept blocks // on the client's SETUP, which only reaches the wire once the client's // machine is polled (and vice versa for the server's own SETUP). let client_fut = async { - client + let (session, driver) = client .connect(TokioRuntime::new(), client_transport) .await - .expect("client handshake failed") + .expect("client handshake failed"); + tokio::spawn(driver); + session }; let server_fut = async { - server + let (session, driver) = server .accept(TokioRuntime::new(), server_transport) .await - .expect("server handshake failed") + .expect("server handshake failed"); + tokio::spawn(driver); + session }; let (client_session, server_session) = tokio::join!(client_fut, server_fut); diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index e7ed342599..73c1e44a4b 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -756,7 +756,12 @@ async fn serve_connection( if let Some(publish) = grants.publish { request = request.with_subscriber(publish); } - let session = request.ok().await?; + let (session, driver) = request.ok().await?; + handle.spawn(async move { + if let Err(err) = driver.await { + tracing::debug!(%err, "session driver ended"); + } + }); let node_connection = peer_hop.map(|origin| serve.cluster.nodes.connect_inbound(id, origin)); tracing::info!(id, version = %session.version(), transport = %moq_tokio::Transport::Quic, "negotiated"); diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 1a0323a367..c14a6dff05 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -175,14 +175,13 @@ where if let Some(publish) = publish { server = server.with_subscriber(publish); } - // Hold the session so it doesn't close early; the machine serves it in place - // (an Inline runtime hands it back instead of spawning), so its lifetime and - // teardown stay tied to this handler task. - let runtime = moq_tokio::runtime::Inline::new(); - let session = server - .accept(runtime.clone(), moq_tokio::transport::Session::new(ws)) + // Keep the driver in this task so cancellation tears down the transport. + let (session, mut driver) = server + .accept( + moq_tokio::runtime::Runtime::new(), + moq_tokio::transport::Session::new(ws), + ) .await?; - let mut driver = runtime.take().expect("accept hands the machine to its runtime"); // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index 07676d14ce..a84ab4fd27 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -302,7 +302,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs index 88e549f2cd..e179710b91 100644 --- a/rs/moq-rtc/src/server/mod.rs +++ b/rs/moq-rtc/src/server/mod.rs @@ -302,7 +302,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-srt/src/dial.rs b/rs/moq-srt/src/dial.rs index 4f49072a91..8ee36fcbd1 100644 --- a/rs/moq-srt/src/dial.rs +++ b/rs/moq-srt/src/dial.rs @@ -148,7 +148,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index 38c33b0c32..3e205dc0e9 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -819,7 +819,7 @@ mod tests { const OFFSET: u64 = 600_000_000; let (origin, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); let mut broadcast = origin.create_broadcast("rewind").unwrap(); broadcast.announce(moq_net::origin::Route::default()).unwrap(); let mut catalog = moq_mux::catalog::Producer::with_catalog( diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index 9a8e6b894a..84058c7279 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -146,7 +146,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index 847b97b2f9..0646ab44b6 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -480,7 +480,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index cedf67e1f8..e7004095da 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -108,7 +108,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index b651ebd479..25f99c5a1a 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -887,7 +887,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index eaa0a1979f..ec2b474e95 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -406,9 +406,7 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - return Ok(moq - .connect(crate::runtime::Runtime::new(), crate::transport::Session::new(session)) - .await?); + return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); } // Unix domain socket (qmux, no TLS). Same-host only; the server can @@ -416,9 +414,7 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - return Ok(moq - .connect(crate::runtime::Runtime::new(), crate::transport::Session::new(session)) - .await?); + return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); } // A WebSocket URL names its transport. No QUIC backend can dial it, so there is @@ -443,9 +439,7 @@ impl Client { crate::iroh::Binding::H3 => self.moq.clone(), }; - return Ok(moq - .connect(crate::runtime::Runtime::new(), crate::transport::Session::new(session)) - .await?); + return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); } #[cfg(feature = "noq")] @@ -467,7 +461,7 @@ impl Client { #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(moq.connect(crate::runtime::Runtime::new(), session).await?); + return Ok(connect_session(&moq, session).await?); } } @@ -490,7 +484,7 @@ impl Client { #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(moq.connect(crate::runtime::Runtime::new(), session).await?); + return Ok(connect_session(&moq, session).await?); } } @@ -507,7 +501,7 @@ impl Client { #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(moq.connect(crate::runtime::Runtime::new(), session).await?); + return Ok(connect_session(&moq, session).await?); } } @@ -525,10 +519,7 @@ impl Client { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - Ok(self - .moq - .connect(crate::runtime::Runtime::new(), crate::transport::Session::new(session)) - .await?) + Ok(connect_session(&self.moq, crate::transport::Session::new(session)).await?) } /// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins. @@ -561,14 +552,10 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(moq.connect(crate::runtime::Runtime::new(), quic).await?), - TransportRace::WebSocket(websocket) => Ok(self - .moq - .connect( - crate::runtime::Runtime::new(), - crate::transport::Session::new(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?) + } } } } @@ -704,6 +691,25 @@ where } } +#[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + all(feature = "uds", unix) +))] +async fn connect_session( + client: &moq_net::Client, + transport: S, +) -> Result { + let (session, driver) = client.connect(crate::runtime::Runtime::new(), transport).await?; + use tracing::Instrument; + tokio::spawn(driver.instrument(tracing::Span::current())); + Ok(session) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rs/moq-tokio/src/origin.rs b/rs/moq-tokio/src/origin.rs index 0320543b35..c199159475 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -14,7 +14,7 @@ /// [`Hop`](moq_net::Hop) id (which uses the default config). pub fn spawn(config: impl Into) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(config.into()); - tokio::spawn(driver.run(crate::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(crate::runtime::Runtime::new())); producer } diff --git a/rs/moq-tokio/src/runtime.rs b/rs/moq-tokio/src/runtime.rs index 4d1c1769c3..6f59f3ccca 100644 --- a/rs/moq-tokio/src/runtime.rs +++ b/rs/moq-tokio/src/runtime.rs @@ -1,46 +1,19 @@ -//! The tokio runtime handle passed to moq-net's session entry points. +//! Tokio-backed timers for MoQ drivers. -use std::{marker::PhantomData, pin::Pin, task::Poll}; +use std::{pin::Pin, task::Poll}; -/// The [`moq_net::Runtime`] for tokio: protocol machines are `tokio::spawn`ed -/// and timers are tokio sleeps. -/// -/// One runtime drives one transport type (`S`), so each dial path constructs -/// its own with [`Runtime::new`]; it is a zero-sized handle. The default `S` -/// makes a bare `Runtime::new()` a transportless [`moq_net::Timers`] handle, -/// for work that only arms timers (the origin driver). -pub struct Runtime(PhantomData); +/// Supplies Tokio's clock and timers to MoQ drivers. +#[derive(Clone, Copy, Debug, Default)] +pub struct Runtime; -impl Runtime { - /// A handle for the transport type this dial produces. +impl Runtime { + /// A handle to Tokio's clock and timers. pub fn new() -> Self { - Self(PhantomData) + Self } } -impl Default for Runtime { - fn default() -> Self { - Self::new() - } -} - -impl Clone for Runtime { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for Runtime {} - -impl std::fmt::Debug for Runtime { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Runtime").finish() - } -} - -// Unbounded: timers don't involve the transport, so any `Runtime` (including -// the transportless default) hands them out. -impl moq_net::Timers for Runtime { +impl moq_net::Timers for Runtime { type Timer = Timer; fn timer(&self) -> Self::Timer { @@ -54,104 +27,6 @@ impl moq_net::Timers for Runtime { } } -// Boxable: tokio work-steals, so the machine must be `Send`. -impl moq_net::Runtime for Runtime { - type Transport = S; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - // The session surfaces the result through `closed()`; the task's own - // result is nothing to keep. - use tracing::Instrument; - tokio::spawn(machine.instrument(tracing::Span::current())); - } -} - -/// A tokio-timer runtime that hands the machine back instead of spawning it. -/// -/// For callers that drive the session inline, in their own task, so its -/// lifetime and teardown stay tied to theirs (the relay's WebSocket handler -/// does this). Accept or connect with a clone, then [`take`](Self::take) the -/// machine and poll it yourself. -/// -/// One handle serves exactly one session. Clones share its slot, which is how -/// the machine gets back from the clone `accept`/`connect` took, so a second -/// session through the same handle panics rather than silently taking the -/// first one's place: whichever way that resolved, some session would end up -/// with the wrong driver or none at all, and a session nobody drives makes no -/// progress and never processes its shutdown. -pub struct Inline { - slot: std::sync::Arc>>>, - _transport: PhantomData, -} - -impl Inline { - /// An empty handle; the next accept/connect through it fills the slot. - pub fn new() -> Self { - Self { - slot: Default::default(), - _transport: PhantomData, - } - } - - /// The machine this handle's accept/connect handed over, if any. - /// - /// The session makes no progress until the caller polls it. - pub fn take(&self) -> Option> { - self.slot.lock().unwrap().take() - } -} - -impl Default for Inline { - fn default() -> Self { - Self::new() - } -} - -impl Clone for Inline { - fn clone(&self) -> Self { - Self { - slot: self.slot.clone(), - _transport: PhantomData, - } - } -} - -impl std::fmt::Debug for Inline { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Inline").finish() - } -} - -impl moq_net::Timers for Inline { - type Timer = Timer; - - fn timer(&self) -> Self::Timer { - Timer { at: None, sleep: None } - } - - fn now(&self) -> moq_net::runtime::Instant { - tokio::time::Instant::now().into_std() - } -} - -impl moq_net::Runtime for Inline { - type Transport = S; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - let mut slot = self.slot.lock().unwrap(); - if slot.is_some() { - // Unlock first: panicking under the guard would poison the slot and - // stop the caller who used this handle correctly from ever taking - // their machine, stranding the session this is meant to protect. - drop(slot); - panic!( - "an Inline runtime drives one session: take() the machine from the previous accept/connect, or use a fresh handle" - ); - } - *slot = Some(machine); - } -} - /// A tokio sleep driven through the [`moq_net::runtime::Timer`] contract. pub struct Timer { at: Option, diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 72067c0a39..e01a778489 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1146,7 +1146,7 @@ fn spawn_stream_request( /// every transport before the caller authorizes. The variant only distinguishes the /// underlying session type; all of them delegate identically. /// A pending moq-net request over transport `S`, driven by our tokio runtime. -type PendingRequest = moq_net::server::Handshake>; +type PendingRequest = moq_net::server::Handshake; pub(crate) enum RequestKind { #[cfg(feature = "noq")] @@ -1404,7 +1404,12 @@ impl Request { /// Accept the session, starting the MoQ session loops. pub async fn ok(self) -> crate::Result { - Ok(request_into!(self.kind, request => request.ok().await?)) + Ok(request_into!(self.kind, request => { + let (session, driver) = request.ok().await?; + use tracing::Instrument; + tokio::spawn(driver.instrument(tracing::Span::current())); + session + })) } /// Returns the network transport carrying this session. diff --git a/rs/moq-uring/README.md b/rs/moq-uring/README.md index 95d68f9f12..7846cbedc6 100644 --- a/rs/moq-uring/README.md +++ b/rs/moq-uring/README.md @@ -29,10 +29,10 @@ the UDP sockets bound through it. - **WebTransport**: browsers negotiate `h3` and `quic::web::Request` runs the HTTP/3 CONNECT handshake (SETTINGS, subprotocol selection, capsule close) over the same adapter via `web-transport-proto`. `quic::web::Session` is - the one transport type the runtime drives, raw or web (`Session::raw`), so - `connect_lite`/`accept_lite` run moq-lite sessions on the worker either - way, with stream and close codes mapped through the HTTP/3 error space in - web mode. + a raw or web transport (`Session::raw`). `connect_lite`/`accept_lite` return + the session and its driver; poll the driver or await it inside a + `Handle::spawn` task to run it on the worker. Web mode maps stream and close codes through the + HTTP/3 error space. - **qlog**: `quic::qlog::Sink` points a group of workers at a directory and `quic::Transport::qlog` turns capture on. The pinned worker never writes to the file: the QUIC stacks want a `Send + Sync` writer, which cannot hold the diff --git a/rs/moq-uring/benches/session_lite.rs b/rs/moq-uring/benches/session_lite.rs index eb8601d625..a1085a1385 100644 --- a/rs/moq-uring/benches/session_lite.rs +++ b/rs/moq-uring/benches/session_lite.rs @@ -172,11 +172,12 @@ mod linux { let conn = quic::server::accept(&server_handle, server_sock, &server_config) .await .expect("quic accept"); - let session = moq_net::Server::new() + let (session, driver) = moq_net::Server::new() .with_publisher(&pub_origin) .accept_lite(server_handle.clone(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); + let _ = driver.await; session.closed().await; }); @@ -186,11 +187,14 @@ mod linux { let conn = quic::client::connect(&handle, client_sock, &dial) .await .expect("quic connect"); - let session = moq_net::Client::new() + let (session, driver) = moq_net::Client::new() .with_subscriber(sub_origin.clone()) .connect_lite(handle.clone(), quic::web::Session::raw(conn)) .await .expect("connect_lite"); + handle.spawn(async move { + let _ = driver.await; + }); let bc = { let consumer = sub_origin.consume(); consumer.routed("bench").await.expect("broadcast announced"); diff --git a/rs/moq-uring/src/lib.rs b/rs/moq-uring/src/lib.rs index b7627cfc17..e934b57b85 100644 --- a/rs/moq-uring/src/lib.rs +++ b/rs/moq-uring/src/lib.rs @@ -16,8 +16,8 @@ //! serves many connections on one socket (demuxed by connection id, dials //! included), each a [`quic::Connection`] implementing the transport traits, //! so `moq_net::Client::connect_lite` and `Server::accept_lite` run real -//! moq-lite sessions on the worker ([`Handle`] is their -//! [`moq_net::Runtime`]). The stack underneath is the `noq` (default), +//! moq-lite sessions on the worker. [`Handle`] supplies [`moq_net::Timers`]; +//! callers run the returned drivers with [`Handle::spawn`]. The stack underneath is the `noq` (default), //! `quinn`, or `quiche` feature; the module is the same either way, and a build with //! neither leaves it out. //! diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index b9d3381e44..7ce0632dd2 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -7,7 +7,7 @@ //! selection) using [`web_transport_proto`]'s state machines, and //! [`Request::respond`] yields a [`Session`]. //! -//! [`Session`] is the one transport type the worker's runtime drives: +//! [`Session`] supports both raw QUIC and WebTransport on the worker: //! [`Session::raw`] wraps a raw-QUIC connection in the same type with the //! WebTransport layering disabled, so native peers and browsers run the same //! machinery. Stream and session error codes map through the HTTP/3 error @@ -419,7 +419,7 @@ struct State { /// A MoQ transport over the worker: raw QUIC, or a WebTransport session. /// -/// The runtime's one transport type. [`Request::respond`] builds the web +/// [`Request::respond`] builds the web /// flavor; [`Session::raw`] wraps a raw-QUIC [`Connection`] with the layering /// disabled. Clones share the session. pub struct Session { @@ -431,7 +431,7 @@ pub struct Session { } impl Session { - /// Wrap a raw-QUIC connection in the runtime's transport type, with no + /// Wrap a raw-QUIC connection in a session, with no /// WebTransport layering: streams and datagrams pass through untouched. pub fn raw(conn: Connection) -> Self { let protocol = web_transport_trait::poll::Session::protocol(&conn).map(str::to_owned); diff --git a/rs/moq-uring/src/worker.rs b/rs/moq-uring/src/worker.rs index 43c2302da4..44393d92a5 100644 --- a/rs/moq-uring/src/worker.rs +++ b/rs/moq-uring/src/worker.rs @@ -544,24 +544,6 @@ impl moq_net::Timers for Handle { } } -// Without a QUIC backend there is no transport to name, so the worker is a -// task and timer runtime only. -#[cfg(any(feature = "noq", feature = "quiche", feature = "quinn"))] -impl moq_net::Runtime for Handle { - type Transport = crate::quic::web::Session; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - // The machine is `!Send` (its transport is), which is exactly what the - // worker's local spawn takes. Its result is the session outcome, which - // the `Session` handle also observes; here it is just the task ending. - self.spawn(async move { - if let Err(err) = machine.await { - tracing::debug!(%err, "session machine ended"); - } - }); - } -} - /// The running kernel release, for error messages. fn kernel_release() -> String { // SAFETY: all-zero is a valid utsname out-buffer. diff --git a/rs/moq-uring/tests/session.rs b/rs/moq-uring/tests/session.rs index 2dd1af2af8..ee0364c813 100644 --- a/rs/moq-uring/tests/session.rs +++ b/rs/moq-uring/tests/session.rs @@ -92,11 +92,12 @@ fn lite_session_over_the_worker() { let conn = quic::server::accept(&server_handle, server_sock, &server_config) .await .expect("quic accept"); - let session = moq_net::Server::new() + let (session, driver) = moq_net::Server::new() .with_publisher(&pub_origin) .accept_lite(server_handle.clone(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); + let _ = driver.await; // Serve until the client walks away. session.closed().await; }); @@ -113,11 +114,14 @@ fn lite_session_over_the_worker() { "negotiated ALPN" ); - let session = moq_net::Client::new() + let (session, driver) = moq_net::Client::new() .with_subscriber(sub.clone()) .connect_lite(handle.clone(), quic::web::Session::raw(conn)) .await .expect("connect_lite"); + handle.spawn(async move { + let _ = driver.await; + }); let bc = { let consumer = sub.consume(); @@ -209,11 +213,12 @@ fn two_lite_sessions_share_the_server_socket() { let pub_origin = pub_origin.clone(); let session_handle = server_handle.clone(); server_handle.spawn(async move { - let session = moq_net::Server::new() + let (session, driver) = moq_net::Server::new() .with_publisher(&pub_origin) .accept_lite(session_handle, quic::web::Session::raw(conn)) .await .expect("accept_lite"); + let _ = driver.await; session.closed().await; }); } @@ -233,11 +238,14 @@ fn two_lite_sessions_share_the_server_socket() { let conn = quic::client::connect(&handle, client_sock, &dial) .await .expect("quic connect"); - let session = moq_net::Client::new() + let (session, driver) = moq_net::Client::new() .with_subscriber(sub.clone()) .connect_lite(handle.clone(), quic::web::Session::raw(conn)) .await .expect("connect_lite"); + handle.spawn(async move { + let _ = driver.await; + }); let bc = { let consumer = sub.consume(); diff --git a/rs/moq-uring/tests/web.rs b/rs/moq-uring/tests/web.rs index b8448c3e83..dc01dedde0 100644 --- a/rs/moq-uring/tests/web.rs +++ b/rs/moq-uring/tests/web.rs @@ -235,13 +235,14 @@ fn lite_session_over_webtransport() { Box::pin(async move { assert_eq!(session.protocol(), Some(PROTO), "negotiated subprotocol"); let (sub_origin, sub_driver) = origin::Producer::new(origin::Config::new(moq_net::Hop::random())); - let driver = tokio::spawn(sub_driver.run(moq_tokio::runtime::Runtime::<()>::new())); + let driver = tokio::spawn(sub_driver.run(moq_tokio::runtime::Runtime::new())); - let moq = moq_net::Client::new() + let (moq, session_driver) = moq_net::Client::new() .with_subscriber(sub_origin.clone()) .connect_lite(moq_tokio::runtime::Runtime::new(), session) .await .expect("connect_lite"); + tokio::spawn(session_driver); let bc = { let consumer = sub_origin.consume(); @@ -281,11 +282,14 @@ fn lite_session_over_webtransport() { } let session = request.respond(response).await.expect("respond"); - let session = moq_net::Server::new() + let (session, driver) = moq_net::Server::new() .with_publisher(&serve_origin) .accept_lite(handle.clone(), session) .await .expect("accept_lite"); + handle.spawn(async move { + let _ = driver.await; + }); session.closed().await; }) .expect("worker"); diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 9ef9ffa745..84174ae36d 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -145,7 +145,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::<()>::new())); + tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-wasm/README.md b/rs/moq-wasm/README.md index 12dff5542b..11c4fa1bbd 100644 --- a/rs/moq-wasm/README.md +++ b/rs/moq-wasm/README.md @@ -17,9 +17,9 @@ that path is unrelated to this crate.) What works today: -- **The architecture is right.** `moq-net` is generic over - `web_transport_trait::poll::Session` and spawns via `web_async::spawn` (not - `tokio::spawn`), so it is not tied to native QUIC. +- **Executor-independent sessions.** `moq-net` is generic over + `web_transport_trait::poll::Session` and returns a driver for the caller to + run. `moq-wasm` spawns that driver via `web_async::spawn` on the browser. - **The browser transport needs no adapter**: `web-transport-wasm` implements the poll traits `moq-net` consumes, so `src/transport.rs` is just the dial (the ALPN list and the browser's two trust modes). diff --git a/rs/moq-wasm/src/lib.rs b/rs/moq-wasm/src/lib.rs index f659e38eda..ca9d762bab 100644 --- a/rs/moq-wasm/src/lib.rs +++ b/rs/moq-wasm/src/lib.rs @@ -9,11 +9,8 @@ //! which is the highest-value target (the `@moq/watch` use case). The publish //! path follows the same shape and is left as the obvious next step. //! -//! moq-net's timers and spawning come from the `moq_net::Runtime` passed at -//! connect; `runtime::Runtime` supplies both for the browser (wasmtimer-backed -//! timers, microtask spawning), so the consume path runs in the browser. -//! (`model/time.rs` has an unused wall-clock helper that isn't wasm-portable, -//! but nothing calls it, so it never runs. See README.md.) +//! `runtime::Runtime` supplies browser timers to moq-net. This crate spawns +//! the returned session drivers on the browser's microtask queue. // Browser-only crate. Empty on native so `cargo check --workspace` stays green. #![cfg(target_arch = "wasm32")] @@ -78,10 +75,12 @@ impl Session { web_async::spawn(origin_driver.run(runtime::Runtime)); let consumer = origin.consume(); let client = moq_net::Client::new().with_subscriber(origin); - // The runtime spawns the protocol machine on the microtask queue. The machine - // holds no session clone, so dropping this `Session` still closes the - // transport, which in turn ends the spawned task. - let inner = client.connect(runtime::Runtime, transport).await.map_err(js_err)?; + // The driver holds no session clone, so dropping this `Session` still + // closes the transport and ends the spawned task. + let (inner, driver) = client.connect(runtime::Runtime, transport).await.map_err(js_err)?; + web_async::spawn(async move { + let _ = driver.await; + }); Ok(Session { inner, consumer }) } diff --git a/rs/moq-wasm/src/runtime.rs b/rs/moq-wasm/src/runtime.rs index 00d9847e64..8aec93c9bb 100644 --- a/rs/moq-wasm/src/runtime.rs +++ b/rs/moq-wasm/src/runtime.rs @@ -1,9 +1,8 @@ -//! The browser runtime: microtask spawning and `wasmtimer`-backed timers. +//! Browser-backed timers for MoQ drivers. use std::{pin::Pin, task::Poll}; -/// The [`moq_net::Runtime`] for the browser: machines run on the microtask -/// queue via `web_async::spawn`, timers are `setTimeout`-backed sleeps. +/// Supplies the browser clock and timers to MoQ drivers. #[derive(Clone, Copy, Debug, Default)] pub struct Runtime; @@ -15,18 +14,6 @@ impl moq_net::Timers for Runtime { } } -impl moq_net::Runtime for Runtime { - type Transport = web_transport_wasm::Session; - - fn spawn(&self, machine: moq_net::runtime::Machine) { - web_async::spawn(async move { - // The session surfaces the result through `closed()`; nothing to do - // with it here. - let _ = machine.await; - }); - } -} - /// A `web_async::time::Sleep` driven through the [`moq_net::runtime::Timer`] /// contract. pub struct Timer { From 8748ae74f6ab50014fe4ac5b78f27eeb8b857428 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 20 Sep 2026 15:19:51 -0700 Subject: [PATCH 2/5] refactor(net)!: drive time and cache cleanup explicitly Supply time when polling session and origin drivers, move runtime sleeping into adapters, and expose Pool::gc for approximate idle cleanup. Co-Authored-By: GPT-6 --- doc/lib/rs/moq-net.md | 44 ++- rs/libmoq/src/publish.rs | 6 +- rs/moq-e2ee/Cargo.toml | 1 + rs/moq-e2ee/src/datagram.rs | 9 +- rs/moq-e2ee/src/tests.rs | 20 +- rs/moq-e2ee/tests/support/harness.rs | 60 +-- rs/moq-e2ee/tests/transport.rs | 4 +- rs/moq-ffi/src/origin.rs | 4 +- rs/moq-ffi/src/producer.rs | 2 +- rs/moq-ffi/src/runtime.rs | 65 ++- rs/moq-ffi/src/session.rs | 4 +- rs/moq-hls/src/export/mod.rs | 2 +- rs/moq-hls/src/export/upstream.rs | 2 +- rs/moq-hls/src/server/mod.rs | 2 +- rs/moq-mux/src/source.rs | 2 +- rs/moq-net/src/client.rs | 74 ++-- rs/moq-net/src/driver.rs | 84 ++-- rs/moq-net/src/goaway.rs | 12 +- rs/moq-net/src/ietf/control.rs | 2 +- rs/moq-net/src/ietf/publisher.rs | 63 ++- rs/moq-net/src/ietf/session.rs | 52 +-- rs/moq-net/src/ietf/subscriber.rs | 111 +++--- rs/moq-net/src/lib.rs | 40 +- rs/moq-net/src/lite/publisher.rs | 65 ++- rs/moq-net/src/lite/session.rs | 35 +- rs/moq-net/src/lite/subscriber.rs | 35 +- rs/moq-net/src/model/cache.rs | 230 +++++++---- rs/moq-net/src/model/clock.rs | 43 +- rs/moq-net/src/model/group.rs | 48 +-- rs/moq-net/src/model/mod.rs | 2 +- rs/moq-net/src/model/origin.rs | 187 +++------ rs/moq-net/src/model/resume.rs | 12 +- rs/moq-net/src/model/time.rs | 23 +- rs/moq-net/src/model/track.rs | 437 +++++++++++++++++---- rs/moq-net/src/runtime.rs | 151 +------ rs/moq-net/src/server.rs | 110 +++--- rs/moq-net/src/session.rs | 38 +- rs/moq-net/src/time.rs | 217 ++++++++++ rs/moq-net/tests/datagram.rs | 40 +- rs/moq-net/tests/goaway.rs | 10 +- rs/moq-net/tests/support/harness.rs | 60 +-- rs/moq-relay/src/uring.rs | 5 +- rs/moq-relay/src/websocket.rs | 7 +- rs/moq-rtc/src/egress.rs | 2 +- rs/moq-rtc/src/server/mod.rs | 2 +- rs/moq-srt/src/dial.rs | 2 +- rs/moq-srt/src/server.rs | 2 +- rs/moq-srt/src/ts.rs | 2 +- rs/moq-stats/src/aggregate.rs | 2 +- rs/moq-stats/src/consume.rs | 2 +- rs/moq-stats/src/produce.rs | 2 +- rs/moq-tokio/src/client.rs | 6 +- rs/moq-tokio/src/origin.rs | 2 +- rs/moq-tokio/src/runtime.rs | 85 ++-- rs/moq-tokio/src/server.rs | 19 +- rs/moq-uring/README.md | 2 +- rs/moq-uring/benches/session_lite.rs | 55 +-- rs/moq-uring/src/lib.rs | 2 +- rs/moq-uring/src/quic/quiche/connection.rs | 10 +- rs/moq-uring/src/quic/quinn/connection.rs | 4 +- rs/moq-uring/src/quic/web.rs | 4 +- rs/moq-uring/src/timer.rs | 42 +- rs/moq-uring/src/worker.rs | 34 +- rs/moq-uring/tests/endpoint.rs | 2 +- rs/moq-uring/tests/session.rs | 39 +- rs/moq-uring/tests/support/quiche.rs | 50 +-- rs/moq-uring/tests/web.rs | 15 +- rs/moq-uring/tests/workers.rs | 2 +- rs/moq-video/src/decode/consumer.rs | 2 +- rs/moq-wasm/README.md | 17 +- rs/moq-wasm/src/lib.rs | 11 +- rs/moq-wasm/src/runtime.rs | 65 ++- 72 files changed, 1539 insertions(+), 1365 deletions(-) create mode 100644 rs/moq-net/src/time.rs diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index a54936068f..1afdcec357 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -39,26 +39,44 @@ grammar lives on the concept page. ## Driving sessions -`Client::connect(timers, transport)`, `Server::accept(timers, transport)`, and +`Client::connect(now, transport)`, `Server::accept(now, transport)`, and `server::Handshake::ok()` return `(Session, Driver)`. The lite-only entry points -return the same pair. `moq-net` never spawns tasks: poll or spawn the driver to -run the protocol, process close requests, and update session statistics. +return the same pair. `moq-net` never spawns tasks or schedules runtime timers. ```rust -let (session, driver) = client - .connect(moq_tokio::runtime::Runtime::new(), transport) - .await?; -tokio::spawn(driver); +let now = tokio::time::Instant::now().into_std(); +let (session, driver) = client.connect(now, transport).await?; +tokio::spawn(moq_tokio::runtime::run(driver)); ``` +A custom event loop calls `driver.poll(now, waiter)` with a nondecreasing +`moq_net::time::Instant`. After `Pending`, wait for external activity or the +instant returned by `driver.timeout()`, whichever comes first. `None` means +there is no timer to schedule. Poll again with fresh time after waking. +Tests use the same interface with explicitly advanced instants. + Dropping the last session handle requests closure when the driver next runs. Dropping the driver cancels the session. Keep both alive while using the -connection. `moq-tokio` and `moq-wasm` spawn the drivers for their callers. - -`Timers` supplies a clock and re-armable timers independently of the transport -and executor. `runtime::Test` (feature `test-runtime`) provides virtual time; -tests advance its clock and poll their drivers explicitly. The lite-only path -also supports native `!Send` transports when driven on their owning thread. +connection. `moq-tokio` and `moq-wasm` drive sessions for their callers; the +lite-only path also supports native `!Send` transports on their owning thread. + +Origin drivers implement the same `time::Driver` interface. +`origin::Producer::new` returns its lifecycle driver, which also calls +`pool.gc(now)` after polling and includes its next cleanup time in the +origin's deadline. For a standalone pool, call `pool.gc(now)` yourself after +polling and at the returned deadline, even when there is no traffic. +`None` means both cache policies are disabled; call again after enabling a +capacity with `pool.resize`. + +Cache reads and writes clear their expiration timestamp without reading the +system clock. A due cleanup pass scans cached groups, dates undated activity, +and expires idle groups except each track's latest. Calls before the cleanup +deadline only advance the sampled clock, so calling after every poll is cheap. +Expiration is approximate: delayed cleanup extends retention. Frame APIs need +no time argument. Datagram insertion takes an explicit arrival instant: +`append_datagram(now, timestamp, payload)` or +`insert_datagram(now, Datagram { sequence, timestamp, payload })`. +This changes Rust APIs, with no wire-format or TypeScript API changes. ## Patterns diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index 4b4d0c2aa9..049bbe049e 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -694,7 +694,11 @@ impl Publish { pub fn track_datagram(&mut self, track: Id, timestamp_us: u64, payload: &[u8]) -> Result { let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; let timestamp = moq_net::Timestamp::from_micros(timestamp_us)?; - Ok(track.append_datagram(timestamp, bytes::Bytes::copy_from_slice(payload))?) + Ok(track.append_datagram( + moq_net::time::Instant::now(), + timestamp, + bytes::Bytes::copy_from_slice(payload), + )?) } /// Finish a raw track. No more groups or frames can be written. diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index 051d7fcadd..4a6ff8c374 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -25,6 +25,7 @@ zeroize = { workspace = true } [dev-dependencies] criterion = { workspace = true } hex = { workspace = true } +moq-net = { workspace = true, features = ["test-runtime"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } web-transport-trait = { workspace = true } diff --git a/rs/moq-e2ee/src/datagram.rs b/rs/moq-e2ee/src/datagram.rs index b5d6760862..3a42553129 100644 --- a/rs/moq-e2ee/src/datagram.rs +++ b/rs/moq-e2ee/src/datagram.rs @@ -40,7 +40,14 @@ pub(crate) fn insert_ciphertext( timestamp: moq_net::Timestamp, payload: Bytes, ) -> moq_net::Result<()> { - track.insert_datagram(sequence, timestamp, payload) + track.insert_datagram( + moq_net::time::Instant::now(), + moq_net::Datagram { + sequence, + timestamp, + payload: moq_net::IntoBytes::into_bytes(payload), + }, + ) } /// Ciphertext budget for a datagram that will encode these fields. diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 1484176e33..4708907b66 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -474,8 +474,15 @@ fn bad_datagram_is_event() { }; payload[0] ^= 1; let payload = bytes::Bytes::from(payload); - net.insert_datagram(1, moq_net::Timestamp::from_millis(2).unwrap(), payload) - .unwrap(); + net.insert_datagram( + std::time::Instant::now(), + moq_net::Datagram { + sequence: 1, + timestamp: moq_net::Timestamp::from_millis(2).unwrap(), + payload: moq_net::IntoBytes::into_bytes(payload), + }, + ) + .unwrap(); producer .insert_datagram(2, moq_net::Timestamp::from_millis(3).unwrap(), b"three") .unwrap(); @@ -609,9 +616,12 @@ fn forged_datagram_does_not_burn_identity() { }; forged[0] ^= 1; net.insert_datagram( - 1, - moq_net::Timestamp::from_millis(2).unwrap(), - bytes::Bytes::from(forged), + std::time::Instant::now(), + moq_net::Datagram { + sequence: 1, + timestamp: moq_net::Timestamp::from_millis(2).unwrap(), + payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from(forged)), + }, ) .unwrap(); // Real seq 1 after the forgery. diff --git a/rs/moq-e2ee/tests/support/harness.rs b/rs/moq-e2ee/tests/support/harness.rs index 85ead4fd0c..dac549d653 100644 --- a/rs/moq-e2ee/tests/support/harness.rs +++ b/rs/moq-e2ee/tests/support/harness.rs @@ -7,60 +7,14 @@ #![allow(dead_code)] -use std::{pin::Pin, task::Poll}; - use moq_net::{Client, Server, Session, Version, origin}; use super::mock::create_mock_session_pair; -/// Tokio's clock and timers for these tests. -#[derive(Clone, Default)] -pub struct TokioRuntime; - -impl TokioRuntime { - pub fn new() -> Self { - Self - } -} - -impl moq_net::runtime::Timers for TokioRuntime { - type Timer = TokioTimer; +pub use moq_net::time::test::run; - fn timer(&self) -> Self::Timer { - TokioTimer { at: None, sleep: None } - } - - fn now(&self) -> moq_net::runtime::Instant { - tokio::time::Instant::now().into_std() - } -} - -/// The [`moq_net::runtime::Timer`] handed out by [`TokioRuntime`]. -pub struct TokioTimer { - at: Option, - // Allocated on the first poll after arming, then re-armed in place via - // `Sleep::reset`; construction panics without a live tokio time driver. - sleep: Option>>, -} - -impl moq_net::runtime::Timer for TokioTimer { - fn set(&mut self, at: Option) { - self.at = at; - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(tokio::time::Instant::from_std(at)); - } - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std(at)))); - if sleep.is_elapsed() { - return Poll::Ready(()); - } - waiter.poll_future(sleep.as_mut()) - } +pub fn now() -> moq_net::time::Instant { + tokio::time::Instant::now().into_std() } /// Options for [`connect_mock`]. @@ -132,18 +86,18 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { // machine is polled (and vice versa for the server's own SETUP). let client_fut = async { let (session, driver) = client - .connect(TokioRuntime::new(), client_transport) + .connect(now(), client_transport) .await .expect("client handshake failed"); - tokio::spawn(driver); + tokio::spawn(run(driver)); session }; let server_fut = async { let (session, driver) = server - .accept(TokioRuntime::new(), server_transport) + .accept(now(), server_transport) .await .expect("server handshake failed"); - tokio::spawn(driver); + tokio::spawn(run(driver)); session }; let (client_session, server_session) = tokio::join!(client_fut, server_fut); diff --git a/rs/moq-e2ee/tests/transport.rs b/rs/moq-e2ee/tests/transport.rs index 6b9d4a6fc7..57c486ffbd 100644 --- a/rs/moq-e2ee/tests/transport.rs +++ b/rs/moq-e2ee/tests/transport.rs @@ -6,13 +6,13 @@ use std::time::Duration; use moq_e2ee::{Credential, PROFILE}; use moq_net::{Hop, Timestamp, Version}; -use support::harness::{MockConnectOptions, MockPair, TokioRuntime, connect_mock}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; const TEST_TIMEOUT: Duration = Duration::from_secs(10); fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); - tokio::spawn(driver.run(TokioRuntime::new())); + tokio::spawn(support::harness::run(driver)); producer } diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index d4a59138dc..b7c2ef5a90 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -206,9 +206,9 @@ impl MoqOriginProducer { pub(crate) fn spawn(config: moq_net::origin::Config) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(config); #[cfg(not(target_arch = "wasm32"))] - crate::ffi::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + crate::ffi::spawn(moq_tokio::runtime::run(driver)); #[cfg(target_arch = "wasm32")] - crate::ffi::spawn(driver.run(crate::runtime::Runtime)); + crate::ffi::spawn(crate::runtime::run(driver)); producer } diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index de62d0c93a..ccf5a2b453 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -763,7 +763,7 @@ impl MoqTrackProducer { let timestamp = moq_net::Timestamp::from_micros(frame.timestamp_us)?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; - Ok(track.append_datagram(timestamp, frame.payload)?) + Ok(track.append_datagram(moq_net::time::Instant::now(), timestamp, frame.payload)?) } /// Abort this track with an application error code. diff --git a/rs/moq-ffi/src/runtime.rs b/rs/moq-ffi/src/runtime.rs index 58196d7379..6bc9252224 100644 --- a/rs/moq-ffi/src/runtime.rs +++ b/rs/moq-ffi/src/runtime.rs @@ -1,45 +1,28 @@ -//! Browser-backed timers for MoQ drivers. +//! Run MoQ drivers with the runtime clock and timer. -use std::{pin::Pin, task::Poll}; +use std::task::Poll; -/// Supplies the browser clock and timers to MoQ drivers. -#[derive(Clone, Copy, Debug, Default)] -pub(crate) struct Runtime; - -impl moq_net::Timers for Runtime { - type Timer = Timer; - - fn timer(&self) -> Self::Timer { - Timer { at: None, sleep: None } - } -} - -/// A `web_async::time::Sleep` driven through the [`moq_net::runtime::Timer`] -/// contract. -pub(crate) struct Timer { - at: Option, - // Allocated on the first poll after arming, then re-armed in place. - sleep: Option>>, -} - -impl moq_net::runtime::Timer for Timer { - fn set(&mut self, at: Option) { - self.at = at; - // Reuse the allocation when there is one; `reset` also clears the - // elapsed state. - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(at); - } - } - - fn poll(&mut self, waiter: &moq_net::kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(web_async::time::sleep_until(at))); - if sleep.is_elapsed() { - return Poll::Ready(()); +/// Run an explicit-time driver on the runtime's clock and timer. +pub async fn run(mut driver: D) -> D::Output { + let mut timer = None; + moq_net::kio::wait(|waiter| { + loop { + let now = web_async::time::Instant::now(); + if let Poll::Ready(result) = driver.poll(now, waiter) { + return Poll::Ready(result); + } + let Some(at) = driver.timeout() else { + timer = None; + return Poll::Pending; + }; + let sleep = timer.get_or_insert_with(|| Box::pin(web_async::time::sleep_until(at))); + if sleep.deadline() != at { + sleep.as_mut().reset(at); + } + if waiter.poll_future(sleep.as_mut()).is_pending() { + return Poll::Pending; + } } - waiter.poll_future(sleep.as_mut()) - } + }) + .await } diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 6e1a72750d..4d20a81201 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -297,11 +297,11 @@ impl Client { let (session, driver) = moq_net::Client::new() .with_publisher(&publish) .with_subscriber(subscribe.clone()) - .connect(crate::runtime::Runtime, transport) + .connect(web_async::time::Instant::now(), transport) .await?; crate::ffi::spawn(async move { - let _ = driver.await; + let _ = crate::runtime::run(driver).await; }); Ok(Arc::new(MoqSession::accepted(session, publish, subscribe))) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 629c1b75fa..a7abbeece9 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -360,7 +360,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-hls/src/export/upstream.rs b/rs/moq-hls/src/export/upstream.rs index 767b1cb51d..4300692438 100644 --- a/rs/moq-hls/src/export/upstream.rs +++ b/rs/moq-hls/src/export/upstream.rs @@ -40,7 +40,7 @@ mod tests { #[tokio::test] async fn self_references_keep_the_catalog_broadcast_after_replacement() { let (origin, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); - let driver = tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + let driver = tokio::spawn(moq_tokio::runtime::run(driver)); let old = origin.create_broadcast("a/live").unwrap(); let source = moq_mux::Source::new(origin.consume(), "a/live"); let upstream = Upstream { diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index 1879cbacf2..25477a97dd 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -146,7 +146,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index f475bf2e60..4b7d2c570b 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -288,7 +288,7 @@ impl BroadcastConfig for hang::catalog::BinaryConfig { pub(crate) fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 1e7dcc1eea..1ef2ae0776 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -1,4 +1,7 @@ use crate::origin; +#[cfg(test)] +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_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06_WIP, Consume, Error, NEGOTIATED, Session, Version, Versions, @@ -6,8 +9,6 @@ use crate::{ ietf, lite, setup, stats, }; -use web_transport_trait::{MaybeSend, MaybeSync}; - /// A MoQ client session builder. #[derive(Default, Clone)] pub struct Client { @@ -152,15 +153,14 @@ impl Client { /// Start a lite session on an already-negotiated version: build our SETUP, /// wire the origins, and return the session and its driver. - fn start_lite( + fn start_lite( &self, - runtime: R, + runtime: Clock, session: S, version: lite::Version, - ) -> Result<(Session, crate::Driver), Error> + ) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { let (publish, subscribe) = self.origins(); @@ -213,11 +213,11 @@ impl Client { /// [`Boxable`](crate::transport::poll::Boxable) transport. An ietf ALPN, an /// unknown one, or the legacy no-ALPN SETUP negotiation is refused with /// [`Error::Version`]. - pub async fn connect_lite(&self, runtime: R, session: S) -> Result<(Session, crate::Driver), Error> + pub async fn connect_lite(&self, now: Instant, session: S) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { + let runtime = Clock::new(now); let version = match session.protocol() { Some(ALPN_LITE_06_WIP) => lite::Version::Lite06Wip, Some(ALPN_LITE_05) => lite::Version::Lite05, @@ -231,14 +231,12 @@ impl Client { /// Perform the MoQ handshake, returning the [`Session`] and its [`Driver`](crate::Driver). /// - /// Poll or spawn the returned driver to run the session. `runtime` supplies - /// only its clock and timers. - pub async fn connect(&self, runtime: R, mut session: S) -> Result<(Session, crate::Driver), Error> + /// Poll the returned driver with nondecreasing time, starting at `now`. + pub async fn connect(&self, now: Instant, mut session: S) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { + let runtime = Clock::new(now); let (publish, subscribe) = self.origins(); // If ALPN was used to negotiate the version, use the appropriate encoding. @@ -681,10 +679,10 @@ mod tests { // Start the returned driver after the handshake completes. let (_session, driver) = client - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); - tokio::spawn(driver); + tokio::spawn(crate::time::test::run(driver)); // Verify the client setup was encoded using Draft14 framing (ALPN_LITE fallback path). let mut setup_bytes = Bytes::from(fake.control_writes()); @@ -732,7 +730,7 @@ mod tests { // on the silent peer trips this rather than hanging the suite. let (_session, _driver) = tokio::time::timeout( std::time::Duration::from_secs(30), - client.connect(crate::runtime::tokio_test::Tokio::new(), transport), + client.connect(tokio::time::Instant::now().into_std(), transport), ) .await .expect("connect waited on a peer that never announced") @@ -757,17 +755,17 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let runtime = crate::runtime::Test::new(); - let (session, mut driver) = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); + let (session, mut driver) = futures::executor::block_on(client.connect(runtime.now(), fake.clone())).unwrap(); assert_eq!(session.version(), Version::Lite(lite::Version::Lite04)); // Construction leaves the driver idle until the caller polls it. - assert!(driver.poll(&kio::Waiter::noop()).is_pending()); + assert!(driver.poll(runtime.now(), &kio::Waiter::noop()).is_pending()); // The caller drops their only session clone; the machine observes the // last handle going away and closes the transport. drop(session); assert!(fake.state.close_events.lock().unwrap().is_empty()); - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -783,17 +781,17 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let runtime = crate::runtime::Test::new(); - let (session, mut driver) = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); + let (session, mut driver) = futures::executor::block_on(client.connect(runtime.now(), fake.clone())).unwrap(); let clone = session.clone(); // One clone dropping does nothing while another is alive. drop(session); assert!(fake.state.close_events.lock().unwrap().is_empty()); - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert!(fake.state.close_events.lock().unwrap().is_empty()); clone.abort(Error::Cancel); - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -801,13 +799,13 @@ mod tests { // And the machine publishes the transport's terminal error, which is // what `closed()` reports. - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); futures::executor::block_on(clone.closed()); // The final drop requests no second close: the handle-side close is once. let closes = fake.state.close_events.lock().unwrap().len(); drop(clone); - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!(fake.state.close_events.lock().unwrap().len(), closes); } @@ -820,7 +818,7 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let runtime = crate::runtime::Test::new(); - let (session, driver) = futures::executor::block_on(client.connect(runtime.clone(), fake.clone())).unwrap(); + let (session, driver) = futures::executor::block_on(client.connect(runtime.now(), fake.clone())).unwrap(); drop(driver); assert!(matches!(futures::executor::block_on(session.closed()), Error::Cancel)); @@ -983,14 +981,14 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let runtime = crate::runtime::Test::new(); - let (session, mut driver) = futures::executor::block_on(client.connect_lite(runtime.clone(), local)).unwrap(); - assert!(driver.poll(&kio::Waiter::noop()).is_pending()); + let (session, mut driver) = futures::executor::block_on(client.connect_lite(runtime.now(), local)).unwrap(); + assert!(driver.poll(runtime.now(), &kio::Waiter::noop()).is_pending()); fn assert_send_sync(_: &T) {} assert_send_sync(&session); session.abort(Error::Cancel); - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -1009,13 +1007,13 @@ mod tests { let server = crate::Server::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let runtime = crate::runtime::Test::new(); - let (session, mut driver) = futures::executor::block_on(server.accept_lite(runtime.clone(), local)).unwrap(); + let (session, mut driver) = futures::executor::block_on(server.accept_lite(runtime.now(), local)).unwrap(); assert_eq!(session.version(), Version::Lite(lite::Version::Lite04)); - assert!(driver.poll(&kio::Waiter::noop()).is_pending()); + assert!(driver.poll(runtime.now(), &kio::Waiter::noop()).is_pending()); drop(session); assert!(fake.state.close_events.lock().unwrap().is_empty()); - let _ = driver.poll(&kio::Waiter::noop()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -1033,7 +1031,7 @@ mod tests { }; let client = Client::new(); let runtime = crate::runtime::Test::new(); - let result = futures::executor::block_on(client.connect_lite(runtime, local)); + let result = futures::executor::block_on(client.connect_lite(runtime.now(), local)); assert!(matches!(result, Err(Error::Version))); } @@ -1047,10 +1045,10 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let (session, driver) = client - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); - tokio::spawn(driver); + tokio::spawn(crate::time::test::run(driver)); // The construction-time snapshot, before the machine sampled anything. assert_eq!( @@ -1080,10 +1078,10 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let (session, driver) = client - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); - tokio::spawn(driver); + tokio::spawn(crate::time::test::run(driver)); assert!( session.send_bandwidth().is_none(), "no send-rate estimate, so nothing samples on its own" @@ -1111,10 +1109,10 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let (session, driver) = client - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); - tokio::spawn(driver); + tokio::spawn(crate::time::test::run(driver)); let mut bandwidth = session.send_bandwidth().expect("backend reports an estimate"); assert_eq!( diff --git a/rs/moq-net/src/driver.rs b/rs/moq-net/src/driver.rs index ae4a4d248a..23f357e11d 100644 --- a/rs/moq-net/src/driver.rs +++ b/rs/moq-net/src/driver.rs @@ -2,26 +2,24 @@ use std::task::Poll; -use crate::{Error, runtime::Timers}; +use crate::Error; +use crate::time::{Clock, Instant}; -/// The future driving a [`crate::Session`]'s protocol state. +/// Drives a session with caller-supplied time. /// -/// Returned by [`crate::Client::connect`] and [`crate::Server::accept`]. Poll it, -/// either as a [`Future`] or by stepping [`poll`](Self::poll) from a -/// [`kio`]-style poll function. It resolves when the session ends, and keeps -/// returning that same result if polled again. +/// Returned by [`crate::Client::connect`] and [`crate::Server::accept`]. Call +/// [`poll`](Self::poll) when external activity wakes the waiter or when +/// [`timeout`](Self::timeout) is reached. Supply nondecreasing instants. +/// Completion is cached, so subsequent polls return the same result. /// /// It holds no session handle: dropping the last [`crate::Session`] requests /// closure on the next poll. Dropping the driver cancels the session, and /// [`crate::Session::closed`] resolves with [`Error::Cancel`]. Its `Send`-ness -/// follows its transport and timer provider. +/// follows its transport. #[must_use = "the session makes no progress unless its driver is polled"] -pub struct Driver { - state: State, - // Retains the waiter across `Future` polls so its kio registrations stay - // live. Kept out of `State` so the borrow `hold` hands back doesn't - // collide with the `&mut` that polling the state needs. - park: kio::Park, +pub struct Driver { + state: State, + clock: Clock, } /// The protocol half of a machine, one variant per negotiated wire protocol. @@ -32,16 +30,15 @@ pub struct Driver { /// demands `Send` on native, which is why the ietf path requires a /// [`Boxable`](crate::transport::poll::Boxable) transport until it too becomes /// a named machine. -pub(crate) enum Protocol { +pub(crate) enum Protocol { /// Boxed for size only: a concrete box, so `Send` stays inferred. - Lite(Box>), + Lite(Box>), Ietf(crate::util::MaybeSendBox<'static, Result<(), Error>>), } -/// Everything the machine polls, split from the park so the two borrow -/// disjointly. -pub(crate) struct State { - pub(crate) protocol: Protocol, +/// Protocol and lifecycle work owned by the driver. +pub(crate) struct State { + pub(crate) protocol: Protocol, // The session supervisor, polled alongside the protocol: it executes the // handles' close requests, publishes the transport's terminal error, and // samples stats. It finishes once the transport reports closed, and the @@ -49,29 +46,26 @@ pub(crate) struct State { // is what `Session::closed` observes, so resolving before it is published // would leave waiters parked on a machine nobody polls again. `None` once // finished, since a completed machine must not be polled again. - pub(crate) supervisor: Option>, + pub(crate) supervisor: Option>, // Cached so a poll after completion doesn't re-poll a finished protocol. pub(crate) result: Option>, } -impl Driver { - pub(crate) fn new(state: State) -> Self { - Self { - state, - park: kio::Park::default(), - } +impl Driver { + pub(crate) fn new(clock: Clock, state: State) -> Self { + Self { state, clock } } - /// Drive the protocol one step, registering `waiter` for the next wakeup. + /// Process ready work at `now`, registering for external activity. /// - /// The `poll_*` counterpart of `.await`ing the machine, for runtimes - /// composing it into their own [`kio`]-style poll loops. - pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + /// Panics if `now` is earlier than the previous poll or construction time. + pub fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Poll> { + self.clock.advance(now); self.state.poll(waiter) } } -impl Protocol { +impl Protocol { fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { match self { Self::Lite(driver) => driver.poll(waiter), @@ -80,7 +74,7 @@ impl Protocol { } } -impl State { +impl State { fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { if let Some(supervisor) = &mut self.supervisor && supervisor.poll(waiter).is_ready() @@ -108,24 +102,24 @@ impl State { } } -// Every part of the machine is a poll machine driven through `&mut` (the one -// pinned piece, the boxed ietf future, is `Unpin` itself), so nothing relies -// on address stability and the machine may move freely between polls. -impl Unpin for Driver {} - -impl Future for Driver { +impl crate::time::Driver for Driver { type Output = Result<(), Error>; + fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Poll { + self.poll(now, waiter) + } + fn timeout(&self) -> Option { + self.timeout() + } +} - fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { - let this = &mut *self; - // Disjoint field borrows: `hold` borrows the park for as long as the - // waiter lives, while the state is polled through its own `&mut`. - let waiter = this.park.hold(cx); - this.state.poll(waiter) +impl Driver { + /// The next instant the caller must poll at, or none while no timer is armed. + pub fn timeout(&self) -> Option { + self.clock.timeout() } } -impl std::fmt::Debug for Driver { +impl std::fmt::Debug for Driver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Driver") .field("done", &self.state.result.is_some()) diff --git a/rs/moq-net/src/goaway.rs b/rs/moq-net/src/goaway.rs index 9ab2f651c0..6ba36fbf38 100644 --- a/rs/moq-net/src/goaway.rs +++ b/rs/moq-net/src/goaway.rs @@ -270,15 +270,15 @@ impl Protocol { /// The draft makes the deadline the sender's promise, not the peer's, so the /// driver arms this after the message hits the wire rather than making the /// caller hold a handle and await it. -pub(crate) struct Enforce { +pub(crate) struct Enforce { session: S, /// The armed deadline, or `None` when the GOAWAY carried no timeout (ready /// immediately: there is nothing to enforce). - deadline: Option<(crate::runtime::Deadline, Duration)>, + deadline: Option<(crate::runtime::Deadline, Duration)>, } -impl Enforce { - pub fn new(runtime: &R, session: S, timeout: Option) -> Self { +impl Enforce { + pub fn new(runtime: &crate::time::Clock, session: S, timeout: Option) -> Self { Self { session, deadline: timeout.map(|timeout| (crate::runtime::Deadline::after(runtime, timeout), timeout)), @@ -307,8 +307,8 @@ impl Enforce( - runtime: &R, +pub(crate) async fn enforce( + runtime: &crate::time::Clock, session: &mut S, timeout: Option, ) { diff --git a/rs/moq-net/src/ietf/control.rs b/rs/moq-net/src/ietf/control.rs index 30ab511c5e..fb0181b026 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -32,7 +32,7 @@ impl Control { /// /// `runtime` arms the give-up timer, so the wait cannot outlive the caller's /// clock. - pub async fn next_request_id(&self, runtime: &R) -> Result { + pub async fn next_request_id(&self, runtime: &crate::time::Clock) -> Result { let mut timeout = crate::runtime::Deadline::after(runtime, std::time::Duration::from_secs(10)); kio::wait(|waiter| { diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 76444c071c..fed93aacb6 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1,3 +1,4 @@ +use crate::runtime::Timers as _; use crate::{frame, group, origin, track}; use std::{ collections::HashMap, @@ -6,7 +7,7 @@ use std::{ time::Duration, }; -use web_transport_trait::{MaybeSend, MaybeSync, poll::SendStream as _}; +use web_transport_trait::poll::SendStream as _; use crate::{ AsPath, Error, Timescale, Timestamp, @@ -220,9 +221,9 @@ enum NamespaceEvent { } #[derive(Clone)] -pub(super) struct Publisher { +pub(super) struct Publisher { // Arms the advertise, retry, and linger timers. - runtime: R, + runtime: crate::time::Clock, session: S, // Traffic stats are attributed through this tagged origin handle. origin: origin::Consumer, @@ -273,14 +274,12 @@ impl Drop for Join { } } -impl Publisher +impl Publisher where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { pub fn new( - runtime: R, + runtime: crate::time::Clock, session: S, origin: origin::Consumer, control: Control, @@ -2526,8 +2525,6 @@ mod serve_tests { use crate::lite::test_transport::{Log, ScriptedSession, SinkSession}; use crate::model::ProduceTest; - type TestRuntime = crate::runtime::tokio_test::Tokio; - fn occurrences(log: &Log, needle: &[u8]) -> usize { let writes = log.writes.lock().unwrap(); writes.windows(needle.len()).filter(|window| *window == needle).count() @@ -2539,7 +2536,7 @@ mod serve_tests { /// A publisher whose origin serves one broadcast ("room") with one track ("video"). struct Serve { - publisher: Publisher, + publisher: Publisher, session: ScriptedSession, log: Log, track: track::Producer, @@ -2559,7 +2556,7 @@ mod serve_tests { peer_setup.set(peer::Peer::default()); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin.consume(), Control::new(None, false), @@ -3371,10 +3368,6 @@ mod tests { use crate::model::ProduceTest; use futures::FutureExt; - /// The tokio-backed test runtime. Its transport parameter is phantom, so one - /// type serves every fake session in this module. - type TestRuntime = crate::runtime::tokio_test::Tokio; - async fn settle() { tokio::time::sleep(Duration::from_millis(1)).await; } @@ -3435,7 +3428,7 @@ mod tests { async fn echo_harness( assigned: crate::Hop, ) -> ( - Publisher, + Publisher, origin::Consumer, Vec, ) { @@ -3445,7 +3438,7 @@ mod tests { let session = crate::lite::test_transport::SinkSession::new(Default::default()); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -3503,7 +3496,7 @@ mod tests { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), crate::lite::test_transport::SinkSession::new(Default::default()), origin.consume(), Control::new(None, false), @@ -3574,7 +3567,7 @@ mod tests { let consumer = origin.consume(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), crate::lite::test_transport::SinkSession::new(Default::default()), origin.consume(), Control::new(None, false), @@ -3616,7 +3609,7 @@ mod tests { let session = SinkSession::gated_bi(gate.consume()); let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin.consume(), Control::new(None, false), @@ -3720,7 +3713,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -3808,7 +3801,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), consumer, Control::new(None, false), @@ -3893,7 +3886,7 @@ mod tests { peer_setup.set(peer::Peer::default()); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -3939,7 +3932,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin.consume(), Control::new(None, false), @@ -4009,7 +4002,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4087,7 +4080,7 @@ mod tests { let session = crate::lite::test_transport::SinkSession::new(Default::default()); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4131,7 +4124,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4213,7 +4206,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4285,7 +4278,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4358,7 +4351,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4423,7 +4416,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4478,7 +4471,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4526,7 +4519,7 @@ mod tests { let log = session.log.clone(); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.consume(), Control::new(None, false), @@ -4572,7 +4565,7 @@ mod tests { /// A publisher talking to a scripted peer that never answers, over one bidi stream. struct Harness { - publisher: Publisher, + publisher: Publisher, session: crate::lite::test_transport::ScriptedSession, log: crate::lite::test_transport::Log, /// Keeps the origin alive; the publisher only holds a consumer. @@ -4589,7 +4582,7 @@ mod tests { peer_setup.set(peer::Peer::default()); let publisher = Publisher::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin.consume(), Control::new(None, false), diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 1b880dff54..55810dfa71 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -7,17 +7,15 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox, TaskSet, err_only}, }; -use web_transport_trait::{MaybeSend, MaybeSync}; - use super::{ Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, cluster, peer, solicit, subscriber::is_protocol_violation, }; /// Everything one moq-transport session needs to start. -pub struct Config { +pub struct Config { /// The runtime that arms the session's timers. - pub runtime: R, + pub runtime: crate::time::Clock, pub session: S, @@ -66,13 +64,9 @@ pub struct Config pub peer_declared: Option, } -pub fn start( - config: Config, -) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> +pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { let Config { runtime, @@ -514,8 +508,8 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( - runtime: R, +async fn run_setup( + runtime: crate::time::Clock, mut session: S, version: Version, path: Option, @@ -587,9 +581,9 @@ async fn run_setup( +async fn run_unis( mut session: S, - subscriber: Subscriber, + subscriber: Subscriber, // Where to record the peer's MoQ Cluster options once its SETUP arrives. `None` // for draft-14..16, whose SETUP rides the control stream instead. peer_setup: Option, @@ -600,8 +594,6 @@ async fn run_unis( ) -> Result<(), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { let outer_version = crate::Version::Ietf(version); let mut tasks = TaskSet::owned(); @@ -705,14 +697,12 @@ where } } -async fn run_uni_group( - subscriber: &mut Subscriber, +async fn run_uni_group( + subscriber: &mut Subscriber, stream: &mut Reader, ) -> Result<(), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { let kind: u64 = stream.decode_peek().await?; @@ -734,16 +724,14 @@ where } /// Accept incoming bidi streams and dispatch to the correct handler based on message type. -async fn run_dispatch( +async fn run_dispatch( session: S, - publisher: Publisher, - mut subscriber: Subscriber, + publisher: Publisher, + mut subscriber: Subscriber, version: Version, ) -> Result<(), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { // PUBLISH_NAMESPACE decodes differently once the MoQ Cluster extension is // negotiated, so the whole dispatch loop waits for the peer's SETUP first. The peer @@ -884,10 +872,6 @@ mod tests { use super::*; use crate::model::ProduceTest; - /// The tokio-backed test runtime. Its transport parameter is phantom, so one - /// type serves every fake session in this module. - type TestRuntime = crate::runtime::tokio_test::Tokio; - fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { let writes = log.writes.lock().unwrap(); writes.windows(needle.len()).filter(|window| *window == needle).count() @@ -934,7 +918,7 @@ mod tests { let log = session.log.clone(); let (driver, _goaway) = start(Config { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session, setup: None, request_id_max: None, @@ -993,7 +977,7 @@ mod tests { let log = session.log.clone(); let (driver, _goaway) = start(Config { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session, setup: None, request_id_max: None, @@ -1043,7 +1027,7 @@ mod tests { let log = session.log.clone(); let (driver, _goaway) = start(Config { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session, setup: None, request_id_max: None, @@ -1150,7 +1134,7 @@ mod tests { let log = session.log.clone(); let (driver, _goaway) = start(Config { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session, setup: None, request_id_max: None, @@ -1219,7 +1203,7 @@ mod tests { let (tasks, _task_set) = TaskSet::new(); let peer_setup = peer::PeerSetup::default(); let subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, true), @@ -1342,7 +1326,7 @@ mod tests { .expect("open the control stream"); let (driver, _goaway) = start(Config { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session, setup: Some(setup), request_id_max: None, diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index f310a91b51..f99a1e2795 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1,3 +1,4 @@ +use crate::runtime::Timers as _; use std::{ collections::{HashMap, hash_map::Entry}, task::{Poll, ready}, @@ -16,7 +17,6 @@ use crate::{ use super::{Message, Version, cluster, error::request, peer}; use web_async::Lock; -use web_transport_trait::{MaybeSend, MaybeSync}; const TRACK_ALIAS_TIMEOUT: Duration = Duration::from_secs(1); @@ -382,9 +382,9 @@ struct Advertised { } #[derive(Clone)] -pub(super) struct Subscriber { +pub(super) struct Subscriber { // Arms the track-alias and request-id timeouts. - runtime: R, + runtime: crate::time::Clock, session: S, // Traffic stats are attributed through this tagged origin handle. origin: origin::Producer, @@ -421,8 +421,8 @@ pub(super) struct Subscriber( - runtime: &R, +async fn resolve_track_alias( + runtime: &crate::time::Clock, aliases: kio::Consumer, alias: u64, ) -> Result { @@ -447,15 +447,13 @@ async fn resolve_track_alias( .await } -impl Subscriber +impl Subscriber where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { #[allow(clippy::too_many_arguments)] pub fn new( - runtime: R, + runtime: crate::time::Clock, session: S, origin: origin::Producer, control: Control, @@ -2097,7 +2095,7 @@ where let producer = crate::recv::Group::new(producer); let res = { - let mut ingest = GroupIngest::new(&group, timescale, self.version, start); + let mut ingest = GroupIngest::new(self.runtime.clone(), &group, timescale, self.version, start); let mut writing = producer.clone(); kio::wait(|waiter| { if let Poll::Ready(err) = track.poll_closed(waiter) { @@ -2128,11 +2126,9 @@ where } } -impl Subscriber +impl Subscriber where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { /// The group producer this subgroup stream writes into, and the Object ID it starts at. /// @@ -2238,6 +2234,7 @@ where /// the id delta, the extension headers (carrying the timestamp), the size, the /// status for empty objects, and the streamed payload. struct GroupIngest { + runtime: crate::time::Clock, has_extensions: bool, has_end: bool, timescale: Option, @@ -2265,8 +2262,15 @@ enum IngestPhase { } impl GroupIngest { - fn new(group: &ietf::GroupHeader, timescale: Option, version: Version, start: u64) -> Self { + fn new( + runtime: crate::time::Clock, + group: &ietf::GroupHeader, + timescale: Option, + version: Version, + start: u64, + ) -> Self { Self { + runtime, has_extensions: group.flags.has_extensions, has_end: group.flags.has_end, timescale, @@ -2278,11 +2282,9 @@ impl GroupIngest { } } -impl Subscriber +impl Subscriber where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { /// Read a fill fetch stream: the head of the group a subscription joins part way through. /// @@ -2516,7 +2518,7 @@ where } _ => None, }; - let timestamp = timestamp.unwrap_or_else(crate::Timestamp::now); + let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); // A fetch object has no status field from draft-16 on; a zero length is simply // an empty object. Draft-14 and 15 still encode Normal (0) after a zero length. @@ -2717,14 +2719,14 @@ impl GroupIngest { } // `create_frame_owned` is the allocation chokepoint and rejects an // oversized `size` before allocating, so no pre-check is needed. - let timestamp = timestamp.unwrap_or_else(crate::Timestamp::now); + let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); let frame = group.create_frame_owned(frame::Info { size, timestamp })?; self.phase = IngestPhase::Payload { frame }; } IngestPhase::Status { timestamp } => { let status: u64 = ready!(reader.poll_decode(&mut cx))?; if status == 0 { - let timestamp = timestamp.unwrap_or_else(crate::Timestamp::now); + let timestamp = timestamp.unwrap_or_else(|| crate::Timestamp::from(self.runtime.now())); let frame = group.create_frame_owned(frame::Info { size: 0, timestamp })?; frame.finish()?; self.phase = IngestPhase::Delta; @@ -2765,11 +2767,10 @@ mod tests { /// The tokio-backed test runtime. Its transport parameter is phantom, so one /// type serves every fake session in this module. - type TestRuntime = crate::runtime::tokio_test::Tokio; #[tokio::test(start_paused = true)] async fn track_alias_waits_for_control_message() { - let runtime = TestRuntime::new(); + let runtime = crate::time::Clock::tokio(); let aliases = TrackAliases::default(); let pending = resolve_track_alias(&runtime, aliases.consume(), 7); tokio::pin!(pending); @@ -2785,7 +2786,7 @@ mod tests { async fn unknown_track_alias_times_out() { let aliases = TrackAliases::default(); assert!(matches!( - resolve_track_alias(&TestRuntime::new(), aliases.consume(), 7).await, + resolve_track_alias(&crate::time::Clock::tokio(), aliases.consume(), 7).await, Err(Error::NotFound) )); } @@ -2812,7 +2813,7 @@ mod tests { let (tasks, _task_set) = crate::util::TaskSet::new(); Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin, Control::new(None, false), @@ -2883,7 +2884,7 @@ mod tests { let log = session.log.clone(); let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), scoped, Control::new(None, false), @@ -2958,7 +2959,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), scoped, Control::new(None, false), @@ -3014,7 +3015,7 @@ mod tests { insert_track_alias(&aliases, 7, RequestId(11)).unwrap(); retire_track_alias(&aliases, 7, RequestId(11)); - let runtime = TestRuntime::new(); + let runtime = crate::time::Clock::tokio(); let resolve = resolve_track_alias(&runtime, aliases.consume(), 7); tokio::pin!(resolve); @@ -3038,7 +3039,7 @@ mod tests { insert_track_alias(&aliases, 7, RequestId(11)).unwrap(); retire_track_alias(&aliases, 7, RequestId(11)); - let err = resolve_track_alias(&TestRuntime::new(), aliases.consume(), 7) + let err = resolve_track_alias(&crate::time::Clock::tokio(), aliases.consume(), 7) .await .expect_err("a retired alias resolves to a cancellation"); @@ -3083,14 +3084,14 @@ mod tests { /// exercised without driving a whole SUBSCRIBE exchange. fn subscriber_with_tracks( tracks: &[(RequestId, &str, &str)], - ) -> Subscriber { + ) -> Subscriber { let (tasks, task_set) = crate::util::TaskSet::new(); // The tests drive binding directly, so nothing spawns; leaking keeps the handle alive // without a spawner. std::mem::forget(task_set); let subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), crate::lite::test_transport::SinkSession::new(Default::default()), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), @@ -3241,7 +3242,7 @@ mod tests { let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), @@ -3297,7 +3298,7 @@ mod tests { let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), @@ -3402,7 +3403,7 @@ mod tests { let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), adapter, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), control, @@ -3518,7 +3519,7 @@ mod tests { let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), @@ -3616,7 +3617,7 @@ mod tests { let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok); let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), @@ -3708,7 +3709,7 @@ mod tests { let consumer = origin.consume(); let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin, Control::new(None, false), @@ -3769,7 +3770,7 @@ mod tests { // hop chain of its own. let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin, Control::new(None, false), @@ -3809,7 +3810,7 @@ mod tests { let (tasks, task_set) = crate::util::TaskSet::new(); std::mem::forget(task_set); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), crate::lite::test_transport::SinkSession::new(Default::default()), origin.clone(), Control::new(None, false), @@ -3845,7 +3846,7 @@ mod tests { fn cluster_subscriber( self_origin: crate::Hop, ) -> ( - Subscriber, + Subscriber, crate::origin::Producer, ) { let session = crate::lite::test_transport::SinkSession::new(Default::default()); @@ -3856,7 +3857,7 @@ mod tests { std::mem::forget(task_set); let subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, origin.clone(), Control::new(None, false), @@ -3989,7 +3990,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -4060,7 +4061,7 @@ mod tests { ..Default::default() }); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -4134,7 +4135,7 @@ mod tests { let (tasks, task_set) = crate::util::TaskSet::new(); std::mem::forget(task_set); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -4189,7 +4190,7 @@ mod tests { let (tasks, task_set) = crate::util::TaskSet::new(); std::mem::forget(task_set); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -4506,7 +4507,7 @@ mod tests { attached: &cluster::Advert, script: Vec, ) -> ( - Subscriber, + Subscriber, crate::origin::Consumer, Stream, crate::origin::Driver, @@ -4521,7 +4522,7 @@ mod tests { std::mem::forget(task_set); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -4551,7 +4552,7 @@ mod tests { attached: &cluster::Advert, updates: &[cluster::Advert], ) -> ( - Subscriber, + Subscriber, crate::origin::Consumer, Stream, ) { @@ -4953,7 +4954,7 @@ mod tests { std::mem::forget(task_set); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -5354,8 +5355,6 @@ mod stitch_tests { util::{TaskSet, Tasks}, }; - type TestRuntime = crate::runtime::tokio_test::Tokio; - const VERSION: Version = Version::Draft20; const ALIAS: u64 = 7; const REQUEST: RequestId = RequestId(1); @@ -5458,7 +5457,7 @@ mod stitch_tests { /// A subscriber holding one draft-20 subscription, as its SUBSCRIBE_OK left it: the /// alias bound, the timescale declared, and `fill` waiting on its fetch stream. struct Harness { - subscriber: Subscriber, + subscriber: Subscriber, session: ScriptedSession, track: track::Producer, fill: kio::Producer, @@ -5472,7 +5471,7 @@ mod stitch_tests { let tasks = TaskSet::new(); let subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -5989,8 +5988,6 @@ mod joining_fetch_tests { util::{TaskSet, Tasks}, }; - type TestRuntime = crate::runtime::tokio_test::Tokio; - const JOINING_DRAFTS: [Version; 6] = [ Version::Draft14, Version::Draft15, @@ -6101,7 +6098,7 @@ mod joining_fetch_tests { } struct JoinRun { - subscriber: Subscriber, + subscriber: Subscriber, session: ScriptedSession, _hold: ( crate::broadcast::Producer, @@ -6117,7 +6114,7 @@ mod joining_fetch_tests { let session = ScriptedSession::per_stream(vec![ok, fetch]); let (tasks, _task_set) = crate::util::TaskSet::new(); let subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), @@ -6262,7 +6259,7 @@ mod joining_fetch_tests { let log = session.log.clone(); let (tasks, _task_set) = crate::util::TaskSet::new(); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), Control::new(None, false), diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 65afd8ffae..737da8c46c 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -46,30 +46,22 @@ //! gives another writer that contributes to the same shared state. Closing the //! last producer signals consumers that no more updates are coming. //! -//! ## Async -//! This library never spawns tasks. [`Client::connect`] and [`Server::accept`] -//! take [`Timers`] and return a `(Session, Driver)` pair. Poll or spawn the -//! [`Driver`] to run the session. It holds no session handle, so dropping the -//! last [`Session`] clone requests transport closure and lets the driver finish. -//! Dropping the driver cancels the session instead. +//! ## Driving and time +//! This library never spawns tasks or schedules runtime timers. [`Client::connect`] +//! and [`Server::accept`] take an initial [`time::Instant`] and return +//! `(Session, Driver)`. Poll the [`Driver`] with the current instant and a +//! [`kio::Waiter`], then wake on external activity or at [`Driver::timeout`]. +//! The last [`Session`] drop requests closure; dropping the driver cancels it. //! -//! `moq-tokio` and `moq-wasm` supply timers and spawn drivers for their callers. -//! Direct callers can use any executor, including a thread-local executor for -//! `!Send` transports. `runtime::Test` (feature `test-runtime`) supplies virtual time for tests. +//! [`origin::Producer::new`] also returns a producer and driver. Its driver runs +//! route changes, serving, linger, teardown, and the origin's cache expiration. +//! Standalone caches expose [`cache::Pool::gc`]. Frame read/write methods +//! clear their expiration timestamp for the next cleanup pass; datagram insertion takes an +//! explicit arrival instant. //! -//! Origins follow the caller-driven pattern: [`origin::Producer::new`] returns a -//! `(Producer, Driver)` pair, [`origin::Driver::run`] takes the [`Timers`] the -//! origin's deadlines arm against, and the returned [`origin::Run`] future runs -//! the lifecycle work (route changes, track serving, teardown). Native tokio -//! applications can use `moq_tokio::origin::spawn` instead of running the -//! driver by hand. -//! -//! The crate has no tokio dependency: every future is built on [`kio`] -//! (plain [`std::task::Waker`] plumbing) and `futures`, so any executor can poll -//! them, and the `poll_xxx` counterparts can be stepped synchronously with a -//! [`kio::Waiter`]. Purely model-layer methods (tracks, groups, frames, -//! origins) never arm a timer and need no [`Timers`] at all; they read the -//! crate's ambient clock for passive stamps (arrival times, cache ticks). +//! Both drivers implement [`time::Driver`]. `moq-tokio` and `moq-wasm` +//! supply runtime adapters, and `moq-uring` can drive thread-local transports. +//! Tests can advance time simply by supplying a later instant. #![warn(missing_docs)] // The browser transport is `!Send`, so on wasm the shared state behind these `Arc`s is @@ -96,10 +88,11 @@ mod setup; mod util; mod version; -pub mod runtime; +mod runtime; pub mod server; pub mod session; pub mod stats; +pub mod time; pub mod transport; pub use client::*; @@ -112,7 +105,6 @@ pub use model::*; pub use path::{ AsPath, InvalidPattern, Path, PathOwned, PathPrefixes, PathRelative, PathRelativeOwned, Pattern, Patterns, }; -pub use runtime::Timers; pub use server::Server; pub use session::Session; pub use version::*; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index c91eed1373..7817a5a3cc 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1,3 +1,4 @@ +use crate::runtime::Timers as _; use crate::{SessionError, announce, frame, group, origin, track}; use std::{ collections::HashMap, @@ -20,9 +21,9 @@ use crate::{ use super::Version; -pub(super) struct PublisherConfig { +pub(super) struct PublisherConfig { /// The runtime that arms the publisher's timers. - pub runtime: R, + pub runtime: crate::time::Clock, pub session: S, /// The origin we read local broadcasts from. Traffic stats are attributed /// through this handle: tag it with [`origin::Consumer::with_stats`] first. @@ -128,18 +129,18 @@ impl Shared { /// The publisher half: accepts control streams and drives each as a child state /// machine. Resolves only on a transport error; children never end the session. -pub(super) struct Publisher { +pub(super) struct Publisher { shared: Arc>, // Cloned into each control-stream child that arms timers (PROBE, announce linger). - runtime: R, + runtime: crate::time::Clock, // A dedicated accept handle: the poll interface takes `&mut self`, and the // shared context stays behind the Arc for the per-stream children. accept: S, - children: kio::Tasks>, + children: kio::Tasks>, } -impl Publisher { - pub fn new(config: PublisherConfig) -> Self { +impl Publisher { + pub fn new(config: PublisherConfig) -> Self { // Identity stamped onto outbound announce hops. Derived from the // origin we're consuming so it matches the local relay identity // across every session, required for cross-session loop detection. @@ -164,10 +165,9 @@ impl Publisher Publisher +impl Publisher where S: crate::transport::poll::Session, - R: crate::runtime::Timers, { pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let _ = self.children.poll(waiter); @@ -194,7 +194,7 @@ where } #[cfg(test)] -impl Publisher { +impl Publisher { /// Test shim: drive one announce-interest stream like the old `run_announce`. async fn run_announce( stream: &mut Stream, @@ -210,17 +210,17 @@ impl Publisher { +struct Control { shared: Arc>, // Handed to the children that arm timers (PROBE, announce linger). - runtime: R, - state: ControlState, + runtime: crate::time::Clock, + state: ControlState, } // A state machine's enum is its storage: one transient instance per stream, so the // big variant is the working state, not padding held in bulk. #[allow(clippy::large_enum_variant)] -enum ControlState { +enum ControlState { /// Reading the stream's type. Start { stream: Stream, @@ -229,7 +229,7 @@ enum ControlState Subscribe(SubscribeServe), Fetch(FetchServe), TrackInfo(TrackInfoServe), - Probe(ProbeServe), + Probe(ProbeServe), /// Decoding the peer's GOAWAY, surfaced through [`crate::Session::draining`]. Goaway { stream: Stream, @@ -237,7 +237,7 @@ enum ControlState Done, } -impl kio::Task for Control { +impl kio::Task for Control { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { if let Err(err) = ready!(self.poll_serve(waiter)) { tracing::warn!(%err, "control stream error"); @@ -246,7 +246,7 @@ impl kio::Task fo } } -impl Control { +impl Control { fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { loop { match &mut self.state { @@ -315,15 +315,15 @@ impl Control { +struct ProbeServe { shared: Arc>, - runtime: R, + runtime: crate::time::Clock, stream: Option>, last_sent: Option<(lite::Probe, crate::runtime::Instant)>, - next_probe: crate::runtime::Deadline, + next_probe: crate::runtime::Deadline, } -impl ProbeServe { +impl ProbeServe { const PROBE_INTERVAL: Duration = Duration::from_millis(100); const PROBE_MAX_AGE: Duration = Duration::from_secs(10); const PROBE_MAX_DELTA: f64 = 0.25; @@ -354,7 +354,7 @@ impl ProbeServe>, runtime: R, stream: Stream) -> Self { + fn new(shared: Arc>, runtime: crate::time::Clock, stream: Stream) -> Self { Self { shared, stream: Some(stream), @@ -1546,7 +1546,11 @@ mod test { let mut subscriber = producer.subscribe(None); producer - .append_datagram(Timestamp::from_millis(1).unwrap(), &b"last"[..]) + .append_datagram( + crate::model::clock::now(), + Timestamp::from_millis(1).unwrap(), + &b"last"[..], + ) .unwrap(); producer.finish().unwrap(); @@ -1640,9 +1644,7 @@ mod announce_test { use crate::model::ProduceTest; use std::sync::Mutex; - /// The tokio-backed test runtime, matching the fake transport. - type TestRuntime = crate::runtime::tokio_test::Tokio; - type TestPublisher = Publisher; + type TestPublisher = Publisher; const VERSION: Version = Version::Lite06Wip; @@ -3013,9 +3015,6 @@ mod tests { use crate::lite::test_transport::SinkSession; use crate::model::ProduceTest; - /// The tokio-backed test runtime, matching the fake transport. - type TestRuntime = crate::runtime::tokio_test::Tokio; - /// A peer that declares no origin in its SETUP is split-horizoned by the identity /// the caller assigned it, on the data plane and not just the announce filter. /// Otherwise it can subscribe its way back to content that already flowed through @@ -3049,7 +3048,7 @@ mod tests { let (_, goaway) = crate::goaway::Handle::new(true); let publisher = Publisher::new(PublisherConfig { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin: origin.consume(), version: Version::Lite06Wip, @@ -3105,7 +3104,7 @@ mod tests { let consumer = origin.consume().excluding(assigned); let mut announced = consumer.announced(); - let mut run = std::pin::pin!(Publisher::::run_announce( + let mut run = std::pin::pin!(Publisher::::run_announce( &mut stream, &consumer, &mut announced, @@ -3147,11 +3146,11 @@ mod tests { session: SinkSession, stream: Stream, version: Version, - ) -> ProbeServe { + ) -> ProbeServe { let origin = Hop::random().produce(); let (_, goaway) = crate::goaway::Handle::new(true); let publisher = Publisher::new(PublisherConfig { - runtime: TestRuntime::new(), + runtime: crate::time::Clock::tokio(), session, origin: origin.consume(), version, diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index d4b91fec39..36721c09c1 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -11,11 +11,11 @@ use super::{ DataType, PeerSetup, Publisher, PublisherConfig, Setup, Subscriber, SubscriberConfig, SubscriberDriver, Version, }; -pub(crate) struct SessionStart { +pub(crate) struct SessionStart { pub recv_bandwidth: Option, /// The session's protocol machine, named so its `Send`-ness stays inferred /// from the transport instead of being fixed by a box. - pub driver: Driver, + pub driver: Driver, /// The session-side GOAWAY halves, stored on the public [`crate::Session`]. pub goaway: crate::goaway::Handle, } @@ -50,9 +50,9 @@ pub async fn accept_setup( } /// Everything one moq-lite session needs to start. -pub struct Config { +pub struct Config { /// The runtime that arms the session's timers. - pub runtime: R, + pub runtime: crate::time::Clock, /// The transport carrying the session. Cloned into every loop that outlives /// [`start`], so the connection closes when the last of them drops. @@ -91,10 +91,9 @@ pub struct Config /// Start a lite session. /// /// Returns the receive-bandwidth consumer (if any) plus the driver that runs the session. -pub fn start(config: Config) -> Result, Error> +pub fn start(config: Config) -> Result, Error> where S: crate::transport::poll::Session, - R: crate::runtime::Timers, { let Config { runtime, @@ -175,6 +174,7 @@ where peer_hop, }); let subscriber = Subscriber::new(SubscriberConfig { + runtime: runtime.clone(), session: session.clone(), origin: subscribe, recv_bandwidth: recv_bw_for_sub, @@ -208,26 +208,25 @@ where /// The lite session driver: one poll function racing every protocol arm, in /// place of a task set of boxed futures. -pub(crate) struct Driver { +pub(crate) struct Driver { /// Advertising our capabilities, or `None` once sent (or on a version with no /// Setup Stream). setup: Option>, /// Sending our single GOAWAY if the drain trigger fires, or `None` once done. - goaway: Option>, + goaway: Option>, /// The legacy session stream (pre-lite-03). Only its *error* ends the race, so /// the publisher and subscriber keep running while it sits idle. session_stream: Option>, - publisher: Publisher, + publisher: Publisher, subscriber: SubscriberDriver, /// For the terminal close: the machine's last act reports the outcome to /// the peer through the transport. session: S, } -impl Driver +impl Driver where S: crate::transport::poll::Session, - R: crate::runtime::Timers, { pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let res = std::task::ready!(self.poll_protocol(waiter)); @@ -371,18 +370,18 @@ impl SendSetup { /// Runs on every version, including those with no GOAWAY message: the deadline is /// the sender's own timer, so a caller draining a lite-03 peer still gets the /// session closed on schedule; the peer just never learns why. -struct SendGoaway { +struct SendGoaway { version: Version, - runtime: R, + runtime: crate::time::Clock, goaway: crate::goaway::Protocol, /// A dedicated handle for the trigger-phase close watch, since `session` opens /// the Goaway stream and each pending operation needs its own handle. closed: S, session: S, - state: SendGoawayState, + state: SendGoawayState, } -enum SendGoawayState { +enum SendGoawayState { /// Parked on the send trigger, racing the transport close so a parked trigger /// never blocks the driver. The trigger fires at most once. Waiting, @@ -398,11 +397,11 @@ enum SendGoawayState), + Enforce(crate::goaway::Enforce), } -impl SendGoaway { - fn new(runtime: R, session: S, goaway: crate::goaway::Protocol, version: Version) -> Self { +impl SendGoaway { + fn new(runtime: crate::time::Clock, session: S, goaway: crate::goaway::Protocol, version: Version) -> Self { Self { version, runtime, diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 59a962b3c1..6c6a8781e5 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1,3 +1,4 @@ +use crate::runtime::Timers as _; use crate::{frame, group, origin, track}; use std::{ collections::HashMap, @@ -18,6 +19,7 @@ use super::Version; use web_async::Lock; pub(super) struct SubscriberConfig { + pub runtime: crate::time::Clock, pub session: S, /// The origin into which remote broadcasts are inserted. Traffic stats are /// attributed through this handle: tag it with [`origin::Producer::with_stats`] @@ -43,6 +45,7 @@ pub(super) struct SubscriberConfig { #[derive(Clone)] pub(super) struct Subscriber { + runtime: crate::time::Clock, session: S, origin: origin::Producer, @@ -95,6 +98,7 @@ impl Subscriber { let self_origin = *config.origin; Self { session: config.session, + runtime: config.runtime, origin: config.origin, recv_bandwidth: config.recv_bandwidth, self_origin, @@ -434,7 +438,14 @@ impl Subscriber { let timestamp = Timestamp::new(dg.timestamp, scale).map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; - entry.producer.insert_datagram(dg.sequence, timestamp, dg.payload)?; + entry.producer.insert_datagram( + self.runtime.now(), + crate::Datagram { + sequence: dg.sequence, + timestamp, + payload: crate::IntoBytes::into_bytes(dg.payload), + }, + )?; Ok(()) } @@ -733,7 +744,7 @@ impl GroupRecv { self.state = GroupRecvState::Serve { group: crate::recv::Group::new(group), track, - ingest: FrameIngest::new(timescale), + ingest: FrameIngest::new(self.subscriber.runtime.clone(), timescale), }; } GroupRecvState::Serve { group, track, ingest } => { @@ -780,6 +791,7 @@ impl GroupRecv { /// Pumps bare FRAME messages from a reader into a group producer: the wire /// format shared by GROUP streams and FETCH responses. struct FrameIngest { + runtime: crate::time::Clock, /// `Some` decodes the lite-05 zigzag-delta timestamp prefix; `None` stamps /// local receive time (pre-lite-05). timescale: Option, @@ -801,11 +813,12 @@ enum IngestPhase { } impl FrameIngest { - fn new(timescale: Option) -> Self { + fn new(runtime: crate::time::Clock, timescale: Option) -> Self { Self { timescale, prev_ts: 0, phase: IngestPhase::Timing, + runtime, } } @@ -847,7 +860,7 @@ impl FrameIngest { // `create_frame_owned` is the allocation chokepoint and rejects an // oversized `size` before allocating, so no pre-check is needed. No // wire timestamp (pre-lite-05) means local receive time. - let timestamp = timestamp.unwrap_or_else(Timestamp::now); + let timestamp = timestamp.unwrap_or_else(|| Timestamp::from(self.runtime.now())); let frame = group.create_frame_owned(frame::Info { size, timestamp })?; self.phase = IngestPhase::Payload { frame }; } @@ -1325,6 +1338,7 @@ mod tests { fn unsubscribe_drops_the_datagram_and_releases_the_producer() { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::default(), origin, recv_bandwidth: None, @@ -1397,6 +1411,7 @@ mod tests { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: session.clone(), origin, recv_bandwidth: None, @@ -1469,6 +1484,7 @@ mod tests { let session = SinkSession::gated_bi(gate.consume()); let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: session.clone(), origin, recv_bandwidth: None, @@ -1893,6 +1909,7 @@ mod tests { let assigned = crate::Hop::new(777).unwrap(); let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin, recv_bandwidth: None, @@ -1948,6 +1965,7 @@ mod tests { let assigned = crate::Hop::new(777).unwrap(); let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin, recv_bandwidth: None, @@ -2003,6 +2021,7 @@ mod tests { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin, recv_bandwidth: None, @@ -2058,6 +2077,7 @@ mod tests { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin, recv_bandwidth: None, @@ -2126,6 +2146,7 @@ mod tests { ); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin: origin.clone(), recv_bandwidth: None, @@ -2182,6 +2203,7 @@ mod tests { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session, origin, recv_bandwidth: None, @@ -2226,6 +2248,7 @@ mod tests { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin, recv_bandwidth: None, @@ -2288,6 +2311,7 @@ mod tests { let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session, origin, recv_bandwidth: None, @@ -2349,6 +2373,7 @@ mod tests { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), session: SinkSession::new(Default::default()), origin, recv_bandwidth: None, @@ -3516,7 +3541,7 @@ impl kio::Task for FetchServeRun { self.state = FetchRunState::Ingest { stream, producer, - ingest: FrameIngest::new(self.timescale), + ingest: FrameIngest::new(self.serve.subscriber.runtime.clone(), self.timescale), }; } FetchRunState::Ingest { diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 77cc47abe6..6bdb7cfe23 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -7,7 +7,7 @@ //! sized proportionally to what it wrote, and pays that debt by aborting its own oldest //! groups with [`Error::Evicted`](crate::Error::Evicted). Reclamation is therefore //! distributed across every writing track and converges on the capacity without any -//! global lock, registry, or background task. +//! global eviction task. //! //! Cross-track ordering comes from one statistic: the mean last-access time of the //! evictable population (every cached group except each track's protected latest). @@ -25,16 +25,11 @@ //! congestion stall can't age content out; the pool's expiry is the orthogonal //! wall-clock bound that keeps unwatched content from pinning RAM. //! -//! Expiry runs from two places. A track's own writes settle it inline, which keeps a -//! busy track's backlog draining without any wakeup. That alone is not a bound: a -//! publisher that stalls with a group still open stops writing, so nothing reclaims -//! its buffer and a reader parked in that group waits forever. So a pool with an -//! expiry window also keeps a registry of its live track accounts and sweeps them on -//! a wall-clock cadence, driven by [`origin::Driver`](crate::origin::Driver). A pool -//! whose origin driver is never run therefore keeps the write-driven half only, which -//! is what a standalone [`broadcast`](crate::broadcast::Info) built without an origin -//! gets. The byte budget deliberately has no such machinery: it is repaid by writes, -//! and a track that never writes never grows the pool. +//! Expiry is driven by [`Pool::gc`], also called by each origin driver. +//! Reads and writes clear the expiration timestamp without reading a clock. +//! The next cleanup pass dates that activity at its supplied instant. Delayed +//! cleanup extends retention; standalone pools must call `gc` too. +//! Byte-pressure eviction still runs inline on writes. //! //! A bare pool is inert by default ([`Pool::unbounded`]): publishers and subscribers //! that never set a capacity or expiry pay only a couple of atomic counters, and @@ -43,7 +38,7 @@ //! every origin so the whole process caches into a single policy. use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock, Weak}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::time::Duration; use super::group; @@ -111,9 +106,8 @@ impl Config { /// [`Error::Old`](crate::Error::Old). This is independent of track retention: /// [`max_age`](crate::track::Info::max_age) uses media timestamps, while this /// window keeps idle content from pinning memory. Reclaiming without a write behind - /// it needs the [`origin::Driver`](crate::origin::Driver) running, which every - /// session-backed origin has; a standalone broadcast keeps only the write-driven - /// half. The value is fixed when the + /// it needs [`Pool::gc`] called periodically (origins do this automatically). + /// The value is fixed when the /// pool is created. Values are rounded up to the pool's 100 ms clock tick, with /// 100 ms as the minimum effective window. pub fn with_expiry(mut self, expiry: impl Into>) -> Self { @@ -127,7 +121,7 @@ impl Config { /// The pool tracks how many payload bytes are cached across every registered group, /// plus the mean last-access time of the evictable ones. It never evicts on its own: /// tracks accrue eviction debt as they write and evict their own oldest groups to -/// pay it, so every operation here is a few atomics with no lock. The capacity is +/// pay it. Idle expiration runs during [`Self::gc`]. The capacity is /// therefore a target usage converges toward, not a hard limit: carried debt, capped /// payments, and the always-protected live edge all let usage transiently exceed it. #[derive(Clone)] @@ -149,7 +143,8 @@ struct Inner { // Wall-clock LRU window in milliseconds; u64::MAX means never expire by idleness. expiry: u64, // Reference point for the coarse tick clock. - epoch: crate::runtime::Instant, + clock: Mutex>, + tick: AtomicU64, // Sum and count of last-access ticks across the evictable population, giving a // count-weighted mean. Tracks add a group when it becomes evictable (demoted // from the live edge, or inserted behind it) and remove it when it leaves. @@ -162,6 +157,12 @@ struct Inner { tracks: kio::Lock>>, } +struct Clock { + epoch: crate::time::Instant, + now: crate::time::Instant, + sweep: Option, +} + impl Pool { /// Create a pool from an initial policy. /// @@ -178,17 +179,21 @@ impl Pool { } ms.max(1).div_ceil(TICK_MS).saturating_mul(TICK_MS) }); - Self { + let pool = Self { inner: Arc::new(Inner { used: AtomicU64::new(0), capacity: AtomicU64::new(config.capacity.unwrap_or(u64::MAX)), expiry, - epoch: crate::model::clock::now(), + clock: Mutex::new(None), + tick: AtomicU64::new(0), access_sum: AtomicU64::new(0), access_count: AtomicU64::new(0), tracks: kio::Lock::new(slab::Slab::new()), }), - } + }; + #[cfg(test)] + crate::model::clock::register(&pool); + pool } /// Create a pool that never evicts. This is the [`Default`]. @@ -234,54 +239,84 @@ impl Pool { } } - /// How often a reader on a lock-free path should re-stamp its group's access - /// time: half the LRU window keeps the stamp comfortably inside it while staying - /// rare on the hot path. Bounded even when expiry is disabled, since the stamp - /// also protects the group from byte-budget eviction. - pub(crate) fn refresh_interval(&self) -> Duration { - self.expiry().unwrap_or(DEFAULT_EXPIRY) / 2 - } - - /// How often [`Self::sweep`] should run, or `None` when idle reclamation is off. + /// Sample recency periodically while either cache policy is enabled. /// - /// Half the window, so a group is reclaimed within 1.5 windows of its last access - /// rather than 2, and each track's pass drains from its oldest entry so that bound - /// holds however deep the backlog is. A shorter cadence buys nothing: the - /// same per-track gate the write path uses ([`EXPIRY_SCAN_TICKS`]) floors an - /// actual scan at one per second, which is also why a window under two seconds - /// reclaims within two windows instead of 1.5. + /// Expiry is approximate: activity is dated on the following cleanup pass, + /// and passes run at half the idle window. pub(crate) fn sweep_interval(&self) -> Option { - self.expiry().map(|expiry| expiry / 2) + self.expiry() + .or_else(|| self.capacity().map(|_| DEFAULT_EXPIRY)) + .map(|window| window / 2) } - /// Expire idle groups in every track holding an account against this pool. - /// - /// This is the write-independent half of the LRU window: a track whose publisher - /// has stalled runs no write path, so nothing else would ever reclaim the group it - /// left open, and a reader parked inside that group would never be told. Each track - /// drains from its oldest entry rather than a rotating window, since nothing else - /// will, but stops as soon as the front stops yielding victims: a sweep over a pool - /// with nothing due costs a clock read and a few entries per track, not a walk of - /// everything cached. - /// - /// A no-op when idle reclamation is disabled: nothing registers. + /// Expire idle groups across the registered tracks. pub(crate) fn sweep(&self) { - // Upgraded under the lock but settled outside it: settling takes the track's - // own state lock, and dropping the last handle to a dead account would - // re-enter this lock through `Track::drop`. - let tracks: Vec> = self + // Upgrade outside each track's lock: dropping its last account unregisters it. + let tracks: Vec<_> = self .inner .tracks .lock() .iter() .filter_map(|(_, track)| track.upgrade()) .collect(); - for track in tracks { track.sweep(); } } + /// Collect idle cache entries and return the next cleanup time. + /// + /// Call after polling and at the returned deadline, including when idle. + /// Calls before that deadline only advance the pool's sampled clock. A due + /// pass visits every cached group, dating accesses since the last pass and + /// reclaiming idle groups except each track's latest. Delayed calls extend + /// retention. Shared pools use the latest supplied instant. + /// + /// `None` means both cache policies are disabled. After enabling a capacity + /// with [`Self::resize`], call this again to resume periodic clock sampling. + pub fn gc(&self, now: crate::time::Instant) -> Option { + self.advance(now, true) + } + + fn advance(&self, now: crate::time::Instant, sweep: bool) -> Option { + // Shared origins must not run overlapping collection passes. + let mut clock = self.inner.clock.lock().unwrap(); + let clock = clock.get_or_insert(Clock { + epoch: now, + now, + sweep: None, + }); + let now = now.max(clock.now); + let tick = u64::try_from(now.duration_since(clock.epoch).as_millis() / u128::from(TICK_MS)) + .expect("cache clock overflow"); + self.inner.tick.store(tick, Ordering::Relaxed); + clock.now = now; + if self.sweep_interval().is_none() { + clock.sweep = None; + } else if sweep && clock.sweep.is_none_or(|at| at <= now) { + self.sweep(); + clock.sweep = self.sweep_interval().and_then(|interval| now.checked_add(interval)); + } + clock.sweep + } + + #[cfg(test)] + pub(crate) fn advance_test(&self, now: crate::time::Instant) { + self.advance(now, false); + let tracks: Vec<_> = self + .inner + .tracks + .lock() + .iter() + .filter_map(|(_, track)| track.upgrade()) + .collect(); + for track in tracks { + if let Some(state) = track.state.upgrade() { + state.read().date_cache_accesses(self.now()); + } + } + } + /// Enter a track account into the sweep registry, returning its key. `None` when /// idle reclamation is off, which is what keeps a bare pool free of bookkeeping. fn register(&self, track: &Arc) -> Option { @@ -317,10 +352,9 @@ impl Pool { self.inner.used.fetch_sub(n, Ordering::Relaxed); } - /// Coarse ticks since the pool was created: the clock access timestamps use. + /// Coarse ticks since the first cleanup call. pub(crate) fn now(&self) -> u64 { - let elapsed = crate::model::clock::now().duration_since(self.inner.epoch); - elapsed.as_millis() as u64 / TICK_MS + self.inner.tick.load(Ordering::Relaxed) } /// Encode the current clock tick and an access-priority tie breaker. @@ -484,10 +518,11 @@ impl Track { pub(crate) fn charge(self: &Arc) -> Charge { self.pool.add(ENTRY_OVERHEAD); self.written.fetch_add(ENTRY_OVERHEAD, Ordering::Relaxed); + let access = Arc::new(Access::new(self.pool.stamp(0))); Charge { track: Some(self.clone()), bytes: ENTRY_OVERHEAD, - access: Arc::new(Access::new(self.pool.stamp(0))), + access, counted: false, } } @@ -512,21 +547,18 @@ impl Track { self.settle_inner(now, false); } - /// Settle from [`Pool::sweep`], draining the stale front of the eviction order. - /// - /// The write path's rotating window is fine while writes keep coming, because the - /// next one revisits the rest. Nothing follows on a track that stopped writing, so - /// the sweep starts at the oldest entry and keeps going while it finds victims: a - /// backlog otherwise needs its own length in windows before the oldest entry is - /// even examined. A track with nothing due costs a handful of entries, not its - /// depth, and this is gated to the same interval a write is. + /// Settle from [`Pool::sweep`], dating activity and expiring every idle candidate. pub(crate) fn sweep(&self) { self.settle_inner(None, true); } fn settle_inner(&self, now: Option, full: bool) { let settle_debt = self.written.load(Ordering::Relaxed) >= WRITE_CHARGE_THRESHOLD; - let scan_expiry = self.expiry_due(now); + let scan_expiry = if full { + self.pool.expiry().is_some() + } else { + self.expiry_due(now) + }; if !settle_debt && !scan_expiry { return; } @@ -629,28 +661,49 @@ pub(crate) struct Charge { /// every parked consumer, and a mere cache access must not wake anyone, so [`Charge`] /// stamps this through a shared guard. #[derive(Default)] -pub(crate) struct Access(AtomicU64); +pub(crate) struct Access { + stamp: AtomicU64, + expires: AtomicU64, +} impl Access { fn new(stamp: u64) -> Self { - Self(AtomicU64::new(stamp)) + Self { + stamp: AtomicU64::new(stamp), + expires: AtomicU64::new(u64::MAX), + } } /// The stamp, tie-breaking bits included. pub(crate) fn get(&self) -> u64 { - self.0.load(Ordering::Relaxed) + self.stamp.load(Ordering::Relaxed) } - /// The coarse clock tick alone, without the priority bits. - pub(crate) fn tick(&self) -> u64 { - self.get() >> ACCESS_SHIFT + /// Clear the expiration timestamp until a cleanup pass observes this access. + pub(crate) fn touch(&self) { + self.expires.store(u64::MAX, Ordering::Relaxed); + } + + /// The last access tick, assigning undated activity only during cleanup. + pub(crate) fn tick(&self, now: Option) -> Option { + let tick = match now { + Some(now) => match self + .expires + .compare_exchange(u64::MAX, now, Ordering::Relaxed, Ordering::Relaxed) + { + Ok(_) => now, + Err(tick) => tick, + }, + None => self.expires.load(Ordering::Relaxed), + }; + (tick != u64::MAX).then_some(tick) } /// Advance to `target` if it is newer, returning the previous stamp. fn bump(&self, target: u64) -> u64 { // `fetch_max` keeps the stamp monotone, and its prior value makes the // paired mean update exact even for back-to-back accesses. - self.0.fetch_max(target, Ordering::Relaxed) + self.stamp.fetch_max(target, Ordering::Relaxed) } } @@ -728,6 +781,8 @@ impl Charge { /// Returns the tick it read, or `None` when the charge is detached. fn touch(&self, boost: u64) -> Option { let track = self.track.as_ref()?; + // Cleanup assigns the next supplied timestamp to this access. + self.access.touch(); let target = track.pool.stamp(boost); let prev = self.access.bump(target); if target > prev && self.counted { @@ -923,7 +978,12 @@ mod test { // A refresh in the same coarse tick still lifts the group above the mean. c.refresh(); assert!(c.accessed() > average); - assert_eq!(c.access().tick(), pool.now(), "priority does not advance expiry time"); + assert_eq!(c.access().tick(None), None, "undated access is protected until cleanup"); + assert_eq!( + c.access().tick(Some(pool.now())), + Some(pool.now()), + "cleanup dates the access" + ); // Repeated same-tick refreshes are idempotent, not runaway. let stamped = c.accessed(); c.refresh(); @@ -935,12 +995,10 @@ mod test { // A bare pool preserves the unbounded contract in both dimensions. let pool = Pool::unbounded(); assert_eq!(pool.expiry(), None); - assert_eq!(pool.refresh_interval(), DEFAULT_EXPIRY / 2); let pool = Pool::new(Config::default().with_expiry(Duration::from_secs(1))); assert_eq!(pool.expiry(), Some(Duration::from_secs(1))); assert_eq!(pool.expiry_ticks(), 10); - assert_eq!(pool.refresh_interval(), Duration::from_millis(500)); let pool = Pool::new(Config::default().with_expiry(Duration::from_millis(1))); assert_eq!(pool.expiry(), Some(Duration::from_millis(TICK_MS))); @@ -951,7 +1009,6 @@ mod test { let pool = Pool::new(Config::default()); assert_eq!(pool.expiry(), None); assert_eq!(pool.expiry_ticks(), u64::MAX); - assert_eq!(pool.refresh_interval(), DEFAULT_EXPIRY / 2); } #[test] @@ -1013,6 +1070,27 @@ mod test { assert_eq!(crate::origin::Config::default().pool.expiry(), Some(DEFAULT_EXPIRY)); } + #[test] + fn collecting_before_the_deadline_does_not_postpone_it() { + let pool = Pool::new(Config::default().with_expiry(Duration::from_secs(2))); + let now = crate::model::clock::now(); + let deadline = pool.gc(now); + assert_eq!(pool.gc(now + Duration::from_millis(500)), deadline); + } + + #[test] + fn bounded_pools_sample_recency_without_expiration() { + let pool = Pool::unbounded(); + let now = crate::model::clock::now(); + assert_eq!(pool.gc(now), None); + pool.resize(1024); + assert_eq!(pool.gc(now), Some(now + DEFAULT_EXPIRY / 2)); + pool.gc(now + DEFAULT_EXPIRY); + assert!(pool.now() > 0); + pool.resize(None); + assert_eq!(pool.gc(now + DEFAULT_EXPIRY), None); + } + #[test] fn resize() { let pool = Pool::unbounded(); diff --git a/rs/moq-net/src/model/clock.rs b/rs/moq-net/src/model/clock.rs index 97ac20a740..20f808b581 100644 --- a/rs/moq-net/src/model/clock.rs +++ b/rs/moq-net/src/model/clock.rs @@ -1,17 +1,9 @@ -//! The model's clock: the real monotonic clock in production, paused and -//! manually advanced under `cfg(test)`. +//! Clock access for the explicit `Timestamp::now` convenience API and model tests. //! -//! Model time is passive measurement against the model's own stamps (arrival -//! ordering vs a latency budget, cache access ticks, datagram age). Nothing -//! here arms a wakeup, so the model needs no runtime handle, and instants -//! minted here never cross into runtime-armed deadlines (durations may). The -//! test clock exists purely so timing tests are deterministic: it starts -//! frozen and only [`advance`] moves it, minting real `Instant`s as base plus -//! offset. (Crates like `mock_instant` do this by substituting the `Instant` -//! type; keeping std's type is the point, so this stays hand-rolled.) -//! -//! The paused state is thread-local so the standard test harness can run tests -//! concurrently in one process without one test aging another's model. +//! Production cache maintenance and datagram arrival times use caller-supplied +//! instants. Tests share a frozen thread-local clock so advancing one test never +//! affects another. Advancing it also dates pending cache activity; expiration +//! remains a separate operation, driven by writes or an explicit cache-driver poll. /// The current instant on the model's clock. #[cfg(not(test))] @@ -29,6 +21,7 @@ pub(crate) fn now() -> crate::runtime::Instant { /// Move the model's clock forward. Test-only; production time moves itself. #[cfg(test)] pub(crate) fn advance(duration: std::time::Duration) { + poll_pools(); OFFSET.with(|offset| { offset.set( offset @@ -37,6 +30,7 @@ pub(crate) fn advance(duration: std::time::Duration) { .expect("advance overflows the test clock"), ); }); + poll_pools(); } #[cfg(test)] @@ -73,3 +67,26 @@ mod tests { assert_eq!(super::now(), before, "another test thread advanced this clock"); } } + +#[cfg(test)] +thread_local! { + static POOLS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +pub(crate) fn register(pool: &crate::cache::Pool) { + pool.advance_test(now()); + POOLS.with(|pools| pools.borrow_mut().push(pool.downgrade())); +} + +#[cfg(test)] +fn poll_pools() { + let pools: Vec<_> = POOLS.with(|pools| { + let mut pools = pools.borrow_mut(); + pools.retain(|pool| pool.upgrade().is_some()); + pools.iter().filter_map(|pool| pool.upgrade()).collect() + }); + for pool in pools { + pool.advance_test(now()); + } +} diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 67cd91f001..d726d391fb 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -874,8 +874,8 @@ impl Producer { } /// Coarse clock tick of the group's last cache access, used by age expiry. - pub(crate) fn cache_accessed_tick(&self) -> u64 { - self.alive.access.tick() + pub(crate) fn cache_accessed_tick(&self, now: Option) -> Option { + self.alive.access.tick(now) } /// Enter the group into the evictable population: demoted from the live edge, @@ -905,8 +905,9 @@ impl Producer { index: 0, end: None, prefetch: Prefetch::default(), - last_refresh: crate::model::clock::now(), - refresh_interval: self.cache.pool().refresh_interval().min(self.track.max_age / 2), + cache: self.cache.clone(), + access: self.alive.access.clone(), + refreshed: self.cache.pool().now(), }), // Untagged: a tagged track attaches the egress meter via `with_meter` // when it hands the consumer to a subscriber/fetch. @@ -1109,15 +1110,10 @@ struct Plain { // A batch of completed frames drained ahead under one lock (whole-frame reads only). prefetch: Prefetch, - // When this consumer last stamped the group's access time. The prefetch bounds - // a batch by frame count, not elapsed time, so pops re-stamp on a time bound - // (see [`Self::refresh_if_stale`]) or a slow reader could go a full LRU - // window without an access and be expired mid-read. - last_refresh: crate::runtime::Instant, - - // The shorter of half the immutable pool expiry and half the publisher's - // media-retention window. - refresh_interval: std::time::Duration, + // Record prefetched reads without entering the group's state on every frame. + cache: Arc, + access: Arc, + refreshed: u64, } impl Clone for Plain { @@ -1129,8 +1125,9 @@ impl Clone for Plain { index: self.index, end: self.end, prefetch: Prefetch::default(), - last_refresh: self.last_refresh, - refresh_interval: self.refresh_interval, + cache: self.cache.clone(), + access: self.access.clone(), + refreshed: self.refreshed, } } } @@ -1608,18 +1605,16 @@ impl Plain { self.state.read().content_range(start, end) } - /// Re-stamp the group's access time from the lock-free prefetch path once half - /// the shorter of its track retention or the pool's idle window has passed. The - /// batch bounds frames, not elapsed time, so without this a reader pacing - /// through a batch could be expired or evicted while demonstrably active. + /// Record prefetched reads, updating the eviction rank once per sampled tick. fn refresh_if_stale(&mut self) { - let now = crate::model::clock::now(); - if now.duration_since(self.last_refresh) < self.refresh_interval { - return; + self.access.touch(); + let tick = self.cache.pool().now(); + if tick != self.refreshed { + self.state.read().charge.refresh(); + self.refreshed = tick; } - self.state.read().charge.refresh(); - self.last_refresh = now; } + // A helper to automatically apply Dropped if the state is closed without an error. fn poll(&self, waiter: &kio::Waiter, f: F) -> Poll> where @@ -1749,9 +1744,8 @@ impl Plain { Err(state) => return Poll::Ready(Err(state.abort.clone().unwrap_or(Error::Dropped))), } - // The refill stamped the group under its lock; restart the staleness clock - // so the pops that follow don't immediately re-stamp. - self.last_refresh = crate::model::clock::now(); + // The refill already updated the eviction rank under the group lock. + self.refreshed = self.cache.pool().now(); // A fresh batch was just filled (empty only on a clean end). Count the whole // batch once here, under no lock, so the drained pops that follow stay free. diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index e44e4d6f77..5b8f9e84c0 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -35,7 +35,7 @@ pub use time::*; /// Publishing broadcasts, announcing routes, and consuming both through an origin. pub mod origin { - pub use super::origin_impl::{Config, Consumer, Cost, Driver, Dynamic, Pending, Producer, Request, Route, Run}; + pub use super::origin_impl::{Config, Consumer, Cost, Driver, Dynamic, Pending, Producer, Request, Route}; } /// Subscribing to route (un)announcements from an origin. diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 14f5ee37ec..4f48afdb05 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -12,13 +12,13 @@ use std::{ use rand::RngExt; use web_async::Lock; -use web_transport_trait::{MaybeSend, MaybeSync}; use super::{Requests, WeakCache, WeakEntry}; use crate::{ AsPath, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, coding::{BoundsExceeded, Decode, DecodeError, Encode, EncodeError}, - runtime::{AnyTimers, Instant, Timers, TimersSlot}, + runtime::{Instant, Timers}, + time::Clock, util::{TaskSet, Tasks, TasksWeak}, }; @@ -1115,8 +1115,8 @@ pub struct Producer { // driver drops, which is what makes later mutations fail with `Closed`. tasks: Tasks, - // The driver's clock and timers, installed by [`Driver::run`]. - timers: TimersSlot, + // The clock advanced by the origin driver. + timers: Clock, } impl std::ops::Deref for Producer { @@ -1132,15 +1132,13 @@ impl Producer { /// prefix and no pre-existing broadcasts, paired with the [`Driver`] that runs /// the origin's lifecycle work. /// - /// Hand the driver a [`crate::Timers`] via [`Driver::run`] and poll the - /// returned [`Run`] (spawn it, await it, or step [`Run::poll`]) for the - /// origin to make progress; see the [`Driver`] docs for the exact contract. + /// Poll the driver with caller-supplied time for the origin to make progress. /// `moq_tokio::origin::spawn` wraps this for tokio callers. pub fn new(config: Config) -> (Self, Driver) { let (tasks, set) = TaskSet::new(); let scope = OriginScope::default(); let shared = kio::Shared::::default(); - let timers = TimersSlot::default(); + let timers = Clock::default(); let pool = config.pool.clone(); let producer = Self { info: config.id, @@ -1160,10 +1158,10 @@ impl Producer { tree: scope.tree, shared, done: false, - sweep: None, }, timers, pool, + gc: None, }; (producer, driver) } @@ -1211,7 +1209,7 @@ impl Producer { default_max_age: track::DEFAULT_MAX_AGE, stats: stats::Session::default(), tasks, - timers: TimersSlot::default(), + timers: Clock::default(), } } @@ -1659,36 +1657,29 @@ impl Drop for AnnounceProducer { } } -/// The origin's lifecycle work, waiting for the [`crate::Timers`] it runs on. +/// Drives origin lifecycle work and cache expiration with caller-supplied time. /// -/// Returned by [`Producer::new`] alongside the producer. Call -/// [`run`](Self::run) with the timers that arm its deadlines (linger, handover -/// holds) and poll the returned [`Run`] for the life of the origin. Route -/// changes, track serving, linger timers, failover, and teardown all run there: -/// exact lookups and eligible announcements still update synchronously in -/// [`Producer::create_broadcast`], but nothing else makes progress without -/// polling. +/// Returned by [`Producer::new`]. Poll on external activity or at [`Self::timeout`], +/// supplying nondecreasing instants. Route changes, track serving, linger, +/// failover, and teardown run here; exact lookups and eligible announcements +/// update synchronously in [`Producer::create_broadcast`]. /// -/// It holds no [`Producer`] clone, so it never keeps the origin alive. -/// Dropping it (before or after `run`) tears the origin down immediately: -/// active fronts abort with [`Error::Dropped`], pending dynamic requests are -/// rejected, announced paths unannounce and announcement cursors end, and later -/// producer mutations fail with [`Error::Closed`]. -/// -/// `moq_tokio::origin::spawn` wraps construction, `run`, and spawning for -/// tokio callers. -#[must_use = "call Driver::run and poll the result or the origin makes no progress"] +/// It holds no [`Producer`] clone, so it never keeps the origin alive. Dropping +/// it aborts active fronts, rejects pending requests, ends announcements, and +/// makes subsequent producer mutations fail with [`Error::Closed`]. +/// `moq_tokio::origin::spawn` handles construction and driving for Tokio callers. +#[must_use = "poll the driver or the origin makes no progress"] pub struct Driver { state: DriverState, - // The producer's slot, filled by `run` so lifecycle work can mint deadlines. - timers: TimersSlot, + // Shared by this origin's lifecycle tasks; advanced only when polled. + timers: Clock, // The cache pool this origin's groups charge into, swept on a wall-clock // cadence so its idle window binds a track whose publisher stopped writing. pool: cache::Pool, + gc: Option, } -/// Everything the driver polls and tears down, split from the park so the two -/// borrow disjointly. +/// Lifecycle work and the state it tears down. struct DriverState { /// Source watchers, fronts, and serve tasks: producers submit, this polls. set: TaskSet, @@ -1699,96 +1690,30 @@ struct DriverState { shared: kio::Shared, /// Cached completion so a poll after `Ready` doesn't re-poll the drained set. done: bool, - /// The cache pool's idle sweep, installed by [`Driver::run`] (which is where the - /// timers arrive) and absent when the pool never expires content. - sweep: Option, } impl Driver { - /// Install the timers and return the runnable driver. - /// - /// The origin's lifecycle work stamps instants and arms deadlines against - /// `timers`; nothing runs until the returned [`Run`] is polled. - pub fn run(self, timers: T) -> Run - where - T: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - T::Timer: MaybeSend + 'static, - { - let timers = AnyTimers::new(timers); - self.timers.install(timers.clone()); - let mut state = self.state; - state.sweep = Sweep::new(&timers, self.pool); - Run { - state, - park: kio::Park::default(), - } + /// Process ready origin work using caller-supplied monotonic time. + pub fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Poll<()> { + self.timers.advance(now); + let result = self.state.poll(waiter); + self.gc = self.pool.gc(now); + result } -} -/// The wall-clock half of the cache pool's idle window. -/// -/// A track settles its own expiry as it writes, which covers every track that is -/// still producing. A publisher that stalls with a group open stops writing, so this -/// is what still reclaims that group (and unblocks whoever is parked inside it): a -/// periodic [`cache::Pool::sweep`] on the origin's own timers. -/// -/// Disarmed, and never allocated, when the pool has no expiry window. -struct Sweep { - timers: AnyTimers, - pool: cache::Pool, - interval: Duration, - deadline: crate::runtime::Deadline, -} - -impl Sweep { - fn new(timers: &AnyTimers, pool: cache::Pool) -> Option { - let interval = pool.sweep_interval()?; - Some(Self { - timers: timers.clone(), - pool, - interval, - deadline: crate::runtime::Deadline::after(timers, interval), - }) + /// The next instant to poll, or none when only external activity can make progress. + pub fn timeout(&self) -> Option { + self.timers.timeout().into_iter().chain(self.gc).min() } - - fn poll(&mut self, waiter: &kio::Waiter) { - // A `Deadline` stays ready until it is re-armed, so re-arm before sweeping - // again; a clock that has not moved lands the next one in the future and - // this returns after one pass. - while self.deadline.poll(waiter).is_ready() { - self.deadline.set(self.timers.now().checked_add(self.interval)); - self.pool.sweep(); - } - } -} - -/// The future running an origin's lifecycle work, from [`Driver::run`]. -/// -/// Poll it for the life of the origin, either by `.await`ing it (typically -/// spawned on an executor) or by stepping [`poll`](Self::poll) from inside -/// another [`kio`]-style poll function. -/// -/// It holds no [`Producer`] clone, so it never keeps the origin alive: it -/// resolves once every producer handle has dropped and the already-submitted -/// lifecycle work has drained, and keeps returning `Ready` if polled again. -/// Dropping it tears the origin down immediately, exactly like dropping the -/// [`Driver`] it came from. -#[must_use = "poll the driver (spawn or await it) or the origin makes no progress"] -pub struct Run { - state: DriverState, - // Retains the waiter across `Future` polls so its kio registrations stay live. - // Kept out of `DriverState` so the borrow `hold` hands back doesn't collide - // with the `&mut` that polling the state needs. - park: kio::Park, } -impl Run { - /// Drive the origin one step, registering `waiter` for the next wakeup. - /// - /// The `poll_*` counterpart of `.await`ing, for callers composing the driver - /// into their own [`kio`]-style poll functions. - pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - self.state.poll(waiter) +impl crate::time::Driver for Driver { + type Output = (); + fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Poll<()> { + self.poll(now, waiter) + } + fn timeout(&self) -> Option { + self.timeout() } } @@ -1797,9 +1722,6 @@ impl DriverState { // Never gates completion: the pool outlives this origin (a relay shares one // across every origin), so a sweep that is still due must not keep the driver // alive after its lifecycle work has drained. - if let Some(sweep) = &mut self.sweep { - sweep.poll(waiter); - } if !self.done { ready!(self.set.poll(waiter)); self.done = true; @@ -1866,18 +1788,6 @@ impl Drop for DriverState { } } -impl Future for Run { - type Output = (); - - fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<()> { - let this = &mut *self; - // Disjoint field borrows: `hold` borrows the park for as long as the - // waiter lives, while the state is polled through its own `&mut`. - let waiter = this.park.hold(cx); - this.state.poll(waiter) - } -} - /// Abort and unpublish every broadcast under `node` with [`Error::Dropped`]. The /// lifecycle tasks are already cancelled, so this finishes the teardown they /// would have run. @@ -2028,7 +1938,7 @@ fn detach_source(state: &kio::Producer, broadcast: &broadcast::Produ struct SourceTask { /// The source broadcast, watched for its end. source: broadcast::Consumer, - timers: TimersSlot, + timers: Clock, /// The leaf the attach landed on. leaf: Lock, /// The front's source table. @@ -2074,7 +1984,7 @@ struct AttachContext<'a> { /// Driver submission handle, for queueing a fresh front's task. tasks: &'a Tasks, /// The driver's clock, threaded into fronts for the track idle linger. - timers: &'a TimersSlot, + timers: &'a Clock, } /// Attach a source to the broadcast at `leaf`, creating (and publishing) the @@ -2171,7 +2081,7 @@ async fn run_front( tree: Lock, full: PathOwned, tasks: Tasks, - slot: TimersSlot, + slot: Clock, ) { enum Step { Serve(Arc, super::resume::Producer), @@ -2264,7 +2174,7 @@ async fn serve_track( state: kio::Producer, name: Arc, mut resume: super::resume::Producer, - slot: TimersSlot, + slot: Clock, ) { enum Step { Closed, @@ -2307,9 +2217,8 @@ async fn serve_track( let mut dead: HashSet = HashSet::new(); // When the spliced segment stopped being read, starting the release countdown. let mut idle_since: Option = None; - // Only the driver polls this body, and `Driver::run` installs the slot - // before the driver can poll anything (see `run_front`). - let timers = slot.get(); + // Only the driver polls this body, after advancing the shared clock. + let timers = slot.clone(); let mut deadline = crate::runtime::Deadline::new(&timers); loop { @@ -2642,7 +2551,7 @@ struct RemoteFrontTask { /// Resolves the requesters parked on the front's channel. request: kio::Producer, tasks: TasksWeak, - timers: TimersSlot, + timers: Clock, } /// Owns a remotely-served front: materializes the path from the best covering @@ -3531,7 +3440,7 @@ pub struct Consumer { // The driver's clock and timers, threaded into fronts for the track idle // linger. - timers: TimersSlot, + timers: Clock, } impl std::ops::Deref for Consumer { @@ -4104,7 +4013,7 @@ impl ProduceTest for Config { fn produce(self) -> Producer { let (producer, driver) = Producer::new(self); if tokio::runtime::Handle::try_current().is_ok() { - web_async::spawn(driver.run(crate::runtime::tokio_test::Tokio::new())); + web_async::spawn(crate::time::test::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. @@ -5314,7 +5223,7 @@ mod tests { async fn driver_resolves_with_live_consumers() { let (producer, driver) = Producer::new(Config::new(origin(1))); let consumer = producer.consume(); - let run = driver.run(crate::runtime::tokio_test::Tokio::new()); + let run = crate::time::test::run(driver); drop(producer); tokio::time::timeout(Duration::from_secs(5), run) .await diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 02c455cb1c..c72c2a4d2a 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -2773,7 +2773,9 @@ mod test { .is_none(), "no datagram yet" ); - track_a.append_datagram(Timestamp::ZERO, b"d0".as_ref()).unwrap(); + track_a + .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"d0".as_ref()) + .unwrap(); let datagram = kio::wait(|waiter| sub.poll_recv_datagram(waiter)) .now_or_never() .expect("datagram should be ready") @@ -4945,14 +4947,18 @@ mod test { // Datagrams are a live best-effort channel: one from a replaced segment // is stale and never surfaces, only the live route's flow does. - track_a.append_datagram(Timestamp::ZERO, b"old".as_ref()).unwrap(); + track_a + .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"old".as_ref()) + .unwrap(); assert!( kio::wait(|waiter| sub.poll_recv_datagram(waiter)) .now_or_never() .is_none(), "stale datagram must not surface" ); - track_b.append_datagram(Timestamp::ZERO, b"new".as_ref()).unwrap(); + track_b + .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"new".as_ref()) + .unwrap(); let datagram = kio::wait(|waiter| sub.poll_recv_datagram(waiter)) .now_or_never() .expect("datagram should be ready") diff --git a/rs/moq-net/src/model/time.rs b/rs/moq-net/src/model/time.rs index c6d340f082..e1a7b5a118 100644 --- a/rs/moq-net/src/model/time.rs +++ b/rs/moq-net/src/model/time.rs @@ -446,16 +446,21 @@ mod clock { }); pub(super) fn now() -> Timestamp { - let (anchor_instant, anchor_duration) = *TIME_ANCHOR; - let instant = crate::model::clock::now(); - let duration = match instant.checked_duration_since(anchor_instant) { - Some(forward) => anchor_duration + forward, - None => anchor_duration - .checked_sub(anchor_instant.duration_since(instant)) - .unwrap_or(std::time::Duration::ZERO), - }; + crate::model::clock::now().into() + } - Timestamp::from_millis(duration.as_millis() as u64).expect("clock is somehow past the year 2300") + impl From for Timestamp { + fn from(instant: crate::time::Instant) -> Timestamp { + let (anchor_instant, anchor_duration) = *TIME_ANCHOR; + let duration = match instant.checked_duration_since(anchor_instant) { + Some(forward) => anchor_duration + forward, + None => anchor_duration + .checked_sub(anchor_instant.duration_since(instant)) + .unwrap_or(std::time::Duration::ZERO), + }; + + Timestamp::from_millis(duration.as_millis() as u64).expect("clock is somehow past the year 2300") + } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index dc18941c30..aa05669c91 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -55,12 +55,12 @@ const EVICT_SCAN: usize = 4; pub(super) struct ExpiryScan { start: usize, // Ceiling on how many entries this pass examines. `EVICT_SCAN` from the write - // path, the whole queue from the pool's sweep. Either way the pass stops once it - // has retained `EVICT_SCAN` entries, so its cost tracks what it reclaims rather - // than how much the track has cached. + // path, the whole queue from the pool's cleanup pass. width: usize, now: u64, max_ticks: u64, + // Cleanup dates pending activity; write-driven scans only check dated entries. + gc: bool, } /// Publisher-side properties of a track. @@ -373,8 +373,7 @@ impl TrackState { } /// Push a datagram onto the buffer, dropping any that have aged past [`MAX_DATAGRAM_AGE`]. - fn push_datagram(&mut self, datagram: Datagram) { - let now = crate::model::clock::now(); + fn push_datagram(&mut self, now: crate::time::Instant, datagram: Datagram) { self.datagrams.push_back((datagram, now)); while let Some((_, at)) = self.datagrams.front() { if now.duration_since(*at) <= MAX_DATAGRAM_AGE { @@ -601,7 +600,7 @@ impl TrackState { /// fetched, or written) entries in front of them: every position is revisited /// within a few writes. Expiry throughput is therefore EVICT_SCAN groups per write; the /// byte budget reclaims the remainder under memory pressure, and the pool's sweep - /// ([`Self::expiry_scan_full`]) covers a track that stopped writing entirely. + /// ([`Self::expiry_scan_drain`]) covers a track that stopped writing entirely. pub(super) fn evict_expired(&mut self) { let scan = self.expiry_scan(); self.evict_expired_scan(scan); @@ -614,25 +613,26 @@ impl TrackState { width: EVICT_SCAN, now: self.cache.pool().now(), max_ticks: self.cache.pool().expiry_ticks(), + gc: false, } } - /// Describe a scan that drains the stale front of the eviction order, for the - /// pool's sweep. - /// - /// A write's rotating window is fine while writes keep coming, because the next - /// one revisits the rest. The sweep is the only thing running on a track nobody - /// writes, so that window would take a backlog's length in sweeps to reach the - /// oldest entry. This starts at the front, where the oldest entries are, and the - /// shared stop rule ends it once the front stops yielding victims. A track that - /// went quiet has its whole backlog stale and contiguous there, so one sweep takes - /// all of it; a track with nothing due costs `EVICT_SCAN` entries, not its depth. + /// Scan every cached candidate so undated accesses and old entries cannot hide + /// behind fresh entries at the front of the eviction order. pub(super) fn expiry_scan_drain(&self) -> ExpiryScan { ExpiryScan { start: 0, width: self.evict.len(), now: self.cache.pool().now(), max_ticks: self.cache.pool().expiry_ticks(), + gc: true, + } + } + + #[cfg(test)] + pub(super) fn date_cache_accesses(&self, now: u64) { + for slot in self.lookup.values() { + slot.group.cache_accessed_tick(Some(now)); } } @@ -655,12 +655,15 @@ impl TrackState { } if slot.group.is_aborted() || (Some(sequence) != self.latest_group - && scan.now.saturating_sub(slot.group.cache_accessed_tick()) > scan.max_ticks) + && slot + .group + .cache_accessed_tick(scan.gc.then_some(scan.now)) + .is_some_and(|tick| scan.now.saturating_sub(tick) > scan.max_ticks)) { return true; } retained += 1; - if retained >= EVICT_SCAN { + if !scan.gc && retained >= EVICT_SCAN { break; } } @@ -699,13 +702,15 @@ impl TrackState { continue; } if Some(sequence) == self.latest_group - || scan.now.saturating_sub(slot.group.cache_accessed_tick()) <= scan.max_ticks + || slot + .group + .cache_accessed_tick(scan.gc.then_some(scan.now)) + .is_none_or(|tick| scan.now.saturating_sub(tick) <= scan.max_ticks) { - // Nothing to reclaim here. The front of the queue is the oldest - // content, so a run of these means the rest is fresher still: stop - // rather than walk a whole cache to find nothing. + // Writes keep their scan bounded. Cleanup visits the entire + // queue to date pending accesses and find idle entries behind them. retained += 1; - if retained >= EVICT_SCAN { + if !scan.gc && retained >= EVICT_SCAN { break; } continue; @@ -1244,7 +1249,12 @@ impl Producer { /// deliver them. Keep payloads well under the 1200-byte minimum path MTU. An origin /// publisher uses this; a relay preserving upstream numbering uses /// [`Self::insert_datagram`]. - pub fn append_datagram(&mut self, timestamp: Timestamp, payload: B) -> Result { + pub fn append_datagram( + &mut self, + now: crate::time::Instant, + timestamp: Timestamp, + payload: B, + ) -> Result { let payload = payload.into_bytes(); if payload.len() > super::datagram::MAX_DATAGRAM_PAYLOAD { return Err(Error::FrameTooLarge); @@ -1266,11 +1276,14 @@ impl Producer { } state.max_sequence = Some(sequence); meter.datagram(payload.len() as u64); - state.push_datagram(Datagram { - sequence, - timestamp, - payload, - }); + state.push_datagram( + now, + Datagram { + sequence, + timestamp, + payload, + }, + ); let cache = state.cache.clone(); drop(state); cache.settle(None); @@ -1282,13 +1295,12 @@ impl Producer { /// Preserves the supplied sequence (bumping the shared `max_sequence` if needed), so a /// relay can forward a datagram without renumbering it. Most origin publishers want /// [`Self::append_datagram`] instead. - pub fn insert_datagram( - &mut self, - sequence: u64, - timestamp: Timestamp, - payload: B, - ) -> Result<()> { - let payload = payload.into_bytes(); + pub fn insert_datagram(&mut self, now: crate::time::Instant, datagram: Datagram) -> Result<()> { + let Datagram { + sequence, + timestamp, + payload, + } = datagram; if payload.len() > super::datagram::MAX_DATAGRAM_PAYLOAD { return Err(Error::FrameTooLarge); } @@ -1305,11 +1317,14 @@ impl Producer { } state.max_sequence = Some(state.max_sequence.unwrap_or(0).max(sequence)); meter.datagram(payload.len() as u64); - state.push_datagram(Datagram { - sequence, - timestamp, - payload, - }); + state.push_datagram( + now, + Datagram { + sequence, + timestamp, + payload, + }, + ); let cache = state.cache.clone(); drop(state); cache.settle(None); @@ -4155,9 +4170,19 @@ mod test { // Interleave groups and datagrams: they draw from one monotonic counter. assert_eq!(producer.append_group().unwrap().sequence, 0); - assert_eq!(producer.append_datagram(ts, &b"a"[..]).unwrap(), 1); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"a"[..]) + .unwrap(), + 1 + ); assert_eq!(producer.append_group().unwrap().sequence, 2); - assert_eq!(producer.append_datagram(ts, &b"b"[..]).unwrap(), 3); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"b"[..]) + .unwrap(), + 3 + ); assert_eq!(producer.latest(), Some(3)); } @@ -4167,7 +4192,9 @@ mod test { let mut dg = producer.subscribe(None); let ts = Timestamp::from_millis(42).unwrap(); - let seq = producer.append_datagram(ts, &b"hello"[..]).unwrap(); + let seq = producer + .append_datagram(crate::model::clock::now(), ts, &b"hello"[..]) + .unwrap(); let got = recv_datagram(&mut dg); assert_eq!(got.sequence, seq); @@ -4183,7 +4210,14 @@ mod test { let ts = Timestamp::from_millis(5).unwrap(); // A relay forwarding an upstream datagram keeps its sequence number. producer - .insert_datagram(100, ts, bytes::Bytes::from_static(b"x")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 100, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), + }, + ) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 100); @@ -4198,10 +4232,22 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram(10, ts, bytes::Bytes::from_static(b"gap")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 10, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"gap")), + }, + ) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 10); - assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 11); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"next"[..]) + .unwrap(), + 11 + ); assert_eq!(producer.append_group().unwrap().sequence, 12); } @@ -4212,15 +4258,34 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram(10, ts, bytes::Bytes::from_static(b"high")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 10, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"high")), + }, + ) .unwrap(); producer - .insert_datagram(5, ts, bytes::Bytes::from_static(b"low")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 5, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"low")), + }, + ) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 10); assert_eq!(recv_datagram(&mut dg).sequence, 5); - assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 11); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"next"[..]) + .unwrap(), + 11 + ); } #[tokio::test] @@ -4230,15 +4295,34 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram(3, ts, bytes::Bytes::from_static(b"first")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 3, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"first")), + }, + ) .unwrap(); producer - .insert_datagram(3, ts, bytes::Bytes::from_static(b"again")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 3, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"again")), + }, + ) .unwrap(); assert_eq!(&recv_datagram(&mut dg).payload[..], b"first"); assert_eq!(&recv_datagram(&mut dg).payload[..], b"again"); - assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 4); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"next"[..]) + .unwrap(), + 4 + ); } #[tokio::test] @@ -4247,16 +4331,38 @@ mod test { let mut dg = producer.subscribe(None); let ts = Timestamp::from_millis(0).unwrap(); - assert_eq!(producer.append_datagram(ts, &b"0"[..]).unwrap(), 0); - assert_eq!(producer.append_datagram(ts, &b"1"[..]).unwrap(), 1); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"0"[..]) + .unwrap(), + 0 + ); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"1"[..]) + .unwrap(), + 1 + ); producer - .insert_datagram(0, ts, bytes::Bytes::from_static(b"stale")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 0, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"stale")), + }, + ) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 0); assert_eq!(recv_datagram(&mut dg).sequence, 1); assert_eq!(recv_datagram(&mut dg).sequence, 0); - assert_eq!(producer.append_datagram(ts, &b"2"[..]).unwrap(), 2); + assert_eq!( + producer + .append_datagram(crate::model::clock::now(), ts, &b"2"[..]) + .unwrap(), + 2 + ); assert_eq!(producer.append_group().unwrap().sequence, 3); } @@ -4268,10 +4374,31 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram(4, ts, bytes::Bytes::from_static(b"a")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 4, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"a")), + }, + ) + .unwrap(); + assert_eq!( + other + .append_datagram(crate::model::clock::now(), ts, &b"b"[..]) + .unwrap(), + 5 + ); + other + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 8, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"c")), + }, + ) .unwrap(); - assert_eq!(other.append_datagram(ts, &b"b"[..]).unwrap(), 5); - other.insert_datagram(8, ts, bytes::Bytes::from_static(b"c")).unwrap(); assert_eq!(producer.append_group().unwrap().sequence, 9); assert_eq!(recv_datagram(&mut dg).sequence, 4); @@ -4285,10 +4412,20 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer.finish().unwrap(); assert!(matches!( - producer.insert_datagram(0, ts, bytes::Bytes::from_static(b"x")), + producer.insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 0, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")) + } + ), + Err(Error::Closed) + )); + assert!(matches!( + producer.append_datagram(crate::model::clock::now(), ts, &b"x"[..]), Err(Error::Closed) )); - assert!(matches!(producer.append_datagram(ts, &b"x"[..]), Err(Error::Closed))); } #[test] @@ -4297,7 +4434,18 @@ mod test { let mut other = producer.clone(); let ts = Timestamp::from_millis(0).unwrap(); producer.abort(Error::Cancel).unwrap(); - assert!(other.insert_datagram(0, ts, bytes::Bytes::from_static(b"x")).is_err()); + assert!( + other + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 0, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")) + } + ) + .is_err() + ); } #[test] @@ -4306,10 +4454,24 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer.finish_at(10).unwrap(); producer - .insert_datagram(5, ts, bytes::Bytes::from_static(b"ok")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 5, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"ok")), + }, + ) .unwrap(); assert!(matches!( - producer.insert_datagram(10, ts, bytes::Bytes::from_static(b"late")), + producer.insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 10, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"late")) + } + ), Err(Error::Closed) )); assert_eq!(producer.append_group().unwrap().sequence, 6); @@ -4321,7 +4483,14 @@ mod test { let mut datagram_only = track_producer("datagram-only", None); let datagram_only_consumer = datagram_only.consume(); datagram_only - .insert_datagram(8, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 8, + timestamp: Timestamp::ZERO, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), + }, + ) .unwrap(); assert_eq!( datagram_only_consumer.resume_position(), @@ -4336,7 +4505,14 @@ mod test { .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); producer - .insert_datagram(8, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 8, + timestamp: Timestamp::ZERO, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), + }, + ) .unwrap(); assert_eq!( @@ -4357,7 +4533,14 @@ mod test { let ts = Timestamp::from_millis(5).unwrap(); producer - .insert_datagram(5, ts, bytes::Bytes::from_static(b"x")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 5, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), + }, + ) .unwrap(); assert_eq!(recv_datagram(&mut datagrams).sequence, 5); @@ -4385,7 +4568,11 @@ mod test { // Supplied at millis; stored/emitted at the track's micro timescale. producer - .append_datagram(Timestamp::from_millis(2).unwrap(), &b"z"[..]) + .append_datagram( + crate::model::clock::now(), + Timestamp::from_millis(2).unwrap(), + &b"z"[..], + ) .unwrap(); let got = recv_datagram(&mut dg); assert_eq!(got.timestamp.scale(), Timescale::MICRO); @@ -4398,11 +4585,18 @@ mod test { let big = bytes::Bytes::from(vec![0u8; crate::model::datagram::MAX_DATAGRAM_PAYLOAD + 1]); let ts = Timestamp::from_millis(0).unwrap(); assert!(matches!( - producer.append_datagram(ts, big.clone()), + producer.append_datagram(crate::model::clock::now(), ts, big.clone()), Err(Error::FrameTooLarge) )); assert!(matches!( - producer.insert_datagram(0, ts, big), + producer.insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 0, + timestamp: ts, + payload: crate::IntoBytes::into_bytes(big) + } + ), Err(Error::FrameTooLarge) )); } @@ -4415,8 +4609,12 @@ mod test { let mut b = producer.subscribe(None); let ts = Timestamp::from_millis(1).unwrap(); - producer.append_datagram(ts, &b"first"[..]).unwrap(); - producer.append_datagram(ts, &b"second"[..]).unwrap(); + producer + .append_datagram(crate::model::clock::now(), ts, &b"first"[..]) + .unwrap(); + producer + .append_datagram(crate::model::clock::now(), ts, &b"second"[..]) + .unwrap(); // Both receive every datagram in order, independently. assert_eq!(&recv_datagram(&mut a).payload[..], b"first"); @@ -4431,11 +4629,15 @@ mod test { let mut dg = producer.subscribe(None); let ts = Timestamp::from_millis(0).unwrap(); - producer.append_datagram(ts, &b"old"[..]).unwrap(); // sequence 0 + producer + .append_datagram(crate::model::clock::now(), ts, &b"old"[..]) + .unwrap(); // sequence 0 // Age past the send-buffer window, then push a fresh datagram: the stale one is evicted. crate::model::clock::advance(MAX_DATAGRAM_AGE + Duration::from_millis(10)); - producer.append_datagram(ts, &b"new"[..]).unwrap(); // sequence 1 + producer + .append_datagram(crate::model::clock::now(), ts, &b"new"[..]) + .unwrap(); // sequence 1 // A lagging consumer resumes at the oldest still-buffered datagram (the fresh one). let got = recv_datagram(&mut dg); @@ -4454,7 +4656,11 @@ mod test { ); producer - .append_datagram(Timestamp::from_millis(0).unwrap(), &b"go"[..]) + .append_datagram( + crate::model::clock::now(), + Timestamp::from_millis(0).unwrap(), + &b"go"[..], + ) .unwrap(); assert_eq!(&recv_datagram(&mut dg).payload[..], b"go"); } @@ -4473,7 +4679,9 @@ mod test { let mut origin = track_producer("test", None); let mut origin_dg = origin.subscribe(None); let ts = Timestamp::from_millis(7).unwrap(); - let seq = origin.append_datagram(ts, &b"payload"[..]).unwrap(); + let seq = origin + .append_datagram(crate::model::clock::now(), ts, &b"payload"[..]) + .unwrap(); let d = recv_datagram(&mut origin_dg); let body = lite::Datagram { @@ -4492,9 +4700,12 @@ mod test { let mut downstream_dg = downstream.subscribe(None); downstream .insert_datagram( - wire.sequence, - Timestamp::new(wire.timestamp, Timescale::MILLI).unwrap(), - wire.payload, + crate::model::clock::now(), + crate::Datagram { + sequence: wire.sequence, + timestamp: Timestamp::new(wire.timestamp, Timescale::MILLI).unwrap(), + payload: crate::IntoBytes::into_bytes(wire.payload), + }, ) .unwrap(); @@ -4845,6 +5056,46 @@ mod test { ); } + #[test] + fn cache_gc_dates_activity_before_expiring_it() { + let pool = cache::Pool::new(cache::Config::default().with_expiry(Duration::from_secs(1))); + let now = crate::model::clock::now(); + assert_eq!(pool.gc(now), Some(now + Duration::from_millis(500))); + let producer = track_producer_pooled("test", pool.clone()); + let mut group = producer.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, b"first".as_slice()).unwrap(); + producer.append_group().unwrap(); + // Activity written while cleanup was idle gets the new supplied time. + let later = now + Duration::from_secs(60); + pool.gc(later); + assert!(producer.state.read().lookup.contains_key(&0)); + pool.gc(later + Duration::from_secs(2)); + assert!(!producer.state.read().lookup.contains_key(&0)); + } + + #[test] + fn cache_gc_reaches_old_entries_behind_a_fresh_front() { + let expiry = Duration::from_secs(1); + let pool = cache::Pool::new(cache::Config::default().with_expiry(expiry)); + let producer = track_producer_pooled("test", pool.clone()); + let now = crate::model::clock::now(); + let groups: Vec<_> = (0..EVICT_SCAN * 3).map(|_| producer.append_group().unwrap()).collect(); + producer.append_group().unwrap(); + pool.gc(now); + // Keep more than a write scan's worth of leading entries fresh. + for group in &groups[..EVICT_SCAN * 2] { + group.cache_refresh(); + } + pool.gc(now + expiry * 2); + let state = producer.state.read(); + for sequence in 0..EVICT_SCAN * 2 { + assert!(state.lookup.contains_key(&(sequence as u64)), "fresh front survives"); + } + for sequence in EVICT_SCAN * 2..EVICT_SCAN * 3 { + assert!(!state.lookup.contains_key(&(sequence as u64)), "old tail is reclaimed"); + } + } + /// One sweep drains a whole idle backlog, not a rotating window of it: a quiet /// track has no writes left to revisit the rest of the queue with, so a bounded /// pass would leave the oldest groups parked for a backlog's length in windows. @@ -4946,7 +5197,9 @@ mod test { producer.append_group().unwrap().finish().unwrap(); // seq 1 crate::model::clock::advance(Duration::from_secs(2)); - producer.append_datagram(Timestamp::ZERO, b"x".as_slice()).unwrap(); + producer + .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"x".as_slice()) + .unwrap(); let expired = !producer.state.read().lookup.contains_key(&0); assert!(expired, "an appended datagram runs expiry"); @@ -4960,7 +5213,14 @@ mod test { crate::model::clock::advance(Duration::from_secs(2)); producer - .insert_datagram(2, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 2, + timestamp: Timestamp::ZERO, + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), + }, + ) .unwrap(); let expired = !producer.state.read().lookup.contains_key(&0); @@ -5750,7 +6010,14 @@ mod test { let mut sub = producer.subscribe(None).ordered(); producer - .insert_datagram(5, Timestamp::from_millis(5).unwrap(), bytes::Bytes::from_static(b"x")) + .insert_datagram( + crate::model::clock::now(), + crate::Datagram { + sequence: 5, + timestamp: Timestamp::from_millis(5).unwrap(), + payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), + }, + ) .unwrap(); producer.create_group(group::Info { sequence: 3 }).unwrap(); @@ -7822,7 +8089,9 @@ mod test { let (mut producer, pool) = pooled_producer(1_000); for _ in 0..10 { finished_group(&mut producer, 1_000); - producer.append_datagram(Timestamp::ZERO, &b"beat"[..]).unwrap(); + producer + .append_datagram(crate::model::clock::now(), Timestamp::ZERO, &b"beat"[..]) + .unwrap(); } let consumer = producer.consume(); diff --git a/rs/moq-net/src/runtime.rs b/rs/moq-net/src/runtime.rs index f7621e5167..a354c89539 100644 --- a/rs/moq-net/src/runtime.rs +++ b/rs/moq-net/src/runtime.rs @@ -1,12 +1,4 @@ -//! Timer integration for session and origin drivers. -//! -//! [`crate::Client::connect`] and [`crate::Server::accept`] take [`Timers`] and -//! return a [`crate::Driver`] for the caller to poll or spawn. This crate never -//! spawns tasks. Timer providers supply a clock and re-armable registrations, -//! without choosing a transport or executor. -//! -//! `Test` (behind `test-runtime`) provides a virtual clock that advances only -//! when the test requests it. +//! Private deadlines driven by the owning driver's supplied clock. use std::task::Poll; @@ -51,136 +43,8 @@ pub trait Timers: Clone { /// A new, disarmed timer. fn timer(&self) -> Self::Timer; - /// The current instant. - /// - /// Defaults to the real clock, which every production runtime should keep. - /// It exists so `Test` can substitute a virtual clock: code that arms - /// relative deadlines (`now + interval`) or compares against armed instants - /// must read *this* clock, or a test that advances virtual time leaves those - /// instants in the past. Pure annotations (activity stamps, logs) may read - /// [`Instant::now`] directly. - fn now(&self) -> Instant { - Instant::now() - } -} - -/// A boxed [`Timer`], minted by [`AnyTimers`]. -pub(crate) struct BoxTimer(MaybeSendTimer); - -#[cfg(not(target_family = "wasm"))] -type MaybeSendTimer = Box; -#[cfg(target_family = "wasm")] -type MaybeSendTimer = Box; - -impl Timer for BoxTimer { - fn set(&mut self, at: Option) { - self.0.set(at); - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - self.0.poll(waiter) - } -} - -/// A type-erased [`Timers`], for shared state that cannot be generic over the -/// runtime (the origin's task machinery). One allocation per minted timer. -pub(crate) struct AnyTimers(MaybeSendErased); - -#[cfg(not(target_family = "wasm"))] -type MaybeSendErased = std::sync::Arc; -#[cfg(target_family = "wasm")] -type MaybeSendErased = std::sync::Arc; - -trait ErasedTimers { + /// The latest instant supplied by the owner. fn now(&self) -> Instant; - fn timer(&self) -> BoxTimer; -} - -#[cfg(not(target_family = "wasm"))] -impl ErasedTimers for T -where - T: Timers, - T::Timer: Send + 'static, -{ - fn now(&self) -> Instant { - Timers::now(self) - } - - fn timer(&self) -> BoxTimer { - BoxTimer(Box::new(Timers::timer(self))) - } -} - -#[cfg(target_family = "wasm")] -impl ErasedTimers for T -where - T: Timers, - T::Timer: 'static, -{ - fn now(&self) -> Instant { - Timers::now(self) - } - - fn timer(&self) -> BoxTimer { - BoxTimer(Box::new(Timers::timer(self))) - } -} - -impl AnyTimers { - #[cfg(not(target_family = "wasm"))] - pub(crate) fn new(timers: T) -> Self - where - T: Timers + Send + Sync + 'static, - T::Timer: Send + 'static, - { - Self(std::sync::Arc::new(timers)) - } - - #[cfg(target_family = "wasm")] - pub(crate) fn new(timers: T) -> Self - where - T: Timers + 'static, - T::Timer: 'static, - { - Self(std::sync::Arc::new(timers)) - } -} - -impl Clone for AnyTimers { - fn clone(&self) -> Self { - Self(self.0.clone()) - } -} - -impl Timers for AnyTimers { - type Timer = BoxTimer; - - fn timer(&self) -> Self::Timer { - self.0.timer() - } - - fn now(&self) -> Instant { - self.0.now() - } -} - -/// A late-bound [`AnyTimers`]: shared state created before the runtime is known -/// (the origin's, at [`crate::origin::Producer::new`]) holds this, and -/// [`crate::origin::Driver::run`] fills it in. -#[derive(Clone, Default)] -pub(crate) struct TimersSlot(std::sync::Arc>); - -impl TimersSlot { - /// Install the timers, ignoring a second install (clones share one slot). - pub(crate) fn install(&self, timers: AnyTimers) { - let _ = self.0.set(timers); - } - - /// The installed timers. Panics before install, so only call from work that - /// the driver polls (it installs before it can poll anything). - pub(crate) fn get(&self) -> AnyTimers { - self.0.get().expect("origin driver is not running").clone() - } } /// A wall-clock deadline: the ergonomic layer over [`Timer`]. @@ -244,11 +108,6 @@ impl Deadline { } self.timer.poll(waiter) } - - /// Wait for the deadline to elapse. Parks forever while disarmed. - pub async fn wait(&mut self) { - kio::wait(|waiter| self.poll(waiter)).await - } } impl std::fmt::Debug for Deadline { @@ -257,15 +116,15 @@ impl std::fmt::Debug for Deadline { } } -#[cfg(feature = "test-runtime")] +#[cfg(test)] mod test; -#[cfg(feature = "test-runtime")] +#[cfg(test)] pub use test::Test; /// A tokio-backed runtime for this crate's own unit tests, so the existing /// `tokio::time::pause`/`advance` tests keep their semantics: `now` reads /// tokio's (pausable) clock and timers are tokio sleeps, which paused tests -/// auto-advance. Production code uses `moq_tokio::runtime::Runtime` instead; this one is +/// auto-advance. Production adapters live outside this crate; this one is /// compiled only into the test harness (integration tests carry their own copy /// in `tests/support`). #[cfg(all(test, not(target_family = "wasm")))] diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 3cf2aadc3c..32db6a5f05 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1,7 +1,10 @@ //! Accepting a MoQ session, including the paused handshake that inspects the //! peer's SETUP before granting origins. +use web_transport_trait::{MaybeSend, MaybeSync}; + 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_LITE, ALPN_LITE_03, ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06_WIP, Consume, Error, NEGOTIATED, Role, Session, SessionError, Version, Versions, @@ -9,8 +12,6 @@ use crate::{ ietf, lite, setup, stats, }; -use web_transport_trait::{MaybeSend, MaybeSync}; - /// A MoQ server session builder. #[derive(Default, Clone)] pub struct Server { @@ -78,17 +79,16 @@ impl Server { /// Start a lite session on an accepted transport: wire the origins, answer /// with our SETUP, and return the session and its driver. - fn start_lite( + fn start_lite( &self, - runtime: R, + runtime: Clock, session: S, version: lite::Version, client_setup: Option, peer_hop: Option, - ) -> Result<(Session, crate::Driver), Error> + ) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { let (publish, subscribe) = self.stat_tagged_origins(); @@ -138,12 +138,11 @@ impl Server { /// with [`Error::Version`]). Completes the handshake immediately; a caller /// gating on the advertised path uses /// [`accept_request_lite`](Self::accept_request_lite) instead. - pub async fn accept_lite(&self, runtime: R, session: S) -> Result<(Session, crate::Driver), Error> + pub async fn accept_lite(&self, now: Instant, session: S) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { - self.accept_request_lite(runtime, session).await?.ok().await + self.accept_request_lite(now, session).await?.ok().await } /// Begin the moq-lite handshake, pausing like @@ -151,11 +150,11 @@ impl Server { /// which is what drops the thread-affinity bounds: a pinned `!Send` /// transport can gate on the advertised path too. Anything but a moq-lite /// ALPN is refused with [`Error::Version`]. - pub async fn accept_request_lite(&self, runtime: R, mut session: S) -> Result, Error> + pub async fn accept_request_lite(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { + let runtime = Clock::new(now); let (path, role, origin, handshake) = match session.protocol() { Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06_WIP)) => { let version = match alpn { @@ -225,21 +224,18 @@ impl Server { /// Perform the MoQ handshake as a server, returning the [`Session`] and its [`Driver`](crate::Driver). /// - /// Poll or spawn the returned driver to run the session. `runtime` supplies - /// only its clock and timers. + /// Poll the returned driver with nondecreasing time, starting at `now`. /// /// Convenience wrapper over [`accept_request`](Self::accept_request) that /// completes the handshake immediately. Use `accept_request` when you need to /// inspect the client's advertised path before deciding what to serve. - pub async fn accept(&self, runtime: R, session: S) -> Result<(Session, crate::Driver), Error> + pub async fn accept(&self, now: Instant, session: S) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R::Timer: MaybeSend, { - self.accept_request(runtime, session).await?.ok().await + self.accept_request(now, session).await?.ok().await } /// Begin the MoQ handshake, pausing once the client's request path is known so @@ -252,14 +248,13 @@ impl Server { /// /// The path is surfaced for moq-lite-05 and every moq-transport draft we speak; /// it's empty on versions with no in-band request path (e.g. lite 01-04). - pub async fn accept_request(&self, runtime: R, mut session: S) -> Result, Error> + pub async fn accept_request(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R::Timer: MaybeSend, { + let runtime = Clock::new(now); let (encoding, supported) = match session.protocol() { Some(alpn @ (ALPN_21 | ALPN_20 | ALPN_19 | ALPN_18 | ALPN_17)) => { let draft = match alpn { @@ -297,7 +292,7 @@ impl Server { // Every lite ALPN goes through the same entry point, which is also // what a `!Send` transport calls directly. Some(ALPN_LITE_05 | ALPN_LITE_06_WIP | ALPN_LITE_04 | ALPN_LITE_03) => { - return self.accept_request_lite(runtime, session).await; + return self.accept_request_lite(now, session).await; } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -365,18 +360,16 @@ impl Server { /// Read a draft-17/18 client's SETUP (with its request path) off its uni stream, /// then pause. `ok()` starts the session and hands the stream back for GOAWAY. - async fn accept_ietf_modern( + async fn accept_ietf_modern( &self, - runtime: R, + runtime: Clock, mut session: S, version: ietf::Version, - ) -> Result, Error> + ) -> Result, Error> where S: crate::transport::poll::Boxable, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R::Timer: MaybeSend, { let peer_setup = ietf::accept_setup(&mut session, version).await?; Ok(Handshake { @@ -406,7 +399,7 @@ impl Server { /// the origins to serve, then call [`ok`](Self::ok) to complete the handshake, or /// [`close`](Self::close) to reject it. Modeled on the WebTransport `Request` in /// moq-tokio. -pub struct Handshake { +pub struct Handshake { path: Option, role: Option, origin: Option, @@ -415,20 +408,20 @@ pub struct Handshake>, + inner: Option>, } /// The parts of a [`Handshake`] consumed by [`Handshake::ok`] / [`Handshake::close`]. -struct RequestInner { +struct RequestInner { server: Server, /// Supplies the clock and timers for the accepted session. - runtime: R, - handshake: PausedHandshake, + runtime: Clock, + handshake: PausedHandshake, } /// The handshake state captured at the pause point. Every variant defers its /// session start to [`Handshake::ok`] so origins set on the handshake still apply. -enum PausedHandshake { +enum PausedHandshake { /// moq-lite 03/04: no Setup Stream. LiteBare { session: S, version: lite::Version }, /// moq-lite 05+: the client's Setup Stream has been read. `ok()` starts the @@ -444,10 +437,10 @@ enum PausedHandshake>), + Boxed(Box>), } -type Accept = crate::util::MaybeSendBox<'static, Result<(Session, crate::Driver), Error>>; +type Accept = crate::util::MaybeSendBox<'static, Result<(Session, crate::Driver), Error>>; /// A paused non-lite handshake. See [`PausedHandshake::Boxed`] for why this is a /// trait object. @@ -455,9 +448,9 @@ type Accept = crate::util::MaybeSendBox<'static, Result<(Session, crate::D /// `MaybeSync` is not decoration: a caller holding a [`Handshake`] across an /// await behind `&self` (moq-relay authenticates that way) needs /// `&Handshake: Send`, which is `Handshake: Sync`, which is this. -trait Paused: MaybeSend + MaybeSync { +trait Paused: MaybeSend + MaybeSync { /// Complete the handshake with the final server config. - fn ok(self: Box, server: Server, runtime: R, peer_hop: Option) -> Accept; + fn ok(self: Box, server: Server, runtime: Clock, peer_hop: Option) -> Accept; /// Reject the handshake, closing the transport with `err`'s wire code. fn close(self: Box, err: Error); @@ -472,15 +465,13 @@ struct PausedIetfModern { peer_setup: ietf::PeerSetup, } -impl Paused for PausedIetfModern +impl Paused for PausedIetfModern where S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { - fn ok(self: Box, server: Server, runtime: R, peer_hop: Option) -> Accept { + fn ok(self: Box, server: Server, runtime: Clock, peer_hop: Option) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -539,15 +530,13 @@ struct PausedLegacy { peer_declared: ietf::peer::Peer, } -impl Paused for PausedLegacy +impl Paused for PausedLegacy where S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, S::RecvStream: MaybeSync, - R: crate::runtime::Timers + MaybeSend + MaybeSync + 'static, - R::Timer: MaybeSend, { - fn ok(self: Box, server: Server, runtime: R, peer_hop: Option) -> Accept { + fn ok(self: Box, server: Server, runtime: Clock, peer_hop: Option) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -631,10 +620,9 @@ where } } -impl Handshake +impl Handshake where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { /// The request path the client advertised in its SETUP. /// @@ -707,14 +695,14 @@ where self } - fn inner_mut(&mut self) -> &mut RequestInner { + fn inner_mut(&mut self) -> &mut RequestInner { self.inner.as_mut().expect("request already responded") } /// Accept the session, returning the [`Session`] and its [`Driver`](crate::Driver). /// /// Poll or spawn the returned driver to run the session. - pub async fn ok(mut self) -> Result<(Session, crate::Driver), Error> { + pub async fn ok(mut self) -> Result<(Session, crate::Driver), Error> { let peer_hop = Some(self.assigned_hop); let RequestInner { server, @@ -742,7 +730,7 @@ where } } -impl RequestInner { +impl RequestInner { fn close(self, err: Error) { let mut session = match self.handshake { PausedHandshake::LiteBare { session, .. } => session, @@ -753,7 +741,7 @@ impl RequestInner } } -impl Drop for Handshake { +impl Drop for Handshake { // A dropped request would otherwise leave the client hanging until its idle // timeout: it already sent SETUP and is waiting on a response. Reject loudly. fn drop(&mut self) { @@ -963,7 +951,7 @@ mod tests { ] { let session = FakeSession::new(alpn, [ietf_setup(version, Some("/team/room"))]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), "/team/room", "{alpn}"); @@ -974,7 +962,7 @@ mod tests { async fn accept_request_ietf_without_path_is_empty() { let session = FakeSession::new(ALPN_19, [ietf_setup(ietf::Version::Draft19, None)]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), ""); @@ -984,7 +972,7 @@ mod tests { async fn accept_request_ietf_empty_path_is_accepted() { let session = FakeSession::new(ALPN_19, [ietf_setup(ietf::Version::Draft19, Some(""))]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), ""); @@ -1001,7 +989,7 @@ mod tests { async fn accept_request_reads_lite05_path() { let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(Some("/team/room"), None, None)]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), "/team/room"); @@ -1012,7 +1000,7 @@ mod tests { async fn accept_request_lite05_without_path_is_empty() { let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), ""); @@ -1024,7 +1012,7 @@ mod tests { // client that wants the root doesn't have to special-case the parameter. let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(Some(""), None, None)]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), ""); @@ -1037,7 +1025,7 @@ mod tests { [lite05_setup(Some("/team/room"), Some(Role::Publisher), None)], ); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.role(), Some(Role::Publisher)); @@ -1052,7 +1040,7 @@ mod tests { [lite05_group(), lite05_setup(Some("/team/room"), None, None)], ); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.path(), "/team/room"); @@ -1063,7 +1051,7 @@ mod tests { let hop = Hop::new(42).unwrap(); let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, Some(hop))]); let request = Server::new() - .accept_request(crate::runtime::tokio_test::Tokio::new(), session) + .accept_request(tokio::time::Instant::now().into_std(), session) .await .unwrap(); assert_eq!(request.peer_hop(), Some(hop)); @@ -1085,7 +1073,7 @@ mod tests { assigned_hop: Hop::random(), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), - runtime: crate::runtime::tokio_test::Tokio::new(), + runtime: Clock::new(tokio::time::Instant::now().into_std()), handshake: PausedHandshake::Boxed(Box::new(PausedIetfModern { session: transport, version, @@ -1120,7 +1108,7 @@ mod tests { .unwrap(); let (session, driver) = request.ok().await.unwrap(); - tokio::spawn(driver); + tokio::spawn(crate::time::test::run(driver)); for _ in 0..100 { if occurrences(&log, b"local-route") > 0 { diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index b7bb52b7a6..8f89d91a87 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -199,17 +199,16 @@ impl Session { } impl Session { - pub(super) fn new( - runtime: R, + pub(super) fn new( + runtime: crate::time::Clock, session: S, version: Version, recv_bandwidth: Option, - protocol: crate::driver::Protocol, + protocol: crate::driver::Protocol, goaway: goaway::Handle, - ) -> (Self, crate::Driver) + ) -> (Self, crate::Driver) where S: crate::transport::poll::Session, - R: crate::runtime::Timers + 'static, { let sample = snapshot(&session); @@ -230,7 +229,7 @@ impl Session { }); let supervisor = Supervisor { - runtime, + runtime: runtime.clone(), closed_watch: session.clone(), session, close: Some(close.consume()), @@ -249,11 +248,14 @@ impl Session { recv_bandwidth, goaway: Arc::new(goaway), }; - let driver = crate::Driver::new(crate::driver::State { - protocol, - supervisor: Some(supervisor), - result: None, - }); + let driver = crate::Driver::new( + runtime.clone(), + crate::driver::State { + protocol, + supervisor: Some(supervisor), + result: None, + }, + ); (session, driver) } @@ -265,8 +267,8 @@ impl Session { /// anyone is consuming them. /// /// Finishes once the transport reports closed; everything else is moot then. -pub(crate) struct Supervisor { - runtime: R, +pub(crate) struct Supervisor { + runtime: crate::time::Clock, session: S, // A dedicated clone for the close watch, since each pending poll operation // needs its own handle. @@ -281,17 +283,19 @@ pub(crate) struct Supervisor { /// The send-rate estimate channel, when the backend reports one. `None` /// also once every consumer is gone for good. send_bandwidth: Option, - mode: SamplerMode, + mode: SamplerMode, } -enum SamplerMode { +enum SamplerMode { /// Nobody wants stats; sampling is paused. Idle, /// Someone does; sample when the deadline elapses. - Polling { deadline: crate::runtime::Deadline }, + Polling { + deadline: crate::runtime::Deadline, + }, } -impl Supervisor { +impl Supervisor { const POLL_INTERVAL: Duration = Duration::from_millis(100); pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { diff --git a/rs/moq-net/src/time.rs b/rs/moq-net/src/time.rs new file mode 100644 index 0000000000..cfcb74fcda --- /dev/null +++ b/rs/moq-net/src/time.rs @@ -0,0 +1,217 @@ +//! Caller-supplied time for protocol and model drivers. + +use crate::runtime::{Timer as _, Timers}; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + task::Poll, +}; + +pub use crate::runtime::Instant; + +/// A state machine polled with caller-supplied time. +pub trait Driver { + /// The result produced when this driver finishes. + type Output; + /// Advance time and process ready work, registering for external activity. + fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Poll; + /// The next time to poll, or none when only external activity can make progress. + fn timeout(&self) -> Option; +} + +/// A private clock shared only by work owned by one driver. +#[derive(Clone, Default)] +pub(crate) struct Clock(Arc>); + +#[derive(Default)] +struct State { + #[cfg(test)] + automatic: bool, + now: Option, + next: u64, + timers: BTreeMap<(Instant, u64), Arc>>, +} + +impl Clock { + #[cfg(test)] + pub(crate) fn tokio() -> Self { + let clock = Self::new(tokio::time::Instant::now().into_std()); + clock.0.lock().unwrap().automatic = true; + clock + } + + pub(crate) fn new(now: Instant) -> Self { + let clock = Self::default(); + clock.advance(now); + clock + } + + pub(crate) fn advance(&self, now: Instant) { + let due = { + let mut state = self.0.lock().unwrap(); + assert!(state.now.is_none_or(|prev| now >= prev), "driver time moved backwards"); + state.now = Some(now); + let mut due = Vec::new(); + while state.timers.first_key_value().is_some_and(|((at, _), _)| *at <= now) { + due.push(state.timers.pop_first().unwrap().1); + } + due + }; + for waiters in due { + // Take the registrations before waking: wake may immediately poll a timer. + let mut ready = waiters.lock().unwrap().take(); + ready.wake(); + } + } + + pub(crate) fn timeout(&self) -> Option { + self.0.lock().unwrap().timers.first_key_value().map(|((at, _), _)| *at) + } +} + +impl Timers for Clock { + type Timer = Timer; + fn now(&self) -> Instant { + let state = self.0.lock().unwrap(); + #[cfg(test)] + if state.automatic { + return tokio::time::Instant::now().into_std(); + } + state.now.expect("driver has not been polled") + } + fn timer(&self) -> Timer { + let mut state = self.0.lock().unwrap(); + let id = state.next; + state.next = state.next.checked_add(1).expect("timer identifier overflow"); + Timer { + #[cfg(test)] + automatic: state + .automatic + .then(|| crate::runtime::tokio_test::Tokio::new().timer()), + clock: self.clone(), + id, + at: None, + waiters: Arc::new(Mutex::new(kio::WaiterList::new())), + } + } +} + +pub(crate) struct Timer { + #[cfg(test)] + automatic: Option, + clock: Clock, + id: u64, + at: Option, + waiters: Arc>, +} + +impl crate::runtime::Timer for Timer { + fn set(&mut self, at: Option) { + #[cfg(test)] + if let Some(timer) = &mut self.automatic { + timer.set(at); + return; + } + if self.at == at { + return; + } + let mut state = self.clock.0.lock().unwrap(); + if let Some(old) = self.at.take() { + state.timers.remove(&(old, self.id)); + } + self.at = at; + if let Some(at) = at + && state.now.is_none_or(|now| at > now) + { + state.timers.insert((at, self.id), self.waiters.clone()); + } + } + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + #[cfg(test)] + if let Some(timer) = &mut self.automatic { + return timer.poll(waiter); + } + let state = self.clock.0.lock().unwrap(); + match self.at { + Some(at) if state.now.is_some_and(|now| now >= at) => Poll::Ready(()), + Some(_) => { + waiter.register(&mut self.waiters.lock().unwrap()); + Poll::Pending + } + None => Poll::Pending, + } + } +} + +impl Drop for Timer { + fn drop(&mut self) { + self.set(None); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn deadlines_follow_only_supplied_time() { + let now = Instant::now(); + let clock = Clock::new(now); + let mut timer = clock.timer(); + let at = now + Duration::from_secs(3); + timer.set(Some(at)); + assert_eq!(clock.timeout(), Some(at)); + assert!(timer.poll(&kio::Waiter::noop()).is_pending()); + clock.advance(at); + assert!(timer.poll(&kio::Waiter::noop()).is_ready()); + assert_eq!(clock.timeout(), None); + timer.set(Some(at + Duration::from_secs(1))); + assert!(timer.poll(&kio::Waiter::noop()).is_pending()); + drop(timer); + assert_eq!(clock.timeout(), None); + } + + #[test] + #[should_panic(expected = "driver time moved backwards")] + fn time_cannot_move_backwards() { + let now = Instant::now(); + let clock = Clock::new(now); + clock.advance(now - Duration::from_secs(1)); + } +} + +/// Runtime adapter for integration tests. +#[cfg(any(test, feature = "test-runtime"))] +#[doc(hidden)] +pub mod test { + use std::task::Poll; + /// Run an explicit-time driver on the runtime's clock and timer. + pub async fn run(mut driver: D) -> D::Output { + let mut timer = None; + crate::kio::wait(|waiter| { + loop { + let now = web_async::time::Instant::now(); + #[cfg(not(target_family = "wasm"))] + let now = now.into_std(); + if let Poll::Ready(result) = driver.poll(now, waiter) { + return Poll::Ready(result); + } + let Some(at) = driver.timeout() else { + timer = None; + return Poll::Pending; + }; + #[cfg(not(target_family = "wasm"))] + let at = web_async::time::Instant::from_std(at); + let sleep = timer.get_or_insert_with(|| Box::pin(web_async::time::sleep_until(at))); + if sleep.deadline() != at { + sleep.as_mut().reset(at); + } + if waiter.poll_future(sleep.as_mut()).is_pending() { + return Poll::Pending; + } + } + }) + .await + } +} diff --git a/rs/moq-net/tests/datagram.rs b/rs/moq-net/tests/datagram.rs index 2a5681df09..5342c43cb1 100644 --- a/rs/moq-net/tests/datagram.rs +++ b/rs/moq-net/tests/datagram.rs @@ -11,7 +11,7 @@ use std::time::Duration; use futures::FutureExt as _; use moq_net::{Hop, Timestamp, Version}; -use support::harness::{MockConnectOptions, MockPair, TokioRuntime, connect_mock}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// Maximum time any single test may run before being treated as a deadlock. const TEST_TIMEOUT: Duration = Duration::from_secs(10); @@ -21,7 +21,7 @@ const PAYLOAD: &[u8] = b"datagram payload"; /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: u64) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); - tokio::spawn(driver.run(TokioRuntime::new())); + tokio::spawn(support::harness::run(driver)); producer } @@ -77,9 +77,12 @@ async fn datagrams_reach_the_subscriber_in_order() { fixture .producer .insert_datagram( - sequence, - Timestamp::from_millis(sequence).unwrap(), - bytes::Bytes::from_static(PAYLOAD), + std::time::Instant::now(), + moq_net::Datagram { + sequence, + timestamp: Timestamp::from_millis(sequence).unwrap(), + payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), + }, ) .unwrap(); } @@ -139,9 +142,12 @@ async fn ietf_does_not_deliver_datagrams() { producer .insert_datagram( - 0, - Timestamp::from_millis(7).unwrap(), - bytes::Bytes::from_static(PAYLOAD), + std::time::Instant::now(), + moq_net::Datagram { + sequence: 0, + timestamp: Timestamp::from_millis(7).unwrap(), + payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), + }, ) .unwrap(); producer @@ -168,17 +174,23 @@ async fn inserted_sequences_survive_the_lite_wire() { fixture .producer .insert_datagram( - 5, - Timestamp::from_millis(5).unwrap(), - bytes::Bytes::from_static(PAYLOAD), + std::time::Instant::now(), + moq_net::Datagram { + sequence: 5, + timestamp: Timestamp::from_millis(5).unwrap(), + payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), + }, ) .unwrap(); fixture .producer .insert_datagram( - 2, - Timestamp::from_millis(2).unwrap(), - bytes::Bytes::from_static(PAYLOAD), + std::time::Instant::now(), + moq_net::Datagram { + sequence: 2, + timestamp: Timestamp::from_millis(2).unwrap(), + payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), + }, ) .unwrap(); diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index 26961c5ce4..20bd7b6219 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -15,7 +15,7 @@ use support::mock::create_mock_session_pair; /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: Hop) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(hop)); - tokio::spawn(driver.run(support::harness::TokioRuntime::new())); + tokio::spawn(support::harness::run(driver)); producer } @@ -274,13 +274,13 @@ async fn duplicate_goaway_keeps_first_payload_moq_lite_04() { let client = moq_net::Client::new().with_versions(version.into()); let server = moq_net::Server::new().with_versions(version.into()); let (client_result, server_result) = tokio::join!( - client.connect(support::harness::TokioRuntime::new(), client_transport), - server.accept(support::harness::TokioRuntime::new(), server_transport) + client.connect(support::harness::now(), client_transport), + server.accept(support::harness::now(), server_transport) ); let (client_session, client_driver) = client_result.expect("client handshake failed"); - tokio::spawn(client_driver); + tokio::spawn(support::harness::run(client_driver)); let (_server_session, server_driver) = server_result.expect("server handshake failed"); - tokio::spawn(server_driver); + tokio::spawn(support::harness::run(server_driver)); // First GOAWAY: observed with its URI. Waiting for the peer to close the // stream guarantees the control message was fully processed. diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 85ead4fd0c..dac549d653 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -7,60 +7,14 @@ #![allow(dead_code)] -use std::{pin::Pin, task::Poll}; - use moq_net::{Client, Server, Session, Version, origin}; use super::mock::create_mock_session_pair; -/// Tokio's clock and timers for these tests. -#[derive(Clone, Default)] -pub struct TokioRuntime; - -impl TokioRuntime { - pub fn new() -> Self { - Self - } -} - -impl moq_net::runtime::Timers for TokioRuntime { - type Timer = TokioTimer; +pub use moq_net::time::test::run; - fn timer(&self) -> Self::Timer { - TokioTimer { at: None, sleep: None } - } - - fn now(&self) -> moq_net::runtime::Instant { - tokio::time::Instant::now().into_std() - } -} - -/// The [`moq_net::runtime::Timer`] handed out by [`TokioRuntime`]. -pub struct TokioTimer { - at: Option, - // Allocated on the first poll after arming, then re-armed in place via - // `Sleep::reset`; construction panics without a live tokio time driver. - sleep: Option>>, -} - -impl moq_net::runtime::Timer for TokioTimer { - fn set(&mut self, at: Option) { - self.at = at; - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(tokio::time::Instant::from_std(at)); - } - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std(at)))); - if sleep.is_elapsed() { - return Poll::Ready(()); - } - waiter.poll_future(sleep.as_mut()) - } +pub fn now() -> moq_net::time::Instant { + tokio::time::Instant::now().into_std() } /// Options for [`connect_mock`]. @@ -132,18 +86,18 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { // machine is polled (and vice versa for the server's own SETUP). let client_fut = async { let (session, driver) = client - .connect(TokioRuntime::new(), client_transport) + .connect(now(), client_transport) .await .expect("client handshake failed"); - tokio::spawn(driver); + tokio::spawn(run(driver)); session }; let server_fut = async { let (session, driver) = server - .accept(TokioRuntime::new(), server_transport) + .accept(now(), server_transport) .await .expect("server handshake failed"); - tokio::spawn(driver); + tokio::spawn(run(driver)); session }; let (client_session, server_session) = tokio::join!(client_fut, server_fut); diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index 73c1e44a4b..2bcf15cae5 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -655,7 +655,7 @@ async fn serve_connection( let request = moq_net::Server::new() .with_versions(serve.versions.clone()) - .accept_request_lite(handle.clone(), transport) + .accept_request_lite(std::time::Instant::now(), transport) .await .context("moq handshake failed")?; @@ -757,8 +757,9 @@ async fn serve_connection( request = request.with_subscriber(publish); } let (session, driver) = request.ok().await?; + let driver_handle = handle.clone(); handle.spawn(async move { - if let Err(err) = driver.await { + if let Err(err) = driver_handle.run(driver).await { tracing::debug!(%err, "session driver ended"); } }); diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index c14a6dff05..006bb37162 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -176,13 +176,16 @@ where server = server.with_subscriber(publish); } // Keep the driver in this task so cancellation tears down the transport. - let (session, mut driver) = server + let (session, driver) = server .accept( - moq_tokio::runtime::Runtime::new(), + tokio::time::Instant::now().into_std(), moq_tokio::transport::Session::new(ws), ) .await?; + let driver = moq_tokio::runtime::run(driver); + tokio::pin!(driver); + // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index a84ab4fd27..d309e00368 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -302,7 +302,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs index e179710b91..8e425e387f 100644 --- a/rs/moq-rtc/src/server/mod.rs +++ b/rs/moq-rtc/src/server/mod.rs @@ -302,7 +302,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-srt/src/dial.rs b/rs/moq-srt/src/dial.rs index 8ee36fcbd1..1a9e02de97 100644 --- a/rs/moq-srt/src/dial.rs +++ b/rs/moq-srt/src/dial.rs @@ -148,7 +148,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index 3e205dc0e9..fa2a2faaa1 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -819,7 +819,7 @@ mod tests { const OFFSET: u64 = 600_000_000; let (origin, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); let mut broadcast = origin.create_broadcast("rewind").unwrap(); broadcast.announce(moq_net::origin::Route::default()).unwrap(); let mut catalog = moq_mux::catalog::Producer::with_catalog( diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index 84058c7279..cdbb6f7d89 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -146,7 +146,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index 0646ab44b6..4f8a34072d 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -480,7 +480,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index e7004095da..1012a186be 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -108,7 +108,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 25f99c5a1a..0ab39c76e1 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -887,7 +887,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index ec2b474e95..29fe2d235d 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -704,9 +704,11 @@ async fn connect_session( client: &moq_net::Client, transport: S, ) -> Result { - let (session, driver) = client.connect(crate::runtime::Runtime::new(), transport).await?; + let (session, driver) = client + .connect(tokio::time::Instant::now().into_std(), transport) + .await?; use tracing::Instrument; - tokio::spawn(driver.instrument(tracing::Span::current())); + tokio::spawn(crate::runtime::run(driver).instrument(tracing::Span::current())); Ok(session) } diff --git a/rs/moq-tokio/src/origin.rs b/rs/moq-tokio/src/origin.rs index c199159475..5babc8bdfc 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -14,7 +14,7 @@ /// [`Hop`](moq_net::Hop) id (which uses the default config). pub fn spawn(config: impl Into) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(config.into()); - tokio::spawn(driver.run(crate::runtime::Runtime::new())); + tokio::spawn(crate::runtime::run(driver)); producer } diff --git a/rs/moq-tokio/src/runtime.rs b/rs/moq-tokio/src/runtime.rs index 6f59f3ccca..f8e268918c 100644 --- a/rs/moq-tokio/src/runtime.rs +++ b/rs/moq-tokio/src/runtime.rs @@ -1,60 +1,29 @@ -//! Tokio-backed timers for MoQ drivers. - -use std::{pin::Pin, task::Poll}; - -/// Supplies Tokio's clock and timers to MoQ drivers. -#[derive(Clone, Copy, Debug, Default)] -pub struct Runtime; - -impl Runtime { - /// A handle to Tokio's clock and timers. - pub fn new() -> Self { - Self - } -} - -impl moq_net::Timers for Runtime { - type Timer = Timer; - - fn timer(&self) -> Self::Timer { - Timer { at: None, sleep: None } - } - - fn now(&self) -> moq_net::runtime::Instant { - // Tokio's clock, so `tokio::time::pause` tests stay coherent; it reads - // the real clock outside a paused runtime. - tokio::time::Instant::now().into_std() - } -} - -/// A tokio sleep driven through the [`moq_net::runtime::Timer`] contract. -pub struct Timer { - at: Option, - // Allocated on the first poll after arming, then re-armed in place via - // `Sleep::reset`. Construction is deferred because it panics without a live - // tokio time driver, and only the poll is guaranteed to run inside the - // runtime. - sleep: Option>>, -} - -impl moq_net::runtime::Timer for Timer { - fn set(&mut self, at: Option) { - self.at = at; - // Reuse the allocation when there is one; `reset` also clears - // `is_elapsed`. - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(tokio::time::Instant::from_std(at)); - } - } - - fn poll(&mut self, waiter: &moq_net::kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std(at)))); - if sleep.is_elapsed() { - return Poll::Ready(()); +//! Run MoQ drivers with the runtime clock and timer. + +use std::task::Poll; + +/// Run an explicit-time driver on the runtime's clock and timer. +pub async fn run(mut driver: D) -> D::Output { + let mut timer = None; + moq_net::kio::wait(|waiter| { + loop { + let now = tokio::time::Instant::now().into_std(); + if let Poll::Ready(result) = driver.poll(now, waiter) { + return Poll::Ready(result); + } + let Some(at) = driver.timeout() else { + timer = None; + return Poll::Pending; + }; + let at = tokio::time::Instant::from_std(at); + let sleep = timer.get_or_insert_with(|| Box::pin(tokio::time::sleep_until(at))); + if sleep.deadline() != at { + sleep.as_mut().reset(at); + } + if waiter.poll_future(sleep.as_mut()).is_pending() { + return Poll::Pending; + } } - waiter.poll_future(sleep.as_mut()) - } + }) + .await } diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index e01a778489..09b102ebda 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -661,7 +661,7 @@ impl Server { // (like the stream bindings). let Accepted { session, url, identity, authority, mut link } = super::noq::accept(_conn, alpns).await?; link.local = local; - let request = server.accept_request(crate::runtime::Runtime::new(), crate::transport::Session::new(session)).await?; + 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)) }) }.boxed()); } @@ -673,7 +673,7 @@ impl Server { self.accept.push(async move { let Accepted { session, url, identity, authority, mut link } = super::quinn::accept(_conn, alpns).await?; link.local = local; - let request = server.accept_request(crate::runtime::Runtime::new(), session).await?; + let request = server.accept_request(tokio::time::Instant::now().into_std(), session).await?; Ok(Request { transport: Transport::Quic, url, identity, authority, link, kind: RequestKind::Quinn(Box::new(request)) }) }.boxed()); } @@ -685,7 +685,7 @@ impl Server { self.accept.push(async move { let Accepted { session, url, identity, authority, mut link } = super::quiche::accept(_conn, alpns).await?; link.local = local; - let request = server.accept_request(crate::runtime::Runtime::new(), session).await?; + let request = server.accept_request(tokio::time::Instant::now().into_std(), session).await?; Ok(Request { transport: Transport::Quic, url, identity, authority, link, kind: RequestKind::Quiche(Box::new(request)) }) }.boxed()); } @@ -694,7 +694,7 @@ impl Server { #[cfg(feature = "iroh")] self.accept.push(async move { let Accepted { session, url, identity, authority, link } = super::iroh::accept(_conn).await?; - let request = server.accept_request(crate::runtime::Runtime::new(), crate::transport::Session::new(session)).await?; + let request = server.accept_request(tokio::time::Instant::now().into_std(), crate::transport::Session::new(session)).await?; Ok(Request { transport: Transport::Iroh, url, identity, authority, link, kind: RequestKind::Iroh(Box::new(request)) }) }.boxed()); } @@ -706,7 +706,7 @@ impl Server { // slow peer doesn't stall the accept loop (spawned like the others). let local = self.websocket_local_addr(); self.accept.push(async move { - let request = server.accept_request(crate::runtime::Runtime::new(), crate::transport::Session::new(session)).await?; + let request = server.accept_request(tokio::time::Instant::now().into_std(), crate::transport::Session::new(session)).await?; let authority = url.host_str().filter(|h| !h.is_empty()).map(str::to_owned); let link = Link { remote: Some(accepted.remote), local, alpn: accepted.protocol, ..Default::default() }; Ok(Request { transport: Transport::WebSocket, url: Some(url), authority, identity: None, link, kind: RequestKind::Qmux(Box::new(request)) }) @@ -1120,7 +1120,10 @@ fn spawn_stream_request( ) { tokio::spawn(async move { match server - .accept_request(crate::runtime::Runtime::new(), crate::transport::Session::new(session)) + .accept_request( + tokio::time::Instant::now().into_std(), + crate::transport::Session::new(session), + ) .await { Ok(request) => { @@ -1146,7 +1149,7 @@ fn spawn_stream_request( /// every transport before the caller authorizes. The variant only distinguishes the /// underlying session type; all of them delegate identically. /// A pending moq-net request over transport `S`, driven by our tokio runtime. -type PendingRequest = moq_net::server::Handshake; +type PendingRequest = moq_net::server::Handshake; pub(crate) enum RequestKind { #[cfg(feature = "noq")] @@ -1407,7 +1410,7 @@ impl Request { Ok(request_into!(self.kind, request => { let (session, driver) = request.ok().await?; use tracing::Instrument; - tokio::spawn(driver.instrument(tracing::Span::current())); + tokio::spawn(crate::runtime::run(driver).instrument(tracing::Span::current())); session })) } diff --git a/rs/moq-uring/README.md b/rs/moq-uring/README.md index 7846cbedc6..72506488c6 100644 --- a/rs/moq-uring/README.md +++ b/rs/moq-uring/README.md @@ -17,7 +17,7 @@ the UDP sockets bound through it. (the shape a later `SENDMSG_ZC` needs). - **Timers**: a heap the worker sweeps; the earliest deadline rides `io_uring_enter` as an absolute timeout. Zero timeout SQEs. The worker's - `Handle` implements `moq_net::Timers`. + `Handle::run` drives MoQ with the worker clock and a single timer. - **Parking**: a futex word per worker. Remote wakes are an atomic store, plus one `futex(2)` wake only while the worker is actually parked (a `FUTEX_WAIT` SQE armed on the word). diff --git a/rs/moq-uring/benches/session_lite.rs b/rs/moq-uring/benches/session_lite.rs index a1085a1385..aceb350fc5 100644 --- a/rs/moq-uring/benches/session_lite.rs +++ b/rs/moq-uring/benches/session_lite.rs @@ -16,8 +16,6 @@ mod support; #[cfg(all(target_os = "linux", any(feature = "noq", feature = "quiche", feature = "quinn")))] mod linux { use std::net::UdpSocket; - use std::pin::Pin; - use std::task::Poll; use std::time::Instant; use criterion::{BenchmarkId, Criterion, Throughput}; @@ -33,48 +31,6 @@ mod linux { const ALPN: &str = "moq-lite-05"; - /// A `Send` timers impl over tokio for the origin drivers, which cannot - /// take the worker's `!Send` handle (see `tests/session.rs`). - #[derive(Clone, Default)] - struct TokioTimers; - - impl moq_net::runtime::Timers for TokioTimers { - type Timer = TokioTimer; - - fn timer(&self) -> Self::Timer { - TokioTimer { at: None, sleep: None } - } - - fn now(&self) -> moq_net::runtime::Instant { - tokio::time::Instant::now().into_std() - } - } - - struct TokioTimer { - at: Option, - sleep: Option>>, - } - - impl moq_net::runtime::Timer for TokioTimer { - fn set(&mut self, at: Option) { - self.at = at; - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(tokio::time::Instant::from_std(at)); - } - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std(at)))); - if sleep.is_elapsed() { - return Poll::Ready(()); - } - waiter.poll_future(sleep.as_mut()) - } - } - struct Ablation { name: &'static str, config: udp::Config, @@ -143,7 +99,7 @@ mod linux { .build() .expect("tokio runtime"); rt.block_on(async move { - tokio::join!(pub_driver.run(TokioTimers), sub_driver.run(TokioTimers)); + tokio::join!(moq_tokio::runtime::run(pub_driver), moq_tokio::runtime::run(sub_driver)); }); }); @@ -174,10 +130,10 @@ mod linux { .expect("quic accept"); let (session, driver) = moq_net::Server::new() .with_publisher(&pub_origin) - .accept_lite(server_handle.clone(), quic::web::Session::raw(conn)) + .accept_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); - let _ = driver.await; + let _ = server_handle.run(driver).await; session.closed().await; }); @@ -189,11 +145,12 @@ mod linux { .expect("quic connect"); let (session, driver) = moq_net::Client::new() .with_subscriber(sub_origin.clone()) - .connect_lite(handle.clone(), quic::web::Session::raw(conn)) + .connect_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("connect_lite"); + let task_handle = handle.clone(); handle.spawn(async move { - let _ = driver.await; + let _ = task_handle.run(driver).await; }); let bc = { let consumer = sub_origin.consume(); diff --git a/rs/moq-uring/src/lib.rs b/rs/moq-uring/src/lib.rs index e934b57b85..3ef9e01536 100644 --- a/rs/moq-uring/src/lib.rs +++ b/rs/moq-uring/src/lib.rs @@ -16,7 +16,7 @@ //! serves many connections on one socket (demuxed by connection id, dials //! included), each a [`quic::Connection`] implementing the transport traits, //! so `moq_net::Client::connect_lite` and `Server::accept_lite` run real -//! moq-lite sessions on the worker. [`Handle`] supplies [`moq_net::Timers`]; +//! moq-lite sessions on the worker. [`Handle::run`] supplies time and schedules driver wakeups; //! callers run the returned drivers with [`Handle::spawn`]. The stack underneath is the `noq` (default), //! `quinn`, or `quiche` feature; the module is the same either way, and a build with //! neither leaves it out. diff --git a/rs/moq-uring/src/quic/quiche/connection.rs b/rs/moq-uring/src/quic/quiche/connection.rs index 585fbde3a4..16895f8f96 100644 --- a/rs/moq-uring/src/quic/quiche/connection.rs +++ b/rs/moq-uring/src/quic/quiche/connection.rs @@ -340,10 +340,10 @@ pub(crate) fn launch( let mut driver = Driver { shared: shared.clone(), socket, - deadline: moq_net::runtime::Deadline::new(handle), + deadline: crate::Timer::new(handle), keep_alive: match keep_alive { - Some(every) => moq_net::runtime::Deadline::after(handle, every), - None => moq_net::runtime::Deadline::new(handle), + Some(every) => crate::Timer::after(handle, every), + None => crate::Timer::new(handle), }, keep_alive_every: keep_alive, carry: None, @@ -612,11 +612,11 @@ impl web_transport_trait::Stats for Stats { struct Driver { shared: Shared, socket: Rc, - deadline: moq_net::runtime::Deadline, + deadline: crate::Timer, /// Fires when the connection owes the peer an ack-eliciting packet, so an /// idle path (and whatever NAT sits on it) stays open. Disarmed when the /// caller asked for no keep-alive. - keep_alive: moq_net::runtime::Deadline, + keep_alive: crate::Timer, keep_alive_every: Option, /// A packet quiche handed us for a different path than the train being /// packed; it opens the next one. diff --git a/rs/moq-uring/src/quic/quinn/connection.rs b/rs/moq-uring/src/quic/quinn/connection.rs index 55fd53d61c..868327a13e 100644 --- a/rs/moq-uring/src/quic/quinn/connection.rs +++ b/rs/moq-uring/src/quic/quinn/connection.rs @@ -338,7 +338,7 @@ pub(crate) fn launch( socket, endpoint, key, - deadline: moq_net::runtime::Deadline::new(handle), + deadline: crate::Timer::new(handle), scratch: Vec::with_capacity(TRAIN_SEGMENTS * SEGMENT), blocked: false, }; @@ -617,7 +617,7 @@ struct Driver { /// frees the slot). Weak, because the endpoint owns us. endpoint: Weak, key: ConnectionHandle, - deadline: moq_net::runtime::Deadline, + deadline: crate::Timer, /// Egress staging: quinn-proto writes into a `Vec`, so a train is built /// here and copied into the socket's registered buffer. scratch: Vec, diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index 7ce0632dd2..7bbe65c474 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -311,7 +311,7 @@ impl Request { // The guard stays armed across the wait below. Cancelling this future // mid-grace would otherwise skip the deliberate close and leak the // connection, which is the very thing the guard is here to prevent. - let mut deadline = moq_net::runtime::Deadline::after(&self.handle, CLOSE_GRACE); + let mut deadline = crate::Timer::after(&self.handle, CLOSE_GRACE); let send = &mut self.send; kio::wait(|waiter| { let mut cx = Context::from_waker(waiter.waker()); @@ -735,7 +735,7 @@ impl web_transport_trait::poll::Session for Session { // the task rather than here because flow control can take it in // pieces, and abandoning a partial frame would leave the browser with // neither the code nor the reason. - let mut deadline = moq_net::runtime::Deadline::after(&web.handle, CLOSE_GRACE); + let mut deadline = crate::Timer::after(&web.handle, CLOSE_GRACE); let reason = reason.to_string(); let mut conn = self.conn.clone(); web.handle.spawn(async move { diff --git a/rs/moq-uring/src/timer.rs b/rs/moq-uring/src/timer.rs index e093e5fe59..2716e5b7a2 100644 --- a/rs/moq-uring/src/timer.rs +++ b/rs/moq-uring/src/timer.rs @@ -3,8 +3,7 @@ //! Every armed deadline lives in one ordered map. The worker fires the due //! prefix each turn and parks with the earliest remaining instant as the //! absolute `io_uring_enter` timeout, so timers cost zero submissions and -//! re-arming is a pure memory operation (what `moq_net::runtime::Timer` -//! demands). +//! re-arming is a pure memory operation. use std::cell::{Cell, RefCell}; use std::collections::BTreeMap; @@ -88,18 +87,18 @@ impl Heap { /// A single re-armable timer slot in a worker's heap. /// -/// Implements [`moq_net::runtime::Timer`]: arm with -/// [`set`](moq_net::runtime::Timer::set), poll from any `poll_*` function on -/// this worker's thread. Minted by [`moq_net::Timers::timer`] on a -/// [`crate::Handle`]. +/// Arm with [`Self::set`] and poll from this worker's thread. +/// Created by [`crate::Handle::timer`]. pub struct Timer { + at: Option, heap: Rc>, slot: Rc, } impl Timer { - pub(crate) fn new(heap: Rc>) -> Self { + pub(crate) fn from_heap(heap: Rc>) -> Self { Self { + at: None, heap, slot: Rc::new(Slot { key: Cell::new(None), @@ -110,8 +109,13 @@ impl Timer { } } -impl moq_net::runtime::Timer for Timer { - fn set(&mut self, at: Option) { +impl Timer { + /// Arm or disarm the timer. + pub fn set(&mut self, at: Option) { + if self.at == at { + return; + } + self.at = at; let mut heap = self.heap.borrow_mut(); if let Some(key) = self.slot.key.take() { heap.cancel(key); @@ -126,7 +130,8 @@ impl moq_net::runtime::Timer for Timer { } } - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + /// Poll for expiration and register for wakeups. + pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { if self.slot.elapsed.get() { return Poll::Ready(()); } @@ -161,3 +166,20 @@ impl std::fmt::Debug for Timer { .finish() } } + +impl Timer { + /// Create a disarmed timer on this worker. + pub fn new(handle: &crate::Handle) -> Self { + handle.timer() + } + /// Create a timer that expires after the given duration. + pub fn after(handle: &crate::Handle, duration: std::time::Duration) -> Self { + let mut timer = handle.timer(); + timer.set(Instant::now().checked_add(duration)); + timer + } + /// Wait for this timer to expire. + pub async fn wait(&mut self) { + kio::wait(|waiter| self.poll(waiter)).await + } +} diff --git a/rs/moq-uring/src/worker.rs b/rs/moq-uring/src/worker.rs index 44393d92a5..c1ea3b7501 100644 --- a/rs/moq-uring/src/worker.rs +++ b/rs/moq-uring/src/worker.rs @@ -478,8 +478,8 @@ impl std::fmt::Debug for Worker { /// A worker's cloneable, thread-local handle. /// /// Everything that is not the drive loop goes through this: -/// [`spawn`](Self::spawn), [`udp`](Self::udp), and the [`moq_net::Timers`] -/// impl for deadlines. `!Send`, like everything the worker owns. +/// [`spawn`](Self::spawn), [`udp`](Self::udp), [`timer`](Self::timer), and +/// [`run`](Self::run) for MoQ drivers. `!Send`, like everything the worker owns. pub struct Handle { shared: Rc, } @@ -536,11 +536,27 @@ impl std::fmt::Debug for Handle { } } -impl moq_net::Timers for Handle { - type Timer = crate::Timer; +impl Handle { + /// Allocate a disarmed timer on this worker. + pub fn timer(&self) -> crate::Timer { + crate::Timer::from_heap(self.shared.timers.clone()) + } - fn timer(&self) -> Self::Timer { - crate::Timer::new(self.shared.timers.clone()) + /// Run a MoQ driver with this worker's timer and monotonic clock. + pub async fn run(&self, mut driver: D) -> D::Output { + let mut timer = self.timer(); + kio::wait(|waiter| { + loop { + if let Poll::Ready(result) = driver.poll(Instant::now(), waiter) { + return Poll::Ready(result); + } + timer.set(driver.timeout()); + if timer.poll(waiter).is_pending() { + return Poll::Pending; + } + } + }) + .await } } @@ -586,8 +602,8 @@ fn retry_teardown_submit(interruptions: &mut usize, err: std::io::Error) -> std: #[cfg(test)] mod tests { use super::*; - use moq_net::Timers; - use moq_net::runtime::Deadline; + + use crate::Timer as Deadline; use std::time::Duration; /// Kernel-gated: `None` (with a loud skip) below the 6.12 floor, so these @@ -665,7 +681,6 @@ mod tests { #[test] fn timer_rearm_and_disarm() { - use moq_net::runtime::Timer as _; let Some(mut worker) = worker() else { return }; let handle = worker.handle(); let mut timer = handle.timer(); @@ -1157,7 +1172,6 @@ mod tests { /// derived heap depth has to come back to zero. #[test] fn metrics_count_timer_churn() { - use moq_net::runtime::Timer as _; let metrics = Metrics::default(); let config = Config { metrics: metrics.clone(), diff --git a/rs/moq-uring/tests/endpoint.rs b/rs/moq-uring/tests/endpoint.rs index 8484b171d4..ef4ce77989 100644 --- a/rs/moq-uring/tests/endpoint.rs +++ b/rs/moq-uring/tests/endpoint.rs @@ -246,7 +246,7 @@ fn unsupported_version_is_negotiated_only_by_servers() { /// Await `future`, failing loudly rather than hanging if it never resolves. async fn within(handle: &moq_uring::Handle, what: &str, future: impl Future) -> T { - let mut deadline = moq_net::runtime::Deadline::after(handle, Duration::from_secs(5)); + let mut deadline = moq_uring::Timer::after(handle, Duration::from_secs(5)); let mut future = std::pin::pin!(future); kio::wait(|waiter| { let mut cx = std::task::Context::from_waker(waiter.waker()); diff --git a/rs/moq-uring/tests/session.rs b/rs/moq-uring/tests/session.rs index ee0364c813..859dc92703 100644 --- a/rs/moq-uring/tests/session.rs +++ b/rs/moq-uring/tests/session.rs @@ -5,14 +5,8 @@ //! `moq_net::Server::accept_lite` / `Client::connect_lite`, with a broadcast, //! a track, and a frame flowing through the model. //! -//! The origin drivers demand `Send` timers (`origin::Driver::run` erases them -//! into the model's shared state), which the worker's `!Send` handle cannot -//! provide yet, so they run on a tokio thread here: the model is `Send + Sync` -//! by design, and this mirrors the relay topology where a main runtime owns -//! the origins while workers run sessions. -//! -//! Kernel-gated: skips loudly below the Linux 6.12 floor (GitHub-hosted CI), -//! and runs everywhere else. +//! Origin drivers run through Tokio's explicit-time adapter while session +//! drivers use the worker's clock and timer. #![cfg(all(target_os = "linux", any(feature = "noq", feature = "quiche", feature = "quinn")))] @@ -52,10 +46,7 @@ fn lite_session_over_the_worker() { .build() .expect("tokio runtime"); rt.block_on(async move { - tokio::join!( - pub_driver.run(support::TokioTimers), - sub_driver.run(support::TokioTimers) - ); + tokio::join!(moq_tokio::runtime::run(pub_driver), moq_tokio::runtime::run(sub_driver)); }); }); @@ -94,10 +85,10 @@ fn lite_session_over_the_worker() { .expect("quic accept"); let (session, driver) = moq_net::Server::new() .with_publisher(&pub_origin) - .accept_lite(server_handle.clone(), quic::web::Session::raw(conn)) + .accept_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); - let _ = driver.await; + let _ = server_handle.run(driver).await; // Serve until the client walks away. session.closed().await; }); @@ -116,11 +107,12 @@ fn lite_session_over_the_worker() { let (session, driver) = moq_net::Client::new() .with_subscriber(sub.clone()) - .connect_lite(handle.clone(), quic::web::Session::raw(conn)) + .connect_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("connect_lite"); + let task_handle = handle.clone(); handle.spawn(async move { - let _ = driver.await; + let _ = task_handle.run(driver).await; }); let bc = { @@ -175,9 +167,9 @@ fn two_lite_sessions_share_the_server_socket() { .expect("tokio runtime"); rt.block_on(async move { tokio::join!( - pub_driver.run(support::TokioTimers), - sub_a_driver.run(support::TokioTimers), - sub_b_driver.run(support::TokioTimers), + moq_tokio::runtime::run(pub_driver), + moq_tokio::runtime::run(sub_a_driver), + moq_tokio::runtime::run(sub_b_driver), ); }); }); @@ -215,10 +207,10 @@ fn two_lite_sessions_share_the_server_socket() { server_handle.spawn(async move { let (session, driver) = moq_net::Server::new() .with_publisher(&pub_origin) - .accept_lite(session_handle, quic::web::Session::raw(conn)) + .accept_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); - let _ = driver.await; + let _ = session_handle.run(driver).await; session.closed().await; }); } @@ -240,11 +232,12 @@ fn two_lite_sessions_share_the_server_socket() { .expect("quic connect"); let (session, driver) = moq_net::Client::new() .with_subscriber(sub.clone()) - .connect_lite(handle.clone(), quic::web::Session::raw(conn)) + .connect_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("connect_lite"); + let task_handle = handle.clone(); handle.spawn(async move { - let _ = driver.await; + let _ = task_handle.run(driver).await; }); let bc = { diff --git a/rs/moq-uring/tests/support/quiche.rs b/rs/moq-uring/tests/support/quiche.rs index f6e48eb0a9..e3e3279789 100644 --- a/rs/moq-uring/tests/support/quiche.rs +++ b/rs/moq-uring/tests/support/quiche.rs @@ -3,59 +3,15 @@ //! //! quiche is sans-IO, which is the point: these helpers wire its packet-in / //! packet-out / timeout surface straight to a [`moq_uring::udp::Socket`] and a -//! [`moq_net::runtime::Deadline`], with no other runtime underneath. +//! [`moq_uring::Timer`], with no other runtime underneath. #![allow(dead_code)] use std::net::SocketAddr; use std::task::Poll; use std::time::Duration; -/// A `Send` [`moq_net::runtime::Timers`] over tokio, for the origin drivers -/// the session tests run on a tokio thread. -#[derive(Clone, Default)] -pub struct TokioTimers; - -impl moq_net::runtime::Timers for TokioTimers { - type Timer = TokioTimer; - - fn timer(&self) -> Self::Timer { - TokioTimer { at: None, sleep: None } - } - - fn now(&self) -> moq_net::runtime::Instant { - tokio::time::Instant::now().into_std() - } -} - -pub struct TokioTimer { - at: Option, - // Allocated on the first poll after arming, then re-armed in place; - // construction panics without a live tokio time driver. - sleep: Option>>, -} - -impl moq_net::runtime::Timer for TokioTimer { - fn set(&mut self, at: Option) { - self.at = at; - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(tokio::time::Instant::from_std(at)); - } - } - - fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std(at)))); - if sleep.is_elapsed() { - return Poll::Ready(()); - } - waiter.poll_future(sleep.as_mut()) - } -} - use anyhow::Context as _; -use moq_net::runtime::Deadline; +use moq_uring::Timer as Deadline; use moq_uring::{Handle, udp}; /// The QUIC UDP payload size both sides use: every full datagram in a GSO @@ -160,7 +116,7 @@ fn connection_id() -> quiche::ConnectionId<'static> { pub struct Peer { pub sock: udp::Socket, pub conn: quiche::Connection, - pub deadline: Deadline, + pub deadline: Deadline, pub local: SocketAddr, } diff --git a/rs/moq-uring/tests/web.rs b/rs/moq-uring/tests/web.rs index dc01dedde0..a7bb3832fa 100644 --- a/rs/moq-uring/tests/web.rs +++ b/rs/moq-uring/tests/web.rs @@ -216,7 +216,7 @@ fn lite_session_over_webtransport() { .enable_time() .build() .expect("tokio runtime"); - rt.block_on(pub_driver.run(support::TokioTimers)); + rt.block_on(moq_tokio::runtime::run(pub_driver)); }); let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); @@ -235,14 +235,14 @@ fn lite_session_over_webtransport() { Box::pin(async move { assert_eq!(session.protocol(), Some(PROTO), "negotiated subprotocol"); let (sub_origin, sub_driver) = origin::Producer::new(origin::Config::new(moq_net::Hop::random())); - let driver = tokio::spawn(sub_driver.run(moq_tokio::runtime::Runtime::new())); + let driver = tokio::spawn(moq_tokio::runtime::run(sub_driver)); let (moq, session_driver) = moq_net::Client::new() .with_subscriber(sub_origin.clone()) - .connect_lite(moq_tokio::runtime::Runtime::new(), session) + .connect_lite(std::time::Instant::now(), session) .await .expect("connect_lite"); - tokio::spawn(session_driver); + tokio::spawn(moq_tokio::runtime::run(session_driver)); let bc = { let consumer = sub_origin.consume(); @@ -284,11 +284,12 @@ fn lite_session_over_webtransport() { let (session, driver) = moq_net::Server::new() .with_publisher(&serve_origin) - .accept_lite(handle.clone(), session) + .accept_lite(std::time::Instant::now(), session) .await .expect("accept_lite"); + let task_handle = handle.clone(); handle.spawn(async move { - let _ = driver.await; + let _ = task_handle.run(driver).await; }); session.closed().await; }) @@ -410,7 +411,7 @@ fn h3_request(peer: &mut support::Peer, control: u64, url: &str) { /// Everything here is a stall or a leak, so the failure mode without the fix /// is a test that never finishes. async fn within(handle: &moq_uring::Handle, what: &str, future: impl Future) -> T { - let mut deadline = moq_net::runtime::Deadline::after(handle, std::time::Duration::from_secs(5)); + let mut deadline = moq_uring::Timer::after(handle, std::time::Duration::from_secs(5)); let mut future = std::pin::pin!(future); kio::wait(|waiter| { let mut cx = std::task::Context::from_waker(waiter.waker()); diff --git a/rs/moq-uring/tests/workers.rs b/rs/moq-uring/tests/workers.rs index 926ffd9d7a..9e99dde943 100644 --- a/rs/moq-uring/tests/workers.rs +++ b/rs/moq-uring/tests/workers.rs @@ -161,7 +161,7 @@ fn a_steered_group_serves_a_shared_port() { std::time::Instant::now() < deadline, "only {total} of {DIALS} dials were accepted" ); - moq_net::runtime::Deadline::after(&handle, std::time::Duration::from_millis(10)) + moq_uring::Timer::after(&handle, std::time::Duration::from_millis(10)) .wait() .await; } diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 84174ae36d..4dcd1f04e8 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -145,7 +145,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); if tokio::runtime::Handle::try_current().is_ok() { - tokio::spawn(driver.run(moq_tokio::runtime::Runtime::new())); + tokio::spawn(moq_tokio::runtime::run(driver)); } else { // A sync test: nothing polls the driver, and dropping it would tear // the origin down, so leak it and rely on the synchronous half. diff --git a/rs/moq-wasm/README.md b/rs/moq-wasm/README.md index 11c4fa1bbd..6276089dd4 100644 --- a/rs/moq-wasm/README.md +++ b/rs/moq-wasm/README.md @@ -19,7 +19,8 @@ What works today: - **Executor-independent sessions.** `moq-net` is generic over `web_transport_trait::poll::Session` and returns a driver for the caller to - run. `moq-wasm` spawns that driver via `web_async::spawn` on the browser. + run. `moq-wasm` spawns a runtime adapter via `web_async::spawn`, supplying + time and scheduling the driver's next wakeup on the browser. - **The browser transport needs no adapter**: `web-transport-wasm` implements the poll traits `moq-net` consumes, so `src/transport.rs` is just the dial (the ALPN list and the browser's two trust modes). @@ -44,15 +45,11 @@ What works today: transport is `!Send`, but `SessionInner` used to hard-code `Send`. `web_async::MaybeSendBoxFuture` picks a `Send` boxed future on native and a local boxed future on wasm. Native behavior is unchanged. -3. Timers and `Instant` routed through `web_async::time` instead of - `tokio::time` (session poll interval, subscriber linger, probe interval, - track-cache eviction). `web-async` re-exports `tokio::time` on native - and `wasmtimer` (a `performance.now()` + `setTimeout` shim) on wasm, so the - same code runs on both. tokio's clock is `std::time::Instant::now()`, which - *panics* on wasm (no clock) under `spawn_local` (no time driver); wasmtimer - fixes that. Native unchanged: `web_async::time::Instant` *is* - `tokio::time::Instant` there, so `tokio::time::pause`/`advance` test clocks - still work. +3. Drivers accept `moq_net::time::Instant` and report their next timeout. + This crate supplies the browser clock and one re-armable sleep through + `web_async::time`, backed by `performance.now()` and `setTimeout`. + `moq-net` handles session sampling, linger, probes, and cache expiration + using the supplied time. It does not create runtime timers or spawn tasks. ### Timestamp fallback diff --git a/rs/moq-wasm/src/lib.rs b/rs/moq-wasm/src/lib.rs index ca9d762bab..3a8c5fafb5 100644 --- a/rs/moq-wasm/src/lib.rs +++ b/rs/moq-wasm/src/lib.rs @@ -9,7 +9,7 @@ //! which is the highest-value target (the `@moq/watch` use case). The publish //! path follows the same shape and is left as the obvious next step. //! -//! `runtime::Runtime` supplies browser timers to moq-net. This crate spawns +//! `runtime::run` drives moq-net with the browser clock and timer. This crate spawns //! the returned session drivers on the browser's microtask queue. // Browser-only crate. Empty on native so `cargo check --workspace` stays green. @@ -72,14 +72,17 @@ impl Session { // Wire a subscribe origin so the session has somewhere to insert the // broadcasts the remote announces; keep a consumer to read them. let (origin, origin_driver) = moq_net::origin::Producer::new(moq_net::Hop::random().into()); - web_async::spawn(origin_driver.run(runtime::Runtime)); + web_async::spawn(crate::runtime::run(origin_driver)); let consumer = origin.consume(); let client = moq_net::Client::new().with_subscriber(origin); // The driver holds no session clone, so dropping this `Session` still // closes the transport and ends the spawned task. - let (inner, driver) = client.connect(runtime::Runtime, transport).await.map_err(js_err)?; + let (inner, driver) = client + .connect(web_async::time::Instant::now(), transport) + .await + .map_err(js_err)?; web_async::spawn(async move { - let _ = driver.await; + let _ = crate::runtime::run(driver).await; }); Ok(Session { inner, consumer }) } diff --git a/rs/moq-wasm/src/runtime.rs b/rs/moq-wasm/src/runtime.rs index 8aec93c9bb..6bc9252224 100644 --- a/rs/moq-wasm/src/runtime.rs +++ b/rs/moq-wasm/src/runtime.rs @@ -1,45 +1,28 @@ -//! Browser-backed timers for MoQ drivers. +//! Run MoQ drivers with the runtime clock and timer. -use std::{pin::Pin, task::Poll}; +use std::task::Poll; -/// Supplies the browser clock and timers to MoQ drivers. -#[derive(Clone, Copy, Debug, Default)] -pub struct Runtime; - -impl moq_net::Timers for Runtime { - type Timer = Timer; - - fn timer(&self) -> Self::Timer { - Timer { at: None, sleep: None } - } -} - -/// A `web_async::time::Sleep` driven through the [`moq_net::runtime::Timer`] -/// contract. -pub struct Timer { - at: Option, - // Allocated on the first poll after arming, then re-armed in place. - sleep: Option>>, -} - -impl moq_net::runtime::Timer for Timer { - fn set(&mut self, at: Option) { - self.at = at; - // Reuse the allocation when there is one; `reset` also clears the - // elapsed state. - if let (Some(at), Some(sleep)) = (at, &mut self.sleep) { - sleep.as_mut().reset(at); - } - } - - fn poll(&mut self, waiter: &moq_net::kio::Waiter) -> Poll<()> { - let Some(at) = self.at else { return Poll::Pending }; - let sleep = self - .sleep - .get_or_insert_with(|| Box::pin(web_async::time::sleep_until(at))); - if sleep.is_elapsed() { - return Poll::Ready(()); +/// Run an explicit-time driver on the runtime's clock and timer. +pub async fn run(mut driver: D) -> D::Output { + let mut timer = None; + moq_net::kio::wait(|waiter| { + loop { + let now = web_async::time::Instant::now(); + if let Poll::Ready(result) = driver.poll(now, waiter) { + return Poll::Ready(result); + } + let Some(at) = driver.timeout() else { + timer = None; + return Poll::Pending; + }; + let sleep = timer.get_or_insert_with(|| Box::pin(web_async::time::sleep_until(at))); + if sleep.deadline() != at { + sleep.as_mut().reset(at); + } + if waiter.poll_future(sleep.as_mut()).is_pending() { + return Poll::Pending; + } } - waiter.poll_future(sleep.as_mut()) - } + }) + .await } From d1ad065f894da835a02265610f367cc9516b4a4a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 20 Sep 2026 15:35:19 -0700 Subject: [PATCH 3/5] refactor(net): keep time out of model datagram APIs Restore the original datagram write signatures and bound Rust and JavaScript datagram send buffers to the newest 64 entries without clock reads. Co-Authored-By: GPT-6 --- doc/lib/rs/moq-net.md | 9 +- js/net/src/track.test.ts | 16 ++ js/net/src/track.ts | 30 +-- rs/libmoq/src/publish.rs | 6 +- rs/moq-e2ee/src/datagram.rs | 9 +- rs/moq-e2ee/src/tests.rs | 20 +- rs/moq-ffi/src/producer.rs | 2 +- rs/moq-net/src/lib.rs | 4 +- rs/moq-net/src/lite/publisher.rs | 6 +- rs/moq-net/src/lite/subscriber.rs | 9 +- rs/moq-net/src/model/clock.rs | 5 +- rs/moq-net/src/model/resume.rs | 12 +- rs/moq-net/src/model/track.rs | 401 +++++++----------------------- rs/moq-net/tests/datagram.rs | 36 +-- 14 files changed, 143 insertions(+), 422 deletions(-) diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1afdcec357..ffec5d1e6c 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -72,10 +72,11 @@ Cache reads and writes clear their expiration timestamp without reading the system clock. A due cleanup pass scans cached groups, dates undated activity, and expires idle groups except each track's latest. Calls before the cleanup deadline only advance the sampled clock, so calling after every poll is cheap. -Expiration is approximate: delayed cleanup extends retention. Frame APIs need -no time argument. Datagram insertion takes an explicit arrival instant: -`append_datagram(now, timestamp, payload)` or -`insert_datagram(now, Datagram { sequence, timestamp, payload })`. +Expiration is approximate: delayed cleanup extends retention. Model read/write +APIs take no wall-clock time. Datagram writes keep their existing signatures: +`append_datagram(timestamp, payload)` and +`insert_datagram(sequence, timestamp, payload)`. Datagram send buffers retain +the newest 64 entries, dropping the oldest at capacity without reading a clock. This changes Rust APIs, with no wire-format or TypeScript API changes. ## Patterns diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index aff41f755d..b5bdcd4af1 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -120,6 +120,22 @@ test("appendDatagram delivers to a subscriber", async () => { expect(got && dec.decode(got.payload)).toBe("hello"); }); +test("datagram buffers drop oldest at capacity without delaying active subscribers", async () => { + const producer = new TrackProducer("test"); + const slow = producer.subscribe(); + const fast = producer.subscribe(); + const count = 192; + for (let sequence = 0; sequence < count; sequence++) { + producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("x")); + expect((await fast.recvDatagram())?.sequence).toBe(sequence); + } + producer.close(); + for (let sequence = count - 64; sequence < count; sequence++) { + expect((await slow.recvDatagram())?.sequence).toBe(sequence); + } + expect(await slow.recvDatagram()).toBeUndefined(); +}); + test("insertDatagram preserves an explicit sequence", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index f74e833058..2e4eae9d03 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -27,17 +27,8 @@ const MAX_TIMEOUT_MS = 2 ** 31 - 1; /** Default {@link Info.maxAge} window (milliseconds) when the publisher does not set one. */ export const DEFAULT_MAX_AGE_MS = Milli(5000); -/** - * How long (milliseconds) a datagram stays in the per-subscriber buffer before it is dropped. - * - * Datagrams are a best-effort send buffer, not a replay cache (unlike groups): only the last few - * tens of milliseconds are kept, so a consumer that stalls loses stale datagrams instead of - * replaying them. Mirrors the Rust `MAX_DATAGRAM_AGE`. - */ -const MAX_DATAGRAM_AGE_MS = 50; - -/** A datagram buffered with its arrival time, so the send buffer can evict by age. */ -type BufferedDatagram = { datagram: Datagram; time: number }; +/** Maximum buffered datagrams per subscriber; mirrors Rust's bounded send buffer. */ +const MAX_DATAGRAMS = 64; /** * Sanity cap on a datagram payload: the QUIC DATAGRAM frame ceiling. The real limit is @@ -306,8 +297,8 @@ class TrackState { // First timestamps mutate group state rather than track state, so held groups // watch this revision as well as arrivals when enforcing latency after handoff. timelineChanged = new Signal(0); - /** Best-effort datagram channel, parallel to {@link groups}; an age-evicted send buffer per subscriber. */ - datagrams = new Signal([]); + /** Best-effort datagram channel, parallel to {@link groups}; a bounded send buffer per subscriber. */ + datagrams = new Signal([]); latest?: number; /** * The exclusive final boundary, stamped when the producer closes cleanly: one past the @@ -722,12 +713,10 @@ export class Producer { // Fan a datagram out to every live subscriber, dropping the oldest once the ring is full. // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache). #publishDatagram(datagram: Datagram): void { - const now = performance.now(); for (const sink of this.#sinks) { sink.datagrams.mutate((list) => { - list.push({ datagram, time: now }); - // Drop anything older than the send-buffer window. - while (list.length > 0 && now - list[0].time > MAX_DATAGRAM_AGE_MS) list.shift(); + if (list.length === MAX_DATAGRAMS) list.shift(); + list.push(datagram); }); } } @@ -1214,13 +1203,8 @@ export class Subscriber { for (;;) { const datagrams = this.#state.datagrams.peek(); - // Evict datagrams older than the send-buffer window (also enforced on write), so a - // reader that stalled skips stale datagrams instead of replaying them. - const cutoff = performance.now() - MAX_DATAGRAM_AGE_MS; - while (datagrams.length > 0 && datagrams[0].time < cutoff) datagrams.shift(); - if (datagrams.length > 0) { - return datagrams.shift()?.datagram; + return datagrams.shift(); } const closed = this.#state.closed.peek(); diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index 2bc6f0cc3e..9a3cc18f9f 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -697,11 +697,7 @@ impl Publish { pub fn track_datagram(&mut self, track: Id, timestamp_us: u64, payload: &[u8]) -> Result { let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; let timestamp = moq_net::Timestamp::from_micros(timestamp_us)?; - Ok(track.append_datagram( - moq_net::time::Instant::now(), - timestamp, - bytes::Bytes::copy_from_slice(payload), - )?) + Ok(track.append_datagram(timestamp, bytes::Bytes::copy_from_slice(payload))?) } /// Finish a raw track. No more groups or frames can be written. diff --git a/rs/moq-e2ee/src/datagram.rs b/rs/moq-e2ee/src/datagram.rs index 3a42553129..b5d6760862 100644 --- a/rs/moq-e2ee/src/datagram.rs +++ b/rs/moq-e2ee/src/datagram.rs @@ -40,14 +40,7 @@ pub(crate) fn insert_ciphertext( timestamp: moq_net::Timestamp, payload: Bytes, ) -> moq_net::Result<()> { - track.insert_datagram( - moq_net::time::Instant::now(), - moq_net::Datagram { - sequence, - timestamp, - payload: moq_net::IntoBytes::into_bytes(payload), - }, - ) + track.insert_datagram(sequence, timestamp, payload) } /// Ciphertext budget for a datagram that will encode these fields. diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 4708907b66..1484176e33 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -474,15 +474,8 @@ fn bad_datagram_is_event() { }; payload[0] ^= 1; let payload = bytes::Bytes::from(payload); - net.insert_datagram( - std::time::Instant::now(), - moq_net::Datagram { - sequence: 1, - timestamp: moq_net::Timestamp::from_millis(2).unwrap(), - payload: moq_net::IntoBytes::into_bytes(payload), - }, - ) - .unwrap(); + net.insert_datagram(1, moq_net::Timestamp::from_millis(2).unwrap(), payload) + .unwrap(); producer .insert_datagram(2, moq_net::Timestamp::from_millis(3).unwrap(), b"three") .unwrap(); @@ -616,12 +609,9 @@ fn forged_datagram_does_not_burn_identity() { }; forged[0] ^= 1; net.insert_datagram( - std::time::Instant::now(), - moq_net::Datagram { - sequence: 1, - timestamp: moq_net::Timestamp::from_millis(2).unwrap(), - payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from(forged)), - }, + 1, + moq_net::Timestamp::from_millis(2).unwrap(), + bytes::Bytes::from(forged), ) .unwrap(); // Real seq 1 after the forgery. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index f6f7d63785..51da37156d 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -765,7 +765,7 @@ impl MoqTrackProducer { let timestamp = moq_net::Timestamp::from_micros(frame.timestamp_us)?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; - Ok(track.append_datagram(moq_net::time::Instant::now(), timestamp, frame.payload)?) + Ok(track.append_datagram(timestamp, frame.payload)?) } /// Abort this track with an application error code. diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 737da8c46c..a4ddb6e0db 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -56,8 +56,8 @@ //! [`origin::Producer::new`] also returns a producer and driver. Its driver runs //! route changes, serving, linger, teardown, and the origin's cache expiration. //! Standalone caches expose [`cache::Pool::gc`]. Frame read/write methods -//! clear their expiration timestamp for the next cleanup pass; datagram insertion takes an -//! explicit arrival instant. +//! clear their expiration timestamp for the next cleanup pass. Datagrams use a bounded +//! FIFO; model read/write APIs take no wall-clock time. //! //! Both drivers implement [`time::Driver`]. `moq-tokio` and `moq-wasm` //! supply runtime adapters, and `moq-uring` can drive thread-local transports. diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 7817a5a3cc..04ed9d445a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1546,11 +1546,7 @@ mod test { let mut subscriber = producer.subscribe(None); producer - .append_datagram( - crate::model::clock::now(), - Timestamp::from_millis(1).unwrap(), - &b"last"[..], - ) + .append_datagram(Timestamp::from_millis(1).unwrap(), &b"last"[..]) .unwrap(); producer.finish().unwrap(); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 6c6a8781e5..e9dbf5bbf0 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -438,14 +438,7 @@ impl Subscriber { let timestamp = Timestamp::new(dg.timestamp, scale).map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; - entry.producer.insert_datagram( - self.runtime.now(), - crate::Datagram { - sequence: dg.sequence, - timestamp, - payload: crate::IntoBytes::into_bytes(dg.payload), - }, - )?; + entry.producer.insert_datagram(dg.sequence, timestamp, dg.payload)?; Ok(()) } diff --git a/rs/moq-net/src/model/clock.rs b/rs/moq-net/src/model/clock.rs index 20f808b581..433dbc1bfe 100644 --- a/rs/moq-net/src/model/clock.rs +++ b/rs/moq-net/src/model/clock.rs @@ -1,9 +1,8 @@ //! Clock access for the explicit `Timestamp::now` convenience API and model tests. //! -//! Production cache maintenance and datagram arrival times use caller-supplied -//! instants. Tests share a frozen thread-local clock so advancing one test never +//! Production cache maintenance uses caller-supplied instants at the GC boundary. Tests share a frozen thread-local clock so advancing one test never //! affects another. Advancing it also dates pending cache activity; expiration -//! remains a separate operation, driven by writes or an explicit cache-driver poll. +//! remains a separate operation, driven by writes or an explicit cleanup call. /// The current instant on the model's clock. #[cfg(not(test))] diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index c72c2a4d2a..02c455cb1c 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -2773,9 +2773,7 @@ mod test { .is_none(), "no datagram yet" ); - track_a - .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"d0".as_ref()) - .unwrap(); + track_a.append_datagram(Timestamp::ZERO, b"d0".as_ref()).unwrap(); let datagram = kio::wait(|waiter| sub.poll_recv_datagram(waiter)) .now_or_never() .expect("datagram should be ready") @@ -4947,18 +4945,14 @@ mod test { // Datagrams are a live best-effort channel: one from a replaced segment // is stale and never surfaces, only the live route's flow does. - track_a - .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"old".as_ref()) - .unwrap(); + track_a.append_datagram(Timestamp::ZERO, b"old".as_ref()).unwrap(); assert!( kio::wait(|waiter| sub.poll_recv_datagram(waiter)) .now_or_never() .is_none(), "stale datagram must not surface" ); - track_b - .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"new".as_ref()) - .unwrap(); + track_b.append_datagram(Timestamp::ZERO, b"new".as_ref()).unwrap(); let datagram = kio::wait(|waiter| sub.poll_recv_datagram(waiter)) .now_or_never() .expect("datagram should be ready") diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index aa05669c91..dd37c36459 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -34,12 +34,12 @@ use std::{ /// Default [`Info::max_age`] when the publisher doesn't set one. pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(5); -/// How long a datagram stays in the per-track buffer before it is dropped. +/// Maximum number of datagrams retained in the per-track send buffer. /// /// Datagrams are a best-effort send buffer, not a replay cache (unlike groups): only the last -/// few tens of milliseconds are kept, so a consumer that stalls loses stale datagrams instead of -/// replaying them. Sized like a typical send buffer for real-time audio/video. -const MAX_DATAGRAM_AGE: Duration = Duration::from_millis(50); +/// 64 datagrams are kept, so a stalled consumer cannot retain an unbounded backlog. +/// The payload size limit also bounds the buffer's memory use. +const MAX_DATAGRAMS: usize = 64; /// Slack before the eviction order is rebuilt, so a track holding just a few groups /// doesn't rebuild on every write. @@ -187,12 +187,11 @@ pub(crate) struct TrackState { // what each track writes and never touches another track's cache. debt: u64, - // Datagrams in arrival order paired with their arrival time, a best-effort send buffer - // evicted by age (see `MAX_DATAGRAM_AGE`). Shares the group `max_sequence` namespace but - // is otherwise independent. - datagrams: VecDeque<(Datagram, crate::runtime::Instant)>, + // Datagrams in arrival order, bounded by `MAX_DATAGRAMS`. Shares the group + // sequence namespace but is otherwise independent. + datagrams: VecDeque, - // Number of datagrams dropped off the front (aged out), mapping a subscriber's absolute + // Number of datagrams dropped off the front (over capacity), mapping a subscriber's absolute // cursor to an index into `datagrams` (mirrors `offset` for groups). datagram_offset: usize, @@ -358,7 +357,7 @@ impl TrackState { /// resumes at the oldest still-buffered datagram, skipping the lost ones. fn poll_recv_datagram(&self, index: usize) -> Poll>> { let start = index.saturating_sub(self.datagram_offset); - if let Some((datagram, _)) = self.datagrams.get(start) { + if let Some(datagram) = self.datagrams.get(start) { return Poll::Ready(Ok(Some((datagram.clone(), self.datagram_offset + start)))); } @@ -372,16 +371,13 @@ impl TrackState { } } - /// Push a datagram onto the buffer, dropping any that have aged past [`MAX_DATAGRAM_AGE`]. - fn push_datagram(&mut self, now: crate::time::Instant, datagram: Datagram) { - self.datagrams.push_back((datagram, now)); - while let Some((_, at)) = self.datagrams.front() { - if now.duration_since(*at) <= MAX_DATAGRAM_AGE { - break; - } + /// Push a datagram, dropping the oldest when the send buffer is full. + fn push_datagram(&mut self, datagram: Datagram) { + if self.datagrams.len() == MAX_DATAGRAMS { self.datagrams.pop_front(); self.datagram_offset += 1; } + self.datagrams.push_back(datagram); } /// Find the smallest-sequence cached group satisfying @@ -1249,12 +1245,7 @@ impl Producer { /// deliver them. Keep payloads well under the 1200-byte minimum path MTU. An origin /// publisher uses this; a relay preserving upstream numbering uses /// [`Self::insert_datagram`]. - pub fn append_datagram( - &mut self, - now: crate::time::Instant, - timestamp: Timestamp, - payload: B, - ) -> Result { + pub fn append_datagram(&mut self, timestamp: Timestamp, payload: B) -> Result { let payload = payload.into_bytes(); if payload.len() > super::datagram::MAX_DATAGRAM_PAYLOAD { return Err(Error::FrameTooLarge); @@ -1276,14 +1267,11 @@ impl Producer { } state.max_sequence = Some(sequence); meter.datagram(payload.len() as u64); - state.push_datagram( - now, - Datagram { - sequence, - timestamp, - payload, - }, - ); + state.push_datagram(Datagram { + sequence, + timestamp, + payload, + }); let cache = state.cache.clone(); drop(state); cache.settle(None); @@ -1295,12 +1283,13 @@ impl Producer { /// Preserves the supplied sequence (bumping the shared `max_sequence` if needed), so a /// relay can forward a datagram without renumbering it. Most origin publishers want /// [`Self::append_datagram`] instead. - pub fn insert_datagram(&mut self, now: crate::time::Instant, datagram: Datagram) -> Result<()> { - let Datagram { - sequence, - timestamp, - payload, - } = datagram; + pub fn insert_datagram( + &mut self, + sequence: u64, + timestamp: Timestamp, + payload: B, + ) -> Result<()> { + let payload = payload.into_bytes(); if payload.len() > super::datagram::MAX_DATAGRAM_PAYLOAD { return Err(Error::FrameTooLarge); } @@ -1317,14 +1306,11 @@ impl Producer { } state.max_sequence = Some(state.max_sequence.unwrap_or(0).max(sequence)); meter.datagram(payload.len() as u64); - state.push_datagram( - now, - Datagram { - sequence, - timestamp, - payload, - }, - ); + state.push_datagram(Datagram { + sequence, + timestamp, + payload, + }); let cache = state.cache.clone(); drop(state); cache.settle(None); @@ -4170,19 +4156,9 @@ mod test { // Interleave groups and datagrams: they draw from one monotonic counter. assert_eq!(producer.append_group().unwrap().sequence, 0); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"a"[..]) - .unwrap(), - 1 - ); + assert_eq!(producer.append_datagram(ts, &b"a"[..]).unwrap(), 1); assert_eq!(producer.append_group().unwrap().sequence, 2); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"b"[..]) - .unwrap(), - 3 - ); + assert_eq!(producer.append_datagram(ts, &b"b"[..]).unwrap(), 3); assert_eq!(producer.latest(), Some(3)); } @@ -4192,9 +4168,7 @@ mod test { let mut dg = producer.subscribe(None); let ts = Timestamp::from_millis(42).unwrap(); - let seq = producer - .append_datagram(crate::model::clock::now(), ts, &b"hello"[..]) - .unwrap(); + let seq = producer.append_datagram(ts, &b"hello"[..]).unwrap(); let got = recv_datagram(&mut dg); assert_eq!(got.sequence, seq); @@ -4210,14 +4184,7 @@ mod test { let ts = Timestamp::from_millis(5).unwrap(); // A relay forwarding an upstream datagram keeps its sequence number. producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 100, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), - }, - ) + .insert_datagram(100, ts, bytes::Bytes::from_static(b"x")) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 100); @@ -4232,22 +4199,10 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 10, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"gap")), - }, - ) + .insert_datagram(10, ts, bytes::Bytes::from_static(b"gap")) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 10); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"next"[..]) - .unwrap(), - 11 - ); + assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 11); assert_eq!(producer.append_group().unwrap().sequence, 12); } @@ -4258,34 +4213,15 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 10, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"high")), - }, - ) + .insert_datagram(10, ts, bytes::Bytes::from_static(b"high")) .unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 5, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"low")), - }, - ) + .insert_datagram(5, ts, bytes::Bytes::from_static(b"low")) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 10); assert_eq!(recv_datagram(&mut dg).sequence, 5); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"next"[..]) - .unwrap(), - 11 - ); + assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 11); } #[tokio::test] @@ -4295,34 +4231,15 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 3, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"first")), - }, - ) + .insert_datagram(3, ts, bytes::Bytes::from_static(b"first")) .unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 3, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"again")), - }, - ) + .insert_datagram(3, ts, bytes::Bytes::from_static(b"again")) .unwrap(); assert_eq!(&recv_datagram(&mut dg).payload[..], b"first"); assert_eq!(&recv_datagram(&mut dg).payload[..], b"again"); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"next"[..]) - .unwrap(), - 4 - ); + assert_eq!(producer.append_datagram(ts, &b"next"[..]).unwrap(), 4); } #[tokio::test] @@ -4331,38 +4248,16 @@ mod test { let mut dg = producer.subscribe(None); let ts = Timestamp::from_millis(0).unwrap(); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"0"[..]) - .unwrap(), - 0 - ); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"1"[..]) - .unwrap(), - 1 - ); + assert_eq!(producer.append_datagram(ts, &b"0"[..]).unwrap(), 0); + assert_eq!(producer.append_datagram(ts, &b"1"[..]).unwrap(), 1); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 0, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"stale")), - }, - ) + .insert_datagram(0, ts, bytes::Bytes::from_static(b"stale")) .unwrap(); assert_eq!(recv_datagram(&mut dg).sequence, 0); assert_eq!(recv_datagram(&mut dg).sequence, 1); assert_eq!(recv_datagram(&mut dg).sequence, 0); - assert_eq!( - producer - .append_datagram(crate::model::clock::now(), ts, &b"2"[..]) - .unwrap(), - 2 - ); + assert_eq!(producer.append_datagram(ts, &b"2"[..]).unwrap(), 2); assert_eq!(producer.append_group().unwrap().sequence, 3); } @@ -4374,31 +4269,10 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 4, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"a")), - }, - ) - .unwrap(); - assert_eq!( - other - .append_datagram(crate::model::clock::now(), ts, &b"b"[..]) - .unwrap(), - 5 - ); - other - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 8, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"c")), - }, - ) + .insert_datagram(4, ts, bytes::Bytes::from_static(b"a")) .unwrap(); + assert_eq!(other.append_datagram(ts, &b"b"[..]).unwrap(), 5); + other.insert_datagram(8, ts, bytes::Bytes::from_static(b"c")).unwrap(); assert_eq!(producer.append_group().unwrap().sequence, 9); assert_eq!(recv_datagram(&mut dg).sequence, 4); @@ -4412,20 +4286,10 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer.finish().unwrap(); assert!(matches!( - producer.insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 0, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")) - } - ), - Err(Error::Closed) - )); - assert!(matches!( - producer.append_datagram(crate::model::clock::now(), ts, &b"x"[..]), + producer.insert_datagram(0, ts, bytes::Bytes::from_static(b"x")), Err(Error::Closed) )); + assert!(matches!(producer.append_datagram(ts, &b"x"[..]), Err(Error::Closed))); } #[test] @@ -4434,18 +4298,7 @@ mod test { let mut other = producer.clone(); let ts = Timestamp::from_millis(0).unwrap(); producer.abort(Error::Cancel).unwrap(); - assert!( - other - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 0, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")) - } - ) - .is_err() - ); + assert!(other.insert_datagram(0, ts, bytes::Bytes::from_static(b"x")).is_err()); } #[test] @@ -4454,24 +4307,10 @@ mod test { let ts = Timestamp::from_millis(0).unwrap(); producer.finish_at(10).unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 5, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"ok")), - }, - ) + .insert_datagram(5, ts, bytes::Bytes::from_static(b"ok")) .unwrap(); assert!(matches!( - producer.insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 10, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"late")) - } - ), + producer.insert_datagram(10, ts, bytes::Bytes::from_static(b"late")), Err(Error::Closed) )); assert_eq!(producer.append_group().unwrap().sequence, 6); @@ -4483,14 +4322,7 @@ mod test { let mut datagram_only = track_producer("datagram-only", None); let datagram_only_consumer = datagram_only.consume(); datagram_only - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 8, - timestamp: Timestamp::ZERO, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), - }, - ) + .insert_datagram(8, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) .unwrap(); assert_eq!( datagram_only_consumer.resume_position(), @@ -4505,14 +4337,7 @@ mod test { .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"head")) .unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 8, - timestamp: Timestamp::ZERO, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), - }, - ) + .insert_datagram(8, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) .unwrap(); assert_eq!( @@ -4533,14 +4358,7 @@ mod test { let ts = Timestamp::from_millis(5).unwrap(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 5, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), - }, - ) + .insert_datagram(5, ts, bytes::Bytes::from_static(b"x")) .unwrap(); assert_eq!(recv_datagram(&mut datagrams).sequence, 5); @@ -4568,11 +4386,7 @@ mod test { // Supplied at millis; stored/emitted at the track's micro timescale. producer - .append_datagram( - crate::model::clock::now(), - Timestamp::from_millis(2).unwrap(), - &b"z"[..], - ) + .append_datagram(Timestamp::from_millis(2).unwrap(), &b"z"[..]) .unwrap(); let got = recv_datagram(&mut dg); assert_eq!(got.timestamp.scale(), Timescale::MICRO); @@ -4585,18 +4399,11 @@ mod test { let big = bytes::Bytes::from(vec![0u8; crate::model::datagram::MAX_DATAGRAM_PAYLOAD + 1]); let ts = Timestamp::from_millis(0).unwrap(); assert!(matches!( - producer.append_datagram(crate::model::clock::now(), ts, big.clone()), + producer.append_datagram(ts, big.clone()), Err(Error::FrameTooLarge) )); assert!(matches!( - producer.insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 0, - timestamp: ts, - payload: crate::IntoBytes::into_bytes(big) - } - ), + producer.insert_datagram(0, ts, big), Err(Error::FrameTooLarge) )); } @@ -4609,12 +4416,8 @@ mod test { let mut b = producer.subscribe(None); let ts = Timestamp::from_millis(1).unwrap(); - producer - .append_datagram(crate::model::clock::now(), ts, &b"first"[..]) - .unwrap(); - producer - .append_datagram(crate::model::clock::now(), ts, &b"second"[..]) - .unwrap(); + producer.append_datagram(ts, &b"first"[..]).unwrap(); + producer.append_datagram(ts, &b"second"[..]).unwrap(); // Both receive every datagram in order, independently. assert_eq!(&recv_datagram(&mut a).payload[..], b"first"); @@ -4623,26 +4426,21 @@ mod test { assert_eq!(&recv_datagram(&mut b).payload[..], b"second"); } - #[tokio::test] - async fn datagram_evicts_stale() { + #[test] + fn datagram_buffer_drops_oldest_at_capacity() { let mut producer = track_producer("test", None); - let mut dg = producer.subscribe(None); - let ts = Timestamp::from_millis(0).unwrap(); - - producer - .append_datagram(crate::model::clock::now(), ts, &b"old"[..]) - .unwrap(); // sequence 0 - - // Age past the send-buffer window, then push a fresh datagram: the stale one is evicted. - crate::model::clock::advance(MAX_DATAGRAM_AGE + Duration::from_millis(10)); - producer - .append_datagram(crate::model::clock::now(), ts, &b"new"[..]) - .unwrap(); // sequence 1 - - // A lagging consumer resumes at the oldest still-buffered datagram (the fresh one). - let got = recv_datagram(&mut dg); - assert_eq!(got.sequence, 1); - assert_eq!(&got.payload[..], b"new"); + let mut slow = producer.subscribe(None); + let mut fast = producer.subscribe(None); + let count = MAX_DATAGRAMS * 3; + for sequence in 0..count { + producer.append_datagram(Timestamp::ZERO, b"x".as_slice()).unwrap(); + assert_eq!(recv_datagram(&mut fast).sequence, sequence as u64); + } + assert_eq!(producer.state.read().datagrams.len(), MAX_DATAGRAMS); + for sequence in count - MAX_DATAGRAMS..count { + assert_eq!(recv_datagram(&mut slow).sequence, sequence as u64); + } + assert!(slow.poll_recv_datagram(&kio::Waiter::noop()).is_pending()); } #[tokio::test] @@ -4656,11 +4454,7 @@ mod test { ); producer - .append_datagram( - crate::model::clock::now(), - Timestamp::from_millis(0).unwrap(), - &b"go"[..], - ) + .append_datagram(Timestamp::from_millis(0).unwrap(), &b"go"[..]) .unwrap(); assert_eq!(&recv_datagram(&mut dg).payload[..], b"go"); } @@ -4679,9 +4473,7 @@ mod test { let mut origin = track_producer("test", None); let mut origin_dg = origin.subscribe(None); let ts = Timestamp::from_millis(7).unwrap(); - let seq = origin - .append_datagram(crate::model::clock::now(), ts, &b"payload"[..]) - .unwrap(); + let seq = origin.append_datagram(ts, &b"payload"[..]).unwrap(); let d = recv_datagram(&mut origin_dg); let body = lite::Datagram { @@ -4700,12 +4492,9 @@ mod test { let mut downstream_dg = downstream.subscribe(None); downstream .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: wire.sequence, - timestamp: Timestamp::new(wire.timestamp, Timescale::MILLI).unwrap(), - payload: crate::IntoBytes::into_bytes(wire.payload), - }, + wire.sequence, + Timestamp::new(wire.timestamp, Timescale::MILLI).unwrap(), + wire.payload, ) .unwrap(); @@ -5197,9 +4986,7 @@ mod test { producer.append_group().unwrap().finish().unwrap(); // seq 1 crate::model::clock::advance(Duration::from_secs(2)); - producer - .append_datagram(crate::model::clock::now(), Timestamp::ZERO, b"x".as_slice()) - .unwrap(); + producer.append_datagram(Timestamp::ZERO, b"x".as_slice()).unwrap(); let expired = !producer.state.read().lookup.contains_key(&0); assert!(expired, "an appended datagram runs expiry"); @@ -5213,14 +5000,7 @@ mod test { crate::model::clock::advance(Duration::from_secs(2)); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 2, - timestamp: Timestamp::ZERO, - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), - }, - ) + .insert_datagram(2, Timestamp::ZERO, bytes::Bytes::from_static(b"x")) .unwrap(); let expired = !producer.state.read().lookup.contains_key(&0); @@ -6010,14 +5790,7 @@ mod test { let mut sub = producer.subscribe(None).ordered(); producer - .insert_datagram( - crate::model::clock::now(), - crate::Datagram { - sequence: 5, - timestamp: Timestamp::from_millis(5).unwrap(), - payload: crate::IntoBytes::into_bytes(bytes::Bytes::from_static(b"x")), - }, - ) + .insert_datagram(5, Timestamp::from_millis(5).unwrap(), bytes::Bytes::from_static(b"x")) .unwrap(); producer.create_group(group::Info { sequence: 3 }).unwrap(); @@ -8089,9 +7862,7 @@ mod test { let (mut producer, pool) = pooled_producer(1_000); for _ in 0..10 { finished_group(&mut producer, 1_000); - producer - .append_datagram(crate::model::clock::now(), Timestamp::ZERO, &b"beat"[..]) - .unwrap(); + producer.append_datagram(Timestamp::ZERO, &b"beat"[..]).unwrap(); } let consumer = producer.consume(); diff --git a/rs/moq-net/tests/datagram.rs b/rs/moq-net/tests/datagram.rs index 5342c43cb1..b16264be9b 100644 --- a/rs/moq-net/tests/datagram.rs +++ b/rs/moq-net/tests/datagram.rs @@ -77,12 +77,9 @@ async fn datagrams_reach_the_subscriber_in_order() { fixture .producer .insert_datagram( - std::time::Instant::now(), - moq_net::Datagram { - sequence, - timestamp: Timestamp::from_millis(sequence).unwrap(), - payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), - }, + sequence, + Timestamp::from_millis(sequence).unwrap(), + bytes::Bytes::from_static(PAYLOAD), ) .unwrap(); } @@ -142,12 +139,9 @@ async fn ietf_does_not_deliver_datagrams() { producer .insert_datagram( - std::time::Instant::now(), - moq_net::Datagram { - sequence: 0, - timestamp: Timestamp::from_millis(7).unwrap(), - payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), - }, + 0, + Timestamp::from_millis(7).unwrap(), + bytes::Bytes::from_static(PAYLOAD), ) .unwrap(); producer @@ -174,23 +168,17 @@ async fn inserted_sequences_survive_the_lite_wire() { fixture .producer .insert_datagram( - std::time::Instant::now(), - moq_net::Datagram { - sequence: 5, - timestamp: Timestamp::from_millis(5).unwrap(), - payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), - }, + 5, + Timestamp::from_millis(5).unwrap(), + bytes::Bytes::from_static(PAYLOAD), ) .unwrap(); fixture .producer .insert_datagram( - std::time::Instant::now(), - moq_net::Datagram { - sequence: 2, - timestamp: Timestamp::from_millis(2).unwrap(), - payload: moq_net::IntoBytes::into_bytes(bytes::Bytes::from_static(PAYLOAD)), - }, + 2, + Timestamp::from_millis(2).unwrap(), + bytes::Bytes::from_static(PAYLOAD), ) .unwrap(); From c0a87f311b4242d65ca7074ec52502da2f51e23c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 20 Sep 2026 15:35:19 -0700 Subject: [PATCH 4/5] fix(hls): pass catalog config in rendition test Adapt the test to the catalog constructor from main so workspace validation compiles. Co-Authored-By: GPT-6 --- rs/moq-hls/src/export/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 349333fd0b..2c01fed748 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -642,7 +642,7 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); let reserved = catalog.reserve(); let mut registration = reserved.video("video0").unwrap(); From ddb629e64d1e8fb82b7703418df6f201d6c1581f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 21 Sep 2026 03:23:52 -0700 Subject: [PATCH 5/5] docs(net): tighten the driver section and fix formatting Co-Authored-By: Claude Opus 5 --- doc/lib/rs/moq-net.md | 48 ++++++++++++++++----------------------- rs/moq-uring/tests/web.rs | 5 +--- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 99dcb458f3..6e7b57e8bb 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -40,8 +40,8 @@ grammar lives on the concept page. ## Driving sessions `Client::connect(now, transport)`, `Server::accept(now, transport)`, and -`server::Handshake::ok()` return `(Session, Driver)`. The lite-only entry points -return the same pair. `moq-net` never spawns tasks or schedules runtime timers. +`server::Handshake::ok()` return `(Session, Driver)`. `moq-net` never spawns +tasks or reads the clock: the caller polls the driver and supplies the time. ```rust let now = tokio::time::Instant::now().into_std(); @@ -51,33 +51,23 @@ tokio::spawn(moq_tokio::runtime::run(driver)); A custom event loop calls `driver.poll(now, waiter)` with a nondecreasing `moq_net::time::Instant`. After `Pending`, wait for external activity or the -instant returned by `driver.timeout()`, whichever comes first. `None` means -there is no timer to schedule. Poll again with fresh time after waking. -Tests use the same interface with explicitly advanced instants. - -Dropping the last session handle requests closure when the driver next runs. -Dropping the driver cancels the session. Keep both alive while using the -connection. `moq-tokio` and `moq-wasm` drive sessions for their callers; the -lite-only path also supports native `!Send` transports on their owning thread. - -Origin drivers implement the same `time::Driver` interface. -`origin::Producer::new` returns its lifecycle driver, which also calls -`pool.gc(now)` after polling and includes its next cleanup time in the -origin's deadline. For a standalone pool, call `pool.gc(now)` yourself after -polling and at the returned deadline, even when there is no traffic. -`None` means both cache policies are disabled; call again after enabling a -capacity with `pool.resize`. - -Cache reads and writes clear their expiration timestamp without reading the -system clock. A due cleanup pass scans cached groups, dates undated activity, -and expires idle groups except each track's latest. Calls before the cleanup -deadline only advance the sampled clock, so calling after every poll is cheap. -Expiration is approximate: delayed cleanup extends retention. Model read/write -APIs take no wall-clock time. Datagram writes keep their existing signatures: -`append_datagram(timestamp, payload)` and -`insert_datagram(sequence, timestamp, payload)`. Datagram send buffers retain -the newest 64 entries, dropping the oldest at capacity without reading a clock. -This changes Rust APIs, with no wire-format or TypeScript API changes. +instant returned by `driver.timeout()` (`None` means no timer is armed), then +poll again with fresh time. Tests drive the same interface with explicitly +advanced instants. + +Dropping the last session handle requests closure on the next poll. Dropping +the driver cancels the session. `moq-tokio` and `moq-wasm` drive sessions for +their callers. + +`origin::Producer::new` returns a driver with the same `time::Driver` +interface. It calls `cache::Pool::gc(now)` after each poll and folds the next +cleanup time into its own `timeout()`. A standalone pool needs `gc(now)` +called by its owner, at least by the returned deadline; `None` means expiry is +disabled. + +Cache activity is dated lazily: reads and writes mark a group active without +reading a clock, and the next `gc` pass stamps it with the supplied instant. +Expiry is therefore approximate; a late `gc` extends retention. ## Patterns diff --git a/rs/moq-uring/tests/web.rs b/rs/moq-uring/tests/web.rs index c0faecd1dd..0f68a358d2 100644 --- a/rs/moq-uring/tests/web.rs +++ b/rs/moq-uring/tests/web.rs @@ -239,10 +239,7 @@ fn lite_session_over_webtransport() { let (moq, session_driver) = moq_net::Client::new() .with_subscriber(sub_origin.clone()) - .connect_lite( - std::time::Instant::now(), - moq_tokio::transport::Session::new(session), - ) + .connect_lite(std::time::Instant::now(), moq_tokio::transport::Session::new(session)) .await .expect("connect_lite"); tokio::spawn(moq_tokio::runtime::run(session_driver));