diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 5ea1aceb5b..6e7b57e8bb 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`: noq, the +It runs over anything implementing `web_transport_trait::poll::Session`: 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,38 @@ 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(now, transport)`, `Server::accept(now, transport)`, and +`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(); +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()` (`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 `Pattern` describes a set of paths; `Patterns` is a union reduced by 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/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/tests/support/harness.rs b/rs/moq-e2ee/tests/support/harness.rs index 11b303bc7b..dac549d653 100644 --- a/rs/moq-e2ee/tests/support/harness.rs +++ b/rs/moq-e2ee/tests/support/harness.rs @@ -2,90 +2,19 @@ //! [`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 moq_net::{Client, Server, Session, Version, origin}; -use super::mock::{MockSession, 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); - -impl TokioRuntime { - pub fn new() -> Self { - Self(PhantomData) - } -} - -impl Clone for TokioRuntime { - fn clone(&self) -> Self { - Self(PhantomData) - } -} - -impl Default for TokioRuntime { - fn default() -> Self { - Self::new() - } -} +use super::mock::create_mock_session_pair; -// Unbounded: timers don't involve the transport, so origin drivers can borrow -// a transportless handle (`TokioRuntime::<()>::new()`). -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() - } -} - -// 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, - // 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`]. @@ -151,21 +80,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 - .connect(TokioRuntime::new(), client_transport) + let (session, driver) = client + .connect(now(), client_transport) .await - .expect("client handshake failed") + .expect("client handshake failed"); + tokio::spawn(run(driver)); + session }; let server_fut = async { - server - .accept(TokioRuntime::new(), server_transport) + let (session, driver) = server + .accept(now(), server_transport) .await - .expect("server handshake failed") + .expect("server handshake failed"); + 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 9051e6be25..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 142f11db2e..1ea84675a0 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -207,9 +207,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/runtime.rs b/rs/moq-ffi/src/runtime.rs index ee8964181e..6bc9252224 100644 --- a/rs/moq-ffi/src/runtime.rs +++ b/rs/moq-ffi/src/runtime.rs @@ -1,58 +1,28 @@ -//! The browser runtime: microtask spawning and `wasmtimer`-backed timers. - -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. -#[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 } - } -} - -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 { - 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 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 = 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 8375257e5a..75c513b911 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) + .connect(web_async::time::Instant::now(), transport) .await?; + crate::ffi::spawn(async move { + 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 565108a2ce..5fb94d44e9 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -362,7 +362,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); 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 eea5b4cf4a..1ae02aec59 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::origin::Config::default()); - 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 a0c4997dec..a2e158cf82 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::origin::Config::default()); 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 460da9ce29..a5fd11a05f 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::origin::Config::default()); 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 d8e48f5adf..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,10 +9,6 @@ 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 _}; - /// A MoQ client session builder. #[derive(Default, Clone)] pub struct Client { @@ -153,10 +152,15 @@ 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: Clock, + session: S, + version: lite::Version, + ) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, { 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,10 +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: R::Transport) -> Result + pub async fn connect_lite(&self, now: Instant, session: S) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, { + 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, @@ -224,17 +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 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 - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, - R::Transport: crate::transport::poll::Boxable, - R::Timer: MaybeSend, + S: crate::transport::poll::Boxable, { + let runtime = Clock::new(now); let (publish, subscribe) = self.origins(); // If ALPN was used to negotiate the version, use the appropriate encoding. @@ -270,12 +272,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 +378,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 +411,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 +677,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 - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + // Start the returned driver after the handshake completes. + let (_session, driver) = client + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); + 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()); @@ -726,9 +728,9 @@ 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), + client.connect(tokio::time::Instant::now().into_std(), transport), ) .await .expect("connect waited on a peer that never announced") @@ -745,31 +747,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.now(), 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(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); - runtime.tick(); + assert!(fake.state.close_events.lock().unwrap().is_empty()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -784,17 +780,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.now(), 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(runtime.now(), &kio::Waiter::noop()); assert!(fake.state.close_events.lock().unwrap().is_empty()); clone.abort(Error::Cancel); - runtime.tick(); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -802,28 +799,28 @@ mod tests { // And the machine publishes the transport's terminal error, which is // what `closed()` reports. - runtime.tick(); + 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); - runtime.tick(); + let _ = driver.poll(runtime.now(), &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.now(), fake.clone())).unwrap(); - runtime.shutdown(); + drop(driver); assert!(matches!(futures::executor::block_on(session.closed()), Error::Cancel)); } @@ -971,7 +968,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 +980,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.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); - runtime.tick(); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -999,7 +996,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 +1006,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.now(), local)).unwrap(); assert_eq!(session.version(), Version::Lite(lite::Version::Lite04)); - assert_eq!(runtime.tick(), 1); + assert!(driver.poll(runtime.now(), &kio::Waiter::noop()).is_pending()); drop(session); - runtime.tick(); + assert!(fake.state.close_events.lock().unwrap().is_empty()); + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); assert_eq!( fake.state.close_events.lock().unwrap()[0].0, SessionError::Cancel.to_code() @@ -1032,8 +1030,8 @@ mod tests { _local: std::rc::Rc::new(()), }; let client = Client::new(); - let runtime = crate::runtime::Test::::new(); - let result = futures::executor::block_on(client.connect_lite(runtime, local)); + let runtime = crate::runtime::Test::new(); + let result = futures::executor::block_on(client.connect_lite(runtime.now(), local)); assert!(matches!(result, Err(Error::Version))); } @@ -1046,10 +1044,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 - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + let (session, driver) = client + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); + tokio::spawn(crate::time::test::run(driver)); // The construction-time snapshot, before the machine sampled anything. assert_eq!( @@ -1078,10 +1077,11 @@ mod tests { fake.set_bytes_sent(Some(0)); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let session = client - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + let (session, driver) = client + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); + tokio::spawn(crate::time::test::run(driver)); assert!( session.send_bandwidth().is_none(), "no send-rate estimate, so nothing samples on its own" @@ -1108,10 +1108,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 - .connect(crate::runtime::tokio_test::Tokio::new(), fake.clone()) + let (session, driver) = client + .connect(tokio::time::Instant::now().into_std(), fake.clone()) .await .unwrap(); + 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 new file mode 100644 index 0000000000..23f357e11d --- /dev/null +++ b/rs/moq-net/src/driver.rs @@ -0,0 +1,128 @@ +//! Drive a session on the caller's executor. + +use std::task::Poll; + +use crate::Error; +use crate::time::{Clock, Instant}; + +/// Drives a session with caller-supplied time. +/// +/// 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. +#[must_use = "the session makes no progress unless its driver is polled"] +pub struct Driver { + state: State, + clock: Clock, +} + +/// 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>>), +} + +/// 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 + // 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(clock: Clock, state: State) -> Self { + Self { state, clock } + } + + /// Process ready work at `now`, registering for external activity. + /// + /// 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 { + 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, + } + } +} + +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() + } +} + +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 { + 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..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 ebca2556bd..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 1031cbe6b5..027d9bb214 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::Runtime + 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 4b8521bdb4..92093e65ef 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 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::Runtime + 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::Runtime + 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::Runtime + 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::Runtime + 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, @@ -992,7 +976,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, @@ -1042,7 +1026,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, @@ -1149,7 +1133,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, @@ -1218,7 +1202,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), @@ -1341,7 +1325,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 998e83cd75..99c5832b82 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::Runtime + 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::Runtime + 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::Runtime + 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), @@ -2880,7 +2881,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), @@ -2952,7 +2953,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), @@ -3008,7 +3009,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); @@ -3032,7 +3033,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"); @@ -3077,14 +3078,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), @@ -3235,7 +3236,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), @@ -3291,7 +3292,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), @@ -3396,7 +3397,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, @@ -3512,7 +3513,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), @@ -3610,7 +3611,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), @@ -3702,7 +3703,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), @@ -3763,7 +3764,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), @@ -3803,7 +3804,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), @@ -3839,7 +3840,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()); @@ -3850,7 +3851,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), @@ -3983,7 +3984,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), @@ -4054,7 +4055,7 @@ mod tests { ..Default::default() }); let mut subscriber = Subscriber::new( - TestRuntime::new(), + crate::time::Clock::tokio(), session.clone(), origin, Control::new(None, false), @@ -4128,7 +4129,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), @@ -4183,7 +4184,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), @@ -4500,7 +4501,7 @@ mod tests { attached: &cluster::Advert, script: Vec, ) -> ( - Subscriber, + Subscriber, crate::origin::Consumer, Stream, crate::origin::Driver, @@ -4515,7 +4516,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), @@ -4545,7 +4546,7 @@ mod tests { attached: &cluster::Advert, updates: &[cluster::Advert], ) -> ( - Subscriber, + Subscriber, crate::origin::Consumer, Stream, ) { @@ -4947,7 +4948,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), @@ -5348,8 +5349,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); @@ -5452,7 +5451,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, @@ -5466,7 +5465,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), @@ -5983,8 +5982,6 @@ mod joining_fetch_tests { util::{TaskSet, Tasks}, }; - type TestRuntime = crate::runtime::tokio_test::Tokio; - const JOINING_DRAFTS: [Version; 6] = [ Version::Draft14, Version::Draft15, @@ -6095,7 +6092,7 @@ mod joining_fetch_tests { } struct JoinRun { - subscriber: Subscriber, + subscriber: Subscriber, session: ScriptedSession, _hold: ( crate::broadcast::Producer, @@ -6111,7 +6108,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), @@ -6256,7 +6253,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 d8a232bc67..a4ddb6e0db 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 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. +//! ## 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. //! -//! 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. +//! [`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. Datagrams use a bounded +//! FIFO; model read/write APIs take no wall-clock time. //! -//! 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 [`Runtime`] 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 @@ -79,6 +71,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 @@ -95,14 +88,16 @@ 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::*; 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 +105,6 @@ pub use model::*; pub use path::{ AsPath, InvalidPattern, Path, PathOwned, PathPrefixes, PathRelative, PathRelativeOwned, Pattern, Patterns, }; -pub use runtime::{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 6ae0051c4d..148d3bf4d9 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::Runtime, { 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), 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 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,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< Self::PROBE_MAX_DELTA * (Self::PROBE_MAX_AGE.as_secs_f64() - t) / range } - fn new(shared: Arc>, runtime: R, stream: Stream) -> Self { + fn new(shared: Arc>, runtime: crate::time::Clock, stream: Stream) -> Self { Self { shared, stream: Some(stream), @@ -1640,9 +1640,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 +3011,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 +3044,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 +3100,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 +3142,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 43f5639733..cdf8de1288 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(config: Config) -> Result, Error> +pub fn start(config: Config) -> Result, Error> where S: crate::transport::poll::Session, - R: crate::runtime::Runtime, { 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::Runtime, { 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 74fe865480..331b0758d6 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.hop(); Self { session: config.session, + runtime: config.runtime, origin: config.origin, recv_bandwidth: config.recv_bandwidth, self_origin, @@ -733,7 +737,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 +784,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 +806,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 +853,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 +1331,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 +1404,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 +1477,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 +1902,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 +1958,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 +2014,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 +2070,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 +2139,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 +2196,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 +2241,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 +2304,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 +2366,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 +3534,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..433dbc1bfe 100644 --- a/rs/moq-net/src/model/clock.rs +++ b/rs/moq-net/src/model/clock.rs @@ -1,17 +1,8 @@ -//! 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 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 cleanup call. /// The current instant on the model's clock. #[cfg(not(test))] @@ -29,6 +20,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 +29,7 @@ pub(crate) fn advance(duration: std::time::Duration) { .expect("advance overflows the test clock"), ); }); + poll_pools(); } #[cfg(test)] @@ -73,3 +66,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 783a852748..9dede1e34a 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, Producer, Request, Requesting, Route, Run}; + pub use super::origin_impl::{Config, Consumer, Cost, Driver, Dynamic, Producer, Request, Requesting, 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 9245db3ba3..7ba2e9b607 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}, }; @@ -1083,8 +1083,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 Producer { @@ -1092,15 +1092,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 { hop: config.hop, @@ -1120,10 +1118,10 @@ impl Producer { tree: scope.tree, shared, done: false, - sweep: None, }, timers, pool, + gc: None, }; (producer, driver) } @@ -1175,7 +1173,7 @@ impl Producer { default_max_age: track::DEFAULT_MAX_AGE, stats: stats::Session::default(), tasks, - timers: TimersSlot::default(), + timers: Clock::default(), } } @@ -1614,36 +1612,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, @@ -1654,96 +1645,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(), - } - } -} - -/// 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), - }) + /// 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 } - 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 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() } } -/// 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() } } @@ -1752,9 +1677,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; @@ -1821,18 +1743,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. @@ -1983,7 +1893,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. @@ -2029,7 +1939,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 @@ -2127,7 +2037,7 @@ async fn run_front( tree: Lock, full: PathOwned, tasks: Tasks, - slot: TimersSlot, + slot: Clock, ) { enum Step { Serve(Arc, super::resume::Producer), @@ -2220,7 +2130,7 @@ async fn serve_track( state: kio::Producer, name: Arc, mut resume: super::resume::Producer, - slot: TimersSlot, + slot: Clock, ) { enum Step { Closed, @@ -2263,9 +2173,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 { @@ -2598,7 +2507,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 @@ -3488,7 +3397,7 @@ pub struct Consumer { // The driver's clock and timers, threaded into fronts for the track idle // linger. - timers: TimersSlot, + timers: Clock, } impl Consumer { @@ -4055,7 +3964,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. @@ -5301,7 +5210,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/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 e366609f6a..402e657720 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. @@ -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. @@ -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,17 +371,13 @@ impl TrackState { } } - /// Push a datagram onto the buffer, dropping any that have aged past [`MAX_DATAGRAM_AGE`]. + /// Push a datagram, dropping the oldest when the send buffer is full. fn push_datagram(&mut self, datagram: Datagram) { - let now = crate::model::clock::now(); - self.datagrams.push_back((datagram, now)); - while let Some((_, at)) = self.datagrams.front() { - if now.duration_since(*at) <= MAX_DATAGRAM_AGE { - break; - } + 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 @@ -601,7 +596,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 +609,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 +651,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 +698,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; @@ -4425,22 +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(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 - - // 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] @@ -4841,6 +4841,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. diff --git a/rs/moq-net/src/runtime.rs b/rs/moq-net/src/runtime.rs index a96e5505d0..a354c89539 100644 --- a/rs/moq-net/src/runtime.rs +++ b/rs/moq-net/src/runtime.rs @@ -1,25 +1,7 @@ -//! Executor integration: who runs session machines and arms their timers. -//! -//! 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. -//! -//! 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. +//! Private deadlines driven by the owning driver's supplied clock. 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 +34,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; @@ -63,157 +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() - } -} - -/// 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); - -#[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`]. @@ -277,178 +108,41 @@ 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 { +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")] +#[cfg(test)] mod test; -#[cfg(feature = "test-runtime")] -pub use test::{Never, Test}; +#[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")))] 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 +154,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..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,10 +12,6 @@ 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 _}; - /// A MoQ server session builder. #[derive(Default, Clone)] pub struct Server { @@ -79,17 +78,17 @@ 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, + runtime: Clock, + 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, { 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,11 +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: R::Transport) -> Result + pub async fn accept_lite(&self, now: Instant, session: S) -> Result<(Session, crate::Driver), Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, { - 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,14 +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: R::Transport, - ) -> Result, Error> + pub async fn accept_request_lite(&self, now: Instant, mut session: S) -> Result, Error> where - R: crate::runtime::Runtime + 'static, + S: crate::transport::poll::Session, { + 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 { @@ -226,24 +222,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 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: R::Transport) -> Result + pub async fn accept(&self, now: Instant, 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, - R::Timer: MaybeSend, + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, { - 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 @@ -256,18 +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: R::Transport, - ) -> Result, Error> + pub async fn accept_request(&self, now: Instant, mut session: S) -> Result, Error> where - R: crate::runtime::Runtime + MaybeSend + MaybeSync + 'static, - R::Transport: crate::transport::poll::Boxable, - ::SendStream: MaybeSync, - ::RecvStream: MaybeSync, - R::Timer: MaybeSend, + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, { + 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 { @@ -305,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)?; @@ -373,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, - mut session: R::Transport, + runtime: Clock, + 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, - R::Timer: MaybeSend, + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, { let peer_setup = ietf::accept_setup(&mut session, version).await?; Ok(Handshake { @@ -414,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, @@ -423,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, - /// Receives the session's machine once `ok()` completes the handshake. - runtime: R, - handshake: PausedHandshake, + /// Supplies the clock and timers for the accepted session. + 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 @@ -452,23 +437,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: Clock, peer_hop: Option) -> Accept; /// Reject the handshake, closing the transport with `err`'s wire code. fn close(self: Box, err: Error); @@ -483,20 +465,13 @@ 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::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: Clock, peer_hop: Option) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -525,12 +500,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 +530,13 @@ 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::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: Clock, peer_hop: Option) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -615,7 +583,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 +605,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() } @@ -652,10 +620,9 @@ where } } -impl Handshake +impl Handshake where S: crate::transport::poll::Session, - R: crate::runtime::Runtime + 'static, { /// The request path the client advertised in its SETUP. /// @@ -728,15 +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`]. + /// 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 +730,7 @@ where } } -impl RequestInner { +impl RequestInner { fn close(self, err: Error) { let mut session = match self.handshake { PausedHandshake::LiteBare { session, .. } => session, @@ -775,7 +741,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) { @@ -985,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}"); @@ -996,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(), ""); @@ -1006,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(), ""); @@ -1023,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"); @@ -1034,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(), ""); @@ -1046,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(), ""); @@ -1059,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)); @@ -1074,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"); @@ -1085,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)); @@ -1107,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, @@ -1141,7 +1107,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(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 286afce81d..8f89d91a87 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,16 @@ impl Session { } impl Session { - pub(super) fn new( - runtime: R, - session: R::Transport, + pub(super) fn new( + runtime: crate::time::Clock, + 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, { 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,41 +248,27 @@ impl Session { recv_bandwidth, goaway: Arc::new(goaway), }; - let machine = crate::runtime::Machine::new(crate::runtime::MachineState { - 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 + let driver = crate::Driver::new( + runtime.clone(), + crate::driver::State { + protocol, + supervisor: Some(supervisor), + result: None, + }, + ); + + (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. /// /// 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. @@ -298,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 4c7bec9f2c..b16264be9b 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 } diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index 035269e921..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,11 +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_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(support::harness::run(client_driver)); + let (_server_session, server_driver) = server_result.expect("server handshake failed"); + 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 11b303bc7b..dac549d653 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -2,90 +2,19 @@ //! [`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 moq_net::{Client, Server, Session, Version, origin}; -use super::mock::{MockSession, 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); - -impl TokioRuntime { - pub fn new() -> Self { - Self(PhantomData) - } -} - -impl Clone for TokioRuntime { - fn clone(&self) -> Self { - Self(PhantomData) - } -} - -impl Default for TokioRuntime { - fn default() -> Self { - Self::new() - } -} +use super::mock::create_mock_session_pair; -// Unbounded: timers don't involve the transport, so origin drivers can borrow -// a transportless handle (`TokioRuntime::<()>::new()`). -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() - } -} - -// 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, - // 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`]. @@ -151,21 +80,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 - .connect(TokioRuntime::new(), client_transport) + let (session, driver) = client + .connect(now(), client_transport) .await - .expect("client handshake failed") + .expect("client handshake failed"); + tokio::spawn(run(driver)); + session }; let server_fut = async { - server - .accept(TokioRuntime::new(), server_transport) + let (session, driver) = server + .accept(now(), server_transport) .await - .expect("server handshake failed") + .expect("server handshake failed"); + 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 81ad515b9e..882021fb07 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -653,7 +653,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")?; @@ -755,7 +755,13 @@ 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?; + let driver_handle = handle.clone(); + handle.spawn(async move { + if let Err(err) = driver_handle.run(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::server::Transport::Quic, "negotiated"); diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 1a0323a367..006bb37162 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -175,14 +175,16 @@ 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, driver) = server + .accept( + tokio::time::Instant::now().into_std(), + moq_tokio::transport::Session::new(ws), + ) .await?; - let mut driver = runtime.take().expect("accept hands the machine to its runtime"); + + 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. diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index 7c07f88ec5..bec8cad0f8 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -298,7 +298,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); 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 2195f0bbb4..0d6d27e34b 100644 --- a/rs/moq-srt/src/dial.rs +++ b/rs/moq-srt/src/dial.rs @@ -146,7 +146,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); 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 4d5bb556e6..35759aa90b 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -835,7 +835,7 @@ mod tests { const OFFSET: u64 = 600_000_000; let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); - 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::new( diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index ce8a380fa3..bcd42025cc 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -145,7 +145,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); 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 d178f0cd35..d9b0b786d6 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::origin::Config::default()); 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 f621373224..80b33ddbd1 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::origin::Config::default()); 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 bfce26ccfe..4720731514 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -883,7 +883,7 @@ mod tests { fn produce_origin() -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); 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 b3adfaf208..d738909827 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -346,9 +346,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 @@ -356,9 +354,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 @@ -383,9 +379,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")] @@ -407,7 +401,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?); } } @@ -425,10 +419,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. @@ -461,14 +452,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?) + } } } } @@ -600,6 +587,25 @@ where } } +#[cfg(any( + feature = "noq", + 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(tokio::time::Instant::now().into_std(), transport) + .await?; + use tracing::Instrument; + tokio::spawn(crate::runtime::run(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 b305b89a6a..2c1fe78f2e 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -17,7 +17,7 @@ pub fn spawn() -> moq_net::origin::Producer { /// Build and spawn an origin producer with an explicit configuration. pub fn spawn_config(config: moq_net::origin::Config) -> moq_net::origin::Producer { let (producer, driver) = moq_net::origin::Producer::new(config); - 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 4d1c1769c3..f8e268918c 100644 --- a/rs/moq-tokio/src/runtime.rs +++ b/rs/moq-tokio/src/runtime.rs @@ -1,185 +1,29 @@ -//! The tokio runtime handle passed to moq-net's session entry points. - -use std::{marker::PhantomData, 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); - -impl Runtime { - /// A handle for the transport type this dial produces. - pub fn new() -> Self { - Self(PhantomData) - } -} - -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 { - 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() - } -} - -// 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, - // 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 1980499f4d..28aa32bf19 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -587,7 +587,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()); } @@ -596,7 +596,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()); } @@ -608,7 +608,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)) }) @@ -1017,7 +1017,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) => { @@ -1043,7 +1046,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")] @@ -1297,7 +1300,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(crate::runtime::run(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 58fb24bf72..5987df7206 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). @@ -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 87e8bf9e17..c276e337d1 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", feature = "noq"))] 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)); }); }); @@ -172,11 +128,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)) + .accept_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); + let _ = server_handle.run(driver).await; session.closed().await; }); @@ -186,11 +143,15 @@ 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)) + .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 _ = task_handle.run(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 d5e1ca4859..33a6865260 100644 --- a/rs/moq-uring/src/lib.rs +++ b/rs/moq-uring/src/lib.rs @@ -16,9 +16,9 @@ //! 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 enabled by the `noq` feature; -//! a build without it leaves the module out. +//! 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 enabled by +//! the `noq` feature; a build without it leaves the module out. //! //! [`metrics::Metrics`] is how the worker's own health leaves its thread: //! relaxed counters for the buffer pools, the batching mechanisms, the ring, diff --git a/rs/moq-uring/src/quic/noq/connection.rs b/rs/moq-uring/src/quic/noq/connection.rs index a4164c641e..d9701dfcae 100644 --- a/rs/moq-uring/src/quic/noq/connection.rs +++ b/rs/moq-uring/src/quic/noq/connection.rs @@ -336,7 +336,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, }; @@ -603,7 +603,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: noq-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 b9d3381e44..7bbe65c474 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 @@ -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()); @@ -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); @@ -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 3d9f74b55d..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,29 +536,27 @@ impl std::fmt::Debug for Handle { } } -impl moq_net::Timers for Handle { - type Timer = crate::Timer; - - fn timer(&self) -> Self::Timer { - crate::Timer::new(self.shared.timers.clone()) +impl Handle { + /// Allocate a disarmed timer on this worker. + pub fn timer(&self) -> crate::Timer { + crate::Timer::from_heap(self.shared.timers.clone()) } -} -// Without a QUIC backend there is no transport to name, so the worker is a -// task and timer runtime only. -#[cfg(feature = "noq")] -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"); + /// 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 } } @@ -604,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 @@ -683,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(); @@ -1175,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 5a3da90a26..423394b071 100644 --- a/rs/moq-uring/tests/endpoint.rs +++ b/rs/moq-uring/tests/endpoint.rs @@ -253,7 +253,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 3a2cfe65e5..2700a4e578 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", feature = "noq"))] @@ -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)); }); }); @@ -92,11 +83,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)) + .accept_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); + let _ = server_handle.run(driver).await; // Serve until the client walks away. session.closed().await; }); @@ -113,11 +105,15 @@ 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)) + .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 _ = task_handle.run(driver).await; + }); let bc = { let consumer = sub.consume(); @@ -171,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), ); }); }); @@ -209,11 +205,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)) + .accept_lite(std::time::Instant::now(), quic::web::Session::raw(conn)) .await .expect("accept_lite"); + let _ = session_handle.run(driver).await; session.closed().await; }); } @@ -233,11 +230,15 @@ 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)) + .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 _ = task_handle.run(driver).await; + }); let bc = { let consumer = sub.consume(); diff --git a/rs/moq-uring/tests/support.rs b/rs/moq-uring/tests/support.rs index aed11c8030..454d3b41d5 100644 --- a/rs/moq-uring/tests/support.rs +++ b/rs/moq-uring/tests/support.rs @@ -1,53 +1,7 @@ -//! Shared test certificates and tokio timers. +//! Shared test certificates. #![allow(dead_code)] -use std::task::Poll; - -/// 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()) - } -} - /// A self-signed localhost certificate on disk. pub struct Certs { pub dir: tempfile::TempDir, diff --git a/rs/moq-uring/tests/web.rs b/rs/moq-uring/tests/web.rs index 0baf0c7bf2..0f68a358d2 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,16 +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::default()); - let driver = tokio::spawn(sub_driver.run(moq_tokio::runtime::Runtime::<()>::new())); + let driver = tokio::spawn(moq_tokio::runtime::run(sub_driver)); - 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(), - 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)); let bc = { let consumer = sub_origin.consume(); @@ -284,11 +282,15 @@ 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) + .accept_lite(std::time::Instant::now(), session) .await .expect("accept_lite"); + let task_handle = handle.clone(); + handle.spawn(async move { + let _ = task_handle.run(driver).await; + }); session.closed().await; }) .expect("worker"); @@ -306,7 +308,7 @@ fn lite_session_over_webtransport() { /// 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 475fb2eae8..6dd1568271 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 b4cdbe384c..aa4a67bc45 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::origin::Config::default()); 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 12dff5542b..6276089dd4 100644 --- a/rs/moq-wasm/README.md +++ b/rs/moq-wasm/README.md @@ -17,9 +17,10 @@ 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 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 2c1539be8a..b69051e465 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::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. #![cfg(target_arch = "wasm32")] @@ -75,13 +72,18 @@ 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::origin::Config::default()); - 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 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(web_async::time::Instant::now(), transport) + .await + .map_err(js_err)?; + web_async::spawn(async move { + 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 00d9847e64..6bc9252224 100644 --- a/rs/moq-wasm/src/runtime.rs +++ b/rs/moq-wasm/src/runtime.rs @@ -1,58 +1,28 @@ -//! The browser runtime: microtask spawning and `wasmtimer`-backed timers. - -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. -#[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 } - } -} - -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 { - 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 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 = 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 }