From ff1a485ddcc7a73e770e60e1bdf16acaf3d7c8b1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 23:56:56 -0700 Subject: [PATCH 01/58] chore(quest): claim quest/m1/api-audit/lifecycle.md Quest-Claim: ff53cd07c6928daf From ad8d92d0a4d83c86c3fd30f4fec5827480146b79 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 23:58:09 -0700 Subject: [PATCH 02/58] chore(quest): claim quest/m1/api-audit/net-names.md Quest-Claim: 1a2186ff325f1c65 From 34240db14ef389267cc9d89c630e6a334c53c707 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 00:00:06 -0700 Subject: [PATCH 03/58] chore(quest): claim quest/m1/api-audit/requests.md Quest-Claim: daf39212be75a198 From 369722bfb8c9cc534fcbc0664945dc26afc6284b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 00:07:59 -0700 Subject: [PATCH 04/58] refactor(net)!: stats::Message, stats::Totals, transport::Metrics transport::Metrics drops the byte counts transport::Wire already holds; session::Stats reads them from the wire. Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/transport/adapter.rs | 18 +++----- rs/moq-mux/src/container/test_util.rs | 4 +- rs/moq-net/src/client.rs | 30 ++++++------- rs/moq-net/src/coding/decode.rs | 2 +- rs/moq-net/src/coding/encode.rs | 2 +- rs/moq-net/src/coding/reader.rs | 8 ++-- rs/moq-net/src/coding/writer.rs | 8 ++-- rs/moq-net/src/ietf/adapter.rs | 10 ++--- rs/moq-net/src/ietf/message.rs | 32 +++++++------- rs/moq-net/src/ietf/request_stream.rs | 6 +-- rs/moq-net/src/ietf/session.rs | 2 +- rs/moq-net/src/lite/announce.rs | 14 +++--- rs/moq-net/src/lite/auth.rs | 6 +-- rs/moq-net/src/lite/fetch.rs | 2 +- rs/moq-net/src/lite/goaway.rs | 2 +- rs/moq-net/src/lite/ietf.rs | 2 +- rs/moq-net/src/lite/info.rs | 2 +- rs/moq-net/src/lite/message.rs | 2 +- rs/moq-net/src/lite/probe.rs | 2 +- rs/moq-net/src/lite/setup.rs | 10 ++--- rs/moq-net/src/lite/subscribe.rs | 12 ++--- rs/moq-net/src/lite/test_transport.rs | 8 ++-- rs/moq-net/src/lite/track.rs | 4 +- rs/moq-net/src/lite/tracks.rs | 6 +-- rs/moq-net/src/server.rs | 9 ++-- rs/moq-net/src/session.rs | 26 ++++++----- rs/moq-net/src/setup.rs | 12 ++--- rs/moq-net/src/stats.rs | 30 ++++++------- rs/moq-net/src/transport.rs | 31 +++++-------- rs/moq-net/src/transport/boxed.rs | 44 +++++++------------ rs/moq-net/src/transport/memory.rs | 6 +-- rs/moq-net/tests/integration/control_stats.rs | 6 +-- rs/moq-net/tests/integration/support/mock.rs | 6 +-- rs/moq-relay/src/internal.rs | 4 +- rs/moq-relay/tests/integration/wire_stats.rs | 2 +- rs/moq-tokio/src/transport.rs | 8 +++- rs/moq-tokio/src/transport/owned.rs | 28 +++++++----- .../tests/integration/receive_credit.rs | 2 +- rs/moq-uring/src/transport/adapter.rs | 18 +++----- rs/moq-uring/src/transport/local.rs | 4 +- rs/moq-wasm/src/transport/adapter.rs | 18 +++----- rs/qmux/src/session.rs | 22 +++------- 42 files changed, 211 insertions(+), 259 deletions(-) diff --git a/rs/moq-ffi/src/transport/adapter.rs b/rs/moq-ffi/src/transport/adapter.rs index d54734e3afd..a5e4cfe2571 100644 --- a/rs/moq-ffi/src/transport/adapter.rs +++ b/rs/moq-ffi/src/transport/adapter.rs @@ -17,14 +17,8 @@ impl moq::Error for Error { } } -struct Stats(S); -impl moq::Stats for Stats { - fn bytes_sent(&self) -> Option { - self.0.bytes_sent() - } - fn bytes_received(&self) -> Option { - self.0.bytes_received() - } +struct Metrics(S); +impl moq::Metrics for Metrics { fn bytes_lost(&self) -> Option { self.0.bytes_lost() } @@ -134,8 +128,8 @@ impl + Clone + 'st fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { backend::Session::poll_closed(&mut self.0, cx).map(Error) } - fn stats(&self) -> impl moq::Stats { - Stats(backend::Session::stats(&self.0)) + fn metrics(&self) -> impl moq::Metrics { + Metrics(backend::Session::stats(&self.0)) } } /// An outgoing WebTransport stream adapted to moq-net. @@ -306,13 +300,13 @@ mod tests { #[test] fn forwards_thread_local_streams_and_keeps_error_codes() { - use moq_net::transport::Stats as _; + use moq_net::transport::Metrics as _; let state = Rc::new(Cell::new(0)); let mut session = Session::new(Backend(state.clone())); let mut cx = Context::from_waker(std::task::Waker::noop()); assert_eq!(poll::Session::protocol(&session), Some("moq-lite-06")); assert_eq!( - poll::Session::stats(&session).rtt(), + poll::Session::metrics(&session).rtt(), Some(std::time::Duration::from_millis(7)) ); let Poll::Ready(Ok((mut send, mut recv))) = diff --git a/rs/moq-mux/src/container/test_util.rs b/rs/moq-mux/src/container/test_util.rs index 48f0be9fd64..fd91507ec5d 100644 --- a/rs/moq-mux/src/container/test_util.rs +++ b/rs/moq-mux/src/container/test_util.rs @@ -548,7 +548,7 @@ impl moq_net::transport::poll::Session for Gated { self.inner.poll_closed(cx) } - fn stats(&self) -> impl moq_net::transport::Stats { - self.inner.stats() + fn metrics(&self) -> impl moq_net::transport::Metrics { + self.inner.metrics() } } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index be90008de04..7a4402b25fb 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -628,7 +628,7 @@ mod tests { close_events: kio::Shared>, control_writes: Arc>>, send_rate: Mutex>, - bytes_sent: Mutex>, + wire: crate::transport::Wire, } fn any_close(events: &kio::Ref<'_, Vec<(u32, String)>>) -> Poll<()> { @@ -652,7 +652,7 @@ mod tests { close_events: kio::Shared::default(), control_writes: writes, send_rate: Mutex::new(None), - bytes_sent: Mutex::new(None), + wire: crate::transport::Wire::new(), }; Self { state: Arc::new(state), @@ -676,8 +676,8 @@ mod tests { self.probe.as_ref().expect("built with_probe").target().as_bps() } - fn set_bytes_sent(&self, bytes: Option) { - *self.state.bytes_sent.lock().unwrap() = bytes; + fn set_sent_bytes(&self, bytes: u64) { + self.state.wire.set_sent_bytes(bytes); } fn control_writes(&self) -> Vec { @@ -776,31 +776,29 @@ mod tests { Poll::Ready(FakeError) } - fn stats(&self) -> impl crate::transport::Stats { - FakeStats { + fn metrics(&self) -> impl crate::transport::Metrics { + FakeMetrics { send_rate: *self.state.send_rate.lock().unwrap(), - bytes_sent: *self.state.bytes_sent.lock().unwrap(), } } + fn wire(&self) -> Option { + Some(self.state.wire.clone()) + } + fn probe(&self) -> Option { self.probe.clone() } } - struct FakeStats { + struct FakeMetrics { send_rate: Option, - bytes_sent: Option, } - impl crate::transport::Stats for FakeStats { + impl crate::transport::Metrics for FakeMetrics { fn estimated_send_rate(&self) -> Option { self.send_rate } - - fn bytes_sent(&self) -> Option { - self.bytes_sent - } } #[derive(Clone, Default)] @@ -1193,7 +1191,7 @@ mod tests { async fn stats_capture_the_final_counters() { let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); fake.set_send_rate(None); - fake.set_bytes_sent(Some(0)); + fake.set_sent_bytes(0); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let (session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); @@ -1203,7 +1201,7 @@ mod tests { "no send-rate estimate, so nothing samples on its own" ); - fake.set_bytes_sent(Some(4242)); + fake.set_sent_bytes(4242); session.abort(Error::Cancel); session.closed().await; diff --git a/rs/moq-net/src/coding/decode.rs b/rs/moq-net/src/coding/decode.rs index 2d050ee3b72..d0ee2c14623 100644 --- a/rs/moq-net/src/coding/decode.rs +++ b/rs/moq-net/src/coding/decode.rs @@ -142,7 +142,7 @@ pub struct Decoder<'a> { /// See [`Self::need`]. need: usize, /// The control message read, counted once the read is consumed. - pub(crate) control: Option, + pub(crate) control: Option, /// Leading bytes left out of the `control` count: a type selector the sender writes /// apart from the message, and so never counts. pub(crate) uncounted: usize, diff --git a/rs/moq-net/src/coding/encode.rs b/rs/moq-net/src/coding/encode.rs index b84d437e2ba..bbd308a5731 100644 --- a/rs/moq-net/src/coding/encode.rs +++ b/rs/moq-net/src/coding/encode.rs @@ -59,7 +59,7 @@ pub trait Encode { pub struct Encoder<'a> { buf: &'a mut Vec, form: Form, - pub(crate) control: Option, + pub(crate) control: Option, } impl<'a> Encoder<'a> { diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 77f60c36862..f9a0d976389 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -496,7 +496,7 @@ mod tests { fn control_counts_consumed_messages_once() { use crate::{ lite, - stats::{Bytes, Control, Registry, Tier}, + stats::{Message, Registry, Tier, Totals}, }; let registry = Registry::new(Default::default()); let session = registry.tier(Tier::default()).session(""); @@ -509,7 +509,7 @@ mod tests { let size = bytes.len() as u64; let (head, tail) = bytes.split_at(2); let counted = || registry.snapshot().control(); - let probes = |n: u64| vec![(Tier::default(), Control::Probe, Bytes { tx: 0, rx: size * n })]; + let probes = |n: u64| vec![(Tier::default(), Message::Probe, Totals { tx: 0, rx: size * n })]; let mut reader = queue(&[bytes, head], version).with_stats(&session); let mut cx = Context::from_waker(std::task::Waker::noop()); @@ -542,8 +542,8 @@ mod tests { )); let auth = ( Tier::default(), - Control::Auth, - Bytes { + Message::Auth, + Totals { tx: 0, rx: reply.len() as u64, }, diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 3ef57596fbf..5635e738e46 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -376,7 +376,7 @@ mod tests { impl Encode for Poison { fn encode(&self, w: &mut Encoder<'_>, _: V) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::Probe); + w.control = Some(crate::stats::Message::Probe); w.slice(b"junk"); Err(EncodeError::BoundsExceeded) } @@ -413,7 +413,7 @@ mod tests { fn control_body_counts_successful_encodes_once_before_transport_delivery() { use crate::{ lite, - stats::{Bytes, Control, Registry, Tier}, + stats::{Message, Registry, Tier, Totals}, }; let stats = Registry::new(Default::default()); let session = stats.tier(Tier::new("internal")).session(""); @@ -432,7 +432,7 @@ mod tests { assert!(writer.poll_flush(&mut Context::from_waker(Waker::noop())).is_pending()); assert_eq!( stats.snapshot().control(), - vec![(Tier::new("internal"), Control::Probe, Bytes { tx: size, rx: 0 })] + vec![(Tier::new("internal"), Message::Probe, Totals { tx: size, rx: 0 })] ); writer .buffer(&lite::Track { @@ -446,7 +446,7 @@ mod tests { .snapshot() .control() .into_iter() - .find(|(_, class, _)| *class == Control::TrackInfo) + .find(|(_, class, _)| *class == Message::TrackInfo) .unwrap() .2 .tx diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index fe84ae1fdee..614da81a359 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -1555,8 +1555,8 @@ impl crate::transport::poll::Session for ControlStreamAdapter { self.inner.poll_closed(cx).map(|_| crate::Error::Closed) } - fn stats(&self) -> impl crate::transport::Stats { - self.inner.stats() + fn metrics(&self) -> impl crate::transport::Metrics { + self.inner.metrics() } } @@ -2428,7 +2428,7 @@ mod tests { use crate::{ coding::{Encode, Stream}, lite::test_transport::{Log, ScriptedSession, SinkSend}, - stats::{Bytes, Control as Class, Registry, Tier}, + stats::{Message as Class, Registry, Tier, Totals}, }; for version in [Version::Draft14, Version::Draft15, Version::Draft16] { let registry = Registry::new(Default::default()); @@ -2465,7 +2465,7 @@ mod tests { physical.write_all(&mut raw.clone()).await.unwrap(); assert_eq!( registry.snapshot().control(), - vec![(Tier::default(), Class::Fetch, Bytes { tx: size, rx: 0 })], + vec![(Tier::default(), Class::Fetch, Totals { tx: size, rx: 0 })], "{version:?}" ); assert_eq!(*log.writes.lock().unwrap(), raw); @@ -2479,7 +2479,7 @@ mod tests { virtual_stream.reader.decode::().await.unwrap(); assert_eq!( registry.snapshot().control(), - vec![(Tier::default(), Class::Fetch, Bytes { tx: size, rx: size })], + vec![(Tier::default(), Class::Fetch, Totals { tx: size, rx: size })], "virtual decode counted a physical frame twice: {version:?}" ); adapter.send_max_request_id(RequestId(123)); diff --git a/rs/moq-net/src/ietf/message.rs b/rs/moq-net/src/ietf/message.rs index 0ff229f5ceb..5458b65945f 100644 --- a/rs/moq-net/src/ietf/message.rs +++ b/rs/moq-net/src/ietf/message.rs @@ -2,16 +2,16 @@ use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}; use super::Version; -pub(super) fn control(id: u64, version: Version) -> crate::stats::Control { +pub(super) fn control(id: u64, version: Version) -> crate::stats::Message { use super::*; - use crate::stats::Control; + use crate::stats::Message as Class; // Draft 14 reuses later drafts' NAMESPACE_DONE type for TRACK_STATUS_OK. if version == Version::Draft14 && (id == TrackStatusOk::ID_14 || id == TRACK_STATUS_ERROR_14) { - return Control::TrackInfo; + return Class::TrackInfo; } // Before draft 16, 0x02 updates only a subscription; later it is the shared REQUEST_UPDATE. if matches!(version, Version::Draft14 | Version::Draft15) && id == SubscribeUpdate::ID { - return Control::Subscribe; + return Class::Subscribe; } match id { PublishNamespace::ID @@ -23,19 +23,19 @@ pub(super) fn control(id: u64, version: Version) -> crate::stats::Control { | SubscribeNamespaceOk::ID | SubscribeNamespaceError::ID | UnsubscribeNamespace::ID - | NamespaceDone::ID => Control::Announce, - TrackStatus::ID | SubscribeTracks::ID | PublishSkipped::ID => Control::TrackInfo, + | NamespaceDone::ID => Class::Announce, + TrackStatus::ID | SubscribeTracks::ID | PublishSkipped::ID => Class::TrackInfo, Subscribe::ID | SubscribeOk::ID | Unsubscribe::ID | Publish::ID | PublishOk::ID | PublishError::ID - | PublishDone::ID => Control::Subscribe, - Fetch::ID | FetchOk::ID | FetchError::ID | FetchCancel::ID => Control::Fetch, - auth::Auth::ID | auth::AuthOk::ID | auth::AuthError::ID => Control::Auth, - GoAway::ID => Control::Goaway, - _ => Control::Other, + | PublishDone::ID => Class::Subscribe, + Fetch::ID | FetchOk::ID | FetchError::ID | FetchCancel::ID => Class::Fetch, + auth::Auth::ID | auth::AuthOk::ID | auth::AuthError::ID => Class::Auth, + GoAway::ID => Class::Goaway, + _ => Class::Other, } } @@ -121,25 +121,25 @@ impl Decode for T { #[cfg(test)] mod tests { use super::*; - use crate::stats::Control; + use crate::stats::Message as Class; /// Draft 14's track-status replies reuse type IDs later drafts give other classes. #[test] fn draft14_track_status_ids_are_track_info() { for id in [super::super::TrackStatusOk::ID_14, super::super::TRACK_STATUS_ERROR_14] { - assert_eq!(control(id, Version::Draft14), Control::TrackInfo); + assert_eq!(control(id, Version::Draft14), Class::TrackInfo); } assert_eq!( control(super::super::NamespaceDone::ID, Version::Draft16), - Control::Announce + Class::Announce ); assert_eq!( control(super::super::SubscribeUpdate::ID, Version::Draft15), - Control::Subscribe + Class::Subscribe ); // Shared replies and updates identify no request class. for id in [super::super::RequestOk::ID, super::super::SubscribeUpdate::ID] { - assert_eq!(control(id, Version::Draft16), Control::Other); + assert_eq!(control(id, Version::Draft16), Class::Other); } } } diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index ae9ee8a0be1..55c94108511 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -592,7 +592,7 @@ mod tests { #[moq_clock::test] async fn request_stream_messages_count_without_their_type() { use crate::lite::test_transport::ScriptedSession; - use crate::stats::{Control, Registry, Tier}; + use crate::stats::{Message, Registry, Tier}; let registry = Registry::new(Default::default()); let stats = registry.tier(Tier::default()).session(""); let version = Version::Draft22; @@ -610,8 +610,8 @@ mod tests { let rows = registry.snapshot().control(); rows.into_iter().find(|row| row.1 == class).map(|row| row.2.rx) }; - assert_eq!(rx(Control::Goaway), Some(4)); - assert_eq!(rx(Control::Other), Some(2)); + assert_eq!(rx(Message::Goaway), Some(4)); + assert_eq!(rx(Message::Other), Some(2)); } #[moq_clock::test] diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index e1610b64efc..73b87bb28c8 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1478,7 +1478,7 @@ async fn read_goaways( let msg: ietf::GoAway = body.message(version)?; reader .stats() - .control(crate::stats::Control::Goaway, body.0.len() + 2, true); + .control(crate::stats::Message::Goaway, body.0.len() + 2, true); tracing::info!(message = ?msg, "received GOAWAY"); // Draft-18 names the first of our own Request IDs it did not process: even for a diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index 834ed8423ac..2257b139852 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -244,7 +244,7 @@ impl Decode for Cost { impl Encode for AnnounceBroadcast<'_> { fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::Announce); + w.control = Some(crate::stats::Message::Announce); if version.has_announce_id() { // Lite06+: outer type discriminator, then a size-prefixed body (like the // subscribe stream). The body varies by type. @@ -333,7 +333,7 @@ impl Encode for AnnounceBroadcast<'_> { impl Decode for AnnounceBroadcast<'_> { fn decode(buf: &mut Decoder<'_>, version: Version) -> Result { - buf.control = Some(crate::stats::Control::Announce); + buf.control = Some(crate::stats::Message::Announce); if version.has_announce_id() { // Lite06+: outer type, then a size-prefixed body decoded within its bounds. let typ = buf.varint()?; @@ -457,7 +457,7 @@ pub struct AnnounceRequest<'a> { } impl Message for AnnounceRequest<'_> { - const CONTROL: Option = Some(crate::stats::Control::Announce); + const CONTROL: Option = Some(crate::stats::Message::Announce); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { let prefix = Path::decode(r, version)?; let exclude_hop = match version.has_exclude_hop() { @@ -502,7 +502,7 @@ enum AnnounceStatus { impl Decode for AnnounceStatus { fn decode(r: &mut Decoder<'_>, _: Version) -> Result { - r.control = Some(crate::stats::Control::Announce); + r.control = Some(crate::stats::Message::Announce); let status = r.u8()?; status.try_into().map_err(|_| DecodeError::InvalidValue) } @@ -510,7 +510,7 @@ impl Decode for AnnounceStatus { impl Encode for AnnounceStatus { fn encode(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::Announce); + w.control = Some(crate::stats::Message::Announce); w.u8(*self as u8); Ok(()) } @@ -559,7 +559,7 @@ impl AnnounceInit<'_> { } impl Message for AnnounceInit<'_> { - const CONTROL: Option = Some(crate::stats::Control::Announce); + const CONTROL: Option = Some(crate::stats::Message::Announce); // Published lite01/02 carry the whole initial set in one message. const MAX_SIZE: usize = 64 * 1024 * 1024; @@ -598,7 +598,7 @@ pub struct AnnounceOk { } impl Message for AnnounceOk { - const CONTROL: Option = Some(crate::stats::Control::Announce); + const CONTROL: Option = Some(crate::stats::Message::Announce); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_announce_ok() { return Err(DecodeError::Version); diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs index c9e0ee7efca..0b042b79224 100644 --- a/rs/moq-net/src/lite/auth.rs +++ b/rs/moq-net/src/lite/auth.rs @@ -17,7 +17,7 @@ pub struct Auth { } impl Message for Auth { - const CONTROL: Option = Some(crate::stats::Control::Auth); + const CONTROL: Option = Some(crate::stats::Message::Auth); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_auth() { return Err(DecodeError::Version); @@ -50,7 +50,7 @@ pub struct AuthOk { const MAX_EXPIRES_MS: u64 = (1 << 53) - 1; impl Message for AuthOk { - const CONTROL: Option = Some(crate::stats::Control::Auth); + const CONTROL: Option = Some(crate::stats::Message::Auth); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_auth() { return Err(DecodeError::Version); @@ -99,7 +99,7 @@ pub struct AuthError { const MAX_REASON: usize = 8192; impl Message for AuthError { - const CONTROL: Option = Some(crate::stats::Control::Auth); + const CONTROL: Option = Some(crate::stats::Message::Auth); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_auth() { return Err(DecodeError::Version); diff --git a/rs/moq-net/src/lite/fetch.rs b/rs/moq-net/src/lite/fetch.rs index a793517dd8d..4d63673dfbc 100644 --- a/rs/moq-net/src/lite/fetch.rs +++ b/rs/moq-net/src/lite/fetch.rs @@ -29,7 +29,7 @@ pub struct Fetch<'a> { } impl Message for Fetch<'_> { - const CONTROL: Option = Some(crate::stats::Control::Fetch); + const CONTROL: Option = Some(crate::stats::Message::Fetch); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { diff --git a/rs/moq-net/src/lite/goaway.rs b/rs/moq-net/src/lite/goaway.rs index 2f93e59842e..27d89a86c9d 100644 --- a/rs/moq-net/src/lite/goaway.rs +++ b/rs/moq-net/src/lite/goaway.rs @@ -13,7 +13,7 @@ pub struct Goaway<'a> { } impl Message for Goaway<'_> { - const CONTROL: Option = Some(crate::stats::Control::Goaway); + const CONTROL: Option = Some(crate::stats::Message::Goaway); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 | Version::Lite03 => { diff --git a/rs/moq-net/src/lite/ietf.rs b/rs/moq-net/src/lite/ietf.rs index fae719f149a..6745a124a3c 100644 --- a/rs/moq-net/src/lite/ietf.rs +++ b/rs/moq-net/src/lite/ietf.rs @@ -317,7 +317,7 @@ impl Records { pub struct TrackProperties(pub Option>); impl Message for TrackProperties { - const CONTROL: Option = Some(crate::stats::Control::TrackInfo); + const CONTROL: Option = Some(crate::stats::Message::TrackInfo); fn encode_msg(&self, w: &mut Encoder<'_>, _: Version) -> Result<(), EncodeError> { // A track published over moq-lite is a lone 0. let Some(properties) = &self.0 else { diff --git a/rs/moq-net/src/lite/info.rs b/rs/moq-net/src/lite/info.rs index 687bd675829..d64eb8e9a13 100644 --- a/rs/moq-net/src/lite/info.rs +++ b/rs/moq-net/src/lite/info.rs @@ -8,7 +8,7 @@ pub struct SessionInfo { } impl Message for SessionInfo { - const CONTROL: Option = Some(crate::stats::Control::Session); + const CONTROL: Option = Some(crate::stats::Message::Session); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => {} diff --git a/rs/moq-net/src/lite/message.rs b/rs/moq-net/src/lite/message.rs index 085e1057170..89b53b5a423 100644 --- a/rs/moq-net/src/lite/message.rs +++ b/rs/moq-net/src/lite/message.rs @@ -24,7 +24,7 @@ pub(super) fn decode_size(r: &mut Decoder<'_>, max: usize) -> Result = None; + const CONTROL: Option = None; /// The largest body this receiver accepts for this message. const MAX_SIZE: usize = MAX_MESSAGE_SIZE; diff --git a/rs/moq-net/src/lite/probe.rs b/rs/moq-net/src/lite/probe.rs index 041bd77d188..2ab94c6ec7f 100644 --- a/rs/moq-net/src/lite/probe.rs +++ b/rs/moq-net/src/lite/probe.rs @@ -13,7 +13,7 @@ pub struct Probe { } impl Message for Probe { - const CONTROL: Option = Some(crate::stats::Control::Probe); + const CONTROL: Option = Some(crate::stats::Message::Probe); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { diff --git a/rs/moq-net/src/lite/setup.rs b/rs/moq-net/src/lite/setup.rs index 558be898275..0b7cb2c7e87 100644 --- a/rs/moq-net/src/lite/setup.rs +++ b/rs/moq-net/src/lite/setup.rs @@ -60,10 +60,10 @@ impl ProbeLevel { /// both qualify: their RTT comes from the handshake, which has already happened. #[cfg(feature = "async")] pub fn detect(session: &crate::driver::Transport) -> Self { - use crate::transport::Stats as _; + use crate::transport::Metrics as _; use crate::transport::poll::Session as _; - let stats = session.stats(); - match stats.estimated_send_rate().is_some() || stats.rtt().is_some() { + let metrics = session.metrics(); + match metrics.estimated_send_rate().is_some() || metrics.rtt().is_some() { true => Self::Report, false => Self::None, } @@ -197,7 +197,7 @@ pub struct Setup { } impl Message for Setup { - const CONTROL: Option = Some(crate::stats::Control::Session); + const CONTROL: Option = Some(crate::stats::Message::Session); const MAX_SIZE: usize = crate::setup::MAX_SETUP_SIZE; fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { @@ -251,7 +251,7 @@ pub(super) struct ReceivedSetup { } impl Message for ReceivedSetup { - const CONTROL: Option = Some(crate::stats::Control::Session); + const CONTROL: Option = Some(crate::stats::Message::Session); const MAX_SIZE: usize = Setup::MAX_SIZE; fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index c8b3aca1982..5f528bd2215 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -53,7 +53,7 @@ impl Version { } impl Message for Subscribe<'_> { - const CONTROL: Option = Some(crate::stats::Control::Subscribe); + const CONTROL: Option = Some(crate::stats::Message::Subscribe); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { let id = r.varint()?; let broadcast = Path::decode(r, version)?; @@ -269,7 +269,7 @@ pub struct SubscribeOk { } impl Message for SubscribeOk { - const CONTROL: Option = Some(crate::stats::Control::Subscribe); + const CONTROL: Option = Some(crate::stats::Message::Subscribe); fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { match version { Version::Lite01 => { @@ -339,7 +339,7 @@ pub struct SubscribeStart { } impl Message for SubscribeStart { - const CONTROL: Option = Some(crate::stats::Control::Subscribe); + const CONTROL: Option = Some(crate::stats::Message::Subscribe); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); @@ -384,7 +384,7 @@ pub struct SubscribeEnd { } impl Message for SubscribeEnd { - const CONTROL: Option = Some(crate::stats::Control::Subscribe); + const CONTROL: Option = Some(crate::stats::Message::Subscribe); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); @@ -422,7 +422,7 @@ pub struct SubscribeUpdate { } impl Message for SubscribeUpdate { - const CONTROL: Option = Some(crate::stats::Control::Subscribe); + const CONTROL: Option = Some(crate::stats::Message::Subscribe); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { @@ -539,7 +539,7 @@ impl SubscribeDrop { } impl Message for SubscribeDrop { - const CONTROL: Option = Some(crate::stats::Control::Subscribe); + const CONTROL: Option = Some(crate::stats::Message::Subscribe); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 | Version::Lite02 => { diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 5614b0897a7..073b218c1e7 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -575,7 +575,7 @@ impl poll::Session for DeadStreamSession { self.log.poll_closed(self.park.hold(cx)) } - fn stats(&self) -> impl crate::transport::Stats { + fn metrics(&self) -> impl crate::transport::Metrics { SinkStats::default() } } @@ -832,7 +832,7 @@ impl poll::Session for SinkSession { self.log.poll_closed(self.park.hold(cx)) } - fn stats(&self) -> impl crate::transport::Stats { + fn metrics(&self) -> impl crate::transport::Metrics { *self.stats.lock().unwrap() } @@ -863,7 +863,7 @@ impl SinkStats { } } -impl crate::transport::Stats for SinkStats { +impl crate::transport::Metrics for SinkStats { fn estimated_send_rate(&self) -> Option { self.estimated_send_rate } @@ -1223,7 +1223,7 @@ impl poll::Session for ScriptedSession { self.log.poll_closed(self.park.hold(cx)) } - fn stats(&self) -> impl crate::transport::Stats { + fn metrics(&self) -> impl crate::transport::Metrics { self.stats } } diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs index 0143105cdf5..372324f4f1f 100644 --- a/rs/moq-net/src/lite/track.rs +++ b/rs/moq-net/src/lite/track.rs @@ -24,7 +24,7 @@ pub struct Track<'a> { } impl Message for Track<'_> { - const CONTROL: Option = Some(crate::stats::Control::TrackInfo); + const CONTROL: Option = Some(crate::stats::Message::TrackInfo); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); @@ -134,7 +134,7 @@ impl Framing { } impl Message for TrackInfo { - const CONTROL: Option = Some(crate::stats::Control::TrackInfo); + const CONTROL: Option = Some(crate::stats::Message::TrackInfo); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_track_stream() { return Err(DecodeError::Version); diff --git a/rs/moq-net/src/lite/tracks.rs b/rs/moq-net/src/lite/tracks.rs index 4287476e211..5b7124386df 100644 --- a/rs/moq-net/src/lite/tracks.rs +++ b/rs/moq-net/src/lite/tracks.rs @@ -24,7 +24,7 @@ pub struct Tracks<'a> { } impl Message for Tracks<'_> { - const CONTROL: Option = Some(crate::stats::Control::TrackInfo); + const CONTROL: Option = Some(crate::stats::Message::TrackInfo); fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { if !version.has_tracks() { return Err(DecodeError::Version); @@ -61,7 +61,7 @@ pub enum TracksEvent<'a> { impl Encode for TracksEvent<'_> { fn encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::TrackInfo); + w.control = Some(crate::stats::Message::TrackInfo); if !version.has_tracks() { return Err(EncodeError::Version); } @@ -90,7 +90,7 @@ impl Encode for TracksEvent<'_> { impl Decode for TracksEvent<'_> { fn decode(r: &mut Decoder<'_>, version: Version) -> Result { - r.control = Some(crate::stats::Control::TrackInfo); + r.control = Some(crate::stats::Message::TrackInfo); if !version.has_tracks() { return Err(DecodeError::Version); } diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 458c8eaf516..4198452097a 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -582,7 +582,7 @@ impl PausedIetfModern { version, peer_setup, } = self; - server.stats.control(stats::Control::Session, peer_setup.size, true); + server.stats.control(stats::Message::Session, peer_setup.size, true); let (publish, subscribe) = server.stat_tagged_origins(); let peer_implementation = setup::PeerImplementation::known(peer_setup.implementation); @@ -666,7 +666,7 @@ impl PausedLegacy { client_setup_size, } = self; let mut stream = stream.with_stats(&server.stats); - server.stats.control(stats::Control::Session, client_setup_size, true); + server.stats.control(stats::Message::Session, client_setup_size, true); let (publish, subscribe) = server.stat_tagged_origins(); // Encode parameters using the version-appropriate type. @@ -941,7 +941,7 @@ impl Handshake { version, client_setup, } => { - server.stats.control(stats::Control::Session, client_setup.size, true); + server.stats.control(stats::Message::Session, client_setup.size, true); server.start_lite(runtime, session, version, Some(client_setup), peer_hop, auth) } PausedHandshake::IetfModern(paused) => paused.ok(server, runtime, peer_hop, auth), @@ -1194,9 +1194,6 @@ mod tests { fn poll_closed(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll { std::task::Poll::Pending } - fn stats(&self) -> impl crate::transport::Stats { - crate::transport::StatsUnavailable - } } #[derive(Clone, Default)] diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 4a0bafec220..2fa491f94c4 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, task::Poll, time::Duration}; use crate::transport::poll::Session as _; -use crate::transport::{Error as _, Stats as _}; +use crate::transport::{Error as _, Metrics as _}; use crate::{Error, SessionError, Version, auth, bandwidth, coding::StreamCodes, goaway}; @@ -808,19 +808,21 @@ impl Supervisor { } } -/// A [`Stats`] snapshot of the transport's counters. +/// A [`Stats`] snapshot of the transport's metrics and wire counters. fn snapshot(session: &S) -> Stats { - let stats = session.stats(); + // Metrics first: a transport that polls its byte counts refreshes its wire while reading them. + let metrics = session.metrics(); + let wire = session.wire(); Stats { - rtt: stats.rtt(), - estimated_send_rate: stats.estimated_send_rate().map(bandwidth::Rate::from_bps), - bytes_sent: stats.bytes_sent(), - bytes_received: stats.bytes_received(), - bytes_lost: stats.bytes_lost(), - packets_sent: stats.packets_sent(), - packets_received: stats.packets_received(), - packets_lost: stats.packets_lost(), - streams_evicted: stats.streams_evicted(), + rtt: metrics.rtt(), + estimated_send_rate: metrics.estimated_send_rate().map(bandwidth::Rate::from_bps), + bytes_sent: wire.as_ref().map(crate::transport::Wire::sent_bytes), + bytes_received: wire.as_ref().map(crate::transport::Wire::received_bytes), + bytes_lost: metrics.bytes_lost(), + packets_sent: metrics.packets_sent(), + packets_received: metrics.packets_received(), + packets_lost: metrics.packets_lost(), + streams_evicted: metrics.streams_evicted(), } } diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index c6358a4ff66..9230ad2ae74 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -139,7 +139,7 @@ impl Setup { impl Encode for Setup { fn encode(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::Session); + w.control = Some(crate::stats::Message::Session); Self::check_version(v); w.varint(SETUP_V17)?; let prefix = w.prefix_u16(); @@ -150,7 +150,7 @@ impl Encode for Setup { impl Decode for Setup { fn decode(r: &mut Decoder<'_>, v: Version) -> Result { - r.control = Some(crate::stats::Control::Session); + r.control = Some(crate::stats::Message::Session); Self::check_version(v); let kind = r.varint()?; if kind != SETUP_V17 { @@ -216,7 +216,7 @@ impl Client { impl Decode for Client { /// Decode a client setup message (draft-14 through draft-16 only). fn decode(r: &mut Decoder<'_>, v: Version) -> Result { - r.control = Some(crate::stats::Control::Session); + r.control = Some(crate::stats::Message::Session); let kind = r.u8()?; if kind != CLIENT_SETUP { return Err(DecodeError::InvalidValue); @@ -245,7 +245,7 @@ impl Decode for Client { impl Encode for Client { /// Encode a client setup message (draft-14 through draft-16 only). fn encode(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::Session); + w.control = Some(crate::stats::Message::Session); w.u8(CLIENT_SETUP); let prefix = prefix_body(w, v)?; self.encode_inner(w, v)?; @@ -314,7 +314,7 @@ impl Server { impl Encode for Server { /// Encode a server setup message (draft-14 through draft-16 only). fn encode(&self, w: &mut Encoder<'_>, v: Version) -> Result<(), EncodeError> { - w.control = Some(crate::stats::Control::Session); + w.control = Some(crate::stats::Message::Session); w.u8(SERVER_SETUP); let prefix = prefix_body(w, v)?; self.encode_inner(w, v)?; @@ -325,7 +325,7 @@ impl Encode for Server { impl Decode for Server { /// Decode a server setup message (draft-14 through draft-16 only). fn decode(r: &mut Decoder<'_>, v: Version) -> Result { - r.control = Some(crate::stats::Control::Session); + r.control = Some(crate::stats::Message::Session); let kind = r.u8()?; if kind != SERVER_SETUP { return Err(DecodeError::InvalidValue); diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index d6c783670e8..3814cf72542 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -307,7 +307,7 @@ struct SessionCounters { bytes: Mutex, wire_tx: AtomicU64, wire_rx: AtomicU64, - control: [[AtomicU64; 2]; Control::ALL.len()], + control: [[AtomicU64; 2]; Message::ALL.len()], } impl SessionCounters { @@ -346,7 +346,7 @@ impl SessionCounters { (presence, empty) } - fn control(&self, totals: &mut [[u64; 2]; Control::ALL.len()]) { + fn control(&self, totals: &mut [[u64; 2]; Message::ALL.len()]) { for (total, counters) in totals.iter_mut().zip(&self.control) { for (total, counter) in total.iter_mut().zip(counters) { *total += counter.load(Ordering::Relaxed); @@ -904,10 +904,10 @@ impl Role { } } -/// Cumulative bytes sent and received, returned per [`Control`] class by [`Snapshot::control`]. +/// Cumulative bytes sent and received, returned per [`Message`] class by [`Snapshot::control`]. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub struct Bytes { +pub struct Totals { /// Bytes successfully encoded for sending, counted even if the stream later fails. pub tx: u64, /// Bytes of complete messages decoded and consumed. @@ -921,7 +921,7 @@ pub struct Bytes { /// framing decodes, before request validation. Shared reply IDs use [`Self::Other`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] -pub enum Control { +pub enum Message { /// Session setup and information. Session, /// Namespace advertisements and listings. @@ -942,7 +942,7 @@ pub enum Control { Other, } -impl Control { +impl Message { const ALL: [Self; 9] = [ Self::Session, Self::Announce, @@ -989,20 +989,20 @@ pub struct Snapshot { traffic: HashMap, /// Session presence per tier; read via [`Self::sessions`]. sessions: HashMap, - control: HashMap, + control: HashMap, } impl Snapshot { /// The `(tier, class, totals)` control-message rows with any traffic, sorted by tier and class. /// Includes prefixes inside complete codec values, but excludes separately coded type selectors, /// media payload, and transport framing, acknowledgements, and retransmissions. - pub fn control(&self) -> Vec<(Tier, Control, Bytes)> { + pub fn control(&self) -> Vec<(Tier, Message, Totals)> { let mut rows = Vec::new(); for (tier, totals) in &self.control { - for class in Control::ALL { + for class in Message::ALL { let [tx, rx] = totals[class as usize]; if tx != 0 || rx != 0 { - rows.push((tier.clone(), class, Bytes { tx, rx })); + rows.push((tier.clone(), class, Totals { tx, rx })); } } } @@ -1667,7 +1667,7 @@ struct Viewer { impl Session { #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn control(&self, class: Control, bytes: usize, received: bool) { + pub(crate) fn control(&self, class: Message, bytes: usize, received: bool) { let Some(inner) = &self.inner else { return }; let current = inner.current.lock().unwrap_or_else(PoisonError::into_inner); if let Some(counters) = ¤t.presence { @@ -2608,12 +2608,12 @@ mod tests { let first = Tier::new("internal"); let second = Tier::new("edge"); let context = registry.tier(first.clone()).session(""); - context.control(Control::Announce, 17, false); + context.control(Message::Announce, 17, false); context.retag(second.clone(), ""); - context.control(Control::TrackInfo, 23, true); + context.control(Message::TrackInfo, 23, true); let expected = vec![ - (second, Control::TrackInfo, Bytes { tx: 0, rx: 23 }), - (first, Control::Announce, Bytes { tx: 17, rx: 0 }), + (second, Message::TrackInfo, Totals { tx: 0, rx: 23 }), + (first, Message::Announce, Totals { tx: 17, rx: 0 }), ]; assert_eq!(registry.snapshot().control(), expected); drop(context); diff --git a/rs/moq-net/src/transport.rs b/rs/moq-net/src/transport.rs index 80b0d4d8da5..00e6652f54d 100644 --- a/rs/moq-net/src/transport.rs +++ b/rs/moq-net/src/transport.rs @@ -121,21 +121,11 @@ impl Probe { } } -/// Connection-level statistics. +/// Connection-level metrics a transport reports, beyond the byte counts its [`Wire`] holds. /// /// Methods return `Option`: `None` means the implementation doesn't track /// this metric, while `Some(0)` means actually zero. -pub trait Stats { - /// Total bytes sent over the connection, including retransmissions and overhead. - fn bytes_sent(&self) -> Option { - None - } - - /// Total bytes received over the connection, including duplicate and overhead. - fn bytes_received(&self) -> Option { - None - } - +pub trait Metrics { /// Total bytes lost (detected via retransmission or acknowledgement). fn bytes_lost(&self) -> Option { None @@ -172,9 +162,9 @@ pub trait Stats { } } -/// Default stats implementation that returns `None` for all metrics. -pub struct StatsUnavailable; -impl Stats for StatsUnavailable {} +/// The metrics of a transport that tracks none. +struct Untracked; +impl Metrics for Untracked {} /// The smallest connection receive window a transport that leases may offer. /// @@ -232,7 +222,7 @@ pub mod poll { use std::collections::VecDeque; - use super::{Error, MaybeSend, MaybeSync, Stats}; + use super::{Error, MaybeSend, MaybeSync, Metrics, Untracked}; use bytes::{Buf, Bytes, BytesMut}; /// A cloneable transport session with independent progress on each handle. @@ -345,11 +335,10 @@ pub mod poll { /// Poll until the connection is closed by either side. fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll; - /// Return connection-level statistics. - /// - /// Return [`super::StatsUnavailable`] if the transport does not track them. Required - /// rather than defaulted for the same reason as [`protocol`](Self::protocol). - fn stats(&self) -> impl Stats; + /// Return connection-level metrics; by default, none are tracked. + fn metrics(&self) -> impl Metrics { + Untracked + } /// Return the wire counters this transport updates, or `None` when it tracks none. fn wire(&self) -> Option { diff --git a/rs/moq-net/src/transport/boxed.rs b/rs/moq-net/src/transport/boxed.rs index ee4111bc8e5..9e50a4714aa 100644 --- a/rs/moq-net/src/transport/boxed.rs +++ b/rs/moq-net/src/transport/boxed.rs @@ -122,11 +122,9 @@ impl RecvStream { /// A boxed [`poll::Lease`], borrowed from a [`RecvStream`]. pub(crate) struct Lease<'a>(Box); -/// The transport's connection stats, read once. +/// The transport's connection metrics, read once. #[derive(Clone, Copy, Debug, Default)] -pub(crate) struct Stats { - bytes_sent: Option, - bytes_received: Option, +pub(crate) struct Metrics { bytes_lost: Option, packets_sent: Option, packets_received: Option, @@ -136,29 +134,21 @@ pub(crate) struct Stats { streams_evicted: Option, } -impl Stats { - fn read(stats: impl super::Stats) -> Self { +impl Metrics { + fn read(metrics: impl super::Metrics) -> Self { Self { - bytes_sent: stats.bytes_sent(), - bytes_received: stats.bytes_received(), - bytes_lost: stats.bytes_lost(), - packets_sent: stats.packets_sent(), - packets_received: stats.packets_received(), - packets_lost: stats.packets_lost(), - rtt: stats.rtt(), - estimated_send_rate: stats.estimated_send_rate(), - streams_evicted: stats.streams_evicted(), + bytes_lost: metrics.bytes_lost(), + packets_sent: metrics.packets_sent(), + packets_received: metrics.packets_received(), + packets_lost: metrics.packets_lost(), + rtt: metrics.rtt(), + estimated_send_rate: metrics.estimated_send_rate(), + streams_evicted: metrics.streams_evicted(), } } } -impl super::Stats for Stats { - fn bytes_sent(&self) -> Option { - self.bytes_sent - } - fn bytes_received(&self) -> Option { - self.bytes_received - } +impl super::Metrics for Metrics { fn bytes_lost(&self) -> Option { self.bytes_lost } @@ -209,7 +199,7 @@ trait ErasedSession: MaybeSend + MaybeSync { fn binding(&self) -> Binding; fn close(&mut self, code: u32, reason: &str); fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll; - fn stats(&self) -> Stats; + fn metrics(&self) -> Metrics; fn wire(&self) -> Option; fn probe(&self) -> Option; } @@ -336,8 +326,8 @@ impl ErasedSession for S { poll::Session::poll_closed(self, cx).map(Error::new) } - fn stats(&self) -> Stats { - Stats::read(poll::Session::stats(self)) + fn metrics(&self) -> Metrics { + Metrics::read(poll::Session::metrics(self)) } fn wire(&self) -> Option { @@ -503,8 +493,8 @@ impl poll::Session for Session { self.inner.poll_closed(cx) } - fn stats(&self) -> impl super::Stats { - self.inner.stats() + fn metrics(&self) -> impl super::Metrics { + self.inner.metrics() } fn wire(&self) -> Option { diff --git a/rs/moq-net/src/transport/memory.rs b/rs/moq-net/src/transport/memory.rs index 43196c9ac32..a8e9b291c5b 100644 --- a/rs/moq-net/src/transport/memory.rs +++ b/rs/moq-net/src/transport/memory.rs @@ -31,7 +31,7 @@ use std::{ use bytes::{Buf, Bytes}; -use super::{StatsUnavailable, poll}; +use super::poll; /// The largest datagram either side sends: QUIC's minimum MTU, so a payload that fits /// here fits any QUIC path. @@ -428,10 +428,6 @@ impl poll::Session for Session { fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { poll_close(&self.side.conn.close, self.closed.hold(cx)) } - - fn stats(&self) -> impl super::Stats { - StatsUnavailable - } } /// One direction of a stream: the messages its writer queued for its reader. diff --git a/rs/moq-net/tests/integration/control_stats.rs b/rs/moq-net/tests/integration/control_stats.rs index 554859399b6..eac3fdc4ebc 100644 --- a/rs/moq-net/tests/integration/control_stats.rs +++ b/rs/moq-net/tests/integration/control_stats.rs @@ -8,7 +8,7 @@ use crate::support::{ }; use moq_net::{ Timescale, Timestamp, origin, - stats::{Control, Registry, Tier}, + stats::{Message, Registry, Tier}, }; fn origin() -> origin::Producer { @@ -57,7 +57,7 @@ async fn control_classes_cross_lite_and_ietf_sessions_without_counting_payload() let mut group = sub.recv_group().await.unwrap().unwrap(); assert_eq!(group.read_frame().await.unwrap().unwrap().payload.len(), 64 * 1024); let rows = registry.snapshot().control(); - for class in [Control::Announce, Control::Subscribe] { + for class in [Message::Announce, Message::Subscribe] { assert!( rows.iter() .any(|(row_tier, row_class, bytes)| *row_tier == tier && *row_class == class && bytes.tx > 0), @@ -103,7 +103,7 @@ async fn setup_counts_under_the_admitted_stats() { assert!( rows.iter() .any(|(row_tier, class, bytes)| *row_tier == tier - && *class == Control::Session + && *class == Message::Session && bytes.tx > 0 && bytes.rx > 0), "{version}: {rows:?}" ); diff --git a/rs/moq-net/tests/integration/support/mock.rs b/rs/moq-net/tests/integration/support/mock.rs index 4212435fae9..2e89dd5a9ff 100644 --- a/rs/moq-net/tests/integration/support/mock.rs +++ b/rs/moq-net/tests/integration/support/mock.rs @@ -447,7 +447,7 @@ struct Side { bidi_types: StreamTypes, /// Stream resets sent by this side. resets: Resets, - /// What [`poll::Session::stats`] reports. + /// What [`poll::Session::metrics`] reports. stats: Mutex, /// How many times the stats were read. stats_reads: AtomicUsize, @@ -466,7 +466,7 @@ pub struct MockStats { pub rtt: Option, } -impl moq_net::transport::Stats for MockStats { +impl moq_net::transport::Metrics for MockStats { fn estimated_send_rate(&self) -> Option { self.estimated_send_rate } @@ -639,7 +639,7 @@ impl poll::Session for MockSession { self.inner.poll_closed(cx) } - fn stats(&self) -> impl moq_net::transport::Stats { + fn metrics(&self) -> impl moq_net::transport::Metrics { assert!( !self.side.panic_on_stats.load(Ordering::Relaxed), "injected transport panic" diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index c88929b32c1..11e519b0c38 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -530,12 +530,12 @@ fn render_metrics( ( "moq_relay_control_tx_bytes_total", "Control-message bytes encoded for sending, including codec prefixes; excludes transport overhead.", - (|b: &moq_net::stats::Bytes| b.tx) as fn(&moq_net::stats::Bytes) -> u64, + (|b: &moq_net::stats::Totals| b.tx) as fn(&moq_net::stats::Totals) -> u64, ), ( "moq_relay_control_rx_bytes_total", "Control-message bytes decoded, including codec prefixes; excludes transport overhead.", - (|b: &moq_net::stats::Bytes| b.rx) as fn(&moq_net::stats::Bytes) -> u64, + (|b: &moq_net::stats::Totals| b.rx) as fn(&moq_net::stats::Totals) -> u64, ), ] { let _ = writeln!(out, "# HELP {name} {help}"); diff --git a/rs/moq-relay/tests/integration/wire_stats.rs b/rs/moq-relay/tests/integration/wire_stats.rs index b29e9984e42..d0fad8a7251 100644 --- a/rs/moq-relay/tests/integration/wire_stats.rs +++ b/rs/moq-relay/tests/integration/wire_stats.rs @@ -68,7 +68,7 @@ async fn wire_sources(io_uring: bool) { && row.wire_tx > 0 && row.wire_rx > 0 && registry.snapshot().control().iter().any(|(_, class, bytes)| { - *class == moq_net::stats::Control::Announce && bytes.tx > 0 && bytes.rx > 0 + *class == moq_net::stats::Message::Announce && bytes.tx > 0 && bytes.rx > 0 }) { break row; } diff --git a/rs/moq-tokio/src/transport.rs b/rs/moq-tokio/src/transport.rs index 38d432457b0..c9e55b36469 100644 --- a/rs/moq-tokio/src/transport.rs +++ b/rs/moq-tokio/src/transport.rs @@ -117,6 +117,8 @@ pub struct Session { request_bi: OpSlot>, recv_datagram: OpSlot>, closed: OpSlot, + // Wire counters for a backend that has none and reports bytes only in its stats. + polled_wire: moq_net::transport::Wire, } /// The stream pair an async-interface accept or open resolves to. @@ -166,6 +168,7 @@ impl Session { request_bi: OpSlot::default(), recv_datagram: OpSlot::default(), closed: OpSlot::default(), + polled_wire: moq_net::transport::Wire::new(), } } } @@ -174,7 +177,10 @@ impl Session { // owns its own progress under the poll contract. impl Clone for Session { fn clone(&self) -> Self { - Self::new(self.session.clone(), self.binding) + Self { + polled_wire: self.polled_wire.clone(), + ..Self::new(self.session.clone(), self.binding) + } } } diff --git a/rs/moq-tokio/src/transport/owned.rs b/rs/moq-tokio/src/transport/owned.rs index d3501ec0f41..3488d2027c6 100644 --- a/rs/moq-tokio/src/transport/owned.rs +++ b/rs/moq-tokio/src/transport/owned.rs @@ -20,17 +20,11 @@ impl moq::Error for Error { } } -struct Stats { +struct Metrics { inner: S, streams_evicted: Option, } -impl moq::Stats for Stats { - fn bytes_sent(&self) -> Option { - self.inner.bytes_sent() - } - fn bytes_received(&self) -> Option { - self.inner.bytes_received() - } +impl moq::Metrics for Metrics { fn bytes_lost(&self) -> Option { self.inner.bytes_lost() } @@ -139,14 +133,24 @@ impl> poll::Session fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { backend::Session::poll_closed(self, cx).map(Error) } - fn stats(&self) -> impl moq::Stats { - Stats { - inner: backend::Session::stats(self), + fn metrics(&self) -> impl moq::Metrics { + let inner = backend::Session::stats(self); + // Reading the metrics refreshes the polled wire, which moq-net reads right after. + if super::Grouped::wire(&self.session).is_none() { + if let Some(sent) = wt::Stats::bytes_sent(&inner) { + self.polled_wire.set_sent_bytes(sent); + } + if let Some(received) = wt::Stats::bytes_received(&inner) { + self.polled_wire.set_received_bytes(received); + } + } + Metrics { + inner, streams_evicted: super::Grouped::streams_evicted(&self.session), } } fn wire(&self) -> Option { - super::Grouped::wire(&self.session) + super::Grouped::wire(&self.session).or_else(|| Some(self.polled_wire.clone())) } fn probe(&self) -> Option { super::Grouped::probe(&self.session) diff --git a/rs/moq-tokio/tests/integration/receive_credit.rs b/rs/moq-tokio/tests/integration/receive_credit.rs index 22a413e7fbd..23f8a5f5e2a 100644 --- a/rs/moq-tokio/tests/integration/receive_credit.rs +++ b/rs/moq-tokio/tests/integration/receive_credit.rs @@ -287,7 +287,7 @@ async fn the_less_urgent_partial_frame_gives_way(version: &str, profile: Option< moq_net::transport::Binding::InBand, ); assert_eq!( - moq_net::transport::Stats::streams_evicted(&moq_net::transport::poll::Session::stats(&session)), + moq_net::transport::Metrics::streams_evicted(&moq_net::transport::poll::Session::metrics(&session)), Some(1), "moq-net sees the eviction count, {version}" ); diff --git a/rs/moq-uring/src/transport/adapter.rs b/rs/moq-uring/src/transport/adapter.rs index 44c4c6b0fe8..fe05a2e6fe8 100644 --- a/rs/moq-uring/src/transport/adapter.rs +++ b/rs/moq-uring/src/transport/adapter.rs @@ -20,17 +20,11 @@ impl moq::Error for Error { } } -struct Stats { +struct Metrics { inner: S, streams_evicted: Option, } -impl moq::Stats for Stats { - fn bytes_sent(&self) -> Option { - self.inner.bytes_sent() - } - fn bytes_received(&self) -> Option { - self.inner.bytes_received() - } +impl moq::Metrics for Metrics { fn bytes_lost(&self) -> Option { self.inner.bytes_lost() } @@ -214,8 +208,8 @@ impl + Clone + 'static> pol fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { backend::Session::poll_closed(&mut self.0, cx).map(Error) } - fn stats(&self) -> impl moq::Stats { - Stats { + fn metrics(&self) -> impl moq::Metrics { + Metrics { inner: backend::Session::stats(&self.0), streams_evicted: Grouped::streams_evicted(&self.0), } @@ -422,13 +416,13 @@ mod tests { #[test] fn forwards_thread_local_streams_and_keeps_error_codes() { - use moq_net::transport::Stats as _; + use moq_net::transport::Metrics as _; let state = Rc::new(Cell::new(0)); let mut session = Session::new(Backend(state.clone()), moq::Binding::InBand); let mut cx = Context::from_waker(std::task::Waker::noop()); assert_eq!(poll::Session::protocol(&session), Some("moq-lite-06")); assert_eq!( - poll::Session::stats(&session).rtt(), + poll::Session::metrics(&session).rtt(), Some(std::time::Duration::from_millis(7)) ); let Poll::Ready(Ok((mut send, mut recv))) = diff --git a/rs/moq-uring/src/transport/local.rs b/rs/moq-uring/src/transport/local.rs index a7805732aac..38093246f0c 100644 --- a/rs/moq-uring/src/transport/local.rs +++ b/rs/moq-uring/src/transport/local.rs @@ -108,8 +108,8 @@ impl poll::Session for Local { self.0.poll_closed(cx) } - fn stats(&self) -> impl moq::Stats { - self.0.stats() + fn metrics(&self) -> impl moq::Metrics { + self.0.metrics() } fn wire(&self) -> Option { diff --git a/rs/moq-wasm/src/transport/adapter.rs b/rs/moq-wasm/src/transport/adapter.rs index d54734e3afd..a5e4cfe2571 100644 --- a/rs/moq-wasm/src/transport/adapter.rs +++ b/rs/moq-wasm/src/transport/adapter.rs @@ -17,14 +17,8 @@ impl moq::Error for Error { } } -struct Stats(S); -impl moq::Stats for Stats { - fn bytes_sent(&self) -> Option { - self.0.bytes_sent() - } - fn bytes_received(&self) -> Option { - self.0.bytes_received() - } +struct Metrics(S); +impl moq::Metrics for Metrics { fn bytes_lost(&self) -> Option { self.0.bytes_lost() } @@ -134,8 +128,8 @@ impl + Clone + 'st fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { backend::Session::poll_closed(&mut self.0, cx).map(Error) } - fn stats(&self) -> impl moq::Stats { - Stats(backend::Session::stats(&self.0)) + fn metrics(&self) -> impl moq::Metrics { + Metrics(backend::Session::stats(&self.0)) } } /// An outgoing WebTransport stream adapted to moq-net. @@ -306,13 +300,13 @@ mod tests { #[test] fn forwards_thread_local_streams_and_keeps_error_codes() { - use moq_net::transport::Stats as _; + use moq_net::transport::Metrics as _; let state = Rc::new(Cell::new(0)); let mut session = Session::new(Backend(state.clone())); let mut cx = Context::from_waker(std::task::Waker::noop()); assert_eq!(poll::Session::protocol(&session), Some("moq-lite-06")); assert_eq!( - poll::Session::stats(&session).rtt(), + poll::Session::metrics(&session).rtt(), Some(std::time::Duration::from_millis(7)) ); let Poll::Ready(Ok((mut send, mut recv))) = diff --git a/rs/qmux/src/session.rs b/rs/qmux/src/session.rs index 2c2b4870e49..33feef78bee 100644 --- a/rs/qmux/src/session.rs +++ b/rs/qmux/src/session.rs @@ -752,14 +752,12 @@ impl poll::Session for Session { Poll::Pending } - fn stats(&self) -> impl net::Stats { + fn metrics(&self) -> impl net::Metrics { let state = self.inner.lock(); // The kernel's view of the socket underneath beats a QX_PING sample: it is there // from the first byte and costs no traffic. let socket = self.inner.socket_stats.as_ref(); - Stats { - sent: state.sent_bytes, - received: state.received_bytes, + Metrics { rtt: socket.and_then(|stats| stats.rtt()).or(state.conn.rtt()), rate: socket.and_then(|stats| stats.estimated_send_rate()), } @@ -770,23 +768,13 @@ impl poll::Session for Session { } } -/// A snapshot of the session's counters. -struct Stats { - sent: u64, - received: u64, +/// A snapshot of the session's metrics; its [`net::Wire`] holds the byte counts. +struct Metrics { rtt: Option, rate: Option, } -impl net::Stats for Stats { - fn bytes_sent(&self) -> Option { - Some(self.sent) - } - - fn bytes_received(&self) -> Option { - Some(self.received) - } - +impl net::Metrics for Metrics { fn rtt(&self) -> Option { self.rtt } From 3e883543f0f88348c6fb259b8b227ab708e1cde6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 00:09:35 -0700 Subject: [PATCH 05/58] refactor(net)!: fold track::Producer::close into abort, rename end to finished Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/publish.rs | 2 +- rs/moq-ffi/src/producer.rs | 2 +- rs/moq-json/src/snapshot/producer.rs | 2 +- rs/moq-net/MIGRATE.md | 4 +- rs/moq-net/src/ietf/subscriber.rs | 10 +- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/src/model/origin.rs | 7 +- rs/moq-net/src/model/resume.rs | 6 +- rs/moq-net/src/model/track.rs | 137 +++--------------- .../tests/integration/empty_replacement.rs | 2 +- 10 files changed, 42 insertions(+), 132 deletions(-) diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index aac4d2e2c0e..a6e93c8041e 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -934,7 +934,7 @@ impl Publish { /// boundary and only releases the handle; the broadcast keeps the track. pub fn track_finish(&mut self, track: Id) -> Result<(), Error> { let track = self.tracks.remove(track).ok_or(Error::TrackNotFound)?; - if track.end().is_none() { + if track.finished().is_none() { track.finish()?; } track.keep(); diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 1ee3187f961..8367206fb4e 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -714,7 +714,7 @@ impl MoqTrackProducer { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; - if track.end().is_none() { + if track.finished().is_none() { track.finish()?; } if let Some(finished) = self.finished.lock().unwrap().take() { diff --git a/rs/moq-json/src/snapshot/producer.rs b/rs/moq-json/src/snapshot/producer.rs index 408b91a933e..872ba84f782 100644 --- a/rs/moq-json/src/snapshot/producer.rs +++ b/rs/moq-json/src/snapshot/producer.rs @@ -321,7 +321,7 @@ impl Inner { let next = track.largest().map_or(0, |largest| largest.group.saturating_add(1)); if track.is_closed() || track - .end() + .finished() .is_some_and(|end| moq_net::track::Position::group(next) >= end) { return Err(moq_net::Error::Closed.into()); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 9dea3a51305..4c6429b1d5e 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -126,8 +126,8 @@ A group followed by a frameless one is judged against the next group that has a ### Ending a track aborts its open groups (#6111) Aborting a track (`abort` or `abort_unused`) now aborts its unfinished groups with the same error, so their readers end with it and their writers fail. -Dropping the last `track::Producer` without a finish aborts them with `Error::Dropped`, and `close` with `Error::Stream(StreamError::Session(SessionError::Cancel))`. -`track::Producer::close`, `track::Producer::received`, and `group::Producer::info` are now public. +Dropping the last `track::Producer` without a finish aborts them with `Error::Dropped`. +`track::Producer::received` and `group::Producer::info` are now public. ### Priorities are signed, with 0 unset (#6142) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e58bd3dc828..62997e3c9d4 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -242,11 +242,11 @@ impl State { request.reject(Error::Cancel); } if let Some(producer) = track.producer { - let _ = producer.close(); + let _ = producer.abort(Error::Stream(crate::StreamError::Session(SessionError::Cancel))); } } for (_, producer) in self.lingering.drain() { - let _ = producer.close(); + let _ = producer.abort(Error::Stream(crate::StreamError::Session(SessionError::Cancel))); } } } @@ -14675,7 +14675,7 @@ mod stitch_tests { h.subscriber.clone().recv_group(&mut stream).await.unwrap(); assert_eq!( - h.track.end(), + h.track.finished(), Some(track::Position { group: SEQUENCE, frame: 1 @@ -14697,7 +14697,7 @@ mod stitch_tests { let h = Harness::new(Fill::Done, vec![marker]); let mut stream = h.stream().await; h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.end(), Some(track::Position { group: 5, frame: 5 })); + assert_eq!(h.track.finished(), Some(track::Position { group: 5, frame: 5 })); assert_eq!( h.track.latest_sequence(), None, @@ -14734,7 +14734,7 @@ mod stitch_tests { let mut stream = h.stream().await; h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.end(), Some(track::Position::group(SEQUENCE))); + assert_eq!(h.track.finished(), Some(track::Position::group(SEQUENCE))); assert_eq!(h.track.latest_sequence(), None, "no group was created"); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index ba0a93f6c9b..1ab43e86d9b 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -565,7 +565,7 @@ impl SubscriptionCleanup { for (_, entry) in self.0.lock().drain() { let _ = match entry.tail.read().is_ended() { true => entry.producer.finish(), - false => entry.producer.close(), + false => entry.producer.abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), }; } } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7bb068b1879..a195c6c49bd 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -18,7 +18,7 @@ use super::{ front::{Action, Candidate, Event, Front, Refusal}, }; use crate::{ - AsPath, Epoch, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, + AsPath, Epoch, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, SessionError, StreamError, coding::{BoundsExceeded, Decode, DecodeError, Decoder, Encode, EncodeError, Encoder, Form, varint}, path::Segment, time::{Deadlines, Instant}, @@ -3880,7 +3880,10 @@ async fn serve_front(task: FrontTask) -> Vec { let Some(io) = tracks.get_mut(&name) else { continue }; io.copy = None; io.held = None; - io.closed = matches!(result, Err(Error::Closed)); + io.closed = matches!( + result, + Err(Error::Stream(StreamError::Session(SessionError::Cancel))) + ); Event::TrackEnded { track: name, source, diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 2db144d166c..f868b9e5b09 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -35,7 +35,7 @@ use std::task::{Poll, ready}; use std::time::Duration; use crate::time::Instant; -use crate::{Error, Result, StreamError, group, stats, track}; +use crate::{Error, Result, SessionError, StreamError, group, stats, track}; use super::delivered::Delivered; use super::subscription::{Anchor, Position, Resolved, Subscription, Unheld, Window}; @@ -1397,7 +1397,9 @@ impl Reader { // ending retains its session error. Some(None) => match serving.map(&done) { None => return Some(Poll::Ready(Err(Error::Unroutable))), - Some(Some(Ok(()) | Err(Error::Closed))) => return Some(Poll::Ready(Ok(None))), + Some(Some(Ok(()) | Err(Error::Stream(StreamError::Session(SessionError::Cancel))))) => { + return Some(Poll::Ready(Ok(None))); + } Some(Some(Err(err))) => return Some(Poll::Ready(Err(err))), Some(None) => {} }, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 206482615fe..3267f8f9e37 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -14,7 +14,7 @@ //! The track is closed with [Error] when all writers or readers are dropped. use crate::sync::Mutex; -use crate::{Error, Result, SessionError, StreamError, Timescale, Timestamp, coding}; +use crate::{Error, Result, StreamError, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; use super::Datagram; @@ -442,8 +442,7 @@ pub(crate) struct TrackState { // cache, so an evicted last group does not lose where the track ended. end: Option, - // Nothing more can arrive: the last producer dropped after a declared boundary, - // or the receiving session closed locally without declaring an end. + // Nothing more can arrive: the last producer dropped after a declared boundary. sealed: bool, // Set once a received copy's end is declared, ahead of group streams below it that QUIC @@ -2310,8 +2309,8 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - // `sealed` also ends a locally closed receive track without a declared end. - // An abort still wins unless that end had already settled: a group below it was still open. + // `sealed` is a declared end the last producer dropped short of. An abort still wins + // unless that end had already settled: a group below it was still open. let reached = self.sealed || self .final_sequence() @@ -2546,8 +2545,6 @@ impl TrackState { Poll::Ready(Ok(end)) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) - } else if self.sealed { - Poll::Ready(Err(Error::Closed)) } else { Poll::Pending } @@ -3036,22 +3033,6 @@ impl Producer { Ok(()) } - /// End the track without declaring where it ends, as a closed session does. - /// - /// Consumers drain the groups that finished and then end cleanly. The open groups are - /// aborted with [`StreamError::Session`], so a consumer that already pulled one ends - /// with it and their writers fail. A reader following the track's routes resumes from - /// another one where this copy stopped. Use [`Self::finish`] or [`Self::finish_at`] - /// when the end is known. - pub fn close(self) -> Result<()> { - let mut state = self.modify()?; - state.sealed = true; - let open = state.tear_down(Error::Stream(StreamError::Session(SessionError::Cancel))); - state.close(); - open.abort(); - Ok(()) - } - /// Mark the track as finished after the last appended group. /// /// Ends the track at the head of the group after the largest one written to its name, @@ -3251,7 +3232,7 @@ impl Producer { /// /// `None` while the track is still open ended. Both methods reject a second end, so /// callers that may have already declared one check here first. - pub fn end(&self) -> Option { + pub fn finished(&self) -> Option { self.state.read().end } @@ -4748,15 +4729,15 @@ impl Consumer { /// Poll for the track reaching a terminal state: `Ok(())` once it is complete /// (the final group was produced), `Err` once it closed or aborted before /// completing. This tells a track that truly ended from one whose serving route - /// died mid-stream. A session closed locally, on purpose, is [`Error::Closed`]. + /// died mid-stream. A session closed locally, on purpose, aborts its tracks with + /// [`StreamError::Session`] carrying [`crate::SessionError::Cancel`]. pub(super) fn poll_complete(&self, waiter: &kio::Waiter) -> Poll> { self.with_state(|state| { let Some(state) = state else { return Poll::Ready(Err(Error::Dropped)); }; match ready!(state.poll(waiter, |state| { - // A local close without a declared end is a dead route, not finished content. - if state.end.is_some() && state.is_complete() { + if state.is_complete() { Poll::Ready(()) } else { state.watch_tail(waiter); @@ -4766,11 +4747,7 @@ impl Consumer { Ok(_) => Poll::Ready(Ok(())), // Closed before completing. Read through the returned guard: it holds // the lock, so re-locking the channel here would deadlock. - Err(closed) => Poll::Ready(Err(match (&closed.abort, closed.sealed) { - (Some(err), _) => err.clone(), - (None, true) => Error::Closed, - (None, false) => Error::Dropped, - })), + Err(closed) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), } }) } @@ -12222,33 +12199,6 @@ mod test { assert!(matches!(write, Err(Error::Cancel)), "{write:?}"); } - /// Closing a track aborts its open groups like [`Producer::abort`], with - /// [`StreamError::Session`]. - #[test] - #[cfg(feature = "async")] - fn close_ends_a_pulled_open_group() { - let producer = track_producer("test", Info::timed()); - let mut consumer = producer.subscribe(None); - let mut writer = producer.append_group().unwrap(); - writer - .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - let mut group = consumer.assert_group(); - assert!(group.read_frame().now_or_never().unwrap().unwrap().is_some()); - - producer.close().unwrap(); - let read = group.read_frame().now_or_never().expect("should not block"); - assert!( - matches!(read, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))), - "{read:?}" - ); - let write = writer.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"y")); - assert!( - matches!(write, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))), - "{write:?}" - ); - } - /// Aborting an unused track aborts an open group whose reader outlived the track's /// consumers. #[test] @@ -12451,7 +12401,7 @@ mod test { assert!(producer.finish_at(Position::group(5)).is_err()); assert!(producer.finish_at(Position::group(6)).is_ok()); - assert_eq!(producer.end(), Some(Position::group(6))); + assert_eq!(producer.finished(), Some(Position::group(6))); // Repeating the end is a no-op, moving it is rejected, and no group at or above the // boundary can be created. @@ -12494,7 +12444,7 @@ mod test { producer.finish_at(Position::group(6)).unwrap(); producer.finish_at(Position { group: 5, frame: 5 }).unwrap(); producer.finish_at(Position { group: 5, frame: 5 }).unwrap(); - assert_eq!(producer.end(), Some(Position { group: 5, frame: 5 })); + assert_eq!(producer.finished(), Some(Position { group: 5, frame: 5 })); for moved in [Position { group: 5, frame: 4 }, Position::group(6), Position::group(5)] { assert!(matches!(producer.finish_at(moved), Err(Error::ProtocolViolation))); } @@ -12513,7 +12463,7 @@ mod test { producer.finish_at(Position { group: 5, frame: 5 }), Err(Error::ProtocolViolation) )); - assert_eq!(producer.end(), None, "a refused end declares nothing"); + assert_eq!(producer.finished(), None, "a refused end declares nothing"); producer.finish_at(Position { group: 5, frame: 6 }).unwrap(); } @@ -12559,13 +12509,13 @@ mod test { #[test] fn end_reports_the_declared_end() { let mut producer = track_producer("test", Info::timed()); - assert_eq!(producer.end(), None); + assert_eq!(producer.finished(), None); let _open = producer.create_group(group::Info { sequence: 5 }).unwrap(); - assert_eq!(producer.end(), None, "a group does not declare an end"); + assert_eq!(producer.finished(), None, "a group does not declare an end"); producer.finish_at(Position::group(9)).unwrap(); - assert_eq!(producer.end(), Some(Position::group(9))); + assert_eq!(producer.finished(), Some(Position::group(9))); // finish() would try to declare a second boundary, so callers check first. assert!(producer.finish().is_err()); @@ -12576,7 +12526,7 @@ mod test { let producer = track_producer("test", Info::timed()); let _open = producer.create_group(group::Info { sequence: 5 }).unwrap(); producer.finish().unwrap(); - assert_eq!(producer.end(), Some(Position::group(6))); + assert_eq!(producer.finished(), Some(Position::group(6))); } #[test] @@ -13045,7 +12995,7 @@ mod test { matches!(producer.finish_at(Position::group(2)), Err(Error::ProtocolViolation)), "a second end is refused" ); - assert_eq!(producer.end(), Some(Position::group(3)), "the first end stands"); + assert_eq!(producer.finished(), Some(Position::group(3)), "the first end stands"); let mut _low = Vec::new(); for sequence in [0, 1] { @@ -13074,55 +13024,10 @@ mod test { assert!(matches!(res, Err(Error::Timeout))); } - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_keeps_finished_groups_without_declaring_an_end() { - let producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - producer.append_group().unwrap().finish().unwrap(); - producer.clone().close().unwrap(); - assert!(producer.end().is_none()); - assert!(matches!(consumer.finished().await, Err(Error::Closed))); - assert!(consumer.recv_group().await.unwrap().is_some()); - assert!(consumer.recv_group().await.unwrap().is_none()); - } - - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_cannot_mask_an_abort_before_declared_end_settles() { - let mut producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - let _group = producer.append_group().unwrap(); - producer.finish_at(Position::group(2)).unwrap(); - producer.clone().abort(Error::Timeout).unwrap(); - assert!(producer.close().is_err()); - assert!(matches!(consumer.recv_group().await, Err(Error::Timeout))); - } - - /// A close drops a group still open that no reader pulled: readers drain the finished - /// groups, then end without a declared end. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_drops_an_unpulled_open_group() { - let producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - producer.append_group().unwrap().finish().unwrap(); - let mut open = producer.append_group().unwrap(); - open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - producer.clone().close().unwrap(); - assert_eq!( - consumer.recv_group().await.unwrap().map(|group| group.sequence), - Some(0) - ); - assert!(consumer.recv_group().await.unwrap().is_none()); - assert!(matches!(consumer.finished().await, Err(Error::Closed))); - } - - /// A local close is a dead route to the origin, not finished content, so a + /// A local session closing is a dead route to the origin, not finished content, so a /// replacement route can take over where it left off. #[test] - fn local_close_is_not_completion_and_keeps_the_resume_position() { + fn session_close_is_not_completion_and_keeps_the_resume_position() { let producer = track_producer("local", Info::timed()); let consumer = producer.consume(); producer.append_group().unwrap().finish().unwrap(); @@ -13133,7 +13038,7 @@ mod test { group .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"b")) .unwrap(); - producer.close().unwrap(); + producer.abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))).unwrap(); assert!(matches!( consumer.poll_complete(&kio::Waiter::noop()), Poll::Ready(Err(_)) @@ -14745,7 +14650,7 @@ mod test { producer.finish_at(Position { group: 0, frame: 2 }), Err(Error::ProtocolViolation) )); - assert_eq!(producer.end(), None); + assert_eq!(producer.finished(), None); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); } diff --git a/rs/moq-net/tests/integration/empty_replacement.rs b/rs/moq-net/tests/integration/empty_replacement.rs index 8c34a501ea8..14bd2057cd5 100644 --- a/rs/moq-net/tests/integration/empty_replacement.rs +++ b/rs/moq-net/tests/integration/empty_replacement.rs @@ -88,7 +88,7 @@ async fn empty_replacement_ends_after_the_cached_group(version: &str) { // before writing anything. let (second, mut subscriber) = serve(&remote, &mut dynamic, "feed").await; second.finish().unwrap(); - assert_eq!(second.end(), Some(track::Position::group(1)), "{version:?}"); + assert_eq!(second.finished(), Some(track::Position::group(1)), "{version:?}"); // Its own readers end at once: the earlier producer's groups never arrive here. let mut local = second.subscribe(None); From dce039a3ab0347788fd5c702c860cc4c95731585 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 00:10:42 -0700 Subject: [PATCH 06/58] refactor(net)!: Session::allocator and Client::with_allocator Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/session.rs | 4 ++-- rs/moq-cli/src/main.rs | 2 +- rs/moq-ffi/src/session.rs | 6 +++--- rs/moq-ffi/src/session/dial.rs | 4 ++-- rs/moq-net/src/client.rs | 26 +++++++++++++------------- rs/moq-net/src/model/bandwidth.rs | 4 ++-- rs/moq-net/src/session.rs | 4 ++-- rs/moq-tokio/src/client.rs | 6 +++--- rs/moq-tokio/src/connection.rs | 6 +++--- 9 files changed, 31 insertions(+), 31 deletions(-) diff --git a/rs/moq-c/src/session.rs b/rs/moq-c/src/session.rs index 56118ff82c1..878aa176a03 100644 --- a/rs/moq-c/src/session.rs +++ b/rs/moq-c/src/session.rs @@ -289,8 +289,8 @@ impl Session { /// Errors with [`Error::Offline`] for an accepted session still in SETUP. pub fn bandwidth(&self, id: Id) -> Result { match self.link(id)? { - Link::Dialed { connection, .. } => Ok(connection.bandwidth()), - Link::Accepted(accepted) => Ok(accepted.as_ref().ok_or(Error::Offline)?.session.bandwidth()), + Link::Dialed { connection, .. } => Ok(connection.allocator()), + Link::Accepted(accepted) => Ok(accepted.as_ref().ok_or(Error::Offline)?.session.allocator()), } } diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index db216e4464a..bf8d71df34c 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -476,7 +476,7 @@ async fn spawn_moq( // Read before the handle moves into the task. This allocator is persistent: it // survives reconnects, granting `None` while down, so it can be wired up before // anything connects, and every session probes for its reservations. - bandwidth = reconnect.bandwidth(); + bandwidth = reconnect.allocator(); let closed = reconnect.clone(); tasks.spawn(async move { Ok(closed.closed().await?) }); connection = Some(reconnect); diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 88f99bbeee8..8b7e974f476 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -975,12 +975,12 @@ impl MoqSession { // Persistent across reconnects: `None` while disconnected, then a grant // again on the next connection. Reservations survive the gap. #[cfg(not(target_arch = "wasm32"))] - Inner::Connection(connection) => connection.bandwidth(), + Inner::Connection(connection) => connection.allocator(), #[cfg(not(target_arch = "wasm32"))] - Inner::Session(session) => session.bandwidth(), + Inner::Session(session) => session.allocator(), // Persistent from before the handshake, so a reservation made while dialing counts. #[cfg(target_arch = "wasm32")] - Inner::Dial(dial) => dial.bandwidth(), + Inner::Dial(dial) => dial.allocator(), } } diff --git a/rs/moq-ffi/src/session/dial.rs b/rs/moq-ffi/src/session/dial.rs index d2a721041a7..4f00abdbfe9 100644 --- a/rs/moq-ffi/src/session/dial.rs +++ b/rs/moq-ffi/src/session/dial.rs @@ -67,7 +67,7 @@ impl Dial { let recv = moq_net::bandwidth::Producer::new(); let bandwidth = moq_net::bandwidth::Allocator::new(send.consume()); let auth = moq_net::auth::Handle::new(); - let client = client.with_bandwidth(bandwidth.clone()).with_auth(auth.clone()); + let client = client.with_allocator(bandwidth.clone()).with_auth(auth.clone()); let dial = Self { state: state.clone(), bandwidth, @@ -188,7 +188,7 @@ impl Dial { } } - pub fn bandwidth(&self) -> moq_net::bandwidth::Allocator { + pub fn allocator(&self) -> moq_net::bandwidth::Allocator { self.bandwidth.clone() } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 7a4402b25fb..e19b326bef8 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -23,7 +23,7 @@ pub struct Client { limits: crate::session::Limits, extensions: setup::Extensions, auth: Option, - bandwidth: Option, + allocator: Option, implementation: Option, } @@ -72,20 +72,20 @@ impl Client { self } - /// Share `bandwidth`'s reservations with every session this client dials. + /// Share `allocator`'s reservations with every session this client dials. /// /// Each session probes for what they add up to, and its - /// [`Session::bandwidth`](crate::Session::bandwidth) reserves into the same registry, + /// [`Session::allocator`](crate::Session::allocator) reserves into the same registry, /// so a reservation taken on one session still counts after a reconnect. Without /// one, each session starts an empty registry of its own. - pub fn with_bandwidth(mut self, bandwidth: crate::bandwidth::Allocator) -> Self { - self.bandwidth = Some(bandwidth); + pub fn with_allocator(mut self, allocator: crate::bandwidth::Allocator) -> Self { + self.allocator = Some(allocator); self } /// The registry the next session probes for. - fn bandwidth(&self) -> crate::bandwidth::Allocator { - self.bandwidth.clone().unwrap_or_default() + fn allocator(&self) -> crate::bandwidth::Allocator { + self.allocator.clone().unwrap_or_default() } /// Restrict which protocol versions to offer, in preference order. @@ -289,7 +289,7 @@ impl Client { goaway: start.goaway, auth: start.auth, estimate: Some(start.estimate), - bandwidth: self.bandwidth(), + bandwidth: self.allocator(), peer_implementation: start.peer_implementation, }, )) @@ -369,7 +369,7 @@ impl Client { goaway, auth, estimate: None, - bandwidth: self.bandwidth(), + bandwidth: self.allocator(), peer_implementation, }, )); @@ -568,7 +568,7 @@ impl Client { goaway, auth, estimate, - bandwidth: self.bandwidth(), + bandwidth: self.allocator(), peer_implementation, }, )) @@ -1268,7 +1268,7 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let (session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); moq_clock::sim::spawn(crate::time::run(driver)); - let bandwidth = session.bandwidth(); + let bandwidth = session.allocator(); let (_watched_broadcast, watched) = reservable(); let _sub = watched.subscribe(None); @@ -1296,7 +1296,7 @@ mod tests { let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); let (session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); moq_clock::sim::spawn(crate::time::run(driver)); - let bandwidth = session.bandwidth(); + let bandwidth = session.allocator(); let (_broadcast, track) = reservable(); let _reserved = bandwidth.reserve(&track.demand(), crate::bandwidth::Rate::from_bps(4_000_000)); @@ -1319,7 +1319,7 @@ mod tests { let bandwidth = crate::bandwidth::Allocator::default(); let client = Client::new() .with_versions(Version::Lite(lite::Version::Lite04).into()) - .with_bandwidth(bandwidth.clone()); + .with_allocator(bandwidth.clone()); let (_broadcast, track) = reservable(); let reserved = bandwidth.reserve(&track.demand(), crate::bandwidth::Rate::from_bps(4_000_000)); diff --git a/rs/moq-net/src/model/bandwidth.rs b/rs/moq-net/src/model/bandwidth.rs index 467eb242f5a..6eddfa56b13 100644 --- a/rs/moq-net/src/model/bandwidth.rs +++ b/rs/moq-net/src/model/bandwidth.rs @@ -205,7 +205,7 @@ impl Weak { /// The reservations also set the session's probe target: the sum of every live /// reservation's ceiling, demanded or not, so a sender that stopped for lack of /// bandwidth keeps asking the connection for its room. A session probes for its own -/// allocator, so take it from `Session::bandwidth` (with the `async` feature) rather +/// allocator, so take it from `Session::allocator` (with the `async` feature) rather /// than building one: an allocator from [`new`](Self::new) divides an estimate but /// probes for nothing. /// @@ -221,7 +221,7 @@ impl Allocator { /// /// For an estimate that is not a session's, such as a test's or another /// reservation's slice. A session's own comes from - /// `Session::bandwidth` with the `async` feature. + /// `Session::allocator` with the `async` feature. pub fn new(estimate: Consumer) -> Self { Self { estimate, diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 2fa491f94c4..4966980d5bb 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -220,10 +220,10 @@ impl Session { /// The session probes up to what the reservations add up to, through its /// transport's [`Probe`](crate::transport::Probe) when it has one, so reserve /// here rather than on an allocator of your own. Clones share one registry: a - /// [`Client::with_bandwidth`](crate::Client::with_bandwidth) allocator's when the + /// [`Client::with_allocator`](crate::Client::with_allocator) allocator's when the /// session was dialed with one, so reservations outlive a reconnect. Reservations /// report `None` when the transport has no estimate. - pub fn bandwidth(&self) -> bandwidth::Allocator { + pub fn allocator(&self) -> bandwidth::Allocator { self.bandwidth.with_estimate(self.send_bandwidth()) } diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 1d82aa33d03..482deb0ad3e 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -236,9 +236,9 @@ impl Client { self } - /// Share `bandwidth`'s reservations with every session this client dials. - pub(crate) fn with_bandwidth(mut self, bandwidth: moq_net::bandwidth::Allocator) -> Self { - self.moq = self.moq.with_bandwidth(bandwidth); + /// Share `allocator`'s reservations with every session this client dials. + pub(crate) fn with_allocator(mut self, allocator: moq_net::bandwidth::Allocator) -> Self { + self.moq = self.moq.with_allocator(allocator); self } diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 24eb9cf367a..c25534b6aba 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -958,7 +958,7 @@ impl Connection { let send_bandwidth = send_bw.consume(); let recv_bandwidth = recv_bw.weak(); let bandwidth = moq_net::bandwidth::Allocator::new(send_bandwidth.clone()); - let client = client.with_bandwidth(bandwidth.clone()); + let client = client.with_allocator(bandwidth.clone()); let closed: CloseGuard = Default::default(); let task_closed = closed.clone(); @@ -1498,12 +1498,12 @@ impl Connection { } /// Divides [`send_bandwidth`](Self::send_bandwidth) among this connection's senders, - /// mirroring [`moq_net::Session::bandwidth`]. + /// mirroring [`moq_net::Session::allocator`]. /// /// Persistent like the estimate: every session the loop dials probes for these /// reservations, so they survive reconnects. Reservations report `None` while /// disconnected, telling a sender to hold its rate. - pub fn bandwidth(&self) -> moq_net::bandwidth::Allocator { + pub fn allocator(&self) -> moq_net::bandwidth::Allocator { self.bandwidth.clone() } From a61533ade31f931e4de9d50f8273f5b10ffc24c5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 00:14:00 -0700 Subject: [PATCH 07/58] refactor(net)!: Reservation::consume and JS Reservation.rate Co-Authored-By: Claude Opus 5.5 --- js/net/src/bandwidth.test.ts | 12 +++++------ js/net/src/bandwidth.ts | 32 ++++++++++++++--------------- js/publish/src/video/encoder.ts | 2 +- rs/moq-c/src/video.rs | 2 +- rs/moq-ffi/src/bandwidth.rs | 6 +++--- rs/moq-ffi/src/video.rs | 2 +- rs/moq-net/src/model/bandwidth.rs | 22 ++++++++++---------- rs/moq-video/src/encode/producer.rs | 2 +- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/js/net/src/bandwidth.test.ts b/js/net/src/bandwidth.test.ts index 0b36595661b..2f10027659c 100644 --- a/js/net/src/bandwidth.test.ts +++ b/js/net/src/bandwidth.test.ts @@ -125,7 +125,7 @@ test("a share wakes when a sibling goes idle", async () => { expect(mine.peek()).toBe(1_000_000); expect(sibling.peek()).toBe(1_000_000); - const next = mine.grant.changed(); + const next = mine.rate.changed(); siblingSub.close(); expect(await next).toBe(2_000_000); expect(mine.peek()).toBe(2_000_000); @@ -144,7 +144,7 @@ test("an unchanged slice still follows the estimate past the cap", async () => { estimate.set(10_000_000); expect(reserved.peek()).toBe(4_000_000); - const next = reserved.grant.changed(); + const next = reserved.rate.changed(); // Still miles above the reservation, so the slice holds at 4 Mbps. estimate.set(9_000_000); expect(reserved.peek()).toBe(4_000_000); @@ -172,7 +172,7 @@ test("a parked share is woken by sibling demand", async () => { expect(reserved.peek()).toBe(1_000_000); expect(sibling.peek()).toBe(1_000_000); - const next = reserved.grant.changed(); + const next = reserved.rate.changed(); siblingSub.close(); expect(await next).toBe(2_000_000); @@ -211,7 +211,7 @@ test("closing a reservation releases it", async () => { const second = allocator.reserve(secondTrack.demand(), 4_000_000); expect(second.peek()).toBe(1_000_000); - const orphan = first.grant; + const orphan = first.rate; expect(orphan.peek()).toBe(1_000_000); const next = orphan.changed(); @@ -258,7 +258,7 @@ test("update wakes a parked reader", async () => { const reserved = allocator.reserve(producer.demand(), 1_000_000); expect(reserved.peek()).toBe(1_000_000); - const next = reserved.grant.changed(); + const next = reserved.rate.changed(); reserved.update(4_000_000); expect(await next).toBe(4_000_000); @@ -284,7 +284,7 @@ test("a closed allocator reports no grant", () => { allocator.close(); expect(reserved.peek()).toBeUndefined(); - expect(reserved.grant.peek()).toBeUndefined(); + expect(reserved.rate.peek()).toBeUndefined(); }); test("reserve and update reject a non-finite or negative ceiling", () => { diff --git a/js/net/src/bandwidth.ts b/js/net/src/bandwidth.ts index 4285b0a1217..023017b2ab9 100644 --- a/js/net/src/bandwidth.ts +++ b/js/net/src/bandwidth.ts @@ -81,7 +81,7 @@ interface Entry { demand: Demand; priority: number; max: number; - grant: Signal; + rate: Signal; } interface Registry { @@ -198,7 +198,7 @@ export class Allocator implements Handle { * as the sender is publishing, change the ceiling with * {@link Reservation.update}, and close it to hand the room back. * - * {@link Reservation.peek} / {@link Reservation.grant} report `undefined` + * {@link Reservation.peek} / {@link Reservation.rate} report `undefined` * while nothing is subscribed to the track or the connection has no estimate. * That tells a sender to hold its current rate rather than encode at zero. */ @@ -207,18 +207,18 @@ export class Allocator implements Handle { this.#prune(); const id = this.#nextId++; - const grant = new Signal(undefined); + const rate = new Signal(undefined); this.#entries.mutate((entries) => { entries.push({ id, demand: track, priority: hooks.demandPriority(track), max, - grant, + rate, }); }); - const reservation = makeReservation(this.#registry, id, grant); + const reservation = makeReservation(this.#registry, id, rate); this.#publish(); return reservation; } @@ -231,7 +231,7 @@ export class Allocator implements Handle { if (!this.#alive.peek()) return; this.#alive.set(false); for (const entry of this.#entries.peek()) { - entry.grant.set(undefined); + entry.rate.set(undefined); } this.#entries.set([]); this.#signals.close(); @@ -249,7 +249,7 @@ export class Allocator implements Handle { const entries = this.#entries.peek(); const wants = wantsOf(entries); for (const entry of entries) { - entry.grant.set(estimate == null ? undefined : allocate(estimate, wants, entry.id)); + entry.rate.set(estimate == null ? undefined : allocate(estimate, wants, entry.id)); } } @@ -282,7 +282,7 @@ export class Allocator implements Handle { } } -let makeReservation: (registry: Registry, id: number, grant: Signal) => Reservation; +let makeReservation: (registry: Registry, id: number, rate: Signal) => Reservation; /** * One track's standing claim on an {@link Allocator}, held for as long as the @@ -295,23 +295,23 @@ let makeReservation: (registry: Registry, id: number, grant: Signal; + readonly #rate: Signal; #closed = false; - private constructor(registry: Registry, id: number, grant: Signal) { + private constructor(registry: Registry, id: number, rate: Signal) { this.#registry = registry; this.#id = id; - this.#grant = grant; + this.#rate = rate; } static { - makeReservation = (registry, id, grant) => new Reservation(registry, id, grant); + makeReservation = (registry, id, rate) => new Reservation(registry, id, rate); } /** * This reservation's slice right now. * - * Stateless, unlike {@link grant}, which is a signal of what was last + * Stateless, unlike {@link rate}, which is a signal of what was last * published and so can lag a synchronous {@link update} by a microtask if * something else writes the registry first. Call this after {@link update}. */ @@ -327,8 +327,8 @@ export class Reservation implements Disposable { * this reservation has been closed: hold the current rate rather than encode * at zero. */ - get grant(): Getter { - return this.#grant; + get rate(): Getter { + return this.#rate; } /** @@ -347,7 +347,7 @@ export class Reservation implements Disposable { close(): void { if (this.#closed) return; this.#closed = true; - this.#grant.set(undefined); + this.#rate.set(undefined); this.#registry.release(this.#id); } diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 6828af26c58..9b75b8096ae 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -689,7 +689,7 @@ export class Encoder { const reservation = effect.get(this.#reservation); if (reservation) { const grant = reservation.peek(); - effect.get(reservation.grant); + effect.get(reservation.rate); if (grant != null) bitrate = Math.min(bitrate, grant); } diff --git a/rs/moq-c/src/video.rs b/rs/moq-c/src/video.rs index ced9a90b0b3..32508928e39 100644 --- a/rs/moq-c/src/video.rs +++ b/rs/moq-c/src/video.rs @@ -523,7 +523,7 @@ impl Video { tokio::select! { biased; _ = closed => {} - _ = follow_reservation(follower, reservation.consumer(), ceiling) => {} + _ = follow_reservation(follower, reservation.consume(), ceiling) => {} } Ok(()) }); diff --git a/rs/moq-ffi/src/bandwidth.rs b/rs/moq-ffi/src/bandwidth.rs index 74f8d079768..5d55722cc46 100644 --- a/rs/moq-ffi/src/bandwidth.rs +++ b/rs/moq-ffi/src/bandwidth.rs @@ -65,8 +65,8 @@ impl MoqReservation { } #[cfg(all(feature = "video", not(target_arch = "wasm32")))] - pub(crate) fn consumer(&self) -> moq_net::bandwidth::Consumer { - self.inner.consumer() + pub(crate) fn consume(&self) -> moq_net::bandwidth::Consumer { + self.inner.consume() } } @@ -213,7 +213,7 @@ mod tests { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = named_track(&broadcast, "a"); let reserved = bandwidth.reserve(&track.demand().unwrap(), 4_000_000); - let mut consumer = reserved.consumer(); + let mut consumer = reserved.consume(); let parked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let parked_flag = parked.clone(); diff --git a/rs/moq-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index a462aef35cc..01d95c4572f 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -647,7 +647,7 @@ impl MoqVideoProducer { }); let follow = reserved .as_ref() - .map(|reservation| spawn_follow(inner.clone(), reservation.consumer(), ceiling, applied.clone())); + .map(|reservation| spawn_follow(inner.clone(), reservation.consume(), ceiling, applied.clone())); Ok(Arc::new(MoqVideoProducer { inner, diff --git a/rs/moq-net/src/model/bandwidth.rs b/rs/moq-net/src/model/bandwidth.rs index 6eddfa56b13..5a83d94ca67 100644 --- a/rs/moq-net/src/model/bandwidth.rs +++ b/rs/moq-net/src/model/bandwidth.rs @@ -312,7 +312,7 @@ impl Allocator { /// released when the track closes either way, since a [`track::Demand`] is a weak /// handle and reserving never keeps a track alive. /// - /// Read the current slice through [`Reservation::consumer`], which reports `None` + /// Read the current slice through [`Reservation::consume`], which reports `None` /// while nothing is subscribed to the track or the connection has no estimate. /// That tells a sender to hold its current rate rather than encode at zero. pub fn reserve(&self, track: &track::Demand, max: Rate) -> Reservation { @@ -384,7 +384,7 @@ impl Reservation { /// /// Cloneable and independent of the reservation: once the reservation is dropped /// these report `None`, the same "hold your rate" a track nobody is watching reports. - pub fn consumer(&self) -> Consumer { + pub fn consume(&self) -> Consumer { Consumer { inner: Inner::Share(Box::new(self.share.clone())), last: None, @@ -400,7 +400,7 @@ impl Reservation { /// picture moved again. /// /// Does nothing once every [`Allocator`] is gone, since there is then nothing left - /// dividing anything; a reader learns that from its [`consumer`](Self::consumer). + /// dividing anything; a reader learns that from its [`consumer`](Self::consume). pub fn update(&self, max: Rate) { let Some(registry) = self.registry.upgrade() else { return; @@ -864,7 +864,7 @@ mod tests { let (_first_broadcast, first) = track(VIDEO); let _first_sub = first.subscribe(None); let first = allocator.reserve(&first.demand(), bps(4_000_000)); - let mut first_share = first.consumer(); + let mut first_share = first.consume(); estimate.set(Some(bps(2_000_000))).unwrap(); // Alone, it gets everything it asked for that the link can carry. @@ -918,7 +918,7 @@ mod tests { let (_mine_broadcast, mine) = track(VIDEO); let _mine_sub = mine.subscribe(None); let mine_share_reserved = allocator.reserve(&mine.demand(), bps(4_000_000)); - let mut mine_share = mine_share_reserved.consumer(); + let mut mine_share = mine_share_reserved.consume(); let (_sibling_broadcast, sibling) = track(VIDEO); let sibling_sub = sibling.subscribe(None); @@ -978,7 +978,7 @@ mod tests { let (_broadcast, track) = track(VIDEO); let _sub = track.subscribe(None); let share_reserved = allocator.reserve(&track.demand(), bps(4_000_000)); - let mut share = share_reserved.consumer(); + let mut share = share_reserved.consume(); estimate.set(Some(bps(10_000_000))).unwrap(); assert_eq!(share.changed().await.unwrap(), Some(bps(4_000_000))); @@ -1006,7 +1006,7 @@ mod tests { let (_mine_broadcast, mine) = track(VIDEO); let _mine_sub = mine.subscribe(None); let share_reserved = allocator.reserve(&mine.demand(), bps(4_000_000)); - let mut share = share_reserved.consumer(); + let mut share = share_reserved.consume(); let (_sibling_broadcast, sibling) = track(VIDEO); let sibling_sub = sibling.subscribe(None); @@ -1031,7 +1031,7 @@ mod tests { let (_broadcast, track) = track(VIDEO); let _sub = track.subscribe(None); let share_reserved = allocator.reserve(&track.demand(), bps(4_000_000)); - let mut share = share_reserved.consumer(); + let mut share = share_reserved.consume(); estimate.set(Some(bps(2_000_000))).unwrap(); assert_eq!(share.changed().await.unwrap(), Some(bps(2_000_000))); @@ -1084,7 +1084,7 @@ mod tests { // A read handle is not the claim: the reservation outliving it is what keeps the // room, and the reservation going away is what returns it. - let mut orphan = first_reserved.consumer(); + let mut orphan = first_reserved.consume(); assert_eq!(orphan.changed().await.unwrap(), Some(bps(1_000_000))); drop(first_reserved); @@ -1178,7 +1178,7 @@ mod tests { let (_broadcast, producer) = track(VIDEO); let _sub = producer.subscribe(None); let reserved = allocator.reserve(&producer.demand(), bps(1_000_000)); - let mut share = reserved.consumer(); + let mut share = reserved.consume(); assert_eq!(share.changed().await.unwrap(), Some(bps(1_000_000))); let (woken, waiter) = Woken::new(); @@ -1200,7 +1200,7 @@ mod tests { let (_broadcast, producer) = track(VIDEO); let _sub = producer.subscribe(None); let share_reserved = allocator.reserve(&producer.demand(), bps(4_000_000)); - let mut share = share_reserved.consumer(); + let mut share = share_reserved.consume(); drop(allocator); drop(estimate); diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 2610d823ea6..063a58d0e3a 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -760,7 +760,7 @@ async fn capture_loop( // so the policy's ceiling is that rate and the target starts there. A // reopened camera starts optimistic again rather than inheriting the // backed-off rate from whatever the link was doing last time. - let mut rate = Some(rate::Follower::new(reservation.consumer(), rate::Policy::new(ceiling))); + let mut rate = Some(rate::Follower::new(reservation.consume(), rate::Policy::new(ceiling))); // Per encoder, since a fresh one opens with a keyframe that serves every earlier ask. let mut forced = Cuts::new(served); From 43190587493981452c5940fe639a5e25bf078be8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 01:52:34 -0700 Subject: [PATCH 08/58] feat(net)!: name request handlers requests() Rename the dynamic()/Dynamic request handlers to requests()/Requests on origin, broadcast, and track producers (and track::Request) in moq-net, @moq/net, moq-ffi, and every wrapper. The frozen moq-c ABI is unchanged. Co-Authored-By: Claude Opus 5.5 --- dart/moq/MIGRATE.md | 5 + dart/moq/lib/src/aliases.dart | 6 +- dart/moq/lib/src/client.dart | 2 +- dart/moq/test/moq_test.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 738 +++++++++--------- doc/lib/dart/index.md | 2 +- doc/lib/go/index.md | 2 +- doc/lib/js/net.md | 2 +- doc/lib/kt/index.md | 2 +- doc/lib/rs/index.md | 2 +- go/wrapper/MIGRATE.md | 5 + go/wrapper/README.md | 2 +- go/wrapper/moq_test.go | 18 +- go/wrapper/origin.go | 24 +- go/wrapper/origin_internal_test.go | 6 +- go/wrapper/publish.go | 50 +- js/net/MIGRATE.md | 4 + js/net/bench/broadcasts.ts | 4 +- js/net/bench/requests.ts | 14 +- js/net/src/broadcast.ts | 2 +- js/net/src/connection/forward.ts | 6 +- js/net/src/connection/pool.test.ts | 2 +- js/net/src/hop.ts | 2 +- js/net/src/ietf/publisher.test.ts | 22 +- js/net/src/integration.test.ts | 4 +- js/net/src/lite/publisher.test.ts | 6 +- js/net/src/lite/tracks.test.ts | 6 +- js/net/src/origin-demand.test.ts | 44 +- js/net/src/origin-request.test.ts | 32 +- js/net/src/origin.test.ts | 88 +-- js/net/src/origin.ts | 36 +- js/net/src/untimed.test.ts | 2 +- js/net/src/wire.ts | 2 +- js/watch/src/broadcast.test.ts | 22 +- kt/moq/MIGRATE.md | 5 + .../kotlin/dev/moq/Aliases.kt | 6 +- .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 6 +- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 2 +- .../kotlin/dev/moq/Server.kt | 2 +- .../kotlin/dev/moq/SmokeTest.kt | 6 +- py/moq-rs/MIGRATE.md | 5 + py/moq-rs/README.md | 10 +- py/moq-rs/docs/index.md | 6 +- py/moq-rs/moq/__init__.py | 12 +- py/moq-rs/moq/origin.py | 16 +- py/moq-rs/moq/publish.py | 26 +- py/moq-rs/tests/test_local.py | 24 +- quest/m1/api-audit/README.md | 1 - quest/m1/api-audit/answer.md | 2 +- quest/m1/api-audit/requests.md | 25 - quest/m1/archive/README.md | 2 +- quest/m1/subscribe-ranges/README.md | 4 +- quest/m1/unbounded-groups/README.md | 2 +- quest/m1/unbounded-groups/front-eviction.md | 8 +- quest/m2/archive-time-ranges.md | 2 +- quest/m2/e2ee/README.md | 6 +- quest/m2/e2ee/js-undeclared-names.md | 8 +- quest/m2/e2ee/undeclared-names.md | 3 +- quest/m2/net-wrap/README.md | 2 +- quest/m2/net-wrap/traits.md | 4 +- rs/moq-archive/src/consumer/mod.rs | 10 +- rs/moq-c/src/origin.rs | 4 +- rs/moq-c/src/publish.rs | 12 +- rs/moq-c/src/test.rs | 6 +- rs/moq-cli/src/play/media.rs | 8 +- rs/moq-cli/src/transcode.rs | 6 +- rs/moq-ffi/MIGRATE.md | 5 + rs/moq-ffi/src/origin.rs | 34 +- rs/moq-ffi/src/producer.rs | 44 +- rs/moq-ffi/src/test.rs | 32 +- rs/moq-ffi/uniffi.toml | 2 +- rs/moq-gst/src/source/imp.rs | 18 +- rs/moq-hls/src/export/mod.rs | 2 +- rs/moq-hls/src/export/rendition.rs | 8 +- rs/moq-mux/src/catalog/hang/consumer.rs | 2 +- rs/moq-mux/src/container/ts/export_test.rs | 18 +- rs/moq-mux/src/source.rs | 2 +- rs/moq-net/MIGRATE.md | 5 + rs/moq-net/benches/group.rs | 2 +- rs/moq-net/benches/origin.rs | 36 +- rs/moq-net/benches/retirement.rs | 4 +- rs/moq-net/benches/track.rs | 4 +- rs/moq-net/benches/viewers.rs | 2 +- rs/moq-net/src/fuzz.rs | 10 +- rs/moq-net/src/ietf/publisher.rs | 16 +- rs/moq-net/src/ietf/subscriber.rs | 80 +- rs/moq-net/src/ietf/subscriber/tracks.rs | 4 +- rs/moq-net/src/lite/publisher.rs | 22 +- rs/moq-net/src/lite/subscriber.rs | 38 +- rs/moq-net/src/model/broadcast.rs | 76 +- rs/moq-net/src/model/follow.rs | 22 +- rs/moq-net/src/model/listing.rs | 30 +- rs/moq-net/src/model/mod.rs | 6 +- rs/moq-net/src/model/origin.rs | 390 ++++----- .../model/{requests.rs => request_queue.rs} | 20 +- rs/moq-net/src/model/resume.rs | 34 +- rs/moq-net/src/model/track.rs | 200 ++--- rs/moq-net/src/model/track/fill.rs | 6 +- rs/moq-net/src/util.rs | 2 +- .../tests/integration/catalog_acquisition.rs | 2 +- rs/moq-net/tests/integration/drained_claim.rs | 6 +- rs/moq-net/tests/integration/fetch_cancel.rs | 8 +- .../tests/integration/ietf_conformance.rs | 2 +- rs/moq-net/tests/integration/restart.rs | 12 +- rs/moq-net/tests/integration/route_change.rs | 2 +- .../tests/integration/track_info_cancel.rs | 4 +- .../tests/integration/track_stream_demand.rs | 4 +- rs/moq-net/tests/integration/untimed.rs | 4 +- rs/moq-net/tests/integration/wide_hop.rs | 2 +- rs/moq-net/tests/loom.rs | 4 +- rs/moq-rtc/src/egress.rs | 2 +- rs/moq-stats/src/produce.rs | 4 +- rs/moq-transcode/src/lib.rs | 14 +- rs/moq-transcode/src/rung.rs | 12 +- swift/MIGRATE.md | 5 + swift/Sources/Moq/Broadcast.swift | 6 +- swift/Sources/Moq/Origin.swift | 14 +- .../Moq/{Dynamic.swift => Requests.swift} | 20 +- swift/Sources/Moq/Track.swift | 4 +- swift/Tests/MoqTests/SmokeTests.swift | 4 +- .../relay-withdraws-lost-publisher.patch | 6 +- .../subscriber-leaks-broadcasts.patch | 2 +- 122 files changed, 1374 insertions(+), 1352 deletions(-) delete mode 100644 quest/m1/api-audit/requests.md rename rs/moq-net/src/model/{requests.rs => request_queue.rs} (92%) rename swift/Sources/Moq/{Dynamic.swift => Requests.swift} (91%) diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index b20e5bbafb8..5a9b9453921 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `dynamic_()` on `OriginProducer`, `BroadcastProducer`, `TrackProducer`, and `TrackRequest` to `requests()`. +Their handles `OriginDynamic`, `BroadcastDynamic`, and `TrackDynamic` are `OriginRequests`, `BroadcastRequests`, and `TrackRequests`; their methods are unchanged. + ### Connection state `Moq.connect` returns a connecting native session. Replace status, connect-count, and failure polls with `session.state()` and `session.stateChanged(after: state.revision)`. `ConnectionState` carries `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 9544a11d095..9816103773c 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -41,7 +41,7 @@ typedef OriginConfig = MoqOriginConfig; typedef OriginConsumer = MoqOriginConsumer; /// A served route: advertises a path prefix and yields broadcast requests beneath it. -typedef OriginDynamic = MoqOriginDynamic; +typedef OriginRequests = MoqOriginRequests; /// A requested broadcast not yet accepted: fulfill it with a producer or reject it. typedef BroadcastRequest = MoqBroadcastRequest; @@ -88,7 +88,7 @@ typedef BroadcastProducer = MoqBroadcastProducer; typedef BroadcastConsumer = MoqBroadcastConsumer; /// Receives tracks requested from a dynamically served broadcast. -typedef BroadcastDynamic = MoqBroadcastDynamic; +typedef BroadcastRequests = MoqBroadcastRequests; /// The write side of a raw track: append groups of frames. typedef TrackProducer = MoqTrackProducer; @@ -97,7 +97,7 @@ typedef TrackProducer = MoqTrackProducer; typedef TrackRequest = MoqTrackRequest; /// A stream of the fills readers of one track need: runs of groups the cache lacks. -typedef TrackDynamic = MoqTrackDynamic; +typedef TrackRequests = MoqTrackRequests; /// A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. typedef TrackDemand = MoqTrackDemand; diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 6b10a5d5386..249add08b67 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -211,7 +211,7 @@ final class Moq { /// Create an unannounced broadcast at [path], invisible to everyone until announced. /// - /// Advertise it with `announce` after populating tracks. Create, `dynamic` + /// Advertise it with `announce` after populating tracks. Create, `requests` /// if tracks are served on demand, populate, then announce. BroadcastProducer createBroadcast(String path) => session.publish().createBroadcast(path: path); diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 709e28701fb..e9571f29c8a 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -500,7 +500,7 @@ void main() { test('dynamic serves a request under a prefix', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); - final dynamic = origin.dynamic_(prefix: 'live'); + final dynamic = origin.requests(prefix: 'live'); final pending = origin.consume().requestBroadcast(path: 'live/cam'); final request = await dynamic.requestedBroadcast().timeout(timeout); expect(request.path(), 'live/cam'); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 1ccba994936..60b7867ab08 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,7 +8,9 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; + import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -11791,97 +11793,10 @@ class FfiConverterMoqOriginConsumer { } } -abstract class MoqOriginDynamicInterface { - void cancel(); - UniffiRustFuture requestedBroadcast(); -} - -final _MoqOriginDynamicFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqorigindynamic(ptr, status)); -}); - -class MoqOriginDynamic implements MoqOriginDynamicInterface { - late final Pointer _ptr; - bool _isDisposed = false; - MoqOriginDynamic._(this._ptr) { - _MoqOriginDynamicFinalizer.attach(this, _ptr, detach: this); - } - factory MoqOriginDynamic.lift(Pointer ptr) { - return MoqOriginDynamic._(ptr); - } - Pointer uniffiClonePointer() { - if (_isDisposed) { - throw StateError("Object has already been disposed"); - } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqorigindynamic(_ptr, status), - ); - } - - void dispose() { - if (_isDisposed) { - return; - } - _isDisposed = true; - _MoqOriginDynamicFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqorigindynamic(_ptr, status)); - } - - void cancel() { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqorigindynamic_cancel( - uniffiClonePointer(), - status, - ); - }, null); - } - - UniffiRustFuture requestedBroadcast() { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqorigindynamic_requested_broadcast( - uniffiClonePointer(), - ), - ffi_moq_ffi_rust_future_poll_u64, - ffi_moq_ffi_rust_future_complete_u64, - ffi_moq_ffi_rust_future_cancel_u64, - ffi_moq_ffi_rust_future_free_u64, - (ptr) => - FfiConverterMoqBroadcastRequest.lift(Pointer.fromAddress(ptr)), - moqExceptionErrorHandler, - ); - } -} - -class FfiConverterMoqOriginDynamic { - static MoqOriginDynamic lift(Pointer ptr) { - return MoqOriginDynamic.lift(ptr); - } - - static Pointer lower(MoqOriginDynamic value) { - return value.uniffiClonePointer(); - } - - static int allocationSize(MoqOriginDynamic value) { - return 8; - } - - static LiftRetVal read(Uint8List buf) { - final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); - final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqOriginDynamic.lift(pointer), 8); - } - - static int write(MoqOriginDynamic value, Uint8List buf) { - final handle = lower(value); - buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); - return 8; - } -} - abstract class MoqOriginProducerInterface { MoqOriginConsumer consume(); MoqBroadcastProducer createBroadcast({required String path}); - MoqOriginDynamic dynamic_({required String prefix}); + MoqOriginRequests requests({required String prefix}); } final _MoqOriginProducerFinalizer = Finalizer>((ptr) { @@ -11950,14 +11865,14 @@ class MoqOriginProducer implements MoqOriginProducerInterface { ); } - MoqOriginDynamic dynamic_({required String prefix}) { + MoqOriginRequests requests({required String prefix}) { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqoriginproducer_dynamic( + (status) => uniffi_moq_ffi_fn_method_moqoriginproducer_requests( uniffiClonePointer(), FfiConverterString.lower(prefix), status, ), - FfiConverterMoqOriginDynamic.lift, + FfiConverterMoqOriginRequests.lift, moqExceptionErrorHandler, ); } @@ -11989,30 +11904,30 @@ class FfiConverterMoqOriginProducer { } } -abstract class MoqBroadcastDynamicInterface { +abstract class MoqOriginRequestsInterface { void cancel(); - UniffiRustFuture requestedTrack(); + UniffiRustFuture requestedBroadcast(); } -final _MoqBroadcastDynamicFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqbroadcastdynamic(ptr, status)); +final _MoqOriginRequestsFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqoriginrequests(ptr, status)); }); -class MoqBroadcastDynamic implements MoqBroadcastDynamicInterface { +class MoqOriginRequests implements MoqOriginRequestsInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqBroadcastDynamic._(this._ptr) { - _MoqBroadcastDynamicFinalizer.attach(this, _ptr, detach: this); + MoqOriginRequests._(this._ptr) { + _MoqOriginRequestsFinalizer.attach(this, _ptr, detach: this); } - factory MoqBroadcastDynamic.lift(Pointer ptr) { - return MoqBroadcastDynamic._(ptr); + factory MoqOriginRequests.lift(Pointer ptr) { + return MoqOriginRequests._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqbroadcastdynamic(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqoriginrequests(_ptr, status), ); } @@ -12021,56 +11936,57 @@ class MoqBroadcastDynamic implements MoqBroadcastDynamicInterface { return; } _isDisposed = true; - _MoqBroadcastDynamicFinalizer.detach(this); + _MoqOriginRequestsFinalizer.detach(this); rustCall( - (status) => uniffi_moq_ffi_fn_free_moqbroadcastdynamic(_ptr, status), + (status) => uniffi_moq_ffi_fn_free_moqoriginrequests(_ptr, status), ); } void cancel() { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqbroadcastdynamic_cancel( + uniffi_moq_ffi_fn_method_moqoriginrequests_cancel( uniffiClonePointer(), status, ); }, null); } - UniffiRustFuture requestedTrack() { + UniffiRustFuture requestedBroadcast() { return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqbroadcastdynamic_requested_track( + () => uniffi_moq_ffi_fn_method_moqoriginrequests_requested_broadcast( uniffiClonePointer(), ), ffi_moq_ffi_rust_future_poll_u64, ffi_moq_ffi_rust_future_complete_u64, ffi_moq_ffi_rust_future_cancel_u64, ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqTrackRequest.lift(Pointer.fromAddress(ptr)), + (ptr) => + FfiConverterMoqBroadcastRequest.lift(Pointer.fromAddress(ptr)), moqExceptionErrorHandler, ); } } -class FfiConverterMoqBroadcastDynamic { - static MoqBroadcastDynamic lift(Pointer ptr) { - return MoqBroadcastDynamic.lift(ptr); +class FfiConverterMoqOriginRequests { + static MoqOriginRequests lift(Pointer ptr) { + return MoqOriginRequests.lift(ptr); } - static Pointer lower(MoqBroadcastDynamic value) { + static Pointer lower(MoqOriginRequests value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqBroadcastDynamic value) { + static int allocationSize(MoqOriginRequests value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqBroadcastDynamic.lift(pointer), 8); + return LiftRetVal(MoqOriginRequests.lift(pointer), 8); } - static int write(MoqBroadcastDynamic value, Uint8List buf) { + static int write(MoqOriginRequests value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -12081,11 +11997,11 @@ abstract class MoqBroadcastProducerInterface { void announce({required MoqAnnounceRoute route}); void close(); MoqBroadcastConsumer consume(); - MoqBroadcastDynamic dynamic_(); MoqTrackProducer publishTrack({ required String name, required MoqTrackInfo? info, }); + MoqBroadcastRequests requests(); void unannounce(); } @@ -12162,17 +12078,6 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { ); } - MoqBroadcastDynamic dynamic_() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqbroadcastproducer_dynamic( - uniffiClonePointer(), - status, - ), - FfiConverterMoqBroadcastDynamic.lift, - moqExceptionErrorHandler, - ); - } - MoqTrackProducer publishTrack({ required String name, required MoqTrackInfo? info, @@ -12189,6 +12094,17 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { ); } + MoqBroadcastRequests requests() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqbroadcastproducer_requests( + uniffiClonePointer(), + status, + ), + FfiConverterMoqBroadcastRequests.lift, + moqExceptionErrorHandler, + ); + } + void unannounce() { return rustCall((status) { uniffi_moq_ffi_fn_method_moqbroadcastproducer_unannounce( @@ -12225,6 +12141,96 @@ class FfiConverterMoqBroadcastProducer { } } +abstract class MoqBroadcastRequestsInterface { + void cancel(); + UniffiRustFuture requestedTrack(); +} + +final _MoqBroadcastRequestsFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbroadcastrequests(ptr, status), + ); +}); + +class MoqBroadcastRequests implements MoqBroadcastRequestsInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqBroadcastRequests._(this._ptr) { + _MoqBroadcastRequestsFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBroadcastRequests.lift(Pointer ptr) { + return MoqBroadcastRequests._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqbroadcastrequests(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + _MoqBroadcastRequestsFinalizer.detach(this); + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbroadcastrequests(_ptr, status), + ); + } + + void cancel() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbroadcastrequests_cancel( + uniffiClonePointer(), + status, + ); + }, null); + } + + UniffiRustFuture requestedTrack() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqbroadcastrequests_requested_track( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => FfiConverterMoqTrackRequest.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } +} + +class FfiConverterMoqBroadcastRequests { + static MoqBroadcastRequests lift(Pointer ptr) { + return MoqBroadcastRequests.lift(ptr); + } + + static Pointer lower(MoqBroadcastRequests value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBroadcastRequests value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBroadcastRequests.lift(pointer), 8); + } + + static int write(MoqBroadcastRequests value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqFillInterface { MoqGroupProducer accept({required int sequence}); void dropGroups({required int start, required int end}); @@ -12560,92 +12566,6 @@ class FfiConverterMoqGroupProducer { } } -abstract class MoqTrackDynamicInterface { - void cancel(); - UniffiRustFuture requestedFill(); -} - -final _MoqTrackDynamicFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqtrackdynamic(ptr, status)); -}); - -class MoqTrackDynamic implements MoqTrackDynamicInterface { - late final Pointer _ptr; - bool _isDisposed = false; - MoqTrackDynamic._(this._ptr) { - _MoqTrackDynamicFinalizer.attach(this, _ptr, detach: this); - } - factory MoqTrackDynamic.lift(Pointer ptr) { - return MoqTrackDynamic._(ptr); - } - Pointer uniffiClonePointer() { - if (_isDisposed) { - throw StateError("Object has already been disposed"); - } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqtrackdynamic(_ptr, status), - ); - } - - void dispose() { - if (_isDisposed) { - return; - } - _isDisposed = true; - _MoqTrackDynamicFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqtrackdynamic(_ptr, status)); - } - - void cancel() { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel( - uniffiClonePointer(), - status, - ); - }, null); - } - - UniffiRustFuture requestedFill() { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( - uniffiClonePointer(), - ), - ffi_moq_ffi_rust_future_poll_u64, - ffi_moq_ffi_rust_future_complete_u64, - ffi_moq_ffi_rust_future_cancel_u64, - ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqFill.lift(Pointer.fromAddress(ptr)), - moqExceptionErrorHandler, - ); - } -} - -class FfiConverterMoqTrackDynamic { - static MoqTrackDynamic lift(Pointer ptr) { - return MoqTrackDynamic.lift(ptr); - } - - static Pointer lower(MoqTrackDynamic value) { - return value.uniffiClonePointer(); - } - - static int allocationSize(MoqTrackDynamic value) { - return 8; - } - - static LiftRetVal read(Uint8List buf) { - final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); - final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqTrackDynamic.lift(pointer), 8); - } - - static int write(MoqTrackDynamic value, Uint8List buf) { - final handle = lower(value); - buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); - return 8; - } -} - abstract class MoqTrackProducerInterface { void abort({required int errorCode}); int appendDatagram({required MoqFrame frame}); @@ -12653,9 +12573,9 @@ abstract class MoqTrackProducerInterface { MoqTrackConsumer consume({required MoqSubscription? subscription}); MoqGroupProducer createGroup({required int sequence}); MoqTrackDemand demand(); - MoqTrackDynamic dynamic_(); void finish(); void finishAt({required int finalSequence}); + MoqTrackRequests requests(); void writeFrame({required MoqFrame frame}); } @@ -12758,17 +12678,6 @@ class MoqTrackProducer implements MoqTrackProducerInterface { ); } - MoqTrackDynamic dynamic_() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqtrackproducer_dynamic( - uniffiClonePointer(), - status, - ), - FfiConverterMoqTrackDynamic.lift, - moqExceptionErrorHandler, - ); - } - void finish() { return rustCall((status) { uniffi_moq_ffi_fn_method_moqtrackproducer_finish( @@ -12788,6 +12697,17 @@ class MoqTrackProducer implements MoqTrackProducerInterface { }, moqExceptionErrorHandler); } + MoqTrackRequests requests() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqtrackproducer_requests( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTrackRequests.lift, + moqExceptionErrorHandler, + ); + } + void writeFrame({required MoqFrame frame}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqtrackproducer_write_frame( @@ -12829,8 +12749,8 @@ abstract class MoqTrackRequestInterface { void abort({required int errorCode}); MoqTrackProducer accept({required MoqTrackInfo? info}); MoqTrackDemand demand(); - MoqTrackDynamic dynamic_(); String name(); + MoqTrackRequests requests(); } final _MoqTrackRequestFinalizer = Finalizer>((ptr) { @@ -12897,24 +12817,24 @@ class MoqTrackRequest implements MoqTrackRequestInterface { ); } - MoqTrackDynamic dynamic_() { + String name() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqtrackrequest_dynamic( + (status) => uniffi_moq_ffi_fn_method_moqtrackrequest_name( uniffiClonePointer(), status, ), - FfiConverterMoqTrackDynamic.lift, + FfiConverterString.lift, moqExceptionErrorHandler, ); } - String name() { + MoqTrackRequests requests() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqtrackrequest_name( + (status) => uniffi_moq_ffi_fn_method_moqtrackrequest_requests( uniffiClonePointer(), status, ), - FfiConverterString.lift, + FfiConverterMoqTrackRequests.lift, moqExceptionErrorHandler, ); } @@ -12946,6 +12866,92 @@ class FfiConverterMoqTrackRequest { } } +abstract class MoqTrackRequestsInterface { + void cancel(); + UniffiRustFuture requestedFill(); +} + +final _MoqTrackRequestsFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqtrackrequests(ptr, status)); +}); + +class MoqTrackRequests implements MoqTrackRequestsInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqTrackRequests._(this._ptr) { + _MoqTrackRequestsFinalizer.attach(this, _ptr, detach: this); + } + factory MoqTrackRequests.lift(Pointer ptr) { + return MoqTrackRequests._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqtrackrequests(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + _MoqTrackRequestsFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqtrackrequests(_ptr, status)); + } + + void cancel() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqtrackrequests_cancel( + uniffiClonePointer(), + status, + ); + }, null); + } + + UniffiRustFuture requestedFill() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqtrackrequests_requested_fill( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => FfiConverterMoqFill.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } +} + +class FfiConverterMoqTrackRequests { + static MoqTrackRequests lift(Pointer ptr) { + return MoqTrackRequests.lift(ptr); + } + + static Pointer lower(MoqTrackRequests value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqTrackRequests value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqTrackRequests.lift(pointer), 8); + } + + static int write(MoqTrackRequests value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqRequestInterface { UniffiRustFuture accept({ MoqOriginProducer? publish = null, @@ -17962,36 +17968,6 @@ uniffi_moq_ffi_fn_method_moqoriginconsumer_request_broadcast( RustBuffer path, ); -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_clone_moqorigindynamic( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_free_moqorigindynamic( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqorigindynamic_cancel( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqorigindynamic_requested_broadcast( - Pointer ptr, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18037,7 +18013,7 @@ uniffi_moq_ffi_fn_method_moqoriginproducer_create_broadcast( @Native< Pointer Function(Pointer, RustBuffer, Pointer) >(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_dynamic( +external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_requests( Pointer ptr, RustBuffer prefix, Pointer uniffiStatus, @@ -18046,7 +18022,7 @@ external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_dynamic( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqbroadcastdynamic( +external Pointer uniffi_moq_ffi_fn_clone_moqoriginrequests( Pointer handle, Pointer uniffiStatus, ); @@ -18054,7 +18030,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqbroadcastdynamic( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqbroadcastdynamic( +external void uniffi_moq_ffi_fn_free_moqoriginrequests( Pointer handle, Pointer uniffiStatus, ); @@ -18062,14 +18038,16 @@ external void uniffi_moq_ffi_fn_free_moqbroadcastdynamic( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqbroadcastdynamic_cancel( +external void uniffi_moq_ffi_fn_method_moqoriginrequests_cancel( Pointer ptr, Pointer uniffiStatus, ); @Native Function(Pointer)>(assetId: _uniffiAssetId) external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastdynamic_requested_track(Pointer ptr); +uniffi_moq_ffi_fn_method_moqoriginrequests_requested_broadcast( + Pointer ptr, +); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, @@ -18119,14 +18097,6 @@ external Pointer uniffi_moq_ffi_fn_method_moqbroadcastproducer_consume( Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_method_moqbroadcastproducer_dynamic( - Pointer ptr, - Pointer uniffiStatus, -); - @Native< Pointer Function( Pointer, @@ -18143,6 +18113,14 @@ uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_track( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqbroadcastproducer_requests( + Pointer ptr, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -18151,6 +18129,36 @@ external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_unannounce( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbroadcastrequests( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbroadcastrequests( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbroadcastrequests_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastrequests_requested_track( + Pointer ptr, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18328,35 +18336,6 @@ external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_clone_moqtrackdynamic( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_free_moqtrackdynamic( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( - Pointer ptr, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18425,28 +18404,28 @@ external Pointer uniffi_moq_ffi_fn_method_moqtrackproducer_demand( Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqtrackproducer_dynamic( +external void uniffi_moq_ffi_fn_method_moqtrackproducer_finish( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqtrackproducer_finish( +external void uniffi_moq_ffi_fn_method_moqtrackproducer_finish_at( Pointer ptr, + int final_sequence, Pointer uniffiStatus, ); -@Native, Uint64, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqtrackproducer_finish_at( +external Pointer uniffi_moq_ffi_fn_method_moqtrackproducer_requests( Pointer ptr, - int final_sequence, Pointer uniffiStatus, ); @@ -18501,22 +18480,51 @@ external Pointer uniffi_moq_ffi_fn_method_moqtrackrequest_demand( Pointer uniffiStatus, ); +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqtrackrequest_name( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqtrackrequest_dynamic( +external Pointer uniffi_moq_ffi_fn_method_moqtrackrequest_requests( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqtrackrequest_name( +external Pointer uniffi_moq_ffi_fn_clone_moqtrackrequests( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqtrackrequests( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqtrackrequests_cancel( Pointer ptr, Pointer uniffiStatus, ); +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqtrackrequests_requested_fill( + Pointer ptr, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -19711,13 +19719,6 @@ uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced_broadcast(); external int uniffi_moq_ffi_checksum_method_moqoriginconsumer_request_broadcast(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqorigindynamic_cancel(); - -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqorigindynamic_requested_broadcast(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqoriginproducer_consume(); @@ -19726,14 +19727,14 @@ external int uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqoriginproducer_dynamic(); +external int uniffi_moq_ffi_checksum_method_moqoriginproducer_requests(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel(); +external int uniffi_moq_ffi_checksum_method_moqoriginrequests_cancel(); @Native(assetId: _uniffiAssetId) external int -uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track(); +uniffi_moq_ffi_checksum_method_moqoriginrequests_requested_broadcast(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce(); @@ -19744,16 +19745,23 @@ external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_close(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_dynamic(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_requests(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbroadcastrequests_cancel(); + +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastrequests_requested_track(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_accept(); @@ -19808,12 +19816,6 @@ external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_abort(); @@ -19832,15 +19834,15 @@ external int uniffi_moq_ffi_checksum_method_moqtrackproducer_create_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_demand(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackproducer_dynamic(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_finish(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_finish_at(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqtrackproducer_requests(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame(); @@ -19854,10 +19856,16 @@ external int uniffi_moq_ffi_checksum_method_moqtrackrequest_accept(); external int uniffi_moq_ffi_checksum_method_moqtrackrequest_demand(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackrequest_dynamic(); +external int uniffi_moq_ffi_checksum_method_moqtrackrequest_name(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqtrackrequest_requests(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackrequest_name(); +external int uniffi_moq_ffi_checksum_method_moqtrackrequests_cancel(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqtrackrequests_requested_fill(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqrequest_accept(); @@ -20439,28 +20447,21 @@ void _checkApiChecksums() { 54627) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqorigindynamic_cancel() != 47453) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqorigindynamic_requested_broadcast() != - 54021) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_consume() != 52357) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast() != - 45756) { + 38558) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginproducer_dynamic() != 17765) { + if (uniffi_moq_ffi_checksum_method_moqoriginproducer_requests() != 8499) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel() != 25875) { + if (uniffi_moq_ffi_checksum_method_moqoriginrequests_cancel() != 18954) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track() != - 5884) { + if (uniffi_moq_ffi_checksum_method_moqoriginrequests_requested_broadcast() != + 31253) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 3965) { @@ -20472,17 +20473,24 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume() != 27634) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_dynamic() != 55635) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track() != 44452) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_requests() != 27464) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce() != 49647) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequests_cancel() != 44028) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequests_requested_track() != + 49236) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqfill_accept() != 63990) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -20537,13 +20545,6 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel() != 15841) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill() != - 22958) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_abort() != 37537) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -20563,15 +20564,15 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackproducer_demand() != 7311) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_dynamic() != 58584) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_finish() != 63940) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_finish_at() != 24581) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqtrackproducer_requests() != 60252) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame() != 32196) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -20584,10 +20585,17 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackrequest_demand() != 3618) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackrequest_dynamic() != 24801) { + if (uniffi_moq_ffi_checksum_method_moqtrackrequest_name() != 56715) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackrequest_name() != 56715) { + if (uniffi_moq_ffi_checksum_method_moqtrackrequest_requests() != 23724) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqtrackrequests_cancel() != 43316) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqtrackrequests_requested_fill() != + 55377) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqrequest_accept() != 55136) { diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index f9aafe9df3b..1b14c3a2dd0 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -137,7 +137,7 @@ The rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. -- **Names.** Types drop the `Moq` prefix (`Session`, `BroadcastProducer`) as aliases, so the generated names still work. `Container`, `Route`, and the exceptions keep it, since Flutter owns those names. `dynamic` is reserved, so the origin method is `dynamic_`. +- **Names.** Types drop the `Moq` prefix (`Session`, `BroadcastProducer`) as aliases, so the generated names still work. `Container`, `Route`, and the exceptions keep it, since Flutter owns those names. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `cut()`: after every frame, or at a segment cadence to align with video. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Decoded frames hold decoder buffers.** `dispose()` each frame from a `video.Consumer` promptly rather than waiting for the garbage collector, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 703a20b27c7..8d2e1c048ae 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -126,7 +126,7 @@ one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. - **Context cancellation.** Every call that can block takes a `context.Context` first, and cancelling it tears down the native work, not just your wait. A one-shot call (`RequestBroadcast`, `FetchGroup`, `Server.Accept`) aborts alone; a stream read (`Next`, `ReadFrame`, and the iterators over them) cancels the stream it reads. -- **Keep owners reachable.** An origin from `moq.NewOriginProducer` has no `Close`. It ends when the garbage collector reaches its last owner (each producer, published broadcast, and `OriginDynamic`), and its consumers then fail with `moq.ErrClosed`. +- **Keep owners reachable.** An origin from `moq.NewOriginProducer` has no `Close`. It ends when the garbage collector reaches its last owner (each producer, published broadcast, and `OriginRequests`), and its consumers then fail with `moq.ErrClosed`. - **Bandwidth ownership.** An encoder you manage can reserve a share of the session's bandwidth. Reuse its reservation when the ceiling changes; before abandoning or replacing it, call `Update(0)` to return its share and automatic probe ceiling without waiting for garbage collection. The track can stay subscribed and the reservation can be reused. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `Cut()`: after every frame, or at a segment cadence to align with video. - **Timeline breaks.** On a seek or pause, call `Discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index c6050cf7a59..af8e3fcdd9b 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -34,7 +34,7 @@ broadcast.announce(); ``` - **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A broadcast is invisible, locally and remotely, until `announce()`. -- **Answer claimed paths with `using`.** `dynamic(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned producer live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. +- **Answer claimed paths with `using`.** `requests(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned producer live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. - **End content by closing tracks.** A broadcast has no finished state. When the input ends, close each track cleanly; the broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. - **Requests can pin an epoch.** `consumer.request(path, { epoch })` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs). The resolved broadcast's `epoch` names the route it came through, pinned or not. - **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 781f0fb57f7..b6f21ee0625 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -97,7 +97,7 @@ The rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. -- **`end`, not `close`.** `broadcast.end()` ends a broadcast for good. `close()`, or leaving `use { }`, releases the handle, which ends the broadcast only once no `dynamic()` handle remains. +- **`end`, not `close`.** `broadcast.end()` ends a broadcast for good. `close()`, or leaving `use { }`, releases the handle, which ends the broadcast only once no `requests()` handle remains. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `cut()`: after every frame, or at a segment cadence to align with video. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Decoded frames hold decoder buffers.** `close()` each frame from a `dev.moq.video.Consumer` promptly (or `use { }` it), or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 9e67f860511..663976b84d1 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -79,7 +79,7 @@ while let Some(event) = announced.next().await { let mut broadcast = origin.publish("my-stream.hang", Default::default())?; // moq-mux (from a container) or moq-video / moq-audio (from a device) fill it. // The route retracts on `unannounce()` or when the broadcast ends. To serve a whole -// subtree on demand instead, `origin.dynamic("room")?` yields each requested path: +// subtree on demand instead, `origin.requests("room")?` yields each requested path: // `prepare(Epoch::mint())`, fill its broadcast, then `publish()`, or reject it. ``` diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index c1f214e6a81..ef43b0aabd6 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `Requests()` (#6752) + +Rename `Dynamic()` on `OriginProducer`, `BroadcastProducer`, `TrackProducer`, and `TrackRequest` to `Requests()`. +Their handles `OriginDynamic`, `BroadcastDynamic`, and `TrackDynamic` are `OriginRequests`, `BroadcastRequests`, and `TrackRequests`; their methods are unchanged. + ### Connection state `Dial` returns a connecting native client. Replace `Session.Status`, `Connects`, and `Failure` with `State()` and `StateChanged(ctx, state.Revision)`. `ConnectionState` carries `Phase`, `Connects`, `Failure`, and `Version`; `ConnectionPhase` replaces `ConnectionStatus`. Cancelling a state wait only cancels that wait. Cancel the session itself to stop its dial. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index ced19add59c..6938539b76a 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -99,7 +99,7 @@ and track themselves. `NewGroupConsumer` decodes a raw group, such as one from `moq.TrackConsumer.RecvGroup` for arrival-order reads. Frames and `Flush` use `time.Duration`; `Demand()` owns track names and subscriber waits. -`BroadcastProducer.Dynamic()` accepts subscriber-requested tracks. Call +`BroadcastProducer.Requests()` accepts subscriber-requested tracks. Call `TrackRequest.Accept()` for raw tracks, or pass the request as `media.Requested` so the media importer selects the timescale. diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index a99fdd267c6..5471e0f688f 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -46,7 +46,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { defer cancel() origin := newOrigin(t) - dynamic, err := origin.Dynamic("") + dynamic, err := origin.Requests("") if err != nil { t.Fatal(err) } @@ -556,7 +556,7 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { t.Fatalf("cached fetch: frame=%+v err=%v", frame, err) } - dynamic, err := track.Dynamic() + dynamic, err := track.Requests() if err != nil { t.Fatal(err) } @@ -916,7 +916,7 @@ func TestDynamicTrackRequest(t *testing.T) { } defer broadcast.Close() - dynamic, err := broadcast.Dynamic() + dynamic, err := broadcast.Requests() if err != nil { t.Fatal(err) } @@ -992,7 +992,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { } defer broadcast.Close() - dynamic, err := broadcast.Dynamic() + dynamic, err := broadcast.Requests() if err != nil { t.Fatal(err) } @@ -1143,7 +1143,7 @@ func TestConsumerCancelConcurrent(t *testing.T) { // that one request rather than the consumer it was made on. func TestRequestBroadcastCancelKeepsTheOrigin(t *testing.T) { origin := newOrigin(t) - dynamic, err := origin.Dynamic("") + dynamic, err := origin.Requests("") if err != nil { t.Fatal(err) } @@ -1219,7 +1219,7 @@ func TestSubscribeTrackCancelKeepsTheBroadcast(t *testing.T) { } defer broadcast.Close() - dynamic, err := broadcast.Dynamic() + dynamic, err := broadcast.Requests() if err != nil { t.Fatal(err) } @@ -1339,7 +1339,7 @@ func TestCancelDoesNotLeakGoroutines(t *testing.T) { } defer broadcast.Close() - dynamic, err := broadcast.Dynamic() + dynamic, err := broadcast.Requests() if err != nil { t.Fatal(err) } @@ -1591,7 +1591,7 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { defer cancel() origin := newOrigin(t) - dynamic, err := origin.Dynamic("live") + dynamic, err := origin.Requests("live") if err != nil { t.Fatal(err) } @@ -1631,7 +1631,7 @@ func TestClosingAPreparedAnswerRefusesTheRequest(t *testing.T) { defer cancel() origin := newOrigin(t) - dynamic, err := origin.Dynamic("live") + dynamic, err := origin.Requests("live") if err != nil { t.Fatal(err) } diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index d0a60966b63..7e41b497f03 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -13,7 +13,7 @@ import ( // consume sink for a full-duplex peer. // // There is no Close: the origin ends once the collector reaches every owner -// (each producer, published broadcast, and [OriginDynamic]), and its consumers +// (each producer, published broadcast, and [OriginRequests]), and its consumers // then fail with [ErrClosed]. Keep an owner reachable for as long as the origin // should live. type OriginProducer struct { @@ -35,18 +35,18 @@ func (o *OriginProducer) Consume() *OriginConsumer { return &OriginConsumer{inner: o.inner.Consume()} } -// Dynamic advertises prefix and serves the requests beneath it. +// Requests advertises prefix and serves the requests beneath it. // // A route claims prefix and every path beneath it ("" claims every path). A // service that only serves some of them rejects the rest as they are requested. -// Create, Dynamic if tracks are served on demand, populate, then +// Create, Requests if tracks are served on demand, populate, then // [BroadcastProducer.Announce]. -func (o *OriginProducer) Dynamic(prefix string) (*OriginDynamic, error) { - inner, err := o.inner.Dynamic(prefix) +func (o *OriginProducer) Requests(prefix string) (*OriginRequests, error) { + inner, err := o.inner.Requests(prefix) if err != nil { return nil, err } - return &OriginDynamic{inner: inner}, nil + return &OriginRequests{inner: inner}, nil } // CreateBroadcast creates a broadcast at the given path, returning the producer @@ -64,14 +64,14 @@ func (o *OriginProducer) CreateBroadcast(path string) (*BroadcastProducer, error return &BroadcastProducer{inner: inner}, nil } -// OriginDynamic streams requests for paths with no existing exact-path broadcast. -type OriginDynamic struct { - inner *ffi.MoqOriginDynamic +// OriginRequests streams requests for paths with no existing exact-path broadcast. +type OriginRequests struct { + inner *ffi.MoqOriginRequests } // RequestedBroadcast blocks until a consumer requests a path with no existing // exact-path broadcast. -func (d *OriginDynamic) RequestedBroadcast(ctx context.Context) (*BroadcastRequest, error) { +func (d *OriginRequests) RequestedBroadcast(ctx context.Context) (*BroadcastRequest, error) { inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedBroadcast) if err != nil { return nil, err @@ -81,12 +81,12 @@ func (d *OriginDynamic) RequestedBroadcast(ctx context.Context) (*BroadcastReque // All ranges over requested broadcasts until the stream errors or the loop // breaks. -func (d *OriginDynamic) All(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { +func (d *OriginRequests) All(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { return bridge.Seq(ctx, d.RequestedBroadcast) } // Cancel stops serving requested broadcasts and retracts the route. -func (d *OriginDynamic) Cancel() { +func (d *OriginRequests) Cancel() { d.inner.Cancel() } diff --git a/go/wrapper/origin_internal_test.go b/go/wrapper/origin_internal_test.go index 467d0d018ea..933909daca3 100644 --- a/go/wrapper/origin_internal_test.go +++ b/go/wrapper/origin_internal_test.go @@ -35,15 +35,15 @@ func TestOriginEndsWithItsProducer(t *testing.T) { } } -// An OriginDynamic owns its origin: once the collector finalizes the last +// An OriginRequests owns its origin: once the collector finalizes the last // OriginProducer, the route still serves and a consumer made earlier still // resolves through it. -func TestDynamicKeepsTheOrigin(t *testing.T) { +func TestRequestsKeepTheOrigin(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() origin := NewOriginProducer() - dynamic, err := origin.Dynamic("") + dynamic, err := origin.Requests("") if err != nil { t.Fatal(err) } diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 4244c48c8b0..3cf02254a65 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -27,13 +27,13 @@ func NewBroadcastProducer() (*BroadcastProducer, error) { return &BroadcastProducer{inner: inner}, nil } -// Dynamic accepts requests for tracks that are not published yet. -func (b *BroadcastProducer) Dynamic() (*BroadcastDynamic, error) { - inner, err := b.inner.Dynamic() +// Requests accepts requests for tracks that are not published yet. +func (b *BroadcastProducer) Requests() (*BroadcastRequests, error) { + inner, err := b.inner.Requests() if err != nil { return nil, err } - return &BroadcastDynamic{inner: inner}, nil + return &BroadcastRequests{inner: inner}, nil } // Announce advertises this broadcast's exact path as a route. @@ -80,13 +80,13 @@ func (b *BroadcastProducer) Close() error { return b.inner.Close() } -// BroadcastDynamic is a stream of subscriber-requested tracks. -type BroadcastDynamic struct { - inner *ffi.MoqBroadcastDynamic +// BroadcastRequests is a stream of subscriber-requested tracks. +type BroadcastRequests struct { + inner *ffi.MoqBroadcastRequests } // RequestedTrack waits for the next subscriber-requested track. -func (d *BroadcastDynamic) RequestedTrack(ctx context.Context) (*TrackRequest, error) { +func (d *BroadcastRequests) RequestedTrack(ctx context.Context) (*TrackRequest, error) { inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedTrack) if err != nil { return nil, err @@ -95,12 +95,12 @@ func (d *BroadcastDynamic) RequestedTrack(ctx context.Context) (*TrackRequest, e } // All ranges over subscriber-requested tracks until the dynamic source ends. -func (d *BroadcastDynamic) All(ctx context.Context) iter.Seq2[*TrackRequest, error] { +func (d *BroadcastRequests) All(ctx context.Context) iter.Seq2[*TrackRequest, error] { return bridge.Seq(ctx, d.RequestedTrack) } // Cancel stops the dynamic request stream. -func (d *BroadcastDynamic) Cancel() { +func (d *BroadcastRequests) Cancel() { d.inner.Cancel() } @@ -123,13 +123,13 @@ func (r *TrackRequest) Demand() (*TrackDemand, error) { return &TrackDemand{inner: inner}, nil } -// Dynamic creates a fetch handler before accepting this requested track. -func (r *TrackRequest) Dynamic() (*TrackDynamic, error) { - inner, err := r.inner.Dynamic() +// Requests creates a fetch handler before accepting this requested track. +func (r *TrackRequest) Requests() (*TrackRequests, error) { + inner, err := r.inner.Requests() if err != nil { return nil, err } - return &TrackDynamic{inner: inner}, nil + return &TrackRequests{inner: inner}, nil } // Accept accepts the request as a raw track. For media, pass the request as media.Requested to an importer constructor. @@ -189,13 +189,13 @@ func (t *TrackProducer) Demand() (*TrackDemand, error) { return &TrackDemand{inner: inner}, nil } -// Dynamic serves fetches for groups that are not currently cached. -func (t *TrackProducer) Dynamic() (*TrackDynamic, error) { - inner, err := t.inner.Dynamic() +// Requests serves fetches for groups that are not currently cached. +func (t *TrackProducer) Requests() (*TrackRequests, error) { + inner, err := t.inner.Requests() if err != nil { return nil, err } - return &TrackDynamic{inner: inner}, nil + return &TrackRequests{inner: inner}, nil } // AppendGroup starts a new group; write frames into it, then Finish. @@ -303,13 +303,13 @@ func (g *GroupProducer) Abort(errorCode uint16) error { return g.inner.Abort(errorCode) } -// TrackDynamic yields the fills readers of one track need: runs of groups the cache lacks. -type TrackDynamic struct { - inner *ffi.MoqTrackDynamic +// TrackRequests yields the fills readers of one track need: runs of groups the cache lacks. +type TrackRequests struct { + inner *ffi.MoqTrackRequests } // RequestedFill waits for the next fill. -func (d *TrackDynamic) RequestedFill(ctx context.Context) (*Fill, error) { +func (d *TrackRequests) RequestedFill(ctx context.Context) (*Fill, error) { inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedFill) if err != nil { return nil, err @@ -318,16 +318,16 @@ func (d *TrackDynamic) RequestedFill(ctx context.Context) (*Fill, error) { } // All ranges over fills until the dynamic source ends. -func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*Fill, error] { +func (d *TrackRequests) All(ctx context.Context) iter.Seq2[*Fill, error] { return bridge.Seq(ctx, d.RequestedFill) } // Cancel stops current and future requested-fill waits. -func (d *TrackDynamic) Cancel() { +func (d *TrackRequests) Cancel() { d.inner.Cancel() } -// Fill is a run of groups readers miss, for a TrackDynamic to serve. +// Fill is a run of groups readers miss, for a TrackRequests to serve. // // Accept each group you have and DropGroups the rest; Next names what is still // owed, in the fill's order. Releasing the fill names whatever is left as dropped diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 2bdc084a66e..918be123dab 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `Origin.Producer.dynamic(prefix)` to `requests(prefix)`, and the `Origin.Dynamic` handle it returns to `Origin.Requests`; its methods are unchanged. + ### Published names refuse navigation segments Broadcast creation and route publication now refuse literal `.` and `..` segments, including segments in the producer's root. diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index e500038b24d..a3520c924d4 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -31,7 +31,7 @@ for (const scope of ["unscoped", "distinct-scopes", "producer-scopes", "rooted-p for (const observerCount of observerCounts) { const origin = new Producer(); const handles = Array.from({ length: routeCount }, (_, index) => - origin.dynamic(Path.from(`room/${index}`)), + origin.requests(Path.from(`room/${index}`)), ); const touched = Path.from(scope === "rooted-producers" ? "0" : "room/0"); let notifications = 0; @@ -247,7 +247,7 @@ const nextEpoch = Epoch.parse("01900000-0000-7000-8000-000000000002"); for (const pathCount of routeCounts) { for (const readerCount of observerCounts) { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room"), { epoch }); + const dynamic = origin.requestsRoute(Path.from("room"), { epoch }); const pending = dynamic.requested(); const requests = Array.from({ length: pathCount }, (_, index) => Array.from({ length: readerCount }, () => origin.consume().request(Path.from(`room/${index}`))), diff --git a/js/net/bench/requests.ts b/js/net/bench/requests.ts index 4afeb144fa2..c62a8c2bbfe 100644 --- a/js/net/bench/requests.ts +++ b/js/net/bench/requests.ts @@ -72,7 +72,7 @@ console.log("unrelated,waiters,cancel_us"); for (const unrelated of [8, 128, 2048]) for (const waiters of [1, 8, 32]) { const origin = new Producer(); - origin.dynamicRoute(Path.from("pending")); + origin.requestsRoute(Path.from("pending")); const held = Array.from({ length: unrelated }, (_, i) => origin.request(Path.from(`pending/${i}`))); const wire = wireOf(origin.consume()); const samples: number[] = []; @@ -106,9 +106,9 @@ for (const failovers of [0, 1, 16, 128]) const sources: Broadcast[] = []; for (let i = 0; i < failovers; i++) { const epoch = Epoch.mint(); - const primary = origin.dynamicRoute(prefix, { epoch, cost: 0n }); + const primary = origin.requestsRoute(prefix, { epoch, cost: 0n }); const ar = primary.requested(); - const br = origin.dynamicRoute(prefix, { epoch, cost: 1n }).requested(); + const br = origin.requestsRoute(prefix, { epoch, cost: 1n }).requested(); const held = origin.request(path); const source = new Broadcast(); sources.push(source); @@ -118,8 +118,8 @@ for (const failovers of [0, 1, 16, 128]) entries.push({ primary, ar, br, held }); } const epoch = Epoch.mint(); - origin.dynamicRoute(prefix, { epoch }); - for (let i = 0; i < extraRoutes; i++) origin.dynamicRoute(prefix, { epoch, cost: BigInt(i + 1) }); + origin.requestsRoute(prefix, { epoch }); + for (let i = 0; i < extraRoutes; i++) origin.requestsRoute(prefix, { epoch, cost: BigInt(i + 1) }); const pending = []; for (const entry of entries) { entry.primary.close(); @@ -181,7 +181,7 @@ for (const operation of ["cancel-forward", "cancel-reverse", "drain-local"]) const settled: number[] = []; for (let round = 0; round < 4; round++) { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("pending")); + const dynamic = origin.requestsRoute(Path.from("pending")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const names = Array.from({ length: paths }, (_, i) => Path.from(`pending/${i}`)); @@ -239,7 +239,7 @@ for (const survivor of [false, true]) const origin = new Producer(); const prefix = Path.from("competing"); const path = Path.from("competing/touched"); - for (let i = 0; i < routes; i++) origin.dynamicRoute(prefix, { cost: BigInt(i) }); + for (let i = 0; i < routes; i++) origin.requestsRoute(prefix, { cost: BigInt(i) }); const wire = wireOf(origin.consume()); const kept = survivor ? new AbortController() : undefined; const remaining = kept ? wire.demand(path, undefined, kept.signal).catch(() => {}) : undefined; diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 07a452d170e..77e02e4dbc0 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -59,7 +59,7 @@ class BroadcastState { requests = new Map(); lookups = new WeakMap(); // Whether something answers on-demand track requests: a wire layer's consumed broadcast, or a - // publisher that has started pulling them. The counterpart of a live Rust `broadcast::Dynamic`; + // publisher that has started pulling them. The counterpart of a live Rust `broadcast::Requests`; // without one, a track nobody publishes is `NotFound` rather than a request nobody will answer. served = false; // Live consumer handles sharing this state (see {@link Consumer.clone}). The broadcast diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 7a3a91ab78b..3524af006f7 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -9,7 +9,7 @@ import { type Dispose, race } from "@moq/signals"; import type * as Announce from "../announced.ts"; import { type Route, routesEqual } from "../hop.ts"; import { coveringPrefixes, hooks } from "../internal.ts"; -import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; +import type { Producer as OriginProducer, RequestSlot, Requests } from "../origin.ts"; import * as Path from "../path.ts"; import { after } from "../util/timer.ts"; import { wireOf } from "../wire.ts"; @@ -39,7 +39,7 @@ interface Interest { * request handing over to another never retracts it. The first reporter drives it. */ interface Reported { - handle: Dynamic; + handle: Requests; /** Each request's latest route for the prefix. */ readonly reporters: Map; } @@ -272,7 +272,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi } /** Answer the requests under a received route by consuming them from the session. */ -async function drive(handle: Dynamic, conn: Established): Promise { +async function drive(handle: Requests, conn: Established): Promise { const session = wireOf(conn); try { for await (const request of handle.requested()) { diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index 4c0df15b5db..378d492e971 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -263,7 +263,7 @@ test("a borrowed connection origin exposes live routes and dynamic serving", asy announcement = event; }); const prefix = Path.from("on-demand"); - const route = table.dynamicRoute(prefix); + const route = table.requestsRoute(prefix); await advance(); expect(announcement).toMatchObject({ kind: "start", prefix }); let withdrawal: unknown; diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index 4cd49d41d13..1d3f85e9e84 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -91,7 +91,7 @@ export const Cost = { /** * The path a route took through the mesh and what using it costs. * - * The metadata half of an advertisement: an origin `dynamic()` pairs it with the + * The metadata half of an advertisement: an origin `requests()` pairs it with the * pattern it covers, a broadcast `announce()` with the broadcast's exact path, and * an announce event carries it so consumers can read it back. */ diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index d125d687742..13af9244743 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -341,7 +341,7 @@ test("TRACK_STATUS outside the grant is refused on every draft", async () => { const session = new NativeSession(pair.server, version, true); const grant = new Signal(nothing()); const { pub, origin } = publisher(pair.server, { session, grant }); - const requests = origin.dynamicRoute(Path.from("room")).requested(); + const requests = origin.requestsRoute(Path.from("room")).requested(); const outside = requestStream(version); await pub.runTrackStatusRequest(trackStatus("room"), outside.stream); @@ -368,7 +368,7 @@ test("a second GOAWAY on an unanswered SUBSCRIBE closes the session", async () = const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.dynamic(Path.from("room")).requested(); + const requests = origin.requests(Path.from("room")).requested(); const held = requestStream(version); const task = pub.runSubscribe( @@ -424,7 +424,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.dynamicRoute(Path.from("room")).requested(); + const requests = origin.requestsRoute(Path.from("room")).requested(); // The broadcast is still resolving: its handler never answers. const early = requestStream(version); @@ -474,7 +474,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.dynamicRoute(Path.from("room")).requested(); + const requests = origin.requestsRoute(Path.from("room")).requested(); // The broadcast is still resolving: its handler never answers. const early = requestStream(version); @@ -675,7 +675,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces // Navigation segments cannot be published. A covering application // route still proves these reserved requests are refused before lookup. const navigation = path.split("/")[0] === "."; - const claim = navigation ? origin.dynamic(Path.empty()) : undefined; + const claim = navigation ? origin.requests(Path.empty()) : undefined; const requested = claim?.requested().next(); const broadcast = navigation ? undefined : publish(origin, Path.from(path)); const track = broadcast?.createTrack("video", { timescale: Timescale.MILLI }); @@ -1226,7 +1226,7 @@ test("a subscription outside a covering route's claim does not hear it", async ( const pair = createMockTransportPair(ALPN.DRAFT_19); const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("room/chat"))])); - const dynamic = chat.dynamicRoute(Path.from("room")); + const dynamic = chat.requestsRoute(Path.from("room")); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1258,8 +1258,8 @@ test("a subscription hears the best covering route its claim allows", async () = const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); const scoped = (path: string) => origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from(path))])); - const chat = scoped("room/chat").dynamicRoute(Path.from("room"), { cost: 1n }); - const video = scoped("room/video").dynamicRoute(Path.from("room"), { cost: 5n }); + const chat = scoped("room/chat").requestsRoute(Path.from("room"), { cost: 1n }); + const video = scoped("room/video").requestsRoute(Path.from("room"), { cost: 5n }); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1299,8 +1299,8 @@ test("a subscription hears a broader covering route when the narrowest cannot se }); const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/chat"))])) - .dynamicRoute(Path.from("tenant")); - const broad = origin.dynamicRoute(Path.empty(), { cost: 5n }); + .requestsRoute(Path.from("tenant")); + const broad = origin.requestsRoute(Path.empty(), { cost: 5n }); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1337,7 +1337,7 @@ test("a covering route's claim is compared relative to the served root", async ( requiresSolicitation: true, }); const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/room/chat"))])); - const dynamic = chat.dynamicRoute(Path.from("tenant/room")); + const dynamic = chat.requestsRoute(Path.from("tenant/room")); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 16a5681dd6d..73c2fa7ee52 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -964,7 +964,7 @@ test("integration: a peer is served the cheaper route it was offered, not the lo const epoch = Epoch.mint(); const local = origin.createBroadcast(path); local.announce({ epoch, cost: 5n }); - const dynamic = origin.dynamicRoute(path, { epoch, cost: 1n }); + const dynamic = origin.requestsRoute(path, { epoch, cost: 1n }); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), @@ -2734,7 +2734,7 @@ test("create then announce is discoverable on the wire", async () => { test("a handle serves a request under live/** over the wire", async () => { const pair = createMockTransportPair(Lite.ALPN_06); const origin = new OriginProducer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const serving = (async () => { for await (const request of handle.requested()) { diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index d827efb477b..97239c322b4 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -2481,7 +2481,7 @@ test("lite draft-06: closing while announces wait for the setup grant does not h test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { @@ -2522,7 +2522,7 @@ test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", as test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { @@ -2585,7 +2585,7 @@ test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it" test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { diff --git a/js/net/src/lite/tracks.test.ts b/js/net/src/lite/tracks.test.ts index 5c7a9185085..fbb96f06756 100644 --- a/js/net/src/lite/tracks.test.ts +++ b/js/net/src/lite/tracks.test.ts @@ -408,7 +408,7 @@ test("a newer listing of a broadcast on one session replaces the older", async ( test("a path served through a covering prefix route is NOT_FOUND", async () => { const origin = new OriginProducer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); void (async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); @@ -425,7 +425,7 @@ test("a path served through a covering prefix route is NOT_FOUND", async () => { test("a local read through a covering prefix route is NOT_FOUND, and through its exact route lists", async () => { const origin = new OriginProducer(); for (const prefix of ["room", "room/bob"]) { - const dynamic = origin.dynamicRoute(Path.from(prefix)); + const dynamic = origin.requestsRoute(Path.from(prefix)); void (async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); @@ -523,7 +523,7 @@ test("an epoch-pinned front lists its serving route's tracks", async () => { test("an epoch-pinned front served through a covering prefix route is NOT_FOUND", async () => { const origin = new OriginProducer(); - const dynamic = origin.dynamicRoute(Path.from("room"), { + const dynamic = origin.requestsRoute(Path.from("room"), { epoch: Epoch.parse("01900000-0000-7000-8000-000000000001"), }); void (async () => { diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index 89d2f5e207c..26746dbddfd 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -28,7 +28,7 @@ test.each(["lite", "ietf", "fetch", "track"] as const)( async (kind) => { const pair = createMockTransportPair(); const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const publisher = kind === "ietf" ? new IetfPublisher({ @@ -107,7 +107,7 @@ test.each(["lite", "ietf", "fetch", "track"] as const)( test("last cancellation finishes the internal wait and discards a late consumer", async () => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const hold = new AbortController(); const wait = wireOf(origin.consume()).demand(path, undefined, hold.signal); @@ -150,7 +150,7 @@ test.each([true, false])( "shared local/remote demand is withdrawn whichever leaves last (local first: %s)", async (localFirst) => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const hold = new AbortController(); const wait = wireOf(origin.consume()) @@ -195,7 +195,7 @@ test.each([ ] as const)("cancelling one waiter preserves a second %s requester across %i routes", async (other, routes) => { const origin = new Producer(); const prefix = Path.from("room"); - const dynamics = Array.from({ length: routes }, (_, i) => origin.dynamicRoute(prefix, { cost: BigInt(i) })); + const dynamics = Array.from({ length: routes }, (_, i) => origin.requestsRoute(prefix, { cost: BigInt(i) })); const hold = new AbortController(); const wire = wireOf(origin.consume()); const first = wire.demand(path, undefined, hold.signal).catch(() => undefined); @@ -224,7 +224,7 @@ test.each([ test("an already cancelled signal never enqueues or subscribes to abort", async () => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const hold = new AbortController(); hold.abort(); @@ -246,7 +246,7 @@ test.each(["accept", "reject", "cancel"] as const)( "%s releases the abort listener without closing a shared producer", async (action) => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const source = new Broadcast(); const keeper = source.consume(); @@ -282,12 +282,12 @@ test.each(["accept", "reject", "cancel"] as const)( test("a pending local request changing routes withdraws the old route's work", async () => { const origin = new Producer(); - const first = origin.dynamicRoute(Path.from("room"), { cost: 5n }); + const first = origin.requestsRoute(Path.from("room"), { cost: 5n }); const a = first.requested(); const local = origin.request(path); const old = (await a.next()).value; if (!old) throw new Error("expected a pending request"); - const second = origin.dynamicRoute(Path.from("room"), { cost: 1n }); + const second = origin.requestsRoute(Path.from("room"), { cost: 1n }); const b = second.requested(); const fresh = (await b.next()).value; if (!fresh) throw new Error("expected a pending request"); @@ -315,7 +315,7 @@ test("a pending local request changing routes withdraws the old route's work", a test("cancelling an old epoch waiter does not decrement a new epoch's demand", async () => { const origin = new Producer(); const epoch = Epoch.mint(); - const dynamic = origin.dynamicRoute(Path.from("room"), { epoch }); + const dynamic = origin.requestsRoute(Path.from("room"), { epoch }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -347,8 +347,8 @@ test.each(["answer", "release"] as const)( const origin = new Producer(); const epoch = Epoch.mint(); const prefix = Path.from("room"); - const a = origin.dynamicRoute(prefix, { epoch, cost: 1n }); - const b = origin.dynamicRoute(prefix, { epoch, cost: 5n }); + const a = origin.requestsRoute(prefix, { epoch, cost: 1n }); + const b = origin.requestsRoute(prefix, { epoch, cost: 5n }); const ar = a.requested(); const br = b.requested(); const held = origin.request(path); @@ -356,7 +356,7 @@ test.each(["answer", "release"] as const)( hooks.acceptRequest((await ar.next()).value, source); await settle(); // Another instance wins new requests. The old handle stays on its own epoch. - const c = origin.dynamicRoute(prefix, { epoch: Epoch.mint(), cost: 0n }); + const c = origin.requestsRoute(prefix, { epoch: Epoch.mint(), cost: 0n }); a.close(); await settle(); const pending = (await br.next()).value; @@ -406,8 +406,8 @@ test.each([0, 1])( const sources: Broadcast[] = []; for (let i = 0; i < 2; i++) { const epoch = Epoch.parse(`00000000-0000-7000-8000-00000000000${i + 1}`); - const primary = origin.dynamicRoute(prefix, { epoch, cost: 0n }); - const standby = origin.dynamicRoute(prefix, { epoch, cost: 1n }); + const primary = origin.requestsRoute(prefix, { epoch, cost: 0n }); + const standby = origin.requestsRoute(prefix, { epoch, cost: 1n }); const ar = primary.requested(); const br = standby.requested(); const held = origin.request(path); @@ -417,7 +417,7 @@ test.each([0, 1])( await settle(); entries.push({ primary, ar, br, held }); } - origin.dynamicRoute(prefix, { epoch: Epoch.parse("00000000-0000-7000-8000-000000000003") }); + origin.requestsRoute(prefix, { epoch: Epoch.parse("00000000-0000-7000-8000-000000000003") }); const pending = []; for (const entry of entries) { entry.primary.close(); @@ -458,7 +458,7 @@ test.each([0, 1])( test("a normal FETCH request FIN keeps unanswered origin demand alive", async () => { const pair = createMockTransportPair(); const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const publisher = new LitePublisher(pair.server, LiteVersion.DRAFT_06, randomHop(), origin.consume()); let input!: ReadableStreamDefaultController; @@ -499,7 +499,7 @@ test.each(["accept", "reject"] as const)( "cancellation racing %s releases only the cancelled waiter", async (answer) => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -528,7 +528,7 @@ test.each(["accept", "reject"] as const)( test.each([true, false])("last waiter cancellation and accept in one turn (cancel first: %s)", async (cancelFirst) => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -569,7 +569,7 @@ test.each([true, false])("last waiter cancellation and accept in one turn (cance test.each([true, false])("local demand survives same-turn cancellation only when rejoined: %s", async (rejoin) => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const local = origin.request(path); const hold = new AbortController(); @@ -615,7 +615,7 @@ test.each([true, false])("local demand survives same-turn cancellation only when test("queued cancellation preserves order, replacement identity, and empty-queue wakeup", async () => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const holds = Array.from({ length: 4 }, () => new AbortController()); @@ -641,7 +641,7 @@ test("queued cancellation preserves order, replacement identity, and empty-queue test("reset clears queued work while a handler is suspended and admits the new epoch", async () => { const origin = new Producer(); const epoch = Epoch.mint(); - const dynamic = origin.dynamicRoute(Path.from("room"), { epoch }); + const dynamic = origin.requestsRoute(Path.from("room"), { epoch }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const waits = [path, Path.from("room/obsolete")].map((name) => wire.demand(name, epoch).catch(() => {})); @@ -665,7 +665,7 @@ test("reset clears queued work while a handler is suspended and admits the new e test("concurrent handlers consume queued requests once across empty batches", async () => { const origin = new Producer(); - const dynamic = origin.dynamicRoute(Path.from("room")); + const dynamic = origin.requestsRoute(Path.from("room")); const handlers = [dynamic.requested(), dynamic.requested()]; const wire = wireOf(origin.consume()); const waits: Promise[] = []; diff --git a/js/net/src/origin-request.test.ts b/js/net/src/origin-request.test.ts index 47a9e024dfa..96bc16cc820 100644 --- a/js/net/src/origin-request.test.ts +++ b/js/net/src/origin-request.test.ts @@ -142,7 +142,7 @@ test("announced requests follow the serving prefix, ignoring descendants and re- const origin = new Producer(); const source = new BroadcastProducer(); const keeper = source.consume(); - const prefix = origin.dynamicRoute(Path.from("pool")); + const prefix = origin.requestsRoute(Path.from("pool")); const serving = (async () => { for await (const request of prefix.requested()) hooks.acceptRequest(request, source.consume()); })(); @@ -178,7 +178,7 @@ test("announced requests follow the serving prefix, ignoring descendants and re- test("an announced request ignores a covering route whose scope excludes its path", async () => { const origin = new Producer(); const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); - const prefix = scoped.dynamicRoute(Path.from("room/alice")); + const prefix = scoped.requestsRoute(Path.from("room/alice")); const exact = origin.createBroadcast(Path.from("room/alice")); exact.announce({ cost: 5n }); const request = origin.request(Path.from("room/alice"), { announced: true }); @@ -195,7 +195,7 @@ test("an announced request follows an epochless route replaced by an identical o const origin = new Producer(); const path = Path.from("room"); const serve = (source: BroadcastProducer) => { - const route = origin.dynamicRoute(path); + const route = origin.requestsRoute(path); void (async () => { for await (const request of route.requested()) hooks.acceptRequest(request, source.consume()); })(); @@ -226,7 +226,7 @@ test("an origin closing settles an announced request a served route resolved", a const origin = new Producer(); const path = Path.from("room"); const source = new BroadcastProducer(); - const route = origin.dynamicRoute(path); + const route = origin.requestsRoute(path); void (async () => { for await (const request of route.requested()) hooks.acceptRequest(request, source.consume()); })(); @@ -245,7 +245,7 @@ test("an origin closing settles an announced request a served route resolved", a test("an announced request keeps a refusal that lands with a re-price", async () => { const origin = new Producer(); const path = Path.from("room"); - const route = origin.dynamicRoute(path); + const route = origin.requestsRoute(path); const requested = route.requested(); const request = origin.request(path, { announced: true }); const pending = (await requested.next()).value; @@ -276,7 +276,7 @@ for (const received of [false, true]) { const events: string[] = []; const stop = wireOf(origin).interests((prefix, wanted) => events.push(`${prefix} ${wanted}`)); const source = new BroadcastProducer(); - const route = received ? wireOf(origin).receive(path, Route.default) : origin.dynamicRoute(path); + const route = received ? wireOf(origin).receive(path, Route.default) : origin.requestsRoute(path); void (async () => { for await (const request of route.requested()) hooks.acceptRequest(request, source.consume()); })(); @@ -300,7 +300,7 @@ for (const received of [false, true]) { test("a claim answer is hidden until published under its own epoch", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; @@ -322,7 +322,7 @@ test("a claim answer is hidden until published under its own epoch", async () => test("a prepared answer cannot announce itself before publish", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; @@ -345,7 +345,7 @@ test("a prepared answer cannot announce itself before publish", async () => { test("an application claim cannot accept an arbitrary broadcast", async () => { const origin = new Producer(); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const request = origin.request(Path.from("room/alice")); const pending: Request | undefined = (await requests.next()).value; @@ -359,7 +359,7 @@ test("an application claim cannot accept an arbitrary broadcast", async () => { test("closing a prepared answer refuses the request and closes its broadcast", async () => { const origin = new Producer(); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const request = origin.request(Path.from("room/alice")); const pending = (await requests.next()).value; @@ -378,7 +378,7 @@ test("closing a prepared answer refuses the request and closes its broadcast", a test("an answer prepared for an abandoned claim is never announced", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; @@ -398,7 +398,7 @@ for (const cleanup of ["close", "dispose", "broadcast"] as const) { test(`prepared ${cleanup} releases shared requesters and admits a new request`, async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const first = origin.request(path); const pending = (await requests.next()).value; @@ -434,7 +434,7 @@ for (const cleanup of ["close", "dispose", "broadcast"] as const) { test("a throwing handler disposes its prepared answer and leaves the claim answerable", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const failed = (async () => { for await (const request of claim.requested()) { using prepared = request.prepare(Epoch.mint()); @@ -463,7 +463,7 @@ test("a throwing handler disposes its prepared answer and leaves the claim answe test("using transfers a published answer without closing it on disposal", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; @@ -492,7 +492,7 @@ for (const cleanup of ["close", "dispose", "broadcast", "publish"] as const) { test(`an old prepared ${cleanup} cannot refuse the next request for its path`, async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.dynamic(Path.from("room")); + const claim = origin.requests(Path.from("room")); const requests = claim.requested(); const first = origin.request(path); const pending = (await requests.next()).value; @@ -527,7 +527,7 @@ for (const cleanup of ["close", "dispose", "broadcast", "publish"] as const) { test("a forwarded request cannot be prepared by setting a publisher property", async () => { const origin = new Producer(); - const route = origin.dynamicRoute(Path.from("room")); + const route = origin.requestsRoute(Path.from("room")); const requests = route.requested(); const request = origin.request(Path.from("room/alice")); const pending = (await requests.next()).value; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 6717f94274b..67ebcbf626c 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -91,7 +91,7 @@ test("a borrowed table exposes dynamic serving and a live scoped broadcast map", expect(changes.some((value) => value.get(localPath)?.cost === 4n)).toBe(true); const prefix = Path.from("room"); - const dynamic = table.dynamicRoute(prefix, { cost: 2n }); + const dynamic = table.requestsRoute(prefix, { cost: 2n }); await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().get(prefix)).toEqual(Route.normalize({ cost: 2n })); dynamic.updateRoute({ cost: 3n }); @@ -1081,7 +1081,7 @@ test("the cheapest received route competes with the local broadcast, not the new test("a handle serves a request under live", async () => { const origin = new Producer(); const consumer = origin.consume(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); expect(await consumer.announced().next()).toMatchObject({ prefix: Path.from("live"), kind: "start" }); @@ -1108,7 +1108,7 @@ test("a handle serves a request under live", async () => { test("an originated route refuses a broadcast a session delivered", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const it = handle.requested(); const pending = wireOf(origin.consume()).demand(Path.from("live/cam")); const { value: req } = await it.next(); @@ -1151,7 +1151,7 @@ test("a received route still serves a broadcast a session delivered", async () = test("a re-priced route is delivered as an update", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const announced = origin.consume().announced(); expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "start" }); handle.updateRoute({ cost: 5n }); @@ -1164,7 +1164,7 @@ test("a re-priced route is delivered as an update", async () => { test("an accepted dynamic broadcast is retired when it closes", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const path = Path.from("live/cam"); const request = origin.request(path); const it = handle.requested(); @@ -1196,7 +1196,7 @@ test("an accepted dynamic broadcast is retired when it closes", async () => { test("reject surfaces the error from demand", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); const pending = wireOf(consumer).demand(Path.from("live/cam")); @@ -1213,8 +1213,8 @@ test("a peer is offered and served the cheapest originated route", async () => { const origin = new Producer(); const consumer = origin.consume(); const prefix = Path.from("live"); - const cheap = origin.dynamicRoute(prefix, { epoch: EPOCH, cost: 1n }); - const pricey = origin.dynamicRoute(prefix, { epoch: EPOCH, cost: 10n }); + const cheap = origin.requestsRoute(prefix, { epoch: EPOCH, cost: 1n }); + const pricey = origin.requestsRoute(prefix, { epoch: EPOCH, cost: 10n }); expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( Route.normalize({ epoch: EPOCH, cost: 1n }), @@ -1245,7 +1245,7 @@ test("a peer is offered and served the cheapest originated route", async () => { test("a shared reject still surfaces from demand", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -1267,7 +1267,7 @@ test("a shared reject still surfaces from demand", async () => { test("a request refusal does not keep demand from asking again", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -1291,7 +1291,7 @@ test("a request refusal does not keep demand from asking again", async () => { test("advancing requested without settling rejects the previous request", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -1315,7 +1315,7 @@ test("advancing requested without settling rejects the previous request", async test("a rejected request is not asked of the same route again", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const it = handle.requested(); const request = origin.request(Path.from("live/cam")); @@ -1628,8 +1628,8 @@ test("a request ends when its winning source goes, even with a standby", async ( test("a refusal is terminal, never falling through to a broader route", async () => { const origin = new Producer(); const consumer = origin.consume(); - const narrow = origin.dynamicRoute(Path.from("live")); - const wide = origin.dynamicRoute(Path.from("")); + const narrow = origin.requestsRoute(Path.from("live")); + const wide = origin.requestsRoute(Path.from("")); const wideRequests = wide.requested(); const wideAsked = wideRequests.next(); @@ -1665,8 +1665,8 @@ test("a better route's refusal ends a served request", async () => { // The narrower route is asked while the broad one keeps serving, then says no. A // costlier sibling at the same prefix could serve, but is never asked. - const narrow = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH, cost: 1n }); - const sibling = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH, cost: 2n }); + const narrow = origin.requestsRoute(Path.from("live"), { epoch: EPOCH, cost: 1n }); + const sibling = origin.requestsRoute(Path.from("live"), { epoch: EPOCH, cost: 2n }); const siblingRequests = sibling.requested(); const siblingAsked = siblingRequests.next(); const { value: req } = await narrow.requested().next(); @@ -1691,14 +1691,14 @@ test("a better route's refusal ends a served request", async () => { test("a refusal from a superseded route does not end the request", async () => { const origin = new Producer(); const consumer = origin.consume(); - const wide = origin.dynamicRoute(Path.from("")); + const wide = origin.requestsRoute(Path.from("")); const wideRequests = wide.requested(); const request = consumer.request(Path.from("live/cam")); const { value: stale } = await wideRequests.next(); // A narrower route takes over the pending request before the broad one answers. - const narrow = origin.dynamicRoute(Path.from("live")); + const narrow = origin.requestsRoute(Path.from("live")); const { value: req } = await narrow.requested().next(); stale?.reject(new Error("unserved")); await settle(); @@ -1720,7 +1720,7 @@ test("a refusal from a superseded route does not end the request", async () => { test("a refusal from a route a local broadcast superseded does not end the request", async () => { const origin = new Producer(); const consumer = origin.consume(); - const wide = origin.dynamicRoute(Path.from("live")); + const wide = origin.requestsRoute(Path.from("live")); const wideRequests = wide.requested(); const request = consumer.request(Path.from("live/cam")); @@ -1746,7 +1746,7 @@ test("a refusal from a route a local broadcast superseded does not end the reque test("close rejects queued requests as unroutable", async () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live")); + const handle = origin.requestsRoute(Path.from("live")); const waiting = handle.requested().next(); const request = origin.request(Path.from("live/cam")); const { value: req } = await waiting; @@ -1905,7 +1905,7 @@ test("a rooted producer shares the table and enforces its pattern union", async await new Promise((resolve) => setImmediate(resolve)); expect(() => scoped.request(Path.from("room/alice/deep"))).toThrow("outside the origin scope"); await new Promise((resolve) => setImmediate(resolve)); - expect(() => scoped.dynamicRoute(Path.from("other"))).toThrow("outside the origin scope"); + expect(() => scoped.requestsRoute(Path.from("other"))).toThrow("outside the origin scope"); await new Promise((resolve) => setImmediate(resolve)); expect(() => scoped.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("other/**")]))).toThrow( "do not overlap", @@ -1933,8 +1933,8 @@ test("closing a derived handle releases only what it registered", async () => { const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("room"))])); const wide = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const inside = publish(scoped, Path.from("room/alice")); - const claim = scoped.dynamic(Path.from("room/claim")); - const ranked = wide.dynamic(Path.from("ranked")); + const claim = scoped.requests(Path.from("room/claim")); + const ranked = wide.requests(Path.from("ranked")); const peer = serve(scoped, Path.from("room/peer"), provider(new BroadcastProducer())); scoped.close(); @@ -1948,7 +1948,7 @@ test("closing a derived handle releases only what it registered", async () => { // A released handle refuses new work, so a borrower notices instead of publishing into nothing. expect(() => scoped.createBroadcast(Path.from("room/late"))).toThrow("origin handle is closed"); - scoped.dynamic(Path.from("room/late")).close(); + scoped.requests(Path.from("room/late")).close(); expect((await announcedView(origin)).peek().has(Path.from("room/late"))).toBe(false); // The owner and fresh handles keep working. @@ -2044,7 +2044,7 @@ test("closing a derived handle withdraws the sessions and answers attached throu test("a scoped dynamic only serves allowed paths and presents relative requests", async () => { const origin = new Producer(); const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); - const dynamic = scoped.dynamicRoute(Path.from("room")); + const dynamic = scoped.requestsRoute(Path.from("room")); await new Promise((resolve) => setImmediate(resolve)); expect(dynamic.prefix).toBe(Path.from("room")); const denied = origin.request(Path.from("tenant/room/alice/video")); @@ -2092,7 +2092,7 @@ test("a scoped dynamic is announced only to readers its scope can serve", async const origin = new Producer(); const dynamic = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/chat")])) - .dynamicRoute(Path.empty()); + .requestsRoute(Path.empty()); const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/chat")])); const video = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/video")])); await new Promise((resolve) => setImmediate(resolve)); @@ -2109,8 +2109,8 @@ test("a scoped dynamic is announced only to readers its scope can serve", async test("a rooted reader presents the most specific covering route", async () => { const origin = new Producer(); - const narrow = origin.dynamicRoute(Path.from("room/alice"), { cost: 9n }); - const broad = origin.dynamicRoute(Path.from("room"), { cost: 1n }); + const narrow = origin.requestsRoute(Path.from("room/alice"), { cost: 9n }); + const broad = origin.requestsRoute(Path.from("room"), { cost: 1n }); const rooted = origin.scope(Path.from("room/alice"), new Path.Patterns([Path.Pattern.all()])); await new Promise((resolve) => setImmediate(resolve)); expect((await announcedView(rooted)).peek().get(Path.empty())?.cost).toBe(9n); @@ -2124,8 +2124,8 @@ test("a rooted reader presents the most specific covering route", async () => { test("a scoped reader picks the best route its scope can see at a prefix", async () => { const origin = new Producer(); const scoped = (pattern: string) => origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse(pattern)])); - const chat = scoped("*/chat").dynamicRoute(Path.empty(), { cost: 1n }); - const video = scoped("*/video").dynamicRoute(Path.empty(), { cost: 5n }); + const chat = scoped("*/chat").requestsRoute(Path.empty(), { cost: 1n }); + const video = scoped("*/video").requestsRoute(Path.empty(), { cost: 5n }); const reader = scoped("room/video"); await new Promise((resolve) => setImmediate(resolve)); @@ -2152,7 +2152,7 @@ test("a scoped reader sees a local broadcast that only loses to a route outside const origin = new Producer(); const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])) - .dynamicRoute(Path.from("room/alice"), { epoch: EPOCH, cost: 0n }); + .requestsRoute(Path.from("room/alice"), { epoch: EPOCH, cost: 0n }); const local = origin.createBroadcast(Path.from("room/alice")); local.announce({ epoch: EPOCH, cost: 5n }); const reader = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*")])); @@ -2186,7 +2186,7 @@ test("broadcast handles carry the path they were created or requested at", async expect(clone?.path).toBe(Path.from("room/alice")); // A dynamic handler's standalone broadcast is named by the request, too. - const dynamic = origin.dynamicRoute(Path.from("live")); + const dynamic = origin.requestsRoute(Path.from("live")); const request = origin.request(Path.from("live/bob")); const pending = await dynamic.requested().next(); const served = new BroadcastProducer(); @@ -2229,7 +2229,7 @@ test("demand held across an epoch change asks again, unless it named the old epo const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("live/cam"); - const handle = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); + const handle = origin.requestsRoute(Path.from("live"), { epoch: EPOCH }); const it = handle.requested(); const pinned = wireOf(consumer).demand(path, EPOCH); const unpinned = wireOf(consumer).demand(path); @@ -2258,7 +2258,7 @@ test("a demand refused across an epoch change does not lose the carried demand's const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("live/cam"); - const handle = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); + const handle = origin.requestsRoute(Path.from("live"), { epoch: EPOCH }); const it = handle.requested(); const unpinned = wireOf(consumer).demand(path); const pinned = wireOf(consumer).demand(path, EPOCH); @@ -2278,7 +2278,7 @@ test("a demand refused across an epoch change does not lose the carried demand's test("demand held across an epoch round trip still asks again", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const handle = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); + const handle = origin.requestsRoute(Path.from("live"), { epoch: EPOCH }); const it = handle.requested(); const demanded = wireOf(origin.consume()).demand(path); const { value: req } = await it.next(); @@ -2304,7 +2304,7 @@ test("demand held across an epoch round trip still asks again", async () => { test("re-pricing from the current route keeps the epoch", () => { const origin = new Producer(); - const handle = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH, cost: 5n }); + const handle = origin.requestsRoute(Path.from("live"), { epoch: EPOCH, cost: 5n }); handle.updateRoute({ ...handle.route, cost: 1n }); expect(handle.route).toEqual(Route.normalize({ epoch: EPOCH, cost: 1n })); @@ -2369,7 +2369,7 @@ test("a request held across an epoch change asks again and never takes the old h const origin = new Producer(); const prefix = Path.from("room"); const path = Path.from("room/cam"); - const dynamic = origin.dynamicRoute(prefix, { epoch: EPOCH }); + const dynamic = origin.requestsRoute(prefix, { epoch: EPOCH }); const unpinned = origin.consume().request(path); const pinned = origin.consume().request(path, { epoch: EPOCH }); const requests = dynamic.requested(); @@ -2403,7 +2403,7 @@ test.each([ const origin = new Producer(); const prefix = Path.from("live"); const path = Path.from("live/cam"); - const dynamic = origin.dynamicRoute(prefix, { epoch: from }); + const dynamic = origin.requestsRoute(prefix, { epoch: from }); const requests = dynamic.requested(); const announced = origin.consume().announced(); expect(await announced.next()).toMatchObject({ prefix, kind: "start" }); @@ -2485,7 +2485,7 @@ test.each([ test("an epoch update does not retain an answer that already closed", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); + const dynamic = origin.requestsRoute(Path.from("live"), { epoch: EPOCH }); const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new BroadcastProducer(); @@ -2513,7 +2513,7 @@ test("an epoch update does not retain an answer that already closed", async () = test("an unread resolved request retains its broadcast until its last handle closes", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); + const dynamic = origin.requestsRoute(Path.from("live"), { epoch: EPOCH }); const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new BroadcastProducer(); @@ -2534,7 +2534,7 @@ test("an unread resolved request retains its broadcast until its last handle clo test("a session's re-request after an epoch update asks the handler again", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); + const dynamic = origin.requestsRoute(Path.from("live"), { epoch: EPOCH }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); @@ -2564,8 +2564,8 @@ test("an update does not override route selection: another claim at the epoch ke const origin = new Producer(); const prefix = Path.from("live"); const path = Path.from("live/cam"); - const first = origin.dynamicRoute(prefix, { epoch: EPOCH, cost: 1n }); - const second = origin.dynamicRoute(prefix, { epoch: EPOCH, cost: 5n }); + const first = origin.requestsRoute(prefix, { epoch: EPOCH, cost: 1n }); + const second = origin.requestsRoute(prefix, { epoch: EPOCH, cost: 5n }); const firstRequests = first.requested(); const secondRequests = second.requested(); @@ -2636,7 +2636,7 @@ test("published names refuse navigation segments", () => { for (const name of ["room/./cam", "room/../cam", ".", ".."]) { const path = Path.from(name); expect(() => origin.createBroadcast(path)).toThrow(); - expect(() => origin.dynamic(path)).toThrow(); + expect(() => origin.requests(path)).toThrow(); expect(() => wireOf(origin).receive(path, Route.default)).toThrow(); } const scoped = origin.scope(Path.from("room/.."), new Path.Patterns([Path.Pattern.all()])); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 2c54c2cb456..e8a3daa393a 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -333,7 +333,7 @@ function unroutable(): StreamError { return new StreamError(StreamCode.Unroutable, { message: "unroutable" }); } -/** A served route from {@link Producer.dynamic}: the queue a handler drains. */ +/** A served route from {@link Producer.requests}: the queue a handler drains. */ class ServeState { readonly root: Path.Valid; /** The prefix the route claims; every served path is at or beneath it. */ @@ -1065,14 +1065,14 @@ export interface Table { /** The available broadcasts under `scope`, as a live stream; see {@link Consumer.announced}. */ announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer; - /** Advertise a prefix and serve requests under it; see {@link Producer.dynamic}. */ - dynamic(prefix: Path.Valid): Dynamic; + /** Advertise a prefix and serve requests under it; see {@link Producer.requests}. */ + requests(prefix: Path.Valid): Requests; /** Insert a route with protocol metadata. @internal */ - dynamicRoute( + requestsRoute( prefix: Path.Valid, route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, - ): Dynamic; + ): Requests; } /** Options for resolving a broadcast path. */ @@ -1098,7 +1098,7 @@ export interface RequestOptions { * Create a broadcast, populate its tracks, then {@link broadcast.Producer.announce}: an * exact-path subscribe before the tracks exist is refused, and nobody can see or reach a * broadcast until it announces. To publish broadcasts on demand instead, claim a prefix with - * {@link dynamic} and answer the requests beneath it. + * {@link requests} and answer the requests beneath it. * * @public */ @@ -1267,15 +1267,15 @@ export class Producer implements Table { * advertises the covering prefix and rejects the rest as they are requested; * consumers narrow with a {@link Path.Pattern} locally. The advertisement is * visible to {@link Consumer.announced} and forwarded by sessions for as long as - * the returned {@link Dynamic} lives. A consumer resolving a path under it that + * the returned {@link Requests} lives. A consumer resolving a path under it that * no announced local broadcast wins is handed to the handle as a {@link Request}. */ - dynamic(prefix: Path.Valid): Dynamic { + requests(prefix: Path.Valid): Requests { return this.#insertRoute(prefix, Route.default, true, true); } /** Insert an originated claim with route metadata that answers through `hooks.acceptRequest`, for tests and benches. @internal */ - dynamicRoute( + requestsRoute( prefix: Path.Valid, route: | Route @@ -1285,13 +1285,13 @@ export class Producer implements Table { cost?: Route["cost"]; auth?: Route["auth"]; } = Route.default, - ): Dynamic { + ): Requests { return this.#insertRoute(prefix, Route.normalize(route), true, false); } /** * Land a route a peer announced, served through the returned handle. Same as - * {@link dynamic} but not originated, so a session never announces it back. + * {@link requests} but not originated, so a session never announces it back. * * @internal */ @@ -1305,11 +1305,11 @@ export class Producer implements Table { cost?: Route["cost"]; auth?: Route["auth"]; } = Route.default, - ): Dynamic { + ): Requests { return this.#insertRoute(prefix, Route.normalize(route), false, false); } - #insertRoute(prefix: Path.Valid, route: Route, originated: boolean, publishes: boolean): Dynamic { + #insertRoute(prefix: Path.Valid, route: Route, originated: boolean, publishes: boolean): Requests { prefix = this.#scope.prefix(prefix); publishPath(prefix); const server = new ServeState(this.#scope.root, prefix, originated); @@ -1542,7 +1542,7 @@ let makeDynamic: ( state: OriginState, retract: Dispose, publisher?: Producer, -) => Dynamic; +) => Requests; let makeRequest: (path: Path.Valid, server: ServeState) => Request; let finishRequest: (request: Request, err: Error) => void; @@ -2183,7 +2183,7 @@ export class Consumer { } /** - * A served route from {@link Producer.dynamic}: advertises a prefix and answers the + * A served route from {@link Producer.requests}: advertises a prefix and answers the * requests beneath it. * * Drop it (or {@link close}) to retract the route and reject anything still waiting @@ -2191,7 +2191,7 @@ export class Consumer { * * @public */ -export class Dynamic { +export class Requests { /** The prefix this handle advertises. */ readonly prefix: Path.Valid; @@ -2217,7 +2217,7 @@ export class Dynamic { static { makeDynamic = (prefix, entry, state, retract, publisher) => - new Dynamic(prefix, entry, state, retract, publisher); + new Requests(prefix, entry, state, retract, publisher); } /** The route this handle advertises. */ @@ -2300,7 +2300,7 @@ export class Dynamic { /** * A pending request for a broadcast to be served on demand. * - * Yielded by {@link Dynamic.requested}. {@link prepare} then {@link Prepared.publish} + * Yielded by {@link Requests.requested}. {@link prepare} then {@link Prepared.publish} * resolves it with a new exact-path broadcast; {@link reject} resolves it with an error. * Advancing the iterator or closing it without either rejects the request. * diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 3946c2f11b0..2282ed5b740 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -128,7 +128,7 @@ test("a forwarded untimed track stays untimed", async () => { // Serve the downstream session from the upstream one. A JS origin refuses to serve a // broadcast a session delivered, so the forwarding node republishes a copy it produced. - const dynamic = relay.dynamicRoute(Path.from("test")); + const dynamic = relay.requestsRoute(Path.from("test")); const inputs: BroadcastConsumer[] = []; const forwarding = (async () => { for await (const request of dynamic.requested()) { diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 4e668fe37e8..fd679f15e0d 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -57,7 +57,7 @@ export interface OriginProducer { receive( prefix: Path.Valid, route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, - ): origin.Dynamic; + ): origin.Requests; attach(discovery: boolean): Dispose; /** * Hold the origin's announcement streams overlapping `prefix` unsynced until diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 0deccde227c..cc972094c6f 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -35,7 +35,7 @@ function broadcast(name: string, paths: string[] = [name]): { source: Broadcast; } // Answer every request `claim` receives with a new exact broadcast, returning the answers. -function serve(claim: Origin.Dynamic, epoch?: Moq.Epoch.Valid): Moq.Broadcast.Producer[] { +function serve(claim: Origin.Requests, epoch?: Moq.Epoch.Valid): Moq.Broadcast.Producer[] { const answers: Moq.Broadcast.Producer[] = []; void (async () => { for await (const request of claim.requested()) @@ -487,7 +487,7 @@ describe("blind resolution", () => { await settle(); // Stand in for a claim answering the request. - const route = owner.dynamic(Path.from("blind.hang")); + const route = owner.requests(Path.from("blind.hang")); const requests = route.requested(); const next = await requests.next(); if (!next.value) throw new Error("expected a request"); @@ -600,7 +600,7 @@ describe("restart", () => { it("moves to a more specific announcement of another instance", async () => { const owner = new Origin.Producer(); const name = Path.from("pool/job.hang"); - const pool = owner.dynamic(Path.from("pool")); + const pool = owner.requests(Path.from("pool")); const answers = serve(pool); const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); await settle(); @@ -642,7 +642,7 @@ describe("restart", () => { // The exact route goes, which ends the request on it, and a prefix answers under the same // epoch before the table is read again. The player still has to ask again. exact.close(); - const pool = owner.dynamic(Path.from("pool")); + const pool = owner.requests(Path.from("pool")); const answers = serve(pool, epoch); await settle(); const resumed = source.out.active.peek(); @@ -660,7 +660,7 @@ describe("restart", () => { it("falls back to a covering prefix when the exact route ends", async () => { const owner = new Origin.Producer(); const name = Path.from("pool/job.hang"); - const pool = owner.dynamic(Path.from("pool")); + const pool = owner.requests(Path.from("pool")); const answers = serve(pool); const exact = publish(owner, name); const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); @@ -693,7 +693,7 @@ describe("refusal", () => { it("reports an unroutable refusal of a restarted publisher", async () => { const owner = new Origin.Producer(); const name = Path.from("room/live.hang"); - const first = owner.dynamic(Path.from("room")); + const first = owner.requests(Path.from("room")); const answers = serve(first); const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); await settle(); @@ -702,7 +702,7 @@ describe("refusal", () => { // The publisher restarts, and refuses with the same code an ended instance reports. first.close(); for (const answer of answers) answer.close(); - const second = owner.dynamic(Path.from("room")); + const second = owner.requests(Path.from("room")); const refused = await second.requested().next(); refused.value?.reject(new Moq.Error.Stream(Moq.StreamCode.Unroutable, { message: "refused" })); await settle(); @@ -738,7 +738,7 @@ describe("refusal", () => { for (const announced of [true, false]) { it(`reports a refusal until a fresh request (announced: ${announced})`, async () => { const owner = new Origin.Producer(); - const route = owner.dynamic(Path.from("room")); + const route = owner.requests(Path.from("room")); const requests = route.requested(); const name = new Signal(Path.from("room/refused.hang")); const enabled = new Signal(true); @@ -1020,7 +1020,7 @@ describe("wildcard renditions", () => { await settle(); expect(videoRenditions(source)).toEqual(["source"]); - const worker = owner.dynamic(Path.from(".pro/transcode")); + const worker = owner.requests(Path.from(".pro/transcode")); const requests = worker.requested(); await settle(); expect(videoRenditions(source)).toEqual(["source", "transcode"]); @@ -1066,8 +1066,8 @@ describe("wildcard renditions", () => { try { // A catch-all (an archive) and a narrower pool both cover the derived path. - const archive = owner.dynamic(Path.empty()); - const worker = owner.dynamic(Path.from(".pro/transcode")); + const archive = owner.requests(Path.empty()); + const worker = owner.requests(Path.from(".pro/transcode")); await settle(); expect(videoRenditions(source)).toEqual(["source", "transcode"]); diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index c42780b3615..f1eb7a8e537 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `dynamic()` on `OriginProducer`, `BroadcastProducer`, `TrackProducer`, and `TrackRequest` to `requests()`. +Their handles `OriginDynamic`, `BroadcastDynamic`, and `TrackDynamic` are `OriginRequests`, `BroadcastRequests`, and `TrackRequests`; their methods and flows are unchanged. + ### Connection state `Moq.connect` returns a connecting native session. Replace `status()`, `connects()`, and failure polls with `session.state()` and `session.stateChanged(state.revision)`. `ConnectionState` carries `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index b10eba5aa94..d71f5e48f07 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -40,7 +40,7 @@ typealias OriginConfig = uniffi.moq.MoqOriginConfig /** The subscribe side of an origin: discover and request published broadcasts. */ typealias OriginConsumer = uniffi.moq.MoqOriginConsumer /** A served route: advertises a path prefix and yields broadcast requests beneath it. */ -typealias OriginDynamic = uniffi.moq.MoqOriginDynamic +typealias OriginRequests = uniffi.moq.MoqOriginRequests /** A requested broadcast not yet accepted: fulfill it with a producer or reject it. */ typealias BroadcastRequest = uniffi.moq.MoqBroadcastRequest /** A claim answer being filled before its exact route is announced. */ @@ -74,13 +74,13 @@ typealias BroadcastProducer = uniffi.moq.MoqBroadcastProducer /** The read side of a broadcast: subscribe to its catalog and tracks. */ typealias BroadcastConsumer = uniffi.moq.MoqBroadcastConsumer /** Receives tracks requested from a dynamically served broadcast. */ -typealias BroadcastDynamic = uniffi.moq.MoqBroadcastDynamic +typealias BroadcastRequests = uniffi.moq.MoqBroadcastRequests /** The write side of a raw track: append groups of frames. */ typealias TrackProducer = uniffi.moq.MoqTrackProducer /** A subscriber-requested track not yet accepted: accept it for a [TrackProducer] or abort it. */ typealias TrackRequest = uniffi.moq.MoqTrackRequest /** A stream of the fills readers of one track need: runs of groups the cache lacks. */ -typealias TrackDynamic = uniffi.moq.MoqTrackDynamic +typealias TrackRequests = uniffi.moq.MoqTrackRequests /** A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. */ typealias TrackDemand = uniffi.moq.MoqTrackDemand /** The read side of a raw track: yields groups in sequence order, skipping ahead if it falls behind. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 3795a732399..e32c7cc78fb 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -37,13 +37,13 @@ fun TrackConsumer.frames(): Flow = pull({ cancel() }) { readFrame() } fun TrackConsumer.datagrams(): Flow = pull({ cancel() }) { recvDatagram() } /** Stream of tracks requested by subscribers. */ -fun BroadcastDynamic.requestedTracks(): Flow = pull({ cancel() }) { requestedTrack() } +fun BroadcastRequests.requestedTracks(): Flow = pull({ cancel() }) { requestedTrack() } /** Stream of the fills readers of one track need. */ -fun TrackDynamic.requestedFills(): Flow = pull({ cancel() }) { requestedFill() } +fun TrackRequests.requestedFills(): Flow = pull({ cancel() }) { requestedFill() } /** Stream of broadcasts requested by consumers. */ -fun OriginDynamic.requestedBroadcasts(): Flow = pull({ cancel() }) { requestedBroadcast() } +fun OriginRequests.requestedBroadcasts(): Flow = pull({ cancel() }) { requestedBroadcast() } /** Stream of timestamped raw frames within a group. */ fun GroupConsumer.frames(): Flow = pull({ cancel() }) { readFrame() } diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 4fd1f624deb..8ad413514db 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -20,7 +20,7 @@ class Moq internal constructor( * Create an unannounced broadcast at [path], invisible to everyone until announced. * * Advertise it with `announce` after populating tracks. `end()` ends it for good; `close()` - * (or `use`) releases the handle, which ends it once no `dynamic()` handle remains. + * (or `use`) releases the handle, which ends it once no `requests()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer = session.publish().createBroadcast(path) diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index 30ec1344892..e62651622ea 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -29,7 +29,7 @@ class Server internal constructor( * * Advertise it with `announce` after populating tracks. `end()` ends it for * good; `close()` (or `use`) releases the handle, which ends it once no - * `dynamic()` handle remains. + * `requests()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer { val origin = publishOrigin ?: throw IllegalStateException("no publish origin configured") diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index e9d7321f5d5..03de65e644d 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -177,7 +177,7 @@ class SmokeTest { fun `origin alias constructs and consumes`() = runTest { OriginProducer(OriginConfig()).use { origin -> origin.consume().use { /* lifecycle smoke */ } - origin.dynamic("").use { /* dynamic origin smoke */ } + origin.requests("").use { /* dynamic origin smoke */ } } } @@ -230,7 +230,7 @@ class SmokeTest { @Test fun `ending a broadcast ends it while a dynamic handle remains`() = runTest { BroadcastProducer().use { broadcast -> - broadcast.dynamic().use { + broadcast.requests().use { val consumer = broadcast.consume() // Releasing the producer alone would leave the dynamic handle holding it open. broadcast.end() @@ -621,7 +621,7 @@ class SmokeTest { @Test fun `dynamic serves a request under a prefix`() = runTest { OriginProducer(OriginConfig()).use { origin -> - origin.dynamic("live").use { dynamic -> + origin.requests("live").use { dynamic -> val pending = async { origin.consume().requestBroadcast("live/cam") } diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 1ce345d17f7..a196d2c8262 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `dynamic()` on `OriginProducer`, `BroadcastProducer`, `TrackProducer`, and `TrackRequest` to `requests()`. +Their handles `OriginDynamic`, `BroadcastDynamic`, and `TrackDynamic` are `OriginRequests`, `BroadcastRequests`, and `TrackRequests`; their methods are unchanged. + ### Connection state Client entry returns a connecting native session. Replace `Session.status()`, `connects()`, and both `failure()` polls with `state()` and `await state_changed(state.revision)`. The `ConnectionState` fields include `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. Wait for `CONNECTED` when work requires an established transport, or await `closed()` for the terminal result. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index b64c16d640e..b3a9ada72f1 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -164,11 +164,11 @@ client = moq.Client( ### Publishing - **`BroadcastProducer()`**. Create a broadcast to publish tracks into. - - `.dynamic() → BroadcastDynamic` + - `.requests() → BroadcastRequests` - `moq.video.Producer(media.CatalogProducer(broadcast), input, output, *, track=None, bandwidth=None)`. Encode raw video frames inside the binding. - `moq.audio.Producer(media.CatalogProducer(broadcast), input, output, *, track=None, bandwidth=None)`. Encode raw PCM frames. A zero frame duration uses the codec's own frame. - `.close()` ends the broadcast for good; a second call is a no-op. -- **`BroadcastDynamic`**. Async source of tracks requested by subscribers. +- **`BroadcastRequests`**. Async source of tracks requested by subscribers. - `await .requested_track() → TrackRequest`. Call `.accept()` on it for a `TrackProducer`, or `.abort(code)` to reject. - Async iterator yielding `TrackRequest` - **`TrackProducer` / `GroupProducer`**. Write raw payloads with no codec parsing. @@ -238,15 +238,15 @@ Omitting time leaves data untimed; choose the track's `timescale` to match. - **`GroupConsumer`**. Async iterator of raw `Frame`s. - `.read_frame() -> Frame | None` returns a raw frame with optional capture time. -Every handle whose cleanup is `cancel()` is an async context manager, so exiting `async with` releases it: the consumers (`media.CatalogConsumer`, `media.TrackConsumer`, `media.GroupConsumer`, `TrackConsumer`, `audio.Consumer`, `video.Consumer`, `GroupConsumer`, `json.SnapshotConsumer`, `json.StreamConsumer`, `json.WindowConsumer`, `AnnounceConsumer`, `AnnouncedBroadcast`) and the dynamic sources (`OriginDynamic`, `BroadcastDynamic`, `TrackDynamic`). +Every handle whose cleanup is `cancel()` is an async context manager, so exiting `async with` releases it: the consumers (`media.CatalogConsumer`, `media.TrackConsumer`, `media.GroupConsumer`, `TrackConsumer`, `audio.Consumer`, `video.Consumer`, `GroupConsumer`, `json.SnapshotConsumer`, `json.StreamConsumer`, `json.WindowConsumer`, `AnnounceConsumer`, `AnnouncedBroadcast`) and the dynamic sources (`OriginRequests`, `BroadcastRequests`, `TrackRequests`). ### Origin (advanced) - **`OriginProducer(*, cache_capacity_bytes=None)`**. Manage broadcast announcements. Set `cache_capacity_bytes` to bound cached groups under this origin. - `.consume() → OriginConsumer` - - `.dynamic(prefix) → OriginDynamic` + - `.requests(prefix) → OriginRequests` - `.create_broadcast(path) → BroadcastProducer` -- **`OriginDynamic`**. Async source of broadcasts requested by consumers. +- **`OriginRequests`**. Async source of broadcasts requested by consumers. - `await .requested_broadcast() → BroadcastRequest`. Call `.prepare(mint_epoch())`, fill its `.broadcast`, then `.publish()` to serve it under a new epoch, or `.reject(code)` to fail the requester. - Async iterator yielding `BroadcastRequest` - **`OriginConsumer`**. Discover broadcasts. diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 2e05799eede..ce395f6fd0d 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -59,11 +59,11 @@ asyncio.run(main()) :nosignatures: BroadcastProducer - BroadcastDynamic + BroadcastRequests BroadcastRequest TrackProducer TrackDemand - TrackDynamic + TrackRequests TrackRequest GroupProducer Fill @@ -179,7 +179,7 @@ broadcast, and producers advertise it in the catalog. OriginProducer OriginConsumer - OriginDynamic + OriginRequests AnnounceConsumer AnnouncedBroadcast Announce diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 4121392f893..19b44355a4e 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -27,18 +27,18 @@ BroadcastPrepared, BroadcastRequest, OriginConsumer, - OriginDynamic, OriginProducer, + OriginRequests, ) from .publish import ( - BroadcastDynamic, BroadcastProducer, + BroadcastRequests, Fill, GroupProducer, TrackDemand, - TrackDynamic, TrackProducer, TrackRequest, + TrackRequests, ) from .server import Request, Server, Transport from .session import Auth, AuthToken, Bandwidth, Reservation, Session @@ -99,7 +99,7 @@ "Backoff", "Bandwidth", "BroadcastConsumer", - "BroadcastDynamic", + "BroadcastRequests", "BroadcastPrepared", "BroadcastProducer", "BroadcastRequest", @@ -122,7 +122,7 @@ "GroupConsumer", "GroupProducer", "OriginConsumer", - "OriginDynamic", + "OriginRequests", "OriginProducer", "Order", "Range", @@ -134,7 +134,7 @@ "Subscription", "TrackConsumer", "TrackDemand", - "TrackDynamic", + "TrackRequests", "TrackInfo", "TrackProducer", "TrackRequest", diff --git a/py/moq-rs/moq/origin.py b/py/moq-rs/moq/origin.py index df807b6f019..f8647bcd68d 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq-rs/moq/origin.py @@ -12,8 +12,8 @@ MoqBroadcastRequest, MoqOriginConfig, MoqOriginConsumer, - MoqOriginDynamic, MoqOriginProducer, + MoqOriginRequests, ) from ._stream import _Stream @@ -138,13 +138,13 @@ def publish(self) -> BroadcastProducer: return BroadcastProducer._from_inner(self._inner.publish()) -class OriginDynamic(_Stream[BroadcastRequest]): +class OriginRequests(_Stream[BroadcastRequest]): """A served route: advertises a prefix and yields requests beneath it. Usable as an async context manager that cancels the route on exit. """ - def __init__(self, inner: MoqOriginDynamic) -> None: + def __init__(self, inner: MoqOriginRequests) -> None: self._inner = inner async def _next(self) -> BroadcastRequest: @@ -202,8 +202,8 @@ class OriginProducer: """The publishing side of an origin: announce broadcasts for consumers to discover. Call :meth:`create_broadcast` to publish at a path, :meth:`consume` for a - matching :class:`OriginConsumer`, or :meth:`dynamic` to advertise a prefix - and serve on-demand requests. Create, :meth:`dynamic` if tracks are served + matching :class:`OriginConsumer`, or :meth:`requests` to advertise a prefix + and serve on-demand requests. Create, :meth:`requests` if tracks are served on demand, populate, then :meth:`BroadcastProducer.announce`. """ @@ -221,7 +221,7 @@ def consume(self) -> OriginConsumer: """Create a consumer that discovers the broadcasts this origin publishes.""" return OriginConsumer(self._inner.consume()) - def dynamic(self, prefix: str) -> OriginDynamic: + def requests(self, prefix: str) -> OriginRequests: """Advertise ``prefix`` and serve the requests beneath it. A route claims ``prefix`` and every path beneath it (``""`` claims every @@ -229,7 +229,7 @@ def dynamic(self, prefix: str) -> OriginDynamic: are requested. Hold the returned handle while the route should stay advertised. """ - return OriginDynamic(self._inner.dynamic(prefix)) + return OriginRequests(self._inner.requests(prefix)) def create_broadcast(self, path: str) -> BroadcastProducer: """Create a broadcast at ``path``, returning the producer that feeds it. @@ -237,7 +237,7 @@ def create_broadcast(self, path: str) -> BroadcastProducer: The broadcast is invisible and unroutable, for this origin's consumers and peers alike, until :meth:`BroadcastProducer.announce`. Announce it after populating tracks. Create, - :meth:`dynamic` if tracks are served on demand, populate, then announce. + :meth:`requests` if tracks are served on demand, populate, then announce. :meth:`BroadcastProducer.close` ends it for good; dropping the last handle does the same. """ diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 8d006df0d5d..63bbde40fc8 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -6,15 +6,15 @@ from typing import TYPE_CHECKING from moq_ffi import ( - MoqBroadcastDynamic, MoqBroadcastProducer, + MoqBroadcastRequests, MoqFill, MoqGroupProducer, MoqOrder, MoqTrackDemand, - MoqTrackDynamic, MoqTrackProducer, MoqTrackRequest, + MoqTrackRequests, ) from ._records import _opt_from_us, _subscription, _track_info @@ -105,9 +105,9 @@ def demand(self) -> TrackDemand: """A watch-only handle to this track's name and whether it has subscribers.""" return TrackDemand(self._inner.demand()) - def dynamic(self) -> TrackDynamic: + def requests(self) -> TrackRequests: """Serve fetches for groups that are not currently cached.""" - return TrackDynamic(self._inner.dynamic()) + return TrackRequests(self._inner.requests()) def append_group(self) -> GroupProducer: """Start a new group; write frames into it, then finish().""" @@ -181,9 +181,9 @@ def demand(self) -> TrackDemand: """Watch this track's subscribers, including after accepting it.""" return TrackDemand(self._inner.demand()) - def dynamic(self) -> TrackDynamic: + def requests(self) -> TrackRequests: """Create a fetch handler before accepting this requested track.""" - return TrackDynamic(self._inner.dynamic()) + return TrackRequests(self._inner.requests()) def abort(self, error_code: int) -> None: """Reject the request with an application error code.""" @@ -191,7 +191,7 @@ def abort(self, error_code: int) -> None: class Fill: - """A run of groups readers miss, for a :class:`TrackDynamic` to serve. + """A run of groups readers miss, for a :class:`TrackRequests` to serve. Call :meth:`accept` for each group you have and :meth:`drop_groups` for the rest; :meth:`next` names what is still owed, in the fill's order. Dropping the fill names @@ -268,13 +268,13 @@ async def unwanted(self, sequence: int) -> None: await self._inner.unwanted(sequence) -class TrackDynamic(_Stream[Fill]): +class TrackRequests(_Stream[Fill]): """Async source of the fills readers of one track need: runs of groups the cache lacks. Usable as an async context manager that cancels on exit. """ - def __init__(self, inner: MoqTrackDynamic) -> None: + def __init__(self, inner: MoqTrackRequests) -> None: self._inner = inner async def _next(self) -> Fill: @@ -289,14 +289,14 @@ def cancel(self) -> None: self._inner.cancel() -class BroadcastDynamic(_Stream[TrackRequest]): +class BroadcastRequests(_Stream[TrackRequest]): """Async source of tracks requested by subscribers. Hold this object while subscriptions to unknown tracks should be accepted. Usable as an async context manager that cancels on exit. """ - def __init__(self, inner: MoqBroadcastDynamic) -> None: + def __init__(self, inner: MoqBroadcastRequests) -> None: self._inner = inner async def _next(self) -> TrackRequest: @@ -329,9 +329,9 @@ def _from_inner(cls, inner: MoqBroadcastProducer) -> BroadcastProducer: self._inner = inner return self - def dynamic(self) -> BroadcastDynamic: + def requests(self) -> BroadcastRequests: """Accept subscriptions to tracks that are not published yet.""" - return BroadcastDynamic(self._inner.dynamic()) + return BroadcastRequests(self._inner.requests()) def announce(self, route: AnnounceRoute | None = None) -> None: """Advertise this broadcast's exact path as a route. diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 5378c8446d3..ee35c3bad76 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -105,7 +105,7 @@ def h264_init() -> bytes: async def test_fetch_of_a_dropped_group_is_not_found(): broadcast = moq.BroadcastProducer() track = broadcast.publish_track("events") - dynamic = track.dynamic() + dynamic = track.requests() consumer = broadcast.consume() async def reject(): @@ -468,7 +468,7 @@ def test_string_sequences_refuse_a_bare_string(): async def test_fetch_group_and_serve_dynamic_miss(): - """Fetch a cached group, then serve an uncached sequence through TrackDynamic.""" + """Fetch a cached group, then serve an uncached sequence through TrackRequests.""" broadcast = moq.BroadcastProducer() track = broadcast.publish_track("events", moq.TrackInfo(timescale=1_000_000)) consumer = broadcast.consume() @@ -481,7 +481,7 @@ async def test_fetch_group_and_serve_dynamic_miss(): assert fetched.sequence == 0 assert [frame.payload async for frame in fetched] == [b"cached"] - dynamic = track.dynamic() + dynamic = track.requests() pending = asyncio.create_task(consumer.fetch_group("events", 7, moq.FetchGroupOptions(priority=11))) fill = await asyncio.wait_for(dynamic.requested_fill(), timeout=5.0) assert (fill.start_group, fill.start_frame, fill.end_group) == (7, 0, 8) @@ -646,7 +646,7 @@ async def test_json_producers_report_demand(): async def test_dynamic_track_request(): broadcast = moq.BroadcastProducer() - dynamic = broadcast.dynamic() + dynamic = broadcast.requests() consumer = broadcast.consume() # The subscribe stays pending until the request is accepted below; run it concurrently. @@ -685,7 +685,7 @@ async def test_media_subscribe_refuses_a_non_rendition(): async def test_dynamic_track_request_can_publish_media(): broadcast = moq.BroadcastProducer() - dynamic = broadcast.dynamic() + dynamic = broadcast.requests() consumer = broadcast.consume() catalog_consumer = await moq.media.CatalogConsumer.subscribe(consumer) @@ -739,7 +739,7 @@ async def test_dynamic_track_request_can_publish_media(): async def test_dynamic_broadcast_request(): origin = moq.OriginProducer(cache_capacity_bytes=4096) - dynamic = origin.dynamic("") + dynamic = origin.requests("") consumer = origin.consume() request_broadcast = asyncio.create_task(consumer.request_broadcast("dynamic/broadcast")) @@ -768,7 +768,7 @@ async def test_dynamic_broadcast_request(): async def test_dynamic_broadcast_request_can_reject(): origin = moq.OriginProducer() - dynamic = origin.dynamic("") + dynamic = origin.requests("") consumer = origin.consume() request_broadcast = asyncio.create_task(consumer.request_broadcast("missing")) @@ -1587,7 +1587,7 @@ async def test_announced_pattern_captures(): consumer = origin.consume() announced = consumer.announced("room", filter="*/chat") - dynamic = origin.dynamic("room") + dynamic = origin.requests("room") overlap = await next_route(announced) assert isinstance(overlap, moq.AnnounceEventStart) assert overlap.announce.prefix == "room" @@ -1608,7 +1608,7 @@ async def test_announced_pattern_captures(): async def test_dynamic_serves_a_request_under_a_prefix(): origin = moq.OriginProducer() - dynamic = origin.dynamic("live") + dynamic = origin.requests("live") consumer = origin.consume() pending = asyncio.create_task(consumer.request_broadcast("live/cam")) @@ -1629,17 +1629,17 @@ async def assert_cancelled(awaitable) -> None: assert moq.is_shutdown(excinfo.value) origin = moq.OriginProducer() - async with origin.dynamic("live") as origin_dynamic: + async with origin.requests("live") as origin_dynamic: pass await assert_cancelled(origin_dynamic.requested_broadcast()) broadcast = moq.BroadcastProducer() - async with broadcast.dynamic() as broadcast_dynamic: + async with broadcast.requests() as broadcast_dynamic: pass await assert_cancelled(broadcast_dynamic.requested_track()) track = broadcast.publish_track("events") - async with track.dynamic() as track_dynamic: + async with track.requests() as track_dynamic: pass await assert_cancelled(track_dynamic.requested_fill()) diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 889e9fb6107..6a5c2353e7c 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -73,7 +73,6 @@ Rejected in planning: - [moq-transport stays private](/quest/m1/api-audit/ietf-private.md) - no public moq-net item exists only for moq-transport, and a client names its request target once as a URL - [One lifecycle vocabulary](/quest/m1/api-audit/lifecycle.md) - finish, abort, closed, and close mean one thing on every handle, and Rust and JS observers fire alike - [moq-net names say one thing](/quest/m1/api-audit/net-names.md) - demand says `wanted`, colliding stats and transport names are renamed, and Rust and JS info lookups mirror -- [Request handlers are named requests](/quest/m1/api-audit/requests.md) - `dynamic()` and `Dynamic` become `requests()` and `Requests` at every layer ## Related diff --git a/quest/m1/api-audit/answer.md b/quest/m1/api-audit/answer.md index 9d6ae251a5a..ad1b29afc83 100644 --- a/quest/m1/api-audit/answer.md +++ b/quest/m1/api-audit/answer.md @@ -20,7 +20,7 @@ public-model goal was dropped - Drop every planned public addition: `answer_source`, the unused-race family (`poll_unheld`, `close_unheld`, group `poll_unused` and `abort_unused`, `reject_unused`), `Producer::empty`/`Consumer::empty`, - `Dynamic::withdrawn`, JS `Origin.Producer.fallback()`, and JS + `origin::Requests::withdrawn`, JS `Origin.Producer.fallback()`, and JS `Broadcast.requested` (deferred until a JS app needs it). - Delete `wire.ts` `OriginConsumer.broadcasts`: registered, never read. diff --git a/quest/m1/api-audit/requests.md b/quest/m1/api-audit/requests.md deleted file mode 100644 index de9cf1127bf..00000000000 --- a/quest/m1/api-audit/requests.md +++ /dev/null @@ -1,25 +0,0 @@ -# [L] Request handlers are named requests - -## Goal - -The handle that answers incoming requests is named for its role at every -layer: `origin`, `broadcast`, and `track` producers have `requests()` -returning `Requests`, which yields `Request`s, in Rust, JS, and the bindings. - -## Plan - -Decided 2026-10-23 (API audit follow-up, maintainer): `dynamic()` names the -mechanism; `auth::Handle::requests() -> auth::Requests` is the precedent. - -- Rename `{origin,broadcast,track}::Producer::dynamic()` (and - `track::Request::dynamic()`) to `requests()`, and each `Dynamic` type to - `Requests`. JS `dynamic()`/`Dynamic` mirror it. moq-ffi and its wrappers - follow; the frozen hand-written moq-c ABI stays as it is. -- Rename the crate-private peer-route hatches (`dynamic_route`, - `update_route`) with the rest. -- Quests that say `Dynamic` (subscribe ranges, archive) switch - to the new name in this PR. - -About 240 Rust, 30 binding, and 10 JS call sites; land it in one sweep. - -Public API: moq-net, `@moq/net`, moq-ffi and wrappers renamed. Wire: none. diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 5f64b877d27..4302334a932 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -57,7 +57,7 @@ archive (per-track `.timeline.z` records named by the catalog's root every rendition) and size-based cuts. The `archive::Producer` records the rest. - **Hosts serve archives, relays do not.** `archive::Consumer` serves a stored - prefix through `broadcast::Dynamic` and `track::Dynamic`, the archived + prefix through `broadcast::Requests` and `track::Requests`, the archived catalog included as an ordinary track, and a host publishes it at a path of its choosing. Rejected: a moq-relay storage fallback. Also rejected: serving the archive as a covering-prefix route under the original path, which diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index fcd21b4bbc1..b31a13739cb 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -34,7 +34,7 @@ relay bounds a sparse FETCH only if the model can request ranges: publisher delivers what it has and fills the rest as upstream answers. - **Model.** `Subscription` gains `ranges` beside its `order`, mirroring the wire; `request_groups` as a separate API was rejected. A publisher's - `track::Dynamic` receives a `track::Fill` per run of misses (landed), and + `track::Requests` receives a `track::Fill` per run of misses (landed), and `fetch_group` becomes a one-range subscription or is removed. This breaks published moq-net APIs. - **moq-transport.** Some deployments use non-contiguous group numbers, so a relay never probes upstream one group at a time. Upstream, each run of @@ -63,7 +63,7 @@ relay bounds a sparse FETCH only if the model can request ranges: - **Time ends** (maintainer, 2026-10-17, for DVR seek in the [archive line](/quest/m1/archive/README.md)). A range end may also be `Time(pts)`, in the track's timescale. Whoever holds the track resolves it: - the cache from its groups' timestamps, or a `track::Dynamic` handler such as + the cache from its groups' timestamps, or a `track::Requests` handler such as an archive consumer. A start resolves to the group containing pts, and an end to just after that group. A lite-07 relay that cannot resolve one forwards it upstream unchanged. An untimed track refuses a time end, and a diff --git a/quest/m1/unbounded-groups/README.md b/quest/m1/unbounded-groups/README.md index 176a42bb376..6f245ff8855 100644 --- a/quest/m1/unbounded-groups/README.md +++ b/quest/m1/unbounded-groups/README.md @@ -94,7 +94,7 @@ Decided 2026-10-16 (maintainer interview): draft's 32 MiB interop cap. The [m1 compression migration](/quest/m1/compress/migrate.md) deletes the flate packages. There is no new page. -The refill API is the `track::Dynamic` fill (`track::Fill`). A handler serves a range by writing into a +The refill API is the `track::Requests` fill (`track::Fill`). A handler serves a range by writing into a group producer bounded to that range, the same API as a live write, so an origin can regenerate data from disk or an archive. diff --git a/quest/m1/unbounded-groups/front-eviction.md b/quest/m1/unbounded-groups/front-eviction.md index 91fe89b1837..e6b6abeecc4 100644 --- a/quest/m1/unbounded-groups/front-eviction.md +++ b/quest/m1/unbounded-groups/front-eviction.md @@ -6,7 +6,7 @@ A group grows without a frame-count or byte cap. When the cache pool is over budget and the group can't go whole, because it is the live edge or is being read, it sheds whole frames from its front. A reader that needs an evicted frame gets it refilled in place. The refill comes from upstream over a -frame-precise range, or from the publisher's `track::Dynamic` handler, and +frame-precise range, or from the publisher's `track::Requests` handler, and the reader gets `Error::Evicted` only when neither exists. `MAX_GROUP_FRAMES` and the group byte cap are removed. The frame cap stays until [Byte holes](/quest/m1/unbounded-groups/byte-holes.md). A moq-json @@ -50,7 +50,7 @@ See the [epic](/quest/m1/unbounded-groups/README.md) for the decisions. the first victim, so merging would also thrash under steady pressure. - A refill must not evict what it was fetched for before the waiting reader takes it: hand its first demanded frame to that reader. Test a - `track::Dynamic` handler that writes more than the pool synchronously, so + `track::Requests` handler that writes more than the pool synchronously, so the reader still progresses. - A refill of frames `0..N` refreshes the recency of the group's frames from `N+1` on (decided 2026-10-24, maintainer). Pressure then sheds the @@ -62,7 +62,7 @@ See the [epic](/quest/m1/unbounded-groups/README.md) for the decisions. - When nothing serves the hole, the reader gets `Error::Evicted`, and a join onto a group whose first frame is evicted asks for a new group as if it were over the `Info::refresh` ratio, on tracks that opt into - refresh (decided 2026-10-24, maintainer). The origin's `track::Dynamic` + refresh (decided 2026-10-24, maintainer). The origin's `track::Requests` handler decides whether to serve old content; refresh is the fallback. - The live edge is never paced by a reader (decided 2026-10-24, maintainer). A slow reader on a 1:1 track falls behind, loses its front, @@ -77,7 +77,7 @@ See the [epic](/quest/m1/unbounded-groups/README.md) for the decisions. allocated. Storing a group as flat header and payload bytes was rejected: it copies every small frame, and lite and IETF encode headers differently. -- Refill goes through a `track::Dynamic` fill. Over +- Refill goes through a `track::Requests` fill. Over moq-transport, a hole that covers whole objects refills; one inside an object is `Evicted`. - Downstream encoders that roll groups by the removed constants (moq-json, diff --git a/quest/m2/archive-time-ranges.md b/quest/m2/archive-time-ranges.md index 88096dfdb80..2814a22664d 100644 --- a/quest/m2/archive-time-ranges.md +++ b/quest/m2/archive-time-ranges.md @@ -23,7 +23,7 @@ handler. [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171) deferred c landed without it. - `moq-net` passes a time anchor the cache can't resolve to the - `track::Dynamic` handler, unresolved, in its fill. + `track::Requests` handler, unresolved, in its fill. - The consumer resolves it from its index: every object key carries its first frame's pts, so the object holding pts is one lookup, and the group within it comes from the stored group timestamps. diff --git a/quest/m2/e2ee/README.md b/quest/m2/e2ee/README.md index 0ca06bc4194..bc8978bdd0b 100644 --- a/quest/m2/e2ee/README.md +++ b/quest/m2/e2ee/README.md @@ -46,11 +46,11 @@ The Rust and TypeScript cores expose the same surface, and nothing else: ### Dynamic names -Physical names are reversible (decided with the maintainer on 2026-10-24 in [#6623](https://github.com/moq-dev/moq/pull/6623)). A physical track name is a 12-byte nonce, the first 12 bytes of an HMAC-SHA-256 of the semantic name padded to a multiple of 16 bytes, followed by the AES-128-GCM encryption of that padded name under that nonce, with both keys derived from the generation (the draft's Key Derivation section). It needs only HMAC-SHA-256 and AES-128-GCM, so it runs on aws-lc-rs, graviola, and WebCrypto with no new dependency (maintainer, 2026-10-24, over AES-GCM-SIV, which graviola lacks). A publisher opens a requested name and recomputes its nonce to recover its semantic name, so the e2ee `dynamic()` answers undeclared names through the [moq-net traits](/quest/m2/net-wrap/README.md) unchanged, and a name that does not open is answered as missing with no state kept. Equal names under one generation still share one physical name, so relays dedupe and cache them, and names stay unlinkable across epochs. AEAD keys bind the semantic name. The cost is that a relay learns each name's length rounded up to 16 bytes: every name up to 15 bytes looks alike, and a longer one leaks its length to within 16 bytes (maintainer, 2026-10-24, over power-of-two buckets that leak less but can double a long name). Broadcast paths stay one-way: e2ee wraps no origin, so nothing requests an undeclared protected broadcast. +Physical names are reversible (decided with the maintainer on 2026-10-24 in [#6623](https://github.com/moq-dev/moq/pull/6623)). A physical track name is a 12-byte nonce, the first 12 bytes of an HMAC-SHA-256 of the semantic name padded to a multiple of 16 bytes, followed by the AES-128-GCM encryption of that padded name under that nonce, with both keys derived from the generation (the draft's Key Derivation section). It needs only HMAC-SHA-256 and AES-128-GCM, so it runs on aws-lc-rs, graviola, and WebCrypto with no new dependency (maintainer, 2026-10-24, over AES-GCM-SIV, which graviola lacks). A publisher opens a requested name and recomputes its nonce to recover its semantic name, so the e2ee `requests()` answers undeclared names through the [moq-net traits](/quest/m2/net-wrap/README.md) unchanged, and a name that does not open is answered as missing with no state kept. Equal names under one generation still share one physical name, so relays dedupe and cache them, and names stay unlinkable across epochs. AEAD keys bind the semantic name. The cost is that a relay learns each name's length rounded up to 16 bytes: every name up to 15 bytes looks alike, and a longer one leaks its length to within 16 bytes (maintainer, 2026-10-24, over power-of-two buckets that leak less but can double a long name). Broadcast paths stay one-way: e2ee wraps no origin, so nothing requests an undeclared protected broadcast. Rejected: -- Declared names only (`reserve_track` over an enumerated universe): it cannot serve an open-ended name such as a moq-stats prefix or presence query, so the e2ee `dynamic()` could not mirror moq-net. +- Declared names only (`reserve_track` over an enumerated universe): it cannot serve an open-ended name such as a moq-stats prefix or presence query, so the e2ee `requests()` could not mirror moq-net. - A name-directory track: a directory the publisher writes is the catalog again and still needs every name in advance. One the subscriber writes requires viewers to publish, links each request to a visible subscriber path, and races the subscription it serves. - Requests that carry an encrypted semantic name beside a one-way name: it needs a moq-lite and moq-transport field that every relay forwards, and a moq-net `track(name)` signature change that breaks the trait mirror. The relay forwards one coalesced subscriber's field, so a relay-authorized party without the secret could race a bad one and get the name refused for everyone. Its field leaks the same rounded length. - One-way names for declared tracks and reversible ones for dynamic tracks: one semantic name would have two physical names, so it would not always mean the same content, and a subscriber cannot know which one the publisher serves. @@ -60,7 +60,7 @@ The broadcast wrappers merged in [#5876](https://github.com/moq-dev/moq/pull/587 ## Required - [Undeclared track names](/quest/m2/e2ee/undeclared-names.md) - an encrypted - broadcast's `dynamic()` serves names nobody created or reserved, under + broadcast's `requests()` serves names nobody created or reserved, under semantic names, in Rust - [JS undeclared track names](/quest/m2/e2ee/js-undeclared-names.md) - `@moq/net` exposes a broadcast request handler and `@moq/e2ee` serves diff --git a/quest/m2/e2ee/js-undeclared-names.md b/quest/m2/e2ee/js-undeclared-names.md index 90b63b6debe..e49fae76896 100644 --- a/quest/m2/e2ee/js-undeclared-names.md +++ b/quest/m2/e2ee/js-undeclared-names.md @@ -15,10 +15,9 @@ Decided with the maintainer on 2026-10-24: - One quest, both halves. Today the JS broadcast's request pull (`requested()` in `js/net/src/broadcast.ts`) is reachable only by wire - layers, and only the origin has a public `dynamic()`. Expose the - broadcast's handler under the name and shape that - [named requests](/quest/m1/api-audit/requests.md) gives Rust's - (`requests()` returning `Requests`), and keep the internal wire path on it. + layers, and only the origin has a public `requests()`. Expose the + broadcast's handler under Rust's name and shape (`requests()` returning + `Requests`), and keep the internal wire path on it. - `@moq/e2ee` mirrors the Rust handler exactly: it opens each request's physical name, claims it, and yields a request whose `name` is semantic. A name that does not open is answered as missing and keeps no state. A @@ -33,6 +32,5 @@ handler on `@moq/e2ee`'s broadcast producer. Wire: none. ## Required -- [Named requests](/quest/m1/api-audit/requests.md) - the handler name and shape this exposes - [Undeclared track names](/quest/m2/e2ee/undeclared-names.md) - the Rust handler this mirrors - [JS mirror](/quest/m2/net-wrap/js.md) - the `@moq/net` interfaces the `@moq/e2ee` wrappers implement diff --git a/quest/m2/e2ee/undeclared-names.md b/quest/m2/e2ee/undeclared-names.md index ea37e597816..cc90af5f147 100644 --- a/quest/m2/e2ee/undeclared-names.md +++ b/quest/m2/e2ee/undeclared-names.md @@ -4,8 +4,7 @@ An encrypted broadcast producer answers requests for track names it never created or reserved, as a plaintext `moq-net` broadcast does. Its request -handler (`dynamic()` today, `requests()` after -[named requests](/quest/m1/api-audit/requests.md)) mirrors moq-net's and +handler (`requests()`) mirrors moq-net's and yields requests named by their semantic name, so a handler such as moq-stats' or moq-transcode's serves an encrypted broadcast unchanged. A request whose physical name does not open under the generation is answered diff --git a/quest/m2/net-wrap/README.md b/quest/m2/net-wrap/README.md index 75d6ad48942..c6d9981d412 100644 --- a/quest/m2/net-wrap/README.md +++ b/quest/m2/net-wrap/README.md @@ -32,7 +32,7 @@ and its trait-versus-bridge comparison: that spawns adds the bound at its spawn site. - **Mirror moq-net's current API exactly.** The trait methods are the methods writers and readers call today, with the same names, arguments, - and errors: `create_track`, `reserve_track`, `dynamic`, `demand`, + and errors: `create_track`, `reserve_track`, `requests`, `demand`, `create_group(group::Info)`, `append_group`, `append_datagram`, `insert_datagram`, `write_frame`, `create_frame`, `write`, `finish`, `abort(self, Error)`, `closed`, `track(name)` then `.subscribe(..)`, diff --git a/quest/m2/net-wrap/traits.md b/quest/m2/net-wrap/traits.md index c0ce52f7a14..0b173326a10 100644 --- a/quest/m2/net-wrap/traits.md +++ b/quest/m2/net-wrap/traits.md @@ -26,8 +26,8 @@ the methods exactly as moq-net has them today. the frame level they stay concrete or buffer whole frames. `frame::Producer<'_>` borrows its group, so its associated type is generic over that lifetime. - - Requests: a broadcast producer's `dynamic` returns its own - `broadcast::Dynamic`, whose `requested_track` yields its own + - Requests: a broadcast producer's `requests` returns its own + `broadcast::Requests`, whose `requested_track` yields its own `track::Request`, so generic code serves undeclared names (as [E2EE undeclared names](/quest/m2/e2ee/undeclared-names.md) needs). A track dynamic's `requested_fill` yields its own `track::Fill`, whose diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs index 832472b86e5..8188cc439be 100644 --- a/rs/moq-archive/src/consumer/mod.rs +++ b/rs/moq-archive/src/consumer/mod.rs @@ -4,7 +4,7 @@ //! stored track on a `moq_net` broadcast, the archived catalog included, with its stored //! timescale and priority and no max age: the store's retention bounds what it holds. Each //! track's live feed starts at its newest stored sequence, and every older group is served on -//! demand from the store through [`track::Dynamic`](moq_net::track::Dynamic) fills, each sequence in its recorded form: a +//! demand from the store through [`track::Requests`](moq_net::track::Requests) fills, each sequence in its recorded form: a //! group as a group, a datagram as a datagram. //! //! A growing archive is followed: every [`Config::refresh`], one delimiter LIST of the prefix @@ -83,7 +83,7 @@ pub struct Consumer { shared: Arc>, broadcast: broadcast::Producer, /// Answers requests for names the archive does not hold, and says when the broadcast closed. - requests: broadcast::Dynamic, + requests: broadcast::Requests, refresh: Duration, tracks: HashMap, /// Whether the prefix held `.end`, so every track ended and nothing is refreshed. @@ -98,7 +98,7 @@ struct Shared { struct Track { index: Arc>, live: track::Live, - dynamic: moq_net::track::Dynamic, + dynamic: moq_net::track::Requests, } /// What one refresh found, applied to the served tracks at once. @@ -137,7 +137,7 @@ impl Consumer { }); let mut consumer = Self { shared, - requests: broadcast.dynamic(), + requests: broadcast.requests(), broadcast, refresh: config.refresh, tracks: HashMap::new(), @@ -237,7 +237,7 @@ impl Consumer { fn apply(&mut self, refreshed: Refreshed) -> Result<()> { for (name, info, index) in refreshed.added { let producer = self.broadcast.create_track(name.as_str(), info)?; - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); self.tracks.insert( name, Track { diff --git a/rs/moq-c/src/origin.rs b/rs/moq-c/src/origin.rs index 7e7eaf7296f..29447be037b 100644 --- a/rs/moq-c/src/origin.rs +++ b/rs/moq-c/src/origin.rs @@ -118,7 +118,7 @@ impl AnnouncedRecord { struct DynamicEntry { task: Task, - inner: Option, + inner: Option, } impl Origin { @@ -338,7 +338,7 @@ impl Origin { let origin = self.active.get(origin).ok_or(Error::OriginNotFound)?; origin.driver.check()?; let driver = origin.driver.clone(); - let result = origin.producer.dynamic(prefix); + let result = origin.producer.requests(prefix); origin.driver.check()?; let inner = result?; let (task, close) = Task::new(on_request); diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index aac4d2e2c0e..915027e6f8b 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -43,9 +43,9 @@ struct GroupRequest { /// What a request handler serves. enum Dynamic { /// Track requests on a broadcast, remembered so media can be published onto them. - Broadcast(moq_net::broadcast::Dynamic, Id, Finished), + Broadcast(moq_net::broadcast::Requests, Id, Finished), /// Group requests (fetches of uncached groups) on a track. - Track(moq_net::track::Dynamic), + Track(moq_net::track::Requests), } /// A published broadcast: its producer, its catalog, and the renditions the caller authored by @@ -663,7 +663,7 @@ impl Publish { /// /// Without a live handler an unknown track name is refused, as before. pub fn dynamic(&mut self, broadcast: Id, on_request: OnStatus) -> Result { - let dynamic = self.producer(broadcast)?.dynamic(); + let dynamic = self.producer(broadcast)?.requests(); let Broadcast { driver, finished, .. } = self.broadcast(broadcast)?; let dynamic = Dynamic::Broadcast(dynamic, broadcast, finished.clone()); self.spawn_dynamic(dynamic, on_request, driver.clone()) @@ -672,7 +672,7 @@ impl Publish { /// Serve fetches of uncached groups on a raw track, delivering each as a group-request /// handle via `on_group`. pub fn track_dynamic(&mut self, track: Id, on_group: OnStatus) -> Result { - let dynamic = self.tracks.get(track).ok_or(Error::TrackNotFound)?.dynamic(); + let dynamic = self.tracks.get(track).ok_or(Error::TrackNotFound)?.requests(); self.spawn_dynamic(Dynamic::Track(dynamic), on_group, Default::default()) } @@ -686,7 +686,7 @@ impl Publish { .get(request) .ok_or(Error::NotFound)? .request - .dynamic(); + .requests(); self.spawn_dynamic(Dynamic::Track(dynamic), on_group, Default::default()) } @@ -1177,7 +1177,7 @@ mod supervision_tests { .unwrap(); let producer = &publish.broadcast(broadcast).unwrap().producer; // A dynamic handle outlives the consumed broadcast, so only an explicit close ends it. - let _dynamic = producer.dynamic(); + let _dynamic = producer.requests(); let consumer = producer.consume(); *trigger .write() diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 731d4bc3920..b098e9448de 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3758,7 +3758,7 @@ fn consume_announced_delivers_a_takeover_once(dynamic_at: &str) { let dynamic = { let _runtime = crate::ffi::RUNTIME.enter(); let origin = crate::Id::try_from(origin).unwrap(); - State::lock().origin.get(origin).unwrap().dynamic(dynamic_at).unwrap() + State::lock().origin.get(origin).unwrap().requests(dynamic_at).unwrap() }; let cb = Callback::new(); let path = b"live"; @@ -3801,10 +3801,10 @@ fn consume_announced_delivers_a_held_takeover_once() { } /// Serve `prefix` on `origin` dynamically. -fn serve_dynamic(origin: u32, prefix: &str) -> moq_net::origin::Dynamic { +fn serve_dynamic(origin: u32, prefix: &str) -> moq_net::origin::Requests { let _runtime = crate::ffi::RUNTIME.enter(); let origin = crate::Id::try_from(origin).unwrap(); - State::lock().origin.get(origin).unwrap().dynamic(prefix).unwrap() + State::lock().origin.get(origin).unwrap().requests(prefix).unwrap() } /// Wait for the broadcast at `path` on `origin`, delivering each instance through `cb`. diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index d99da3a67ab..bc36f61eade 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -1596,7 +1596,7 @@ mod tests { /// Answer the next request through `claim` with one `audio` rendition, under `route`'s epoch. async fn answer( - claim: &moq_net::origin::Dynamic, + claim: &moq_net::origin::Requests, route: &moq_net::origin::Route, ) -> ( moq_net::broadcast::Producer, @@ -1684,7 +1684,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_refused_broadcast_ends_the_player() { let origin = moq_tokio::origin::spawn(); - let handler = origin.dynamic("").unwrap(); + let handler = origin.requests("").unwrap(); let recorder = Recorder::default(); let player = tokio::spawn(media(&origin, Duration::from_millis(50), recorder.clone()).run()); let request = handler.requested_broadcast().await.unwrap(); @@ -1848,7 +1848,7 @@ mod tests { // The broadcast ends while its route stands, so the player goes idle. tokio::time::sleep(Duration::from_secs(60)).await; - let pool = origin.dynamic("").unwrap(); + let pool = origin.requests("").unwrap(); drop(exact); let (served, served_catalog, mut new) = answer(&pool, &route).await; write(&mut new, 10, NEW).await; @@ -1885,7 +1885,7 @@ mod tests { } exact.unannounce(); - let pool = origin.dynamic("").unwrap(); + let pool = origin.requests("").unwrap(); let (mirror, mirror_catalog, mut copy) = answer(&pool, &route).await; for index in 50..60 { copy.write(packet(index, NEW)).unwrap(); diff --git a/rs/moq-cli/src/transcode.rs b/rs/moq-cli/src/transcode.rs index a118cf40038..6bd1e15de6a 100644 --- a/rs/moq-cli/src/transcode.rs +++ b/rs/moq-cli/src/transcode.rs @@ -227,7 +227,7 @@ mod tests { } }; let old = origin.publish("source", route()).unwrap(); - let _old_dynamic = old.dynamic(); + let _old_dynamic = old.requests(); let mut derivatives = moq_mux::follow::Instances::new(&publish.consume(), "derived", None).unwrap(); let following = Following { origin: origin.consume(), @@ -245,7 +245,7 @@ mod tests { .await .unwrap(); let new = origin.publish("source", route()).unwrap(); - let _new_dynamic = new.dynamic(); + let _new_dynamic = new.requests(); let second = next(&mut derivatives).await; assert!(!first.is_clone(&second)); assert_ne!(first.info().epoch, second.info().epoch); @@ -293,7 +293,7 @@ mod tests { assert!(!task.is_finished(), "a clean end stopped following"); let new = origin.publish("source", route()).unwrap(); - let _new_dynamic = new.dynamic(); + let _new_dynamic = new.requests(); let second = next(&mut derivatives).await; assert!(!first.is_clone(&second)); task.abort(); diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 056aa8a1fe4..a58478d519f 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,11 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `dynamic()` on `MoqOriginProducer`, `MoqBroadcastProducer`, `MoqTrackProducer`, and `MoqTrackRequest` to `requests()`. +Their handles `MoqOriginDynamic`, `MoqBroadcastDynamic`, and `MoqTrackDynamic` are `MoqOriginRequests`, `MoqBroadcastRequests`, and `MoqTrackRequests`; their methods are unchanged. + ### Connection state `MoqClient::connect` returns a connecting session without waiting for its first dial to succeed, natively and in the browser. Read `MoqSession::state()` and pass its `revision` to `state_changed(after)` to observe first-dial failures and reconnects. `MoqConnectionState` carries `phase`, `connects`, `failure`, and `version` together. These replace `status()`, `connects()`, and both `failure()` polls. Cancel the returned session to stop dialing; cancelling or releasing the client does not close it. diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index f25a243a251..76331fac139 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -67,7 +67,7 @@ impl TryFrom for moq_net::origin::Route { /// Carried by `MoqAnnounceConsumer` events. A route claims capability, not /// inventory: by convention a publisher announces each broadcast's exact path, /// so subscribers can enumerate broadcasts, while a service advertises a prefix -/// with `MoqOriginProducer::dynamic` and answers whatever is requested beneath it. +/// with `MoqOriginProducer::requests` and answers whatever is requested beneath it. #[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] pub struct MoqRoute { /// Hop ids of the relay hops the route traversed, oldest first. 0 is the @@ -149,9 +149,9 @@ pub struct MoqAnnounceConsumer { /// /// Keeps its origin running, like a published broadcast, after every /// `MoqOriginProducer` is gone. -pub struct MoqOriginDynamic { +pub struct MoqOriginRequests { slot: Slot, - task: std::sync::Mutex>>>, + task: std::sync::Mutex>>>, } #[derive(uniffi::Object)] @@ -174,17 +174,17 @@ struct Announced { driver: Background, } -/// The served route behind one `MoqOriginProducer::dynamic` handle, shared +/// The served route behind one `MoqOriginProducer::requests` handle, shared /// between the handle and the task draining it. Emptied by the handle's cancel, /// which retracts the route: the task sees the empty slot and ends. -type Slot = Arc>>; +type Slot = Arc>>; -struct OriginDynamic { +struct OriginRequests { slot: Slot, driver: Background, } -impl OriginDynamic { +impl OriginRequests { async fn requested_broadcast(&mut self) -> Result, MoqError> { let request = self .driver @@ -398,16 +398,16 @@ impl MoqOriginProducer { /// the covering prefix and rejects the rest as they are requested. Hold /// the returned handle while the route should stay advertised and missing /// broadcasts should be served. - pub fn dynamic(&self, prefix: String) -> Result, MoqError> { + pub fn requests(&self, prefix: String) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.inner.driver.check()?; - let result = self.inner.dynamic(prefix); + let result = self.inner.requests(prefix); self.inner.driver.check()?; let dynamic = result?; let slot = Arc::new(std::sync::Mutex::new(Some(dynamic))); - Ok(Arc::new(MoqOriginDynamic { + Ok(Arc::new(MoqOriginRequests { slot: slot.clone(), - task: std::sync::Mutex::new(Some(Arc::new(Task::new(OriginDynamic { + task: std::sync::Mutex::new(Some(Arc::new(Task::new(OriginRequests { slot, driver: self.inner.driver.clone(), })))), @@ -419,11 +419,11 @@ impl MoqOriginProducer { /// The broadcast exists for nobody, on this origin or its peers, until /// [`MoqBroadcastProducer::announce`]: until then announcement streams skip it /// and requests for its path are unroutable. Announce after populating - /// tracks; an on-demand handler is [`Self::dynamic`]. Create, `dynamic()` if + /// tracks; an on-demand handler is [`Self::requests`]. Create, `requests()` if /// tracks are served on demand, populate, then announce. /// /// [`MoqBroadcastProducer::close`] ends it for good; dropping its last handle, - /// `dynamic()` included, does the same. + /// `requests()` included, does the same. pub fn create_broadcast(&self, path: String) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.inner.driver.check()?; @@ -509,10 +509,10 @@ impl MoqOriginConsumer { } } -// ---- MoqOriginDynamic ---- +// ---- MoqOriginRequests ---- #[uniffi::export] -impl MoqOriginDynamic { +impl MoqOriginRequests { /// Wait for the next broadcast requested through this handle's prefix. /// /// Returns a [`MoqBroadcastRequest`]: accept it with a broadcast producer or reject @@ -676,7 +676,7 @@ mod supervision_tests { driver: completion.clone(), }); let broadcast = origin.create_broadcast("retained".into()).unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); drop(origin); *trigger .write() @@ -705,7 +705,7 @@ mod supervision_tests { }); let broadcast = origin.create_broadcast("retained".into()).unwrap(); // A dynamic handle outlives the consumed producer, so only an explicit close ends it. - let _dynamic = broadcast.dynamic().unwrap(); + let _dynamic = broadcast.requests().unwrap(); let consumer = broadcast.state.lock().unwrap().as_ref().unwrap().broadcast.consume(); let catalog = crate::media::MoqMediaCatalogProducer::new(broadcast.clone()).unwrap(); *trigger diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 1ee3187f961..5f0edb69e8b 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -150,36 +150,36 @@ pub struct MoqBroadcastProducer { /// A handler for requests for tracks in a broadcast. #[derive(uniffi::Object)] -pub struct MoqBroadcastDynamic { - task: Task, +pub struct MoqBroadcastRequests { + task: Task, } /// Serves on-demand fetches of uncached groups for one track. #[derive(uniffi::Object)] -pub struct MoqTrackDynamic { +pub struct MoqTrackRequests { task: Task, } -impl MoqTrackDynamic { - fn new(inner: moq_net::track::Dynamic) -> Self { +impl MoqTrackRequests { + fn new(inner: moq_net::track::Requests) -> Self { Self { task: Task::new(TrackDynamicProducer { inner }), } } } -struct DynamicProducer { - inner: moq_net::broadcast::Dynamic, +struct RequestsProducer { + inner: moq_net::broadcast::Requests, finished: Finished, } struct TrackDynamicProducer { - inner: moq_net::track::Dynamic, + inner: moq_net::track::Requests, } -impl DynamicProducer { +impl RequestsProducer { async fn requested_track(&mut self) -> Result, MoqError> { - // Hand back the un-accepted request, mirroring `moq_net::broadcast::Dynamic`: the caller + // Hand back the un-accepted request, mirroring `moq_net::broadcast::Requests`: the caller // accepts it (raw, at a chosen timescale) or publishes media onto it (importer accepts). // The subscriber's subscribe stays pending until then. let request = self.inner.requested_track().await?; @@ -255,12 +255,12 @@ impl MoqBroadcastProducer { /// /// Hold the returned object for as long as missing track requests should be /// accepted. Dropping it makes future subscriptions to unknown tracks fail. - pub fn dynamic(&self) -> Result, MoqError> { + pub fn requests(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { - Ok(Arc::new(MoqBroadcastDynamic { - task: Task::new(DynamicProducer { - inner: state.broadcast.dynamic(), + Ok(Arc::new(MoqBroadcastRequests { + task: Task::new(RequestsProducer { + inner: state.broadcast.requests(), finished: state.finished.clone(), }) .supervised(state.driver.clone()), @@ -349,10 +349,10 @@ impl MoqBroadcastProducer { } } -// ---- Dynamic Broadcast Producer ---- +// ---- Broadcast Requests ---- #[uniffi::export] -impl MoqBroadcastDynamic { +impl MoqBroadcastRequests { /// Wait for the next subscriber-requested track. /// /// Returns a [`MoqTrackRequest`]: accept it for raw writes with @@ -376,10 +376,10 @@ impl MoqBroadcastDynamic { } } -// ---- Dynamic Track Producer ---- +// ---- Track Requests ---- #[uniffi::export] -impl MoqTrackDynamic { +impl MoqTrackRequests { /// Wait for the next run of groups readers miss: a fetch of an uncached group, or /// history a subscription reads that the cache no longer holds. /// @@ -548,11 +548,11 @@ impl MoqTrackRequest { /// Obtain and retain this handle before `accept()` when the track itself was /// requested by a fetch. This keeps the pending group request serviceable across /// the transition from request to producer. - pub fn dynamic(&self) -> Result, MoqError> { + pub fn requests(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); let request = guard.as_ref().ok_or(MoqError::Closed)?; - Ok(Arc::new(MoqTrackDynamic::new(request.dynamic()))) + Ok(Arc::new(MoqTrackRequests::new(request.requests()))) } /// Accept the request as a raw track, fixing its [`MoqTrackInfo`] (timescale, etc.). @@ -615,11 +615,11 @@ impl MoqTrackProducer { /// /// Hold the returned object for as long as cache misses should wait to be /// served. Without a live dynamic handler, a missing group fails with `NotFound`. - pub fn dynamic(&self) -> Result, MoqError> { + pub fn requests(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); let track = guard.as_ref().ok_or(MoqError::Closed)?; - Ok(Arc::new(MoqTrackDynamic::new(track.dynamic()))) + Ok(Arc::new(MoqTrackRequests::new(track.requests()))) } /// A watch-only handle to this track's name and whether it has subscribers. diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index c0bede188d1..47885e8a338 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -1755,7 +1755,7 @@ async fn json_stream_roundtrip() { #[tokio::test] async fn json_on_requested_track() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let config = MoqJsonStreamConfig { compression: false }; // The subscribe stays pending until the request is accepted, so run it concurrently. @@ -1820,7 +1820,7 @@ async fn json_consumer_takes_an_unread_track() { #[tokio::test] async fn track_request_demand_survives_accept() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let subscribe = tokio::spawn(async move { consumer.subscribe_track("events".into(), None).await }); let request = dynamic.requested_track().await.unwrap(); @@ -1845,7 +1845,7 @@ async fn track_request_demand_survives_accept() { #[tokio::test] async fn track_request_demand_ends_on_rejection() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let subscribe = tokio::spawn(async move { consumer.subscribe_track("events".into(), None).await }); let request = dynamic.requested_track().await.unwrap(); @@ -1859,7 +1859,7 @@ async fn track_request_demand_ends_on_rejection() { #[tokio::test] async fn dynamic_track_request() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); // The subscribe stays pending until the request is accepted below, so run it on a @@ -2077,7 +2077,7 @@ async fn raw_group_abort_reaches_consumer() { #[tokio::test] async fn dynamic_track_request_can_abort() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); // The subscribe stays pending until the request is resolved; aborting an @@ -2438,7 +2438,7 @@ async fn media_group_fails_loudly_on_bad_containers() { async fn dynamic_track_serves_fetch_miss_and_priority() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("events".into(), Some(timed_info())).unwrap(); - let dynamic = track.dynamic().unwrap(); + let dynamic = track.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { @@ -2489,7 +2489,7 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { async fn fill_goes_unused_when_the_fetcher_leaves() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("events".into(), None).unwrap(); - let dynamic = track.dynamic().unwrap(); + let dynamic = track.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); @@ -2518,7 +2518,7 @@ async fn fill_goes_unused_when_the_fetcher_leaves() { async fn dynamic_track_rejects_fetch_miss() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("events".into(), None).unwrap(); - let dynamic = track.dynamic().unwrap(); + let dynamic = track.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); @@ -2559,7 +2559,7 @@ async fn fetch_unknown_track_is_not_found() { #[tokio::test] async fn requested_track_dynamic_survives_accept() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let broadcast_dynamic = broadcast.dynamic().unwrap(); + let broadcast_dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("archive".into(), 9, None).await }); @@ -2567,7 +2567,7 @@ async fn requested_track_dynamic_survives_accept() { .await .expect("timed out waiting for track request") .unwrap(); - let track_dynamic = request.dynamic().unwrap(); + let track_dynamic = request.requests().unwrap(); let _track = request.accept(Some(timed_info())).unwrap(); let fill = tokio::time::timeout(TIMEOUT, track_dynamic.requested_fill()) @@ -2679,7 +2679,7 @@ async fn track_stream_producer_publishes_its_last_access_unit() { #[tokio::test] async fn requested_track_keeps_its_name() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let subscribe = tokio::spawn(async move { MoqMediaTrackConsumer::subscribe( @@ -2712,7 +2712,7 @@ async fn requested_track_keeps_its_name() { #[tokio::test] async fn dynamic_track_request_can_publish_media() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let dynamic = broadcast.dynamic().unwrap(); + let dynamic = broadcast.requests().unwrap(); let consumer = broadcast.consume().unwrap(); let catalog_consumer = MoqMediaCatalogConsumer::subscribe(&consumer).await.unwrap(); @@ -3144,7 +3144,7 @@ async fn announced_filters_patterns_and_reports_captures() { }) .unwrap(); - let _broad = origin.dynamic("room".into()).unwrap(); + let _broad = origin.requests("room".into()).unwrap(); let overlap = next_announced(&announced).await; assert_eq!(overlap.prefix, "room"); assert_eq!(overlap.captures, None, "an overlap does not pin the wildcard"); @@ -4159,8 +4159,8 @@ async fn announced_broadcast() { _broadcast.close().unwrap(); } -fn serve(origin: &MoqOriginProducer, prefix: &str) -> Arc { - origin.dynamic(prefix.into()).unwrap() +fn serve(origin: &MoqOriginProducer, prefix: &str) -> Arc { + origin.requests(prefix.into()).unwrap() } #[tokio::test] @@ -5415,7 +5415,7 @@ async fn announced_broadcast_hands_out_the_instance_again_after_an_end() { .unwrap(); // A covering claim answering under the same epoch. - let dynamic = origin.inner().inner.dynamic("").unwrap(); + let dynamic = origin.inner().inner.requests("").unwrap(); broadcast.unannounce().unwrap(); let again = tokio::spawn(async move { followed.available().await }); let request = tokio::time::timeout(TIMEOUT, dynamic.requested_broadcast()) diff --git a/rs/moq-ffi/uniffi.toml b/rs/moq-ffi/uniffi.toml index 4dda0865a5a..eba2c541b3d 100644 --- a/rs/moq-ffi/uniffi.toml +++ b/rs/moq-ffi/uniffi.toml @@ -1,5 +1,5 @@ # Kotlin objects are AutoCloseable, and `close()` there releases the handle, -# which ends the broadcast only once no `dynamic()` handle remains. The +# which ends the broadcast only once no `requests()` handle remains. The # generated forced end is `end()` so it doesn't collide with it. [bindings.kotlin.rename] "MoqBroadcastProducer.close" = "end" diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 31307fbd704..33dd6967623 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -2231,7 +2231,7 @@ mod session_tests { /// Block until a consumer asks the broadcast for a track, returning the request unanswered so /// its subscriber stays parked. Bounded so a session that never subscribes fails the test. - fn await_request(dynamic: &mut moq_net::broadcast::Dynamic) -> moq_net::track::Request { + fn await_request(dynamic: &mut moq_net::broadcast::Requests) -> moq_net::track::Request { super::RUNTIME .block_on(async { tokio::time::timeout(Duration::from_secs(10), dynamic.requested_track()).await }) .expect("no track was ever requested") @@ -2256,11 +2256,11 @@ mod session_tests { let element = element(); let origin = origin(); let mut catalog_broadcast = origin.create_broadcast("room/catalog").unwrap(); - let mut local = catalog_broadcast.dynamic(); + let mut local = catalog_broadcast.requests(); let left = origin.create_broadcast("room/left").unwrap(); - let mut remote = left.dynamic(); + let mut remote = left.requests(); let right = origin.create_broadcast("room/right").unwrap(); - let mut replacement = right.dynamic(); + let mut replacement = right.requests(); let mut catalog = moq_mux::catalog::Producer::new(&mut catalog_broadcast, Default::default()).unwrap(); let mut config = video_rendition(); config.broadcast = Some("./left".into()); @@ -2319,7 +2319,7 @@ mod session_tests { let mut broadcast = moq_net::broadcast::Info::new().produce(); // A live handler is what makes an unserved name park rather than resolve `NotFound`, // which is how it behaves over the wire: the publisher just never answers. - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); // First update announces audio only, and no producer ever answers for it. @@ -2395,7 +2395,7 @@ mod session_tests { let element = element(); let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); { @@ -2946,7 +2946,7 @@ mod session_tests { let element = element(); let mut broadcast = moq_net::broadcast::Info::new().produce(); - let _dynamic = broadcast.dynamic(); + let _dynamic = broadcast.requests(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); // A video rendition nobody serves, alongside an audio one that arrives. The audio pad @@ -3483,7 +3483,7 @@ mod session_tests { let recorder = Recorder::attach(&element); let origin = origin(); - let claim = origin.dynamic("").unwrap(); + let claim = origin.requests("").unwrap(); let (answers, answered) = std::sync::mpsc::channel(); let handler = super::RUNTIME.spawn(async move { while let Ok(request) = claim.requested_broadcast().await { @@ -3516,7 +3516,7 @@ mod session_tests { let element = element(); let recorder = Recorder::attach(&element); let origin = origin(); - let pool = origin.dynamic("").unwrap(); + let pool = origin.requests("").unwrap(); let session = follow(&element, &origin); let pending = super::RUNTIME.block_on(async { tokio::time::timeout(Duration::from_secs(10), pool.requested_broadcast()) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 97be0f11c38..0557d73c5ea 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -4755,7 +4755,7 @@ mod tests { async fn a_pending_timeline_subscription_ends_with_the_broadcast() { let test = Timelines::new(); // A handler keeps the unpublished timelines' requests waiting rather than refused. - let _handler = test.broadcast.dynamic(); + let _handler = test.broadcast.requests(); tokio::time::sleep(Duration::from_secs(60)).await; assert!(!test.rendition("video0").snapshot().finished); diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index e6630cd7ec5..94c78f7858d 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1359,13 +1359,13 @@ mod tests { async fn pending_reads_preserve_a_later_authorization_refusal() { let origin = moq_tokio::origin::spawn(); let broadcast = origin.create_broadcast("room/live").unwrap(); - let dynamic = origin.dynamic("room/secret").unwrap(); + let dynamic = origin.requests("room/secret").unwrap(); let source = moq_mux::Source::new(origin.consume(), broadcast.consume()); let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8); config.broadcast = Some(moq_net::path::Relative::new("./secret").to_owned()); let rendition = Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); let request = dynamic.requested_broadcast().await.unwrap(); - let other = origin.dynamic("room/hidden").unwrap(); + let other = origin.requests("room/hidden").unwrap(); config.broadcast = Some(moq_net::path::Relative::new("./hidden").to_owned()); let resolving = Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); let other_request = other.requested_broadcast().await.unwrap(); @@ -1421,7 +1421,7 @@ mod tests { fn capture_keeps_the_queried_track_when_end_lands_during_the_query() { struct Serve { broadcast: moq_net::broadcast::Producer, - dynamic: std::sync::Mutex, + dynamic: std::sync::Mutex, track: std::sync::Mutex>, // Kept alive here, since a list only holds it weakly. waiter: std::sync::OnceLock, @@ -1450,7 +1450,7 @@ mod tests { let broadcast = moq_net::broadcast::Info::new().produce(); let serve = Arc::new(Serve { - dynamic: std::sync::Mutex::new(broadcast.dynamic()), + dynamic: std::sync::Mutex::new(broadcast.requests()), broadcast, track: Default::default(), waiter: Default::default(), diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 513abcd4b7b..70bc6a53181 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -163,7 +163,7 @@ mod test { /// takes when it answers an origin request. async fn publish_catalog_served(published: Catalog<()>) -> Result> { let origin = crate::source::produce_origin(); - let dynamic = origin.dynamic("").unwrap(); + let dynamic = origin.requests("").unwrap(); let requesting = origin.consume().request_broadcast("a/pub", None); // The handler hands its route back: dropping it would retract the route and end // the broadcast it just served. diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 4932fbf6dc8..1ac14207130 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -9175,7 +9175,7 @@ async fn a_follower_awaits_a_replacement_catalog_past_the_follow() { moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), ) .unwrap(); - let unanswered = second.dynamic(); + let unanswered = second.requests(); assert!( tokio::time::timeout(follow * 3, follower.next()).await.is_err(), "awaiting the replacement's catalog past the follow" @@ -9245,7 +9245,7 @@ async fn a_follower_keeps_following_when_a_return_goes_before_it_resolves() { let second = origin .publish("live", moq_net::origin::Route::default().with_epoch(epoch)) .unwrap(); - let unanswered = second.dynamic(); + let unanswered = second.requests(); assert!( tokio::time::timeout(Duration::from_secs(1), follower.next()) .await @@ -9267,7 +9267,7 @@ fn publish_refusing(origin: &moq_net::origin::Producer, epoch: &moq_net::Epoch) let broadcast = origin .publish("live", moq_net::origin::Route::default().with_epoch(epoch.clone())) .unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); tokio::spawn(async move { let _broadcast = broadcast; while let Ok(request) = dynamic.requested_track().await { @@ -9361,7 +9361,7 @@ async fn a_follower_stitches_onto_the_next_replacement_when_one_goes_before_it_r // Replaced, but the replacement's catalog request is never answered before it goes. let second = origin.publish("live", route()).unwrap(); - let unanswered = second.dynamic(); + let unanswered = second.requests(); assert!( tokio::time::timeout(Duration::from_secs(1), follower.next()) .await @@ -9423,7 +9423,7 @@ async fn a_follower_keeps_a_return_awaiting_its_catalog_when_a_stitch_goes() { moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), ) .unwrap(); - let unanswered = third.dynamic(); + let unanswered = third.requests(); assert!( tokio::time::timeout(Duration::from_secs(1), follower.next()) .await @@ -9728,10 +9728,10 @@ async fn a_follower_ignores_a_covering_prefix_while_the_exact_route_serves() { let mut follower = super::Follower::new(open_export(source).await.unwrap()).unwrap(); let quiet = Duration::from_secs(1); - let pool = origin.dynamic("pool").unwrap(); + let pool = origin.requests("pool").unwrap(); assert!(tokio::time::timeout(quiet, follower.next()).await.is_err(), "a start"); drop(pool); - let pool = origin.dynamic("pool").unwrap(); + let pool = origin.requests("pool").unwrap(); assert!(tokio::time::timeout(quiet, follower.next()).await.is_err(), "a restart"); drop(pool); assert!(tokio::time::timeout(quiet, follower.next()).await.is_err(), "an end"); @@ -9794,7 +9794,7 @@ async fn a_midstream_stitch_keeps_the_old_instance_playing_until_it_switches() { moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), ) .unwrap(); - let _unanswered = second.dynamic(); + let _unanswered = second.requests(); for ms in (0..200).step_by(20) { write_aac(&mut track, ms); } @@ -9831,7 +9831,7 @@ async fn a_nested_stitch_keeps_awaiting_the_pending_replacement() { moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), ) .unwrap(); - let unanswered = third.dynamic(); + let unanswered = third.requests(); assert!( tokio::time::timeout(Duration::from_secs(1), follower.next()) .await diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index f68afe5663a..fbd2bbf2586 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -475,7 +475,7 @@ mod tests { let _catalog = origin.publish("pub", Default::default()).unwrap(); settle().await; let source = resolved(&origin, "pub").await; - let dynamic = origin.dynamic("media").unwrap(); + let dynamic = origin.requests("media").unwrap(); let binding = source.bind(Some(&Relative::new("media")), None).unwrap(); let request = dynamic.requested_broadcast().await.unwrap(); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 9dea3a51305..191abd7d461 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `origin::Producer::dynamic(prefix)`, `broadcast::Producer::dynamic()`, `track::Producer::dynamic()`, and `track::Request::dynamic()` to `requests`. +Their handles `origin::Dynamic`, `broadcast::Dynamic`, and `track::Dynamic` are `origin::Requests`, `broadcast::Requests`, and `track::Requests`; their methods are unchanged. + ### `stats::Refusal` is `stats::RefusalKind`, and `time::now` is gone (#6510) Rename `moq_net::stats::Refusal` to `moq_net::stats::RefusalKind`, so it no longer shares a name with `moq_auth::Refusal`; its variants and `Refusals` are unchanged. diff --git a/rs/moq-net/benches/group.rs b/rs/moq-net/benches/group.rs index 2a53a36d212..07368266a30 100644 --- a/rs/moq-net/benches/group.rs +++ b/rs/moq-net/benches/group.rs @@ -382,7 +382,7 @@ fn bench_stamp(c: &mut Criterion) { let ctx = filled_track(n, &payload); // A pending fetch keeps the landing index occupied beyond this batch, // separating the no-reader fast path from an unrelated parked read. - let dynamic = waiting.then(|| ctx.track.dynamic()); + let dynamic = waiting.then(|| ctx.track.requests()); let mut fetching = dynamic .as_ref() .map(|_| ctx.track.consume().fetch_group((2 * n + 1) as u64, None)); diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 8691745f057..9d631e79c7e 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -168,7 +168,7 @@ fn bench_withdraw(c: &mut Criterion) { .unwrap(); let routes: Vec<_> = (0..publishers) .map(|i| { - moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), peer_route(9, 1)).unwrap() + moq_net::fuzz::requests_route(&producer, format!("room/{i}"), peer_route(9, 1)).unwrap() }) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); @@ -224,12 +224,13 @@ fn bench_withdraw_wave(c: &mut Criterion) { .unwrap(); let near: Vec<_> = (0..broadcasts) .map(|i| { - moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 2])).unwrap() + moq_net::fuzz::requests_route(&producer, format!("room/{i}"), route(&[9, 2])).unwrap() }) .collect(); let far: Vec<_> = (0..broadcasts) .map(|i| { - moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 3, 4])).unwrap() + moq_net::fuzz::requests_route(&producer, format!("room/{i}"), route(&[9, 3, 4])) + .unwrap() }) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); @@ -274,13 +275,13 @@ fn bench_route_churn(c: &mut Criterion) { group.bench_function(id, |b| { let (producer, _driver) = origin::Producer::new(origin::Config::default()); let _routes: Vec<_> = (0..broadcasts) - .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), peer_route(1, 1)).unwrap()) + .map(|i| moq_net::fuzz::requests_route(&producer, format!("room/{i}"), peer_route(1, 1)).unwrap()) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); for cursor in &mut cursors { while cursor.try_next().is_some() {} } - b.iter(|| drop(moq_net::fuzz::dynamic_route(&producer, "room/0", peer_route(2, 5)).unwrap())); + b.iter(|| drop(moq_net::fuzz::requests_route(&producer, "room/0", peer_route(2, 5)).unwrap())); for cursor in &mut cursors { assert!(cursor.try_next().is_none(), "the winner is unchanged"); } @@ -405,7 +406,7 @@ fn bench_announce_duplicate(c: &mut Criterion) { // the entries are distinct routes rather than one re-priced in place. let _routes: Vec<_> = (1..=duplicates) .map(|peer| { - moq_net::fuzz::dynamic_route(&producer, PATH, peer_route(peer as u64, INCUMBENT_COST)).unwrap() + moq_net::fuzz::requests_route(&producer, PATH, peer_route(peer as u64, INCUMBENT_COST)).unwrap() }) .collect(); let mut cursors: Vec = (0..subscribers) @@ -416,7 +417,7 @@ fn bench_announce_duplicate(c: &mut Criterion) { } b.iter(|| { - let handle = moq_net::fuzz::dynamic_route( + let handle = moq_net::fuzz::requests_route( &producer, PATH, peer_route(duplicates as u64 + 1, INCUMBENT_COST - 1), @@ -456,12 +457,12 @@ fn bench_reprice_duplicate(c: &mut Criterion) { &Patterns::from(Pattern::subtree(&format!("{PATH}/p{}", (peer - 1) % 2)).unwrap()), ) .unwrap(); - moq_net::fuzz::dynamic_route(&scoped, PATH, peer_route(peer as u64, incumbent_cost)).unwrap() + moq_net::fuzz::requests_route(&scoped, PATH, peer_route(peer as u64, incumbent_cost)).unwrap() }) .collect(); let challenger = duplicates as u64 + 1; let route = - moq_net::fuzz::dynamic_route(&producer, PATH, peer_route(challenger, incumbent_cost + 1)).unwrap(); + moq_net::fuzz::requests_route(&producer, PATH, peer_route(challenger, incumbent_cost + 1)).unwrap(); let mut cursors: Vec = (0..subscribers) .map(|peer| { producer @@ -522,7 +523,7 @@ fn bench_announce_fronts(c: &mut Criterion) { let (producer, mut driver) = origin::Producer::new(origin::Config::default()); let consumer = producer.consume(); // Served but never answered: every request under it parks a front. - let _served = producer.dynamic("room").unwrap(); + let _served = producer.requests("room").unwrap(); let _requests: Vec<_> = (0..fronts) .map(|i| consumer.request_broadcast(format!("room/{i}"), None)) .collect(); @@ -562,7 +563,7 @@ fn bench_serve_idle(c: &mut Criterion) { let (producer, _driver) = origin::Producer::new(origin::Config::default()); // One route per announced path, exactly as a session lands a peer's. let dynamics: Vec<_> = (0..routes) - .map(|i| producer.dynamic(format!(".stats/p{}/node/edge{i}", i % 8)).unwrap()) + .map(|i| producer.requests(format!(".stats/p{}/node/edge{i}", i % 8)).unwrap()) .collect(); b.iter(|| { // A fresh waiter per sweep, so every poll pays a real registration: a @@ -717,9 +718,10 @@ fn bench_unroutable_churn(c: &mut Criterion) { group.bench_function(id, |b| { let (producer, mut driver) = origin::Producer::new(origin::Config::default()); let consumer = producer.consume(); - let server = moq_net::fuzz::dynamic_route(&producer, "room", origin::Route::default()).unwrap(); + let server = + moq_net::fuzz::requests_route(&producer, "room", origin::Route::default()).unwrap(); let unrelated = - moq_net::fuzz::dynamic_route(&producer, "other", origin::Route::default()).unwrap(); + moq_net::fuzz::requests_route(&producer, "other", origin::Route::default()).unwrap(); let mut waiting = Waiting::new( (0..requesters) .map(|i| { @@ -823,7 +825,7 @@ const POOL_HOP: u64 = 1_000; struct Pool { producer: origin::Producer, driver: origin::Driver, - _members: Vec, + _members: Vec, _producers: Vec, _consumers: Vec, cursors: Vec, @@ -843,7 +845,7 @@ fn pool(members: usize, paths: usize, cursors: usize) -> Pool { let consumer = producer.consume(); let cursors = (0..cursors).map(|_| consumer.announced()).collect(); let members: Vec<_> = (0..members) - .map(|i| moq_net::fuzz::dynamic_route(&producer, "pool", pool_route(POOL_HOP + i as u64)).unwrap()) + .map(|i| moq_net::fuzz::requests_route(&producer, "pool", pool_route(POOL_HOP + i as u64)).unwrap()) .collect(); let waiter = kio::Waiter::noop(); @@ -902,7 +904,7 @@ fn bench_pool_churn(c: &mut Criterion) { let waiter = kio::Waiter::noop(); let hop = POOL_HOP + members as u64; b.iter(|| { - let joined = moq_net::fuzz::dynamic_route(&pool.producer, "pool", pool_route(hop)).unwrap(); + let joined = moq_net::fuzz::requests_route(&pool.producer, "pool", pool_route(hop)).unwrap(); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); drop(joined); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); @@ -930,7 +932,7 @@ fn bench_pool_cursors(c: &mut Criterion) { let waiter = kio::Waiter::noop(); let hop = POOL_HOP + members as u64; b.iter(|| { - let joined = moq_net::fuzz::dynamic_route(&pool.producer, "pool", pool_route(hop)).unwrap(); + let joined = moq_net::fuzz::requests_route(&pool.producer, "pool", pool_route(hop)).unwrap(); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); drop(joined); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); diff --git a/rs/moq-net/benches/retirement.rs b/rs/moq-net/benches/retirement.rs index fdc5281f03a..365ec2b6a00 100644 --- a/rs/moq-net/benches/retirement.rs +++ b/rs/moq-net/benches/retirement.rs @@ -22,7 +22,7 @@ fn retirement(c: &mut Criterion) { || { let (origin, driver) = origin::Producer::new(origin::Config::default()); let routes: Vec<_> = (0..routes) - .map(|i| origin.dynamic(format!("route/{i}")).unwrap()) + .map(|i| origin.requests(format!("route/{i}")).unwrap()) .collect(); let mut watches = Vec::new(); let mut controls = Vec::new(); @@ -46,7 +46,7 @@ fn retirement(c: &mut Criterion) { (origin, driver, routes, watches, controls, pending) }, |fixture| { - let replacement = fixture.0.dynamic("route/0/jobs").unwrap(); + let replacement = fixture.0.requests("route/0/jobs").unwrap(); let refused = fixture .5 .iter() diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 057fff21af1..49d6e516652 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -1650,7 +1650,7 @@ fn bench_time_end(c: &mut Criterion) { struct Fills { _broadcast: broadcast::Producer, _track: track::Producer, - dynamic: track::Dynamic, + dynamic: track::Requests, readers: Vec, runs: u64, } @@ -1659,7 +1659,7 @@ impl Fills { fn new(readers: usize, runs: u64) -> Self { let broadcast = broadcast::Info::default().produce(); let mut track = broadcast.create_track("bench", timed()).unwrap(); - let dynamic = track.dynamic(); + let dynamic = track.requests(); // The feed starts past every run, so each is a miss asked at once. let start = runs * 2; track.start_at(start).unwrap(); diff --git a/rs/moq-net/benches/viewers.rs b/rs/moq-net/benches/viewers.rs index fcd617a9646..13c9d267369 100644 --- a/rs/moq-net/benches/viewers.rs +++ b/rs/moq-net/benches/viewers.rs @@ -181,7 +181,7 @@ fn bench_churn(c: &mut Criterion) { room.leave(subscription); } b.iter(|| { - let covering = room.producer.dynamic("room").unwrap(); + let covering = room.producer.requests("room").unwrap(); room.poll(); drop(covering); room.poll(); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index e9633ecfed1..4c7ef321535 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -299,16 +299,16 @@ pub fn excluding(origin: crate::origin::Consumer, peer: crate::Hop) -> crate::or } /// Land a claim with a peer's wire route, as a session does, for the origin bench. -pub fn dynamic_route( +pub fn requests_route( origin: &crate::origin::Producer, prefix: impl crate::AsPath, route: crate::origin::Route, -) -> Result { - origin.dynamic_route(prefix, route) +) -> Result { + origin.requests_route(prefix, route) } /// Replace a peer claim's route in place, as a session does on a route update. -pub fn update_route(dynamic: &crate::origin::Dynamic, route: crate::origin::Route) -> Result<(), crate::Error> { +pub fn update_route(dynamic: &crate::origin::Requests, route: crate::origin::Route) -> Result<(), crate::Error> { dynamic.update_route(route) } @@ -1426,7 +1426,7 @@ pub struct RequestValidity(crate::model::broadcast::RequestValidity); impl RequestValidity { /// Watch which instance this route's peer selects for a path. - pub fn new(route: &crate::origin::Dynamic, path: &crate::Path) -> Self { + pub fn new(route: &crate::origin::Requests, path: &crate::Path) -> Self { Self(crate::model::broadcast::RequestValidity::for_source(route, path)) } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 8eef9575a41..c7171f73727 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1272,7 +1272,7 @@ impl Publisher { }; // We just received a subscribe for this exact namespace, so the peer must have // already seen the announcement. `request_broadcast` resolves it immediately, or - // falls back to the route covering it (an `origin::Dynamic`), if any. Pinned to + // falls back to the route covering it (an `origin::Requests`), if any. Pinned to // the instance it names, if any: another is refused, never served. let requesting = origin.request_broadcast(&msg.track_namespace, msg.epoch.clone()); let broadcast = match self @@ -9066,7 +9066,7 @@ mod serve_tests { let mut h = serve(version); let claim = h ._origin - .dynamic_route("live", crate::origin::Route::default()) + .requests_route("live", crate::origin::Route::default()) .unwrap(); let auth = crate::auth::Link::new(false); auth.handle().authorize(¬hing()); @@ -9089,7 +9089,7 @@ mod serve_tests { let mut h = serve(version); let claim = h ._origin - .dynamic_route("live", crate::origin::Route::default()) + .requests_route("live", crate::origin::Route::default()) .unwrap(); let auth = crate::auth::Link::new(false); h.publisher = h.publisher.clone().with_auth(auth.clone()); @@ -9424,7 +9424,7 @@ mod serve_tests { for version in [Version::Draft21, Version::Draft22] { for path in [".", "./child", ".session", ".session/extension"] { let h = serve(version); - let claim = h._origin.dynamic_route("", crate::origin::Route::default()).unwrap(); + let claim = h._origin.requests_route("", crate::origin::Route::default()).unwrap(); settle().await; let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) .await @@ -11134,7 +11134,7 @@ mod serve_tests { for version in [Version::Draft20, Version::Draft21, Version::Draft22] { let h = serve(version); let request = h._broadcast.reserve_track("cam").unwrap(); - let dynamic = request.dynamic(); + let dynamic = request.requests(); let fetching = request.consume().fetch_group(4, None); let mut fetched = dynamic.requested_fill_group().await.unwrap().accept(None).unwrap(); for object in 0..2 { @@ -11756,7 +11756,7 @@ mod serve_tests { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let (claim, published) = match parked { Parked::Resolving => ( - Some(origin.dynamic_route("room", crate::origin::Route::default()).unwrap()), + Some(origin.requests_route("room", crate::origin::Route::default()).unwrap()), None, ), Parked::Acking | Parked::Ending => { @@ -11946,7 +11946,7 @@ mod serve_tests { let mut idle = None; match setup { Setup::Resolving => { - let route = origin.dynamic_route("room", crate::origin::Route::default()).unwrap(); + let route = origin.requests_route("room", crate::origin::Route::default()).unwrap(); let request = { let requested = std::pin::pin!(route.requested_broadcast()); match futures::future::select(requested, serving.as_mut()).await { @@ -11958,7 +11958,7 @@ mod serve_tests { } Setup::Subscribing => { let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let request = { let requested = std::pin::pin!(dynamic.requested_track()); match futures::future::select(requested, serving.as_mut()).await { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e58bd3dc828..eca8693e1d3 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -545,7 +545,7 @@ fn largest_group(track: &track::Producer, answer: Option) -> Opt struct Idle { track: track::Producer, /// Serves its cache misses with a group FETCH, subscribed or not. - dynamic: track::Dynamic, + dynamic: track::Requests, demand: track::Demand, timescale: Option, /// The publisher ended the track, so a returning subscriber reads the copy as it is. @@ -621,7 +621,7 @@ struct BroadcastState { // The served route: dropping it (and the serve task's clone) retracts the // route and rejects its queued requests. `None` while the session's limit holds // it back, though the peer still advertises it. - dynamic: Option, + dynamic: Option, // Bumped each time the route attaches, so a serve task outlived by a limit that // took the route away and gave it back ends rather than serve beside the new one. @@ -2397,7 +2397,7 @@ impl Subscriber { if self.going_away.is_set() { route.cost = crate::origin::Cost::DRAIN; } - entry.dynamic = Some(self.origin.dynamic_route(path, route)?); + entry.dynamic = Some(self.origin.requests_route(path, route)?); entry.generation += 1; tracing::info!(route = %self.origin.absolute(path), "namespace authorized again"); Ok(Some(entry.generation)) @@ -2489,7 +2489,7 @@ impl Subscriber { return Ok(()); } // Propagates Error::Unauthorized if the namespace is out of scope. - let dynamic = self.origin.dynamic_route(&path, route.clone())?; + let dynamic = self.origin.requests_route(&path, route.clone())?; entry.insert(BroadcastState { route: route.clone(), @@ -2653,7 +2653,7 @@ impl Subscriber { let source = crate::model::broadcast::SourceGuard::new(self.origin.create_source(&requested)); // The handler exists before the requester sees the source, so a track it asks // for before `run_broadcast` first polls queues instead of failing `NotFound`. - let dynamic = source.dynamic(); + let dynamic = source.requests(); // The source serves the instance the route names now: every request for it names // that epoch, so the peer refuses one that would reach another instance. let (validity, epoch) = { @@ -2724,7 +2724,7 @@ impl Subscriber { &self, path: Path<'_>, source: crate::model::broadcast::SourceGuard, - mut broadcast: broadcast::Dynamic, + mut broadcast: broadcast::Requests, route: Option>, ) -> Result<(), Error> { let mut subscribes = TaskSet::owned(); @@ -2831,7 +2831,7 @@ impl Subscriber { broadcast_path: Path<'_>, // Held for the subscription's lifetime but never watched: the broadcast ending // is a retraction, which does not disturb a subscription the scope allows. - _broadcast: broadcast::Dynamic, + _broadcast: broadcast::Requests, // Open while the namespace stays announced and authorized, which its own token may // have done in place of the scope. `None` once retracted. route: Option>, @@ -3374,7 +3374,7 @@ impl Subscriber { let request = request.request_start(start); // Serves cache misses with a group FETCH. Registered before accepting, so a // miss queued meanwhile waits for it rather than failing for want of a handler. - let dynamic = request.dynamic(); + let dynamic = request.requests(); let copy = request.stage(info); let track = track::Producer::clone(©); staged = Some(copy); @@ -4315,7 +4315,7 @@ impl Subscriber { }; tracing::debug!(broadcast = %self.origin.absolute(broadcast_path), track = %track_name, "track status known"); - let dynamic = request.dynamic(); + let dynamic = request.requests(); // Nothing feeds this copy live: what it caches is fetched, never the live edge. Idle // from the accept, so no reader takes it as live in between. let track = request.idle().stage(accepted_info(&ok.properties, self.version)); @@ -6191,7 +6191,7 @@ mod tests { #[moq_clock::test] async fn session_death_rejects_a_subscribe_still_setting_up() { let broadcast = crate::broadcast::Info::new().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let consumer = broadcast.consume(); let mut waiting = std::pin::pin!(consumer.track("video").unwrap().subscribe(None)); assert!(poll!(&mut waiting).is_pending()); @@ -7261,7 +7261,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7334,7 +7334,7 @@ mod tests { let (mut subscriber, log, _auth, _tokens, _task_set) = waiting_on_auth(VERSION); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7373,7 +7373,7 @@ mod tests { let (mut subscriber, log, _auth, _tokens, _task_set) = waiting_on_auth(VERSION); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7433,7 +7433,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7505,7 +7505,7 @@ mod tests { .with_auth(auth.clone()); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7570,7 +7570,7 @@ mod tests { .with_auth(auth.clone()); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7637,7 +7637,7 @@ mod tests { subscriber.validity = crate::model::broadcast::RequestValidity::token(Some(route.consume())); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -7722,7 +7722,7 @@ mod tests { assert!(gate.poll_denied(&waiter).is_pending()); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let mut subscription = Box::pin(track.subscribe(None)); @@ -7754,7 +7754,7 @@ mod tests { }), _ => { let track = request.accept(track::Info::default().with_timescale(None)); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let mut pending = Box::pin(track.consume().fetch_group(0, None)); assert!(futures::poll!(pending.as_mut()).is_pending()); let request = dynamic.requested_fill_group().await.expect("group request"); @@ -7915,7 +7915,7 @@ mod tests { subscriber.validity = crate::model::broadcast::RequestValidity::token(Some(route.consume())); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let _subscription = track.subscribe(None); @@ -7947,7 +7947,7 @@ mod tests { "video", track::Info::default().with_timescale(None), ); - let requested = track.dynamic(); + let requested = track.requests(); let mut pending = Box::pin(track.consume().fetch_group(0, None)); assert!(futures::poll!(pending.as_mut()).is_pending()); let group = requested.requested_fill_group().await.expect("group request"); @@ -8055,7 +8055,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -8129,7 +8129,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let _low = track.subscribe(track::Subscription::default().with_priority(1)); @@ -8208,7 +8208,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let _short = track.subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(1))); @@ -8346,7 +8346,7 @@ mod tests { // A consumer asking for a track is what dispatches a request to the session. let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = track.subscribe(None); @@ -8433,7 +8433,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let _subscription = track.subscribe(None); @@ -8499,7 +8499,7 @@ mod tests { struct Peer { version: Version, - dynamic: Mutex>, + dynamic: Mutex>, park: Mutex, log: Mutex>, } @@ -8581,7 +8581,7 @@ mod tests { let resolved = origin.consume().request_broadcast("broadcast", None).await.unwrap(); let peer = Arc::new(Peer { version, - dynamic: Mutex::new(Some(source.dynamic())), + dynamic: Mutex::new(Some(source.requests())), park: Default::default(), log: Default::default(), }); @@ -8652,7 +8652,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let mut pending = Box::pin(async { @@ -8738,7 +8738,7 @@ mod tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); // Demand with nobody subscribing learns the track from TRACK_STATUS. @@ -9645,7 +9645,7 @@ mod tests { let (subscriber, origin) = cluster_subscriber(crate::Hop::new(1).unwrap()); let run = |route: &kio::Producer<()>| { let source = crate::model::broadcast::SourceGuard::new(origin.create_source("pool/p")); - let (holder, dynamic) = (source.consume(), source.dynamic()); + let (holder, dynamic) = (source.consume(), source.requests()); let (this, route) = (subscriber.clone(), route.consume()); let task = moq_clock::sim::spawn(async move { this.run_broadcast(crate::Path::new("pool/p"), source, dynamic, Some(route)) @@ -15594,7 +15594,7 @@ mod stitch_tests { timescale: Option, end: Option, ) -> (kio::Producer, group::Consumer) { - let dynamic = self.track.dynamic(); + let dynamic = self.track.requests(); let mut fetch = Box::pin(self.track.consume().fetch_group(SEQUENCE, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); let request = dynamic.requested_fill_group().await.expect("group request"); @@ -15791,7 +15791,7 @@ mod joining_fetch_tests { ); let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("video").unwrap(); let subscription = match start { @@ -15923,7 +15923,7 @@ mod joining_fetch_tests { ); let auth = subscriber.auth.clone(); let broadcast = crate::broadcast::Info::default().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let consumer = broadcast.consume(); let track = consumer.track("video").unwrap(); let mut subscribing = Box::pin(track.subscribe(None)); @@ -16392,7 +16392,7 @@ mod joining_fetch_tests { "video", track::Info::default().with_timescale(None), ); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let consumer = track.consume(); let mut fetch = Box::pin(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); @@ -16635,7 +16635,7 @@ mod joining_fetch_tests { "video", track::Info::default().with_timescale(None), ); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); @@ -16701,7 +16701,7 @@ mod joining_fetch_tests { "video", track::Info::default().with_timescale(None), ); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group( GROUP, @@ -16847,7 +16847,7 @@ mod joining_fetch_tests { "video", track::Info::default().with_timescale(None), ); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group( GROUP, @@ -16968,7 +16968,7 @@ mod new_group_tests { Default::default(), ); let broadcast = crate::broadcast::Info::default().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let track = broadcast.consume().track("video").unwrap(); let first = track.subscribe(None); let request = dynamic.requested_track().await.expect("no track requested"); @@ -17271,7 +17271,7 @@ mod track_property_tests { Default::default(), ); let broadcast = crate::broadcast::Info::default().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let track = broadcast.consume().track("video").unwrap(); let subscribing = track.subscribe(None); let request = dynamic.requested_track().await.expect("no track requested"); diff --git a/rs/moq-net/src/ietf/subscriber/tracks.rs b/rs/moq-net/src/ietf/subscriber/tracks.rs index 53881fc05a4..61ae13a7db7 100644 --- a/rs/moq-net/src/ietf/subscriber/tracks.rs +++ b/rs/moq-net/src/ietf/subscriber/tracks.rs @@ -1279,7 +1279,7 @@ mod tests { Default::default(), ); let broadcast = crate::broadcast::Info::default().produce(); - let lister = Arc::new(broadcast.dynamic().lister().unwrap()); + let lister = Arc::new(broadcast.requests().lister().unwrap()); let tracks = broadcast.consume().tracks().unwrap(); let listening = subscriber.listen(PathOwned::from("bcast"), lister); Harness { @@ -1365,7 +1365,7 @@ mod tests { kio::Pending, moq_clock::sim::JoinHandle<()>, ) { - let mut dynamic = self._broadcast.dynamic(); + let mut dynamic = self._broadcast.requests(); let track = self._broadcast.consume().track(name).unwrap(); let reader = track.subscribe(None); let request = dynamic.requested_track().await.expect("no track requested"); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index c6760a44464..712efca91aa 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1679,7 +1679,7 @@ impl RequestServe { if requesting.is_none() { // The peer requested this exact path, so it has already seen an // announcement for it. `request_broadcast` resolves it immediately, is - // served on demand by the route covering it (an `origin::Dynamic`), or + // served on demand by the route covering it (an `origin::Requests`), or // errors when there is none. let origin = ready!(self.shared.poll_serving_origin(waiter)); let path = R::target(msg).0.clone(); @@ -6971,7 +6971,7 @@ mod tests { let mut echoed_hops = Hops::new(); echoed_hops.push(crate::Hop::UNKNOWN).unwrap(); let _echoed = origin - .dynamic_route( + .requests_route( "echoed", crate::origin::Route::default() .with_hops(echoed_hops) @@ -6982,7 +6982,7 @@ mod tests { let mut local_hops = Hops::new(); local_hops.push(upstream).unwrap(); let _local = origin - .dynamic_route("local", crate::origin::Route::default().with_hops(local_hops)) + .requests_route("local", crate::origin::Route::default().with_hops(local_hops)) .unwrap(); // A SETUP that declares no origin of its own, so only the assigned one applies. @@ -7454,10 +7454,10 @@ mod tests { let mut _route = None; let mut held = None; match wait { - Wait::Broadcast => _route = Some(origin.dynamic_route("room", crate::origin::Route::default()).unwrap()), + Wait::Broadcast => _route = Some(origin.requests_route("room", crate::origin::Route::default()).unwrap()), Wait::Track => { let broadcast = origin.create_broadcast("room").unwrap(); - let dynamic = broadcast.dynamic(); + let dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); held = Some((broadcast, dynamic)); } @@ -7685,7 +7685,7 @@ mod tests { async fn a_subscribe_update_is_not_a_close() { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let broadcast = origin.create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let peer_setup = crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand); @@ -7802,7 +7802,7 @@ mod tests { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let broadcast = origin.create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let peer_setup = crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand); @@ -8071,7 +8071,7 @@ mod tests { for version in [Version::Lite05, Version::Lite06, Version::Lite07] { let origin = crate::origin::Config::new(Hop::random()).produce(); let broadcast = origin.create_broadcast("room").unwrap(); - let _dynamic = broadcast.dynamic(); + let _dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let request = lite::Subscribe { order: crate::track::Order::Descending, @@ -8185,7 +8185,7 @@ mod tests { for version in [Version::Lite05, Version::Lite06, Version::Lite07] { let origin = crate::origin::Config::new(Hop::random()).produce(); let broadcast = origin.create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let request = lite::Subscribe { order: crate::track::Order::Descending, @@ -8320,7 +8320,7 @@ mod tests { /// with `info`, until the subscription runs. async fn subscribe_at( publisher: &Publisher, - dynamic: &mut broadcast::Dynamic, + dynamic: &mut broadcast::Requests, broadcast: &'static str, track: &'static str, priority: i8, @@ -8375,7 +8375,7 @@ mod tests { async fn send_group_ranks_by_the_subscriber_then_the_publisher() { let origin = crate::origin::Config::new(Hop::new(1).unwrap()).produce(); let broadcast = origin.create_broadcast("a").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let (publisher, log) = grouped_publisher(origin.consume()); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index ba0a93f6c9b..fab3510861f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1837,7 +1837,7 @@ struct MintedSource { epoch: Option, source: crate::model::broadcast::SourceGuard, /// The source's track handler, registered before its requester could ask for a track. - dynamic: crate::broadcast::Dynamic, + dynamic: crate::broadcast::Requests, /// Closes when the route that minted the source goes. route: kio::Consumer<()>, } @@ -1853,7 +1853,7 @@ struct SourceServe { path: PathOwned, epoch: Option, source: crate::model::broadcast::SourceGuard, - dynamic: crate::broadcast::Dynamic, + dynamic: crate::broadcast::Requests, route: kio::Consumer<()>, // A dedicated close-watch handle, since each pending operation needs its own. closed: Transport, @@ -2112,7 +2112,7 @@ mod tests { fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::Fill) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", info); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let fetching = track.consume().fetch_group(sequence, None); let req = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); (track, fetching, req) @@ -2288,7 +2288,7 @@ mod tests { ) -> (track::Producer, Vec>, Vec) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let mut callers = Vec::new(); let mut requests = Vec::new(); for sequence in sequences { @@ -2460,7 +2460,7 @@ mod tests { let sub = feed_sub(&tail).await; let info = track::Info::default().with_timescale(None); let mut track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let mut reader = track.subscribe( Subscription::default() .with_range(0..) @@ -2514,7 +2514,7 @@ mod tests { let tail = kio::Producer::new(Tail::default()); let sub = feed_sub(&tail).await; let (track, caller, req) = feed_fetch(1); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let mut fetches = FeedFetches::new(&clock); fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); @@ -2542,7 +2542,7 @@ mod tests { let tail = kio::Producer::new(Tail::default()); let sub = feed_sub(&tail).await; let (track, mut callers, requests) = feed_batch([1, 2]); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let mut fetches = FeedFetches::new(&clock); fetches.max = 2; for req in requests { @@ -3787,7 +3787,7 @@ mod tests { }); let broadcast = crate::broadcast::Info::new().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let consumer = broadcast.consume(); let mut waiting = std::pin::pin!(consumer.track("video").unwrap().subscribe(None)); assert!( @@ -3882,7 +3882,7 @@ mod tests { }); let broadcast = crate::broadcast::Info::new().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let consumer = broadcast.consume(); let mut waiting = Box::pin(consumer.track("video").unwrap().subscribe(None)); assert!( @@ -3937,7 +3937,7 @@ mod tests { }); let broadcast = crate::broadcast::Info::new().produce(); let consumer = broadcast.consume(); - let dynamic = broadcast.dynamic(); + let dynamic = broadcast.requests(); let route = kio::Producer::new(()); let serve = SourceServe::new( subscriber, @@ -4060,7 +4060,7 @@ mod tests { }); let broadcast = crate::broadcast::Info::new().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let consumer = broadcast.consume(); let mut waiting = Box::pin(consumer.track("video").unwrap().subscribe(None)); assert!(futures::poll!(waiting.as_mut()).is_pending()); @@ -4290,7 +4290,7 @@ mod tests { if !dynamic_first { // Isolate the subscription-changed arm, which can observe a concurrent // abort after the dynamic-request poll returned Pending. - serving.dynamic = other.dynamic(); + serving.dynamic = other.requests(); } serving.serving.clone().abort(Error::ProtocolViolation).unwrap(); assert!(matches!( @@ -5895,7 +5895,7 @@ impl Announced { } /// Attach a route, queued for its first serve pass. - fn attach(&mut self, path: PathOwned, route: crate::origin::Route, dynamic: crate::origin::Dynamic) { + fn attach(&mut self, path: PathOwned, route: crate::origin::Route, dynamic: crate::origin::Requests) { let wake = crate::util::QueueWake::new(path.clone(), self.ready.clone()); let route = AnnouncedRoute::new(route, dynamic, wake); route.waker.wake_by_ref(); @@ -5915,7 +5915,7 @@ impl Announced { return false; } // An error means the prefix is outside our origin's scope, so don't serve it. - match subscriber.origin.dynamic_route(&path, route.clone()) { + match subscriber.origin.requests_route(&path, route.clone()) { Ok(dynamic) => { self.attach(path, route, dynamic); true @@ -6013,7 +6013,7 @@ impl Announced { // asks for before the serve machine first runs queues instead of failing // `NotFound`. Accepted before the push, so the requester holds the source // before its serve machine can see it unheld. - let dynamic = source.dynamic(); + let dynamic = source.requests(); let validity = if entry.route.epoch.is_some() { Default::default() } else { @@ -6082,7 +6082,7 @@ struct AnnouncedRoute { /// without recomputing the chain. route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. - dynamic: crate::origin::Dynamic, + dynamic: crate::origin::Requests, /// Closes as the route goes, which closes every source it minted. live: kio::Producer<()>, /// Whether the GOAWAY drain already re-priced this route. @@ -6097,7 +6097,7 @@ struct AnnouncedRoute { impl AnnouncedRoute { fn new( route: crate::origin::Route, - dynamic: crate::origin::Dynamic, + dynamic: crate::origin::Requests, wake: Arc>, ) -> Self { Self { @@ -6863,7 +6863,7 @@ struct ServeLoop { /// Watches `serving`'s subscribers, to release the copy once nobody reads it. demand: track::Demand, /// Serve on-demand fetches of uncached groups from this session. - dynamic: track::Dynamic, + dynamic: track::Requests, sub: Sub, fetches: kio::Tasks, /// Fetches a pre-lite-05 upstream has no FETCH for, held for the live feed. @@ -7113,7 +7113,7 @@ impl ServeLoop { // Register the fetch handler before accepting: `accept` releases the // request's own fetch gate, and a cache-miss fetch queued while TRACK_INFO // was in flight would be drained as NotFound in the gap. - let dynamic = request.dynamic(); + let dynamic = request.requests(); // Lite-06+ resolves each subscription's start from its budget, so the floor // the SUBSCRIBE asks for is not where delivery begins until SUBSCRIBE_START says. let request = match serve.subscriber.version.resolves_start() { diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index 51a6ec08f26..72d9b95c964 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -18,7 +18,7 @@ use crate::Error; use crate::origin::Route; use super::origin_impl::Announcer; -use super::{Requests, WeakCache}; +use super::{RequestQueue, WeakCache}; #[path = "listing.rs"] mod listing; @@ -112,7 +112,7 @@ struct BroadcastState { // and waiting for a dynamic handler to accept or deny them. A request leaves // here once handed out (the handler caches it in `tracks`, so lookups keep // coalescing onto it there). - requests: Requests, track::Request>, + requests: RequestQueue, track::Request>, // The tracks the broadcast lists, and the readers listing them; see [`Tracks`]. listing: listing::ListingState, @@ -136,7 +136,7 @@ impl BroadcastState { /// [`track::Info`] that can no longer arrive fail with `err` instead of parking. /// /// Covers a reservation nobody accepted and a request still queued for a handler. - /// A request a [`Dynamic`] already took is left alone: it may be in flight to a peer + /// A request a [`Requests`] already took is left alone: it may be in flight to a peer /// still serving it, and the handler answers or drops it. So is a track that carries /// its info: an end there is that publisher's call, and its cache stays readable. fn reject_unserved(&mut self, err: Error) { @@ -204,12 +204,12 @@ impl BroadcastState { /// /// Create tracks up front with [Self::create_track], reserve a name to fill in /// later with [Self::reserve_track], or handle on-demand consumer requests via -/// [Self::dynamic]. +/// [Self::requests]. /// /// # Lifetime /// /// A broadcast lives until [`Self::close`] or until the last [`Producer`] (or -/// [`Dynamic`]) drops, whichever comes first; both end it the same way. A publisher +/// [`Requests`]) drops, whichever comes first; both end it the same way. A publisher /// whose input ended cleanly finishes its tracks first: a subscriber still catching up /// drains them after the close. Children /// do *not* keep it alive: cloning a [`Consumer`] or holding a [`track::Producer`] @@ -220,7 +220,7 @@ pub struct Producer { // handle (threaded down by [`Self::create_track`] / [`Self::reserve_track`]). info: Arc, - // Broadcast liveness, shared with every `Dynamic`. Consumers watch it (read-only) + // Broadcast liveness, shared with every `Requests`. Consumers watch it (read-only) // for close; the guard ends the broadcast when the last of those handles drops. alive: Arc, @@ -355,7 +355,7 @@ impl Producer { let mut state = self.state.lock(); // A consumer may have requested this name before it existed (a live - // [`Dynamic`] queues such requests). Creating the track fulfills that + // [`Requests`] queues such requests). Creating the track fulfills that // request: its consumers resolve against this very producer. Without // this they would be stranded, since the name is taken the moment the // track exists, so no handler could ever serve their queue entry. @@ -381,7 +381,7 @@ impl Producer { /// [`track::Request::accept`] to set its info and start producing. Use this when /// the producer can't pick the track's properties (e.g. timescale) until it has /// inspected the media, the same shape as a consumer-driven - /// [`Dynamic::poll_requested_track`]. + /// [`Requests::poll_requested_track`]. /// /// Subscribers wait on the name until it is accepted, so a reservation the producer /// ends up never filling has to be dropped or rejected. Ending the broadcast @@ -434,8 +434,8 @@ impl Producer { } /// Create a dynamic producer that handles on-demand track requests from consumers. - pub fn dynamic(&self) -> Dynamic { - Dynamic::new( + pub fn requests(&self) -> Requests { + Requests::new( self.info.clone(), self.alive.clone(), self.state.clone(), @@ -463,7 +463,7 @@ impl Producer { /// with [`Error::Closed`]. Tracks already handed out carry on and end with their /// own finish or abort. Every later [`Consumer::track`], and every name reserved or /// still queued for a handler, answers [`Error::Unroutable`]: the same answer an - /// origin gives for a path nobody publishes. A request a [`Dynamic`] already took is + /// origin gives for a path nobody publishes. A request a [`Requests`] already took is /// left for that handler to answer. /// /// Dropping the last producer does the same. Closing twice is a no-op. @@ -500,7 +500,7 @@ impl Producer { } /// Ends the broadcast on [`Producer::close`] or when the last [`Producer`] or -/// [`Dynamic`] drops, closing the liveness channel every [`Consumer`] watches. +/// [`Requests`] drops, closing the liveness channel every [`Consumer`] watches. /// /// A refcount rather than a "am I the last one?" check inside `Drop`: that answer is /// a snapshot, and acting on it is exactly what invalidates it. @@ -607,7 +607,7 @@ impl RequestValidity { } #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn for_source(route: &crate::origin::Dynamic, path: &crate::Path) -> Self { + pub(crate) fn for_source(route: &crate::origin::Requests, path: &crate::Path) -> Self { route .request_validity(path) .map(|selected| Self::Selected(std::sync::Arc::new(selected))) @@ -653,7 +653,7 @@ impl Producer { /// A session-owned handle to a source broadcast created via /// [`crate::origin::Producer::create_broadcast`], closing it on drop even while the -/// session's serve machines still hold its [`Dynamic`]. +/// session's serve machines still hold its [`Requests`]. /// /// A peer's retraction and a dead session end the source the same way: a broadcast /// carries no end cause past this hop. Shared by the lite and IETF subscribers. @@ -689,7 +689,7 @@ impl Drop for SourceGuard { /// [`track::Request::reject`]s it. Dropped when no longer needed; pending requests /// are automatically aborted. #[derive(Clone)] -pub struct Dynamic { +pub struct Requests { info: Arc, // Keeps the broadcast alive while a handler exists (mirrors a producer). alive: Arc, @@ -703,7 +703,7 @@ pub struct Dynamic { _handler: Handler, } -/// Counts one live [`Dynamic`], rejecting the queued requests when the last one drops. +/// Counts one live [`Requests`], rejecting the queued requests when the last one drops. struct Handler(kio::Shared); impl Handler { @@ -736,7 +736,7 @@ impl Drop for Handler { } } -impl Dynamic { +impl Requests { fn new(info: Arc, alive: Arc, state: kio::Shared, stats: stats::Scope) -> Self { Self { info, @@ -825,7 +825,7 @@ use futures::FutureExt; #[cfg(test)] #[expect(missing_docs, reason = "test-only assertion helpers")] -impl Dynamic { +impl Requests { pub fn assert_request(&mut self) -> track::Request { kio::wait(|waiter| self.poll_requested_track(waiter)) .now_or_never() @@ -915,7 +915,7 @@ impl Consumer { /// Nothing is requested until the handle queries, subscribes, or fetches, each of /// which looks the name up again. Fails with [`Error::Unroutable`] once the broadcast /// has ended, and [`Error::NotFound`] when nothing publishes the track and no - /// [`Dynamic`] would serve a request for it. + /// [`Requests`] would serve a request for it. pub fn track(&self, name: &str) -> Result { let current = { let mut state = self.state.lock(); @@ -941,7 +941,7 @@ impl Consumer { ) } - /// Join the demand of the track serving `name`, requesting it from a [`Dynamic`] + /// Join the demand of the track serving `name`, requesting it from a [`Requests`] /// when nothing serves it yet. Registers the subscription before exposing demand. /// Errors as [`Self::track`] does. pub(super) fn resolve_track( @@ -1203,7 +1203,7 @@ mod test { #[test] fn a_subscription_is_registered_before_its_request_is_visible() { struct Serve { - dynamic: std::sync::Mutex, + dynamic: std::sync::Mutex, request: std::sync::Mutex>, subscription: std::sync::Mutex>>, } @@ -1223,7 +1223,7 @@ mod test { let producer = Info::new().produce(); let serve = Arc::new(Serve { - dynamic: std::sync::Mutex::new(producer.dynamic()), + dynamic: std::sync::Mutex::new(producer.requests()), request: Default::default(), subscription: Default::default(), }); @@ -1531,7 +1531,7 @@ mod test { #[cfg(feature = "async")] async fn closed() { let mut producer = Info::new().produce(); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); consumer.assert_not_closed(); @@ -1586,7 +1586,7 @@ mod test { #[moq_clock::test] #[cfg(feature = "async")] async fn requests() { - let mut producer = Info::new().produce().dynamic(); + let mut producer = Info::new().produce().requests(); let consumer = producer.consume(); let consumer2 = consumer.clone(); @@ -1627,7 +1627,7 @@ mod test { #[moq_clock::test] #[cfg(feature = "async")] async fn stale_producer() { - let mut broadcast = Info::new().produce().dynamic(); + let mut broadcast = Info::new().produce().requests(); let consumer = broadcast.consume(); // Subscribe to a track and serve it. @@ -1666,7 +1666,7 @@ mod test { fn replacement_starts_over() { let at = |millis| crate::Timestamp::from_millis(millis).unwrap(); let producer = Info::new().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let track = consumer.track("media").unwrap().subscribe(None); @@ -1693,7 +1693,7 @@ mod test { #[test] fn gone_names_are_forgotten() { let producer = Info::new().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); for i in 0..1000 { let track = consumer.track(&format!("requested/{i}")).unwrap().subscribe(None); @@ -1716,7 +1716,7 @@ mod test { #[moq_clock::test] #[cfg(feature = "async")] async fn requested_unused() { - let mut broadcast = Info::new().produce().dynamic(); + let mut broadcast = Info::new().produce().requests(); let bc = broadcast.consume(); // Subscribe to a track that doesn't exist yet, then serve it. @@ -1771,7 +1771,7 @@ mod test { #[cfg(feature = "async")] async fn create_track_fulfills_queued_request() { let producer = Info::new().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let bc = dynamic.consume(); // Queue a request for a track that doesn't exist yet. @@ -1791,14 +1791,14 @@ mod test { again.assert_is_clone(&track.subscribe(None)); } - // Cloning a `Dynamic` and dropping the clone must not flip the handler + // Cloning a `Requests` and dropping the clone must not flip the handler // count to zero. The relay's lite subscriber clones the // dynamic per spawned subscribe; if Clone skipped the increment, the // first finished subscribe would tear down the broadcast and any // follow-up `track` would return `NotFound`. #[test] fn dynamic_clone_keeps_alive() { - let broadcast = Info::new().produce().dynamic(); + let broadcast = Info::new().produce().requests(); let consumer = broadcast.consume(); let clone = broadcast.clone(); @@ -1815,7 +1815,7 @@ mod test { #[test] fn close_unheld_yields_to_holders() { let producer = Info::new().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = producer.consume(); let weak = consumer.weak(); assert!(producer.is_held()); @@ -1860,7 +1860,7 @@ mod test { #[cfg(feature = "async")] async fn close_resolves_a_queued_request() { let producer = Info::new().produce(); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = dynamic.consume(); let pending = subscribe_pending!(consumer, "track1"); @@ -1871,11 +1871,11 @@ mod test { } /// A queued request answers the same when the broadcast ends by its last handle - /// dropping, even when that handle is the `Dynamic` that would have served it. + /// dropping, even when that handle is the `Requests` that would have served it. #[moq_clock::test] #[cfg(feature = "async")] async fn dropping_the_last_handle_resolves_a_queued_request() { - let dynamic = Info::new().produce().dynamic(); + let dynamic = Info::new().produce().requests(); let consumer = dynamic.consume(); let pending = subscribe_pending!(consumer, "track1"); @@ -1890,7 +1890,7 @@ mod test { #[cfg(feature = "async")] async fn dropping_the_last_handler_resolves_a_queued_request_dropped() { let producer = Info::new().produce(); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = dynamic.consume(); let pending = subscribe_pending!(consumer, "track1"); @@ -1907,7 +1907,7 @@ mod test { #[cfg(feature = "async")] async fn close_leaves_a_claimed_request_to_its_handler() { let producer = Info::new().produce(); - let mut dynamic = producer.dynamic(); + let mut dynamic = producer.requests(); let consumer = dynamic.consume(); let accepted = subscribe_pending!(consumer, "track1"); @@ -1943,7 +1943,7 @@ mod test { let consumer = producer.consume(); let request = producer.reserve_track("track1").unwrap(); - let dynamic = request.dynamic(); + let dynamic = request.requests(); let track = consumer.track("track1").unwrap(); let pending_fetch = track.fetch_group(0, None); let fetch = dynamic.requested_fill_group().await.unwrap(); diff --git a/rs/moq-net/src/model/follow.rs b/rs/moq-net/src/model/follow.rs index e26d92af015..696dee7d29e 100644 --- a/rs/moq-net/src/model/follow.rs +++ b/rs/moq-net/src/model/follow.rs @@ -335,7 +335,7 @@ mod tests { let epoch = Epoch::mint(); // A prefix above the path covers it. - let pool = origin.dynamic_route("pool", route(&epoch)).unwrap(); + let pool = origin.requests_route("pool", route(&epoch)).unwrap(); assert_eq!(followed(&mut follow).await, ("start", "pool".into())); // A route beneath the path serves another broadcast. @@ -370,7 +370,7 @@ mod tests { #[moq_clock::test] async fn follow_starts_on_the_serving_route_when_joining_late() { let origin = Hop::new(1).unwrap().produce(); - let _pool = origin.dynamic_route("pool", Route::default()).unwrap(); + let _pool = origin.requests_route("pool", Route::default()).unwrap(); let _exact = origin.publish("pool/job", Route::default()).unwrap(); let mut follow = origin.consume().follow("pool/job").unwrap(); @@ -391,7 +391,7 @@ mod tests { // Both land before the follower reads again, and the cursor delivers the prefix's start // ahead of the path's end, but the path went unserved in between. drop(exact); - let _pool = origin.dynamic_route("pool", route(&epoch)).unwrap(); + let _pool = origin.requests_route("pool", route(&epoch)).unwrap(); assert_eq!(followed(&mut follow).await, ("end", "pool/job".into())); assert_eq!(followed(&mut follow).await, ("start", "pool".into())); @@ -412,7 +412,7 @@ mod tests { assert_eq!(followed(&mut follow).await, ("start", "pool/job".into())); let epoch = Epoch::mint(); - let _pool = origin.dynamic_route("pool", route(&epoch)).unwrap(); + let _pool = origin.requests_route("pool", route(&epoch)).unwrap(); exact.announce(route(&epoch)).unwrap(); assert_eq!(followed(&mut follow).await, ("restart", "pool/job".into())); @@ -426,7 +426,7 @@ mod tests { let origin = Hop::new(1).unwrap().produce(); let mut follow = origin.consume().follow("pool/job").unwrap(); - let _pool = origin.dynamic_route("pool", Route::default()).unwrap(); + let _pool = origin.requests_route("pool", Route::default()).unwrap(); assert_eq!(followed(&mut follow).await, ("start", "pool".into())); let _exact = origin.publish("pool/job", Route::default()).unwrap(); @@ -442,7 +442,7 @@ mod tests { let _chat = origin .scope("", &chat) .unwrap() - .dynamic_route("room/alice", Route::default()) + .requests_route("room/alice", Route::default()) .unwrap(); let mut follow = origin.consume().follow("room/alice").unwrap(); @@ -489,7 +489,7 @@ mod tests { } /// The next request a dynamic route receives for its path. - async fn requested(dynamic: &crate::origin::Dynamic) -> crate::origin::Request { + async fn requested(dynamic: &crate::origin::Requests) -> crate::origin::Request { moq_clock::timeout(Duration::from_secs(1), dynamic.requested_broadcast()) .await .expect("never asked") @@ -501,7 +501,7 @@ mod tests { #[moq_clock::test] async fn announced_hands_out_a_held_takeover_once() { let origin = Hop::new(1).unwrap().produce(); - let dynamic = origin.dynamic("live").unwrap(); + let dynamic = origin.requests("live").unwrap(); let mut announced = origin.consume().announced_broadcast("live", None).unwrap(); // The wait is on the dynamic route's answer when the exact path takes over without an epoch. @@ -517,7 +517,7 @@ mod tests { #[moq_clock::test] async fn announced_hands_out_a_dropped_takeover_again() { let origin = Hop::new(1).unwrap().produce(); - let dynamic = origin.dynamic("live").unwrap(); + let dynamic = origin.requests("live").unwrap(); let mut announced = origin.consume().announced_broadcast("live", None).unwrap(); let (dropped, _publish) = futures::join!(available(&mut announced), async { @@ -537,7 +537,7 @@ mod tests { #[moq_clock::test] async fn announced_hands_out_a_gap_while_resolving_once() { let origin = Hop::new(1).unwrap().produce(); - let dynamic = origin.dynamic("").unwrap(); + let dynamic = origin.requests("").unwrap(); let mut announced = origin.consume().announced_broadcast("live", None).unwrap(); let (_arrival, _publish) = futures::join!(available(&mut announced), async { @@ -560,7 +560,7 @@ mod tests { let exact = origin.publish("live", route(&epoch)).unwrap(); let first = available(&mut announced).await; - let dynamic = origin.dynamic_route("", route(&epoch)).unwrap(); + let dynamic = origin.requests_route("", route(&epoch)).unwrap(); exact.unannounce(); let served = broadcast::Info::new().produce(); let (again, ()) = futures::join!(available(&mut announced), async { diff --git a/rs/moq-net/src/model/listing.rs b/rs/moq-net/src/model/listing.rs index 71dd0bc4c5c..a61b1ab96a8 100644 --- a/rs/moq-net/src/model/listing.rs +++ b/rs/moq-net/src/model/listing.rs @@ -559,7 +559,7 @@ impl Consumer { } } -/// A handler's answer to the broadcast's listings, from [`Dynamic::lister`](super::Dynamic::lister). +/// A handler's answer to the broadcast's listings, from [`Requests::lister`](super::Requests::lister). /// /// A broadcast has at most one. While it exists, readers wait for it to /// [`sync`](Self::synced) before reporting [`TracksEvent::Synced`]. Answer while [`poll_used`](Self::poll_used): list what the @@ -797,7 +797,7 @@ mod tests { #[test] fn a_lister_finishes_or_removes_a_track() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); lister.add("up", track::Info::default()).unwrap(); lister.add("gone", track::Info::default()).unwrap(); lister.synced(); @@ -829,7 +829,7 @@ mod tests { // The producer's finished track is what a handler's answer of its name served. let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); lister.add("a", track::Info::default()).unwrap(); lister.synced(); let a = broadcast.create_track("a", None).unwrap(); @@ -842,7 +842,7 @@ mod tests { // A handler's end is clean too, and its abort is not. for abort in [false, true] { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); lister.add("up", track::Info::default()).unwrap(); lister.synced(); let mut tracks = broadcast.consume().tracks().unwrap(); @@ -900,7 +900,7 @@ mod tests { #[test] fn a_track_served_on_request_is_never_listed() { let broadcast = Info::new().produce(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let consumer = broadcast.consume(); let mut tracks = consumer.tracks().unwrap(); assert_eq!(drain(&mut tracks), ["synced"]); @@ -983,7 +983,7 @@ mod tests { fn readers_wait_for_the_lister_to_sync() { let broadcast = Info::new().produce(); let _own = broadcast.create_track("own", None).unwrap(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); let waiter = kio::Waiter::noop(); assert!(lister.poll_used(&waiter).is_pending()); @@ -1011,7 +1011,7 @@ mod tests { fn a_name_listed_by_both_is_reported_once() { let broadcast = Info::new().produce(); let own = broadcast.create_track("a", None).unwrap(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); lister.add("a", track::Info::default()).unwrap(); lister.synced(); let mut tracks = broadcast.consume().tracks().unwrap(); @@ -1025,7 +1025,7 @@ mod tests { #[test] fn an_aborted_answer_fails_its_readers_and_a_later_reader_asks_again() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); let waiter = kio::Waiter::noop(); let mut tracks = broadcast.consume().tracks().unwrap(); lister.add("up", track::Info::default()).unwrap(); @@ -1051,7 +1051,7 @@ mod tests { #[test] fn a_dropped_lister_withdraws_its_answer_and_syncs() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); lister.add("up", track::Info::default()).unwrap(); let mut tracks = broadcast.consume().tracks().unwrap(); assert_eq!(drain(&mut tracks), ["+up"]); @@ -1118,16 +1118,16 @@ mod tests { #[test] fn a_broadcast_has_one_lister() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); - assert!(matches!(broadcast.dynamic().lister(), Err(Error::Duplicate))); + let lister = broadcast.requests().lister().unwrap(); + assert!(matches!(broadcast.requests().lister(), Err(Error::Duplicate))); drop(lister); - broadcast.dynamic().lister().expect("the first lister left"); + broadcast.requests().lister().expect("the first lister left"); } #[test] fn an_ended_answer_ends_its_readers_and_a_later_reader_asks_again() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); let waiter = kio::Waiter::noop(); let mut tracks = broadcast.consume().tracks().unwrap(); lister.add("up", track::Info::default()).unwrap(); @@ -1145,7 +1145,7 @@ mod tests { #[test] fn a_cleared_answer_unsyncs_a_reader_still_waiting() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); let _a = broadcast.create_track("a", None).unwrap(); let _b = broadcast.create_track("b", None).unwrap(); lister.synced(); @@ -1160,7 +1160,7 @@ mod tests { #[test] fn a_lister_sees_the_broadcast_end() { let broadcast = Info::new().produce(); - let lister = broadcast.dynamic().lister().unwrap(); + let lister = broadcast.requests().lister().unwrap(); broadcast.close(); assert!(matches!( lister.poll_used(&kio::Waiter::noop()), diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 48101f0b509..504d0412799 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -25,7 +25,7 @@ mod expiry; // The DEFLATE codec behind compressed tracks. mod flate; pub(crate) mod object; -mod requests; +mod request_queue; mod resume; mod subscription; mod time; @@ -35,7 +35,7 @@ mod weak_cache; #[cfg(test)] mod test_tracing; -use requests::Requests; +use request_queue::RequestQueue; use weak_cache::{WeakCache, WeakEntry}; pub use bytes::*; @@ -49,7 +49,7 @@ pub use timed::Timed; pub mod origin { pub use super::follow::AnnouncedBroadcast; pub use super::origin_impl::{ - Config, Consumer, Cost, Driver, Dynamic, HopLimit, Link, Preference, Prepared, Producer, Request, Requesting, + Config, Consumer, Cost, Driver, HopLimit, Link, Preference, Prepared, Producer, Request, Requesting, Requests, Route, Routing, Source, }; } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7bb068b1879..b7161978dbc 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -13,7 +13,7 @@ use std::{ use rand::RngExt; use super::{ - Requests, WeakCache, WeakEntry, + RequestQueue, WeakCache, WeakEntry, follow::AnnouncedBroadcast, front::{Action, Candidate, Event, Front, Refusal}, }; @@ -470,7 +470,7 @@ impl From for Preference { /// The path a route took through the mesh and what using it costs. /// -/// The metadata half of an advertisement: [`Producer::dynamic`] pairs it with +/// The metadata half of an advertisement: [`Producer::requests`] pairs it with /// the prefix it covers, [`broadcast::Producer::announce`] with the /// broadcast's exact path, and [`Consumer::announced`] yields both. A route /// claims capability, not inventory: it says paths under its prefix are @@ -603,7 +603,7 @@ impl Route { /// Where the route entered this origin, as delivered by [`Consumer::announced`]. /// /// Set by the origin, not the announcer: a route handed to - /// [`Producer::dynamic`] reports [`Source::Local`] until the origin delivers it. + /// [`Producer::requests`] reports [`Source::Local`] until the origin delivers it. pub fn source(&self) -> Source { self.source } @@ -1243,7 +1243,7 @@ struct RouteEntry { /// The link the entry arrived on, from the handle that inserted it. link: Link, /// The queue requests under this route are served from, when the announcer - /// serves content on demand (a [`Dynamic`]). `None` for an advertise-only + /// serves content on demand (a [`Requests`]). `None` for an advertise-only /// announcement ([`Producer::announce`]) and for a local broadcast. server: Option>, /// The broadcast published on this origin at exactly `prefix`, when the @@ -1256,7 +1256,7 @@ struct RouteEntry { advertised: bool, /// Whether a peer the chain passes through has since withdrawn this prefix. /// The route was derived from that peer's advertisement, so it serves nobody - /// until the peer announces again. See [`Dynamic::withdrawn`]. + /// until the peer announces again. See [`Requests::withdrawn`]. stale: bool, /// [`prefix_claim`] of [`Self::prefix`], built once at announce time. /// @@ -1371,12 +1371,12 @@ fn prefix_claim(prefix: &Path) -> Result { /// A served route's request queue: what materializes a requested path on demand. /// /// Shared by every requester resolving through the owning route and the -/// [`Dynamic`] draining it, so both sides work under one lock. +/// [`Requests`] draining it, so both sides work under one lock. #[derive(Default)] struct ServeState { // Result channels for pending requests, keyed by absolute path so concurrent // `request_broadcast` calls for the same path coalesce onto one channel. - requests: Requests>, + requests: RequestQueue>, // Broadcasts the handler has already served, kept weakly so a repeat request for the // same path resolves to a shared clone instead of re-invoking the handler (which would @@ -2067,7 +2067,7 @@ impl Producer { /// Rank the link this handle (and any handle derived from it) is given to: /// higher wins, and every handle starts at the neutral [`Preference::default`]. /// - /// Applies to what [`Self::dynamic`] advertises, which is how a session lands its + /// Applies to what [`Self::requests`] advertises, which is how a session lands its /// peer's routes, not to a broadcast published here. A route with a newer epoch still wins, /// but among the rest preference decides before cost, so links to different /// operators rank by this instead of comparing their metrics. Hand each CDN's @@ -2142,11 +2142,11 @@ impl Producer { /// until then no announce cursor lists it and a request for its path fails /// with [`Error::Unroutable`], for a consumer of this origin exactly as for a /// peer. Announce once the tracks a subscriber needs first exist. To serve - /// paths on demand without publishing each one, use [`Self::dynamic`]. + /// paths on demand without publishing each one, use [`Self::requests`]. /// /// Announcing is visible to local consumers before it returns; only /// lifecycle work (track serving, teardown) waits for the [`Driver`] to be - /// polled. Register a [`broadcast::Producer::dynamic`] handler before + /// polled. Register a [`broadcast::Producer::requests`] handler before /// announcing, so the first consumer finds the tracks it serves. /// /// End the broadcast with [`broadcast::Producer::close`] or by dropping it; @@ -2235,7 +2235,7 @@ impl Producer { /// Mint a standalone source broadcast for a served-route request: it carries /// this origin's cache policy and ingress attribution, but /// is *not* entered into the route table. Sessions answer - /// [`Dynamic`] requests with one of these; the requester already holds + /// [`Requests`] requests with one of these; the requester already holds /// the request's result channel, so the table never needs to resolve it. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn create_source(&self, path: impl AsPath) -> broadcast::Producer { @@ -2257,10 +2257,10 @@ impl Producer { /// can be served, answered by nothing. /// /// A request under an advertise-only route resolves [`Error::Unroutable`] - /// unless an announced broadcast or a served route ([`Self::dynamic`]) covers + /// unless an announced broadcast or a served route ([`Self::requests`]) covers /// the path too. Tests use it to shape the route table; everything else /// advertises through a broadcast ([`broadcast::Producer::announce`]) or a - /// [`Dynamic`] handler, which serve what they claim. + /// [`Requests`] handler, which serve what they claim. #[cfg(test)] pub(crate) fn announce(&self, prefix: impl AsPath, route: Route) -> Result { Announcing::new(self, prefix)?.announce( @@ -2339,7 +2339,7 @@ impl Producer { /// thing on every hop. /// /// The advertisement is visible to [`Consumer::announced`] and forwarded by - /// sessions for as long as the returned [`Dynamic`] (and every clone) lives. + /// sessions for as long as the returned [`Requests`] (and every clone) lives. /// A consumer resolving a path under it through this route is handed to the /// handler as a [`Request`] to materialize on demand: [`Request::prepare`] /// under a new epoch, fill, then [`Prepared::publish`] the exact path. Each @@ -2352,16 +2352,16 @@ impl Producer { /// /// The prefix must overlap this producer's pattern scope. Individual requests /// remain authoritative and are refused when they do not match the scope. - pub fn dynamic(&self, prefix: impl AsPath) -> Result { - self.dynamic_inner(prefix, Route::default(), true) + pub fn requests(&self, prefix: impl AsPath) -> Result { + self.requests_inner(prefix, Route::default(), true) } /// Land a peer's claim with its wire route metadata. - pub(crate) fn dynamic_route(&self, prefix: impl AsPath, route: Route) -> Result { - self.dynamic_inner(prefix, route, false) + pub(crate) fn requests_route(&self, prefix: impl AsPath, route: Route) -> Result { + self.requests_inner(prefix, route, false) } - fn dynamic_inner(&self, prefix: impl AsPath, route: Route, publishes: bool) -> Result { + fn requests_inner(&self, prefix: impl AsPath, route: Route, publishes: bool) -> Result { let announcing = Announcing::new(self, prefix)?; let serve = kio::Shared::::default(); serve.lock().requests.add_handler(); @@ -2373,7 +2373,7 @@ impl Producer { advertised: true, }, )?; - Ok(Dynamic { + Ok(Requests { announcement, state: serve, publisher: publishes.then(|| self.clone()), @@ -2500,7 +2500,7 @@ impl Producer { } /// What it takes to insert a route: the prefixes it covers and the origin table -/// to insert them into. Built by [`Producer::announce`], [`Producer::dynamic`], +/// to insert them into. Built by [`Producer::announce`], [`Producer::requests`], /// and [`Announcer`], which is the same advertisement re-issued from a broadcast. struct Announcing { hop: Hop, @@ -2658,7 +2658,7 @@ impl Announcer { /// The write half of an advertisement: a live claim that paths under a /// [`Pattern`] can be served. /// -/// Held by a [`Dynamic`] and by a broadcast's [`Announcer`]; dropping it +/// Held by a [`Requests`] and by a broadcast's [`Announcer`]; dropping it /// retracts the route, which [`AnnounceConsumer`]s observe and sessions withdraw /// from their peers. #[must_use = "dropping an announcement retracts the route"] @@ -2672,7 +2672,7 @@ pub(crate) struct AnnounceProducer { } impl AnnounceProducer { - /// Replace the route in place, epoch included; see [`Dynamic::update_route`]. + /// Replace the route in place, epoch included; see [`Requests::update_route`]. /// /// The prefix is fixed at announce time and a [`Route`] cannot name one: to /// move an advertisement, drop this and announce again. Fails with @@ -2834,7 +2834,7 @@ impl Drop for AnnounceProducer { /// /// It holds no [`Producer`] clone, so it never keeps the origin alive. It /// finishes once every owner is gone: each [`Producer`] clone, published -/// broadcast, and [`Dynamic`]. Read handles ([`Consumer`], [`AnnounceConsumer`]) +/// broadcast, and [`Requests`]. Read handles ([`Consumer`], [`AnnounceConsumer`]) /// are not owners. Dropping it aborts active fronts, rejects pending requests, /// ends announcements, and makes subsequent producer mutations fail with /// [`Error::Closed`]. @@ -2866,7 +2866,7 @@ impl Driver { /// /// See [`crate::time::Driver`] for the contract. Finishes with /// [`Error::Closed`] once every owner (a [`Producer`] clone, published - /// broadcast, or [`Dynamic`]) has dropped and the remaining lifecycle work + /// broadcast, or [`Requests`]) has dropped and the remaining lifecycle work /// has drained. pub fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Result, Error> { waiter.batch(|| { @@ -3198,7 +3198,7 @@ async fn serve_front(task: FrontTask) -> Vec { // The front serves its broadcast on demand: every track a reader names is // handed here, and its readers read it from whichever source serves the path. - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let lister = dynamic .lister() .expect("only the front answers its broadcast's listings"); @@ -4786,7 +4786,7 @@ struct OriginState { fronts: WeakCache, // Peers that withdrew a prefix while routes there still passed through them, - // which are stale. See [`Dynamic::withdrawn`]. + // which are stale. See [`Requests::withdrawn`]. withdrawn: HashMap>, // Set when the origin's driver dropped: new requests fail with `Closed` @@ -5295,10 +5295,10 @@ impl Resolution { } } -/// A served route, from [`Producer::dynamic`]: advertises a path prefix and +/// A served route, from [`Producer::requests`]: advertises a path prefix and /// answers the [`Consumer::request_broadcast`] calls beneath it. /// -/// The origin-level analogue of [`broadcast::Dynamic`]: where that serves tracks +/// The origin-level analogue of [`broadcast::Requests`]: where that serves tracks /// on demand within a broadcast, this serves whole broadcasts on demand within /// an origin. A relay holds one per route a peer announces to it, materializing /// a requested path from that peer; an application holds one to answer a @@ -5309,8 +5309,8 @@ impl Resolution { /// /// It keeps the origin's [`Driver`] running, like a published broadcast: a /// handler can drop every [`Producer`] and keep serving. -#[must_use = "dropping an origin::Dynamic retracts the route"] -pub struct Dynamic { +#[must_use = "dropping an origin::Requests retracts the route"] +pub struct Requests { /// The advertisement, retracted on drop. announcement: AnnounceProducer, state: kio::Shared, @@ -5322,7 +5322,7 @@ pub struct Dynamic { _keepalive: Keepalive, } -impl Dynamic { +impl Requests { #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(super) fn request_validity(&self, path: &Path) -> Option { let shared = &self.announcement.shared; @@ -5399,7 +5399,7 @@ impl Dynamic { /// `dynamic.update_route(dynamic.route().with_cost(cost))`. /// /// The prefix is fixed at announce time: to move a route, drop this and call - /// [`Producer::dynamic`] again. Fails with [`Error::Closed`] once the origin's + /// [`Producer::requests`] again. Fails with [`Error::Closed`] once the origin's /// [`Driver`] has been dropped. pub(crate) fn update_route(&self, route: Route) -> Result<(), Error> { self.announcement.update(route) @@ -5537,7 +5537,7 @@ impl ServeState { /// A pending request for a broadcast to be served on demand. /// -/// Yielded by [`Dynamic::poll_requested_broadcast`]. The requester is awaiting inside +/// Yielded by [`Requests::poll_requested_broadcast`]. The requester is awaiting inside /// [`Consumer::request_broadcast`]; [`prepare`](Self::prepare) then [`Prepared::publish`] /// resolves it with a new exact-path broadcast, and [`reject`](Self::reject) resolves it /// with an error. Dropping the request without either rejects it. @@ -5665,7 +5665,7 @@ impl Drop for Request { /// The pollable result of [`Consumer::request_broadcast`]. /// /// Awaited via the [`kio::Pending`] wrapper; resolves to the [`broadcast::Consumer`] -/// immediately when the broadcast was already announced, or once an [`Dynamic`] +/// immediately when the broadcast was already announced, or once a [`Requests`] /// handler serves the request. Resolves to an error if the request is rejected or every /// handler drops before serving it. pub struct Requesting { @@ -7221,7 +7221,7 @@ mod tests { /// Yield to the driver until the server's front watcher delivers a request. #[cfg(feature = "async")] - async fn queued(server: &Dynamic) -> Request { + async fn queued(server: &Requests) -> Request { let mut request = None; settle(|| match server.poll_requested_broadcast(&kio::Waiter::noop()) { Poll::Ready(Ok(popped)) => { @@ -7558,7 +7558,7 @@ mod tests { #[cfg(feature = "async")] async fn mount_resolves_on_the_target_front() { let producer = origin(1).produce(); - let server = producer.dynamic_route(".svc", Route::default()).unwrap(); + let server = producer.requests_route(".svc", Route::default()).unwrap(); let project = mounted(&producer, "p1", &["**"]); let through = project.request_broadcast(".svc/foo", None); @@ -7741,11 +7741,11 @@ mod tests { .unwrap(); assert!(matches!(project.create_broadcast(".svc/foo"), Err(Error::Unauthorized))); assert!(matches!( - project.dynamic_route(".svc", Route::default()), + project.requests_route(".svc", Route::default()), Err(Error::Unauthorized) )); assert!(matches!( - project.dynamic_route(".svc/foo", Route::default()), + project.requests_route(".svc/foo", Route::default()), Err(Error::Unauthorized) )); project.publish("cam", Route::default()).unwrap(); @@ -8020,10 +8020,10 @@ mod tests { // arrived in `order`. fn winners(order: impl Iterator) -> Vec { let producer = origin(1).produce(); - let _pool: Vec = order + let _pool: Vec = order .map(|id| { producer - .dynamic_route("pool", Route::default().with_hops(hops(&[id])).with_cost(3)) + .requests_route("pool", Route::default().with_hops(hops(&[id])).with_cost(3)) .unwrap() }) .collect(); @@ -8093,7 +8093,7 @@ mod tests { async fn a_reprice_is_an_update() { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); - let route = producer.dynamic_route("room", Route::default().with_cost(3)).unwrap(); + let route = producer.requests_route("room", Route::default().with_cost(3)).unwrap(); announced.expect_next_active("room").await; route.update_route(Route::default().with_cost(5)).unwrap(); @@ -8114,7 +8114,7 @@ mod tests { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); let route = producer - .dynamic_route("room", Route::default().with_auth(first.clone())) + .requests_route("room", Route::default().with_auth(first.clone())) .unwrap(); assert_eq!(announced.expect_next_active("room").await.auth, Some(first)); @@ -8143,12 +8143,12 @@ mod tests { let mut announced = consumer.announced(); let peer = producer.clone().with_link(Link::Peer); let old = peer - .dynamic_route("pool/job-0", Route::default().with_hops(hops(&[11]))) + .requests_route("pool/job-0", Route::default().with_hops(hops(&[11]))) .unwrap(); announced.expect_next_active("pool/job-0").await; let new = peer - .dynamic_route("pool/job-0", Route::default().with_hops(hops(&[10]))) + .requests_route("pool/job-0", Route::default().with_hops(hops(&[10]))) .unwrap(); announced.assert_next_wait(); @@ -8185,12 +8185,12 @@ mod tests { let consumer = producer.consume(); let mut announced = consumer.announced(); let _old = producer - .dynamic_route("pool/job-0", Route::default().with_hops(hops(&[old_hop]))) + .requests_route("pool/job-0", Route::default().with_hops(hops(&[old_hop]))) .unwrap(); announced.expect_next_active("pool/job-0").await; let new = producer - .dynamic_route("pool/job-0", Route::default().with_hops(hops(&[new_hop]))) + .requests_route("pool/job-0", Route::default().with_hops(hops(&[new_hop]))) .unwrap(); assert_eq!( announced.expect_next_restarted("pool/job-0").await.hops, @@ -8206,7 +8206,7 @@ mod tests { /// An origin delivering restarts at once, and a way to claim `prefix` with a /// producer scoped to `scope`, from `hop` at `cost`. #[cfg(feature = "async")] - fn scoped_pool() -> (Producer, impl Fn(&str, &str, u64, u64) -> Dynamic) { + fn scoped_pool() -> (Producer, impl Fn(&str, &str, u64, u64) -> Requests) { let producer = Config { update_hold: Duration::ZERO, ..Config::new(origin(1)) @@ -8217,7 +8217,7 @@ mod tests { claims .scope("", &scopes(&[scope])) .unwrap() - .dynamic_route(prefix, Route::default().with_hops(hops(&[hop])).with_cost(cost)) + .requests_route(prefix, Route::default().with_hops(hops(&[hop])).with_cost(cost)) .unwrap() }; (producer, claim) @@ -8226,7 +8226,7 @@ mod tests { /// Request `path` from `consumer` and have `server` answer it: the answer, and /// the requester's broadcast. #[cfg(feature = "async")] - async fn hold(consumer: &Consumer, path: &str, server: &Dynamic) -> (broadcast::Producer, broadcast::Consumer) { + async fn hold(consumer: &Consumer, path: &str, server: &Requests) -> (broadcast::Producer, broadcast::Consumer) { let pending = consumer.request_broadcast(path, None); let answer = broadcast::Info::new().produce(); queued(server).await.accept(&answer); @@ -8315,7 +8315,7 @@ mod tests { let _sticky = hold(&consumer, "room/job", &a).await; let c = producer - .dynamic_route("room", Route::default().with_hops(hops(&[12])).with_cost(1)) + .requests_route("room", Route::default().with_hops(hops(&[12])).with_cost(1)) .unwrap(); assert_eq!(announced.expect_next_restarted("room").await.cost, Cost::new(1)); quiet(&mut announced).await; @@ -8448,10 +8448,10 @@ mod tests { let consumer = producer.consume(); let anonymous = producer - .dynamic_route("room", Route::default().with_hops(hops(&[0])).with_cost(1)) + .requests_route("room", Route::default().with_hops(hops(&[0])).with_cost(1)) .unwrap(); let identified = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10])).with_cost(5)) + .requests_route("room", Route::default().with_hops(hops(&[10])).with_cost(5)) .unwrap(); let _pending = consumer.request_broadcast("room/alice", None); @@ -8472,10 +8472,10 @@ mod tests { let demoted = producer .clone() .with_preference(Preference::new(-1)) - .dynamic_route("room", Route::default().with_hops(hops(&[10])).with_cost(1)) + .requests_route("room", Route::default().with_hops(hops(&[10])).with_cost(1)) .unwrap(); let unranked = producer - .dynamic_route("room", Route::default().with_hops(hops(&[11])).with_cost(5)) + .requests_route("room", Route::default().with_hops(hops(&[11])).with_cost(5)) .unwrap(); let _pending = consumer.request_broadcast("room/alice", None); @@ -8507,7 +8507,7 @@ mod tests { let mut announced = producer.consume().announced(); let dynamic = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_cost(5)) + .requests_route("room", Route::default().with_epoch(epoch()).with_cost(5)) .unwrap(); announced.expect_next_active("room").await; dynamic.update_route(dynamic.route().with_cost(1)).unwrap(); @@ -8634,7 +8634,7 @@ mod tests { assert_eq!(announced.expect_next_active("room/alice").await.cost, Cost::new(5)); let server = producer - .dynamic_route( + .requests_route( "room/alice", Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(1), ) @@ -8687,7 +8687,7 @@ mod tests { .expect("resolves locally"); let server = producer - .dynamic_route( + .requests_route( "room/alice", Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(1), ) @@ -8770,7 +8770,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let source = producer.publish("room/alice", Route::default()).unwrap(); - let lister = source.dynamic().lister().unwrap(); + let lister = source.requests().lister().unwrap(); lister.add("video", crate::track::Info::default()).unwrap(); lister.synced(); @@ -8804,7 +8804,7 @@ mod tests { assert_eq!(listed(&mut tracks).await, "synced"); let server = producer - .dynamic_route( + .requests_route( "room/alice", Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(1), ) @@ -8825,7 +8825,7 @@ mod tests { async fn a_front_through_a_covering_prefix_lists_nothing() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let pending = consumer.request_broadcast("room/alice", None); let upstream = broadcast::Info::new().produce(); let _video = upstream.create_track("video", None).unwrap(); @@ -8846,7 +8846,7 @@ mod tests { let consumer = producer.consume(); let _local = producer.publish("room/alice", Route::default()).unwrap(); - let server = producer.dynamic_route("room/alice", Route::default()).unwrap(); + let server = producer.requests_route("room/alice", Route::default()).unwrap(); // Were the claim to win, the request would park on its handler forever. let resolved = moq_clock::timeout(Duration::from_secs(1), consumer.request_broadcast("room/alice", None)) @@ -8901,7 +8901,7 @@ mod tests { let mut announced = consumer.announced(); let server = producer - .dynamic_route( + .requests_route( "room/alice", Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(2), ) @@ -8956,7 +8956,7 @@ mod tests { let consumer = producer.consume(); let broadcast = producer.publish("room/alice", Route::default()).unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let resolved = consumer.request_broadcast("room/alice", None).await.expect("resolves"); let track = resolved.track("video").unwrap(); let subscribing = moq_clock::sim::spawn(async move { track.subscribe(None).await }); @@ -8983,15 +8983,15 @@ mod tests { /// A front resolved through a served route, as a relay's upstream session serves /// one: the upstream broadcast's track requests arrive on the returned handle. #[cfg(feature = "async")] - async fn served_front() -> (Dynamic, broadcast::Producer, broadcast::Dynamic, broadcast::Consumer) { + async fn served_front() -> (Requests, broadcast::Producer, broadcast::Requests, broadcast::Consumer) { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .dynamic_route("room/alice", Route::default().with_hops(hops(&[10]))) + .requests_route("room/alice", Route::default().with_hops(hops(&[10]))) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); let upstream = broadcast::Info::new().produce(); - let dynamic = upstream.dynamic(); + let dynamic = upstream.requests(); queued(&server).await.accept(&upstream); let resolved = pending.await.expect("resolves"); (server, upstream, dynamic, resolved) @@ -9068,7 +9068,7 @@ mod tests { let producer = origin(1).produce(); // A prefix route, which resolves any path beneath it optimistically. let server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", crate::track::Info::timed()).unwrap(); @@ -9120,7 +9120,7 @@ mod tests { async fn a_hop_in_a_chain_gets_its_own_front() { let producer = origin(1).produce(); let _incumbent = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let fronts = || producer.shared.lock().fronts.len(); @@ -9131,7 +9131,7 @@ mod tests { // The same publisher, now also reached through the peer. let _echo = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10, 7]))) + .requests_route("room", Route::default().with_hops(hops(&[10, 7]))) .unwrap(); let _filtered = peer.request_broadcast("room/alice", None); assert_eq!(fronts(), 2, "the peer still shares the plain front"); @@ -9150,7 +9150,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", crate::track::Info::timed()).unwrap(); @@ -9217,7 +9217,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", None).unwrap(); @@ -9280,11 +9280,11 @@ mod tests { async fn a_peers_filtered_front_ends_once_unread() { let producer = origin(1).produce(); let server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); // The same publisher, also reached through the peer, at a price the plain front skips. let _echo = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10, 7])).with_cost(100)) + .requests_route("room", Route::default().with_hops(hops(&[10, 7])).with_cost(100)) .unwrap(); let upstream = broadcast::Info::new().produce(); let source = upstream.create_track("video", crate::track::Info::timed()).unwrap(); @@ -9320,7 +9320,7 @@ mod tests { let peer = producer.consume().excluding(origin(7)); let mut view = peer.announced(); let incumbent = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10]))) .unwrap(); view.expect_next_active("room").await; @@ -9331,7 +9331,7 @@ mod tests { // The same publisher, now also reached through the peer, outlives the incumbent. let echo = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10, 7]))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10, 7]))) .unwrap(); drop(incumbent); drop(source); @@ -9360,14 +9360,14 @@ mod tests { async fn takeover_keeps_the_open_group_head(dies_first: bool) { let producer = origin(1).produce(); let first_server = producer - .dynamic_route( + .requests_route( "room/alice", Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(5), ) .unwrap(); let pending = producer.consume().request_broadcast("room/alice", None); let upstream = broadcast::Info::new().produce(); - let mut dynamic = upstream.dynamic(); + let mut dynamic = upstream.requests(); queued(&first_server).await.accept(&upstream); let resolved = pending.await.unwrap(); @@ -9415,7 +9415,7 @@ mod tests { } let cost = if dies_first { 5 } else { 0 }; let replacement_server = producer - .dynamic_route( + .requests_route( "room/alice", Route::default() .with_epoch(epoch()) @@ -9424,14 +9424,14 @@ mod tests { ) .unwrap(); let replacement = broadcast::Info::new().produce(); - let mut replacement_dynamic = replacement.dynamic(); + let mut replacement_dynamic = replacement.requests(); queued(&replacement_server).await.accept(&replacement); let request = moq_clock::timeout(Duration::from_secs(1), replacement_dynamic.requested_track()) .await .expect("the front asked the replacement") .unwrap(); // The replacement serves the whole group to a fetch, frames still to come included. - let fetches = request.dynamic(); + let fetches = request.requests(); let fetched = Arc::new(crate::sync::Mutex::new(Vec::::new())); let serving = fetched.clone(); let _fetching = moq_clock::sim::spawn(async move { @@ -9529,7 +9529,7 @@ mod tests { let consumer = producer.consume(); let broadcast = producer.publish("room/alice", Route::default()).unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let resolved = consumer.request_broadcast("room/alice", None).await.expect("resolves"); let track = resolved.track("video").unwrap(); let subscribing = moq_clock::sim::spawn(async move { track.subscribe(None).await }); @@ -9645,8 +9645,8 @@ mod tests { #[cfg(feature = "async")] async fn duplicate_routes_aggregate_until_the_last_leaves() { let producer = origin(1).produce(); - let first = producer.dynamic_route("live", Route::default().with_cost(3)).unwrap(); - let second = producer.dynamic_route("live", Route::default().with_cost(1)).unwrap(); + let first = producer.requests_route("live", Route::default().with_cost(3)).unwrap(); + let second = producer.requests_route("live", Route::default().with_cost(1)).unwrap(); let mut announced = producer.consume().announced(); let Some(Some(AnnounceEvent::Start(update))) = announced.next().now_or_never() else { @@ -9670,25 +9670,25 @@ mod tests { let producer = origin(1).produce(); let scoped = producer.scope("", &scopes(&["room"])).unwrap(); let _broad = scoped - .dynamic_route("", Route::default()) + .requests_route("", Route::default()) .expect("an overlapping prefix is accepted"); let _ok = scoped - .dynamic_route("room/alice", Route::default()) + .requests_route("room/alice", Route::default()) .expect("a contained prefix is accepted"); assert!(matches!( - scoped.dynamic_route("other", Route::default()), + scoped.requests_route("other", Route::default()), Err(Error::Unauthorized) )); } #[moq_clock::test] #[cfg(feature = "async")] - async fn dynamic_route_keeps_its_producer_scope() { + async fn requests_route_keeps_its_producer_scope() { let producer = origin(1).produce(); let scope = Patterns::from("*/chat".parse::().unwrap()); let scoped = producer.scope("", &scope).unwrap(); - let dynamic = scoped.dynamic_route("", Route::default()).unwrap(); + let dynamic = scoped.requests_route("", Route::default()).unwrap(); let mut matching = producer .consume() @@ -9725,10 +9725,10 @@ mod tests { .unwrap() }; let _chat = scoped("*/chat") - .dynamic_route("", Route::default().with_cost(1)) + .requests_route("", Route::default().with_cost(1)) .unwrap(); let _video = scoped("*/video") - .dynamic_route("", Route::default().with_cost(5)) + .requests_route("", Route::default().with_cost(5)) .unwrap(); let mut video = producer @@ -9759,7 +9759,7 @@ mod tests { let chat = producer .scope("", &scopes(&["room/live/chat"])) .unwrap() - .dynamic_route("room/live", Route::default().with_hops(hops(&[7])).with_cost(1)) + .requests_route("room/live", Route::default().with_hops(hops(&[7])).with_cost(1)) .unwrap(); assert_eq!(all.expect_next_active("room/live").await.cost, Cost::new(1)); assert_eq!(rooted.expect_next_active("").await.cost, Cost::new(1)); @@ -9769,7 +9769,7 @@ mod tests { let video_route = producer .scope("", &scopes(&["room/live/video"])) .unwrap() - .dynamic_route("room/live", Route::default().with_hops(hops(&[8])).with_cost(5)) + .requests_route("room/live", Route::default().with_hops(hops(&[8])).with_cost(5)) .unwrap(); all.assert_next_wait(); rooted.assert_next_wait(); @@ -9794,9 +9794,9 @@ mod tests { #[cfg(feature = "async")] async fn root_cursor_keeps_the_more_specific_covering_route() { let producer = origin(1).produce(); - let broad = producer.dynamic_route("room", Route::default().with_cost(1)).unwrap(); + let broad = producer.requests_route("room", Route::default().with_cost(1)).unwrap(); let narrow = producer - .dynamic_route("room/live", Route::default().with_cost(9)) + .requests_route("room/live", Route::default().with_cost(9)) .unwrap(); let mut announced = producer .consume() @@ -9824,7 +9824,7 @@ mod tests { let mut announced = producer.consume().announced(); let dynamic = producer - .dynamic_route(&path, Route::default()) + .requests_route(&path, Route::default()) .expect("max depth is allowed"); announced.expect_next_active(&path).await; @@ -9837,7 +9837,7 @@ mod tests { async fn dynamic_exclusion_skips_routes_through_the_subscriber() { let producer = origin(1).produce(); let _server = producer - .dynamic_route("live", Route::default().with_hops(hops(&[7]))) + .requests_route("live", Route::default().with_hops(hops(&[7]))) .unwrap(); let mut excluded = producer.consume().excluding(origin(7)).announced(); @@ -9853,7 +9853,7 @@ mod tests { async fn announce_consumer_is_a_stream() { use futures::StreamExt; let producer = origin(1).produce(); - let server = producer.dynamic_route("live", Route::default()).unwrap(); + let server = producer.requests_route("live", Route::default()).unwrap(); let mut announced = producer.consume().announced(); let mut next = || StreamExt::next(&mut announced).now_or_never(); let Some(Some(AnnounceEvent::Start(update))) = next() else { @@ -9868,7 +9868,7 @@ mod tests { #[moq_clock::test] async fn dynamic_retracts() { let producer = origin(1).produce(); - let server = producer.dynamic_route("live", Route::default()).unwrap(); + let server = producer.requests_route("live", Route::default()).unwrap(); let mut announced = producer.consume().announced(); announced.expect_next_active("live").await; @@ -9900,7 +9900,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let pending = consumer.request_broadcast("room/alice", None); let request = queued(&server).await; @@ -9923,7 +9923,7 @@ mod tests { async fn served_requests_coalesce() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let first = consumer.request_broadcast("room/alice", None); let second = consumer.request_broadcast("room/alice", None); @@ -9945,7 +9945,7 @@ mod tests { async fn retract_rejects_pending_requests() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let pending = consumer.request_broadcast("room/alice", None); drop(server); @@ -9971,9 +9971,9 @@ mod tests { // Three identical routes, oldest first: the newest identical route wins // requests, and swapping between them emits no announce update. - let standby_server = producer.dynamic_route("room", Route::default()).unwrap(); - let second_server = producer.dynamic_route("room", Route::default()).unwrap(); - let incumbent_server = producer.dynamic_route("room", Route::default()).unwrap(); + let standby_server = producer.requests_route("room", Route::default()).unwrap(); + let second_server = producer.requests_route("room", Route::default()).unwrap(); + let incumbent_server = producer.requests_route("room", Route::default()).unwrap(); let mut resolving = Box::pin(consumer.routed_broadcast("room/alice")); assert!((&mut resolving).now_or_never().is_none()); @@ -10000,7 +10000,7 @@ mod tests { async fn split_horizon_skips_routes_through_the_requester() { let producer = origin(1).produce(); let _server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[7]))) + .requests_route("room", Route::default().with_hops(hops(&[7]))) .unwrap(); // The requester's own bytes must not be served back to it. @@ -10026,11 +10026,11 @@ mod tests { let mut announced = producer.consume().announced(); let _ingest = producer - .dynamic_route("client", Route::default().with_hops(hops(&[5])).with_via(origin(5))) + .requests_route("client", Route::default().with_hops(hops(&[5])).with_via(origin(5))) .unwrap(); let _gateway = producer.publish("gateway", Route::default()).unwrap(); let _forwarded = peer - .dynamic_route( + .requests_route( "forwarded", Route::default().with_hops(hops(&[5, 7])).with_via(origin(7)), ) @@ -10045,7 +10045,9 @@ mod tests { // The mark survives narrowing the handle. let scoped = peer.scope("room", &Patterns::from(Pattern::all())).unwrap(); - let _nested = scoped.dynamic_route("x", Route::default().with_via(origin(8))).unwrap(); + let _nested = scoped + .requests_route("x", Route::default().with_via(origin(8))) + .unwrap(); assert_eq!( announced.expect_next_active("room/x").await.source(), Source::Peer(origin(8)) @@ -10079,10 +10081,10 @@ mod tests { .with_hops(hops(chain)) .with_via(origin(via)) }; - let near = peer.dynamic_route("room", route(&[9, 2], 2)).unwrap(); + let near = peer.requests_route("room", route(&[9, 2], 2)).unwrap(); announced.assert_try_next_active("room"); - let _far = peer.dynamic_route("room", route(&[9, 3, 4], 4)).unwrap(); + let _far = peer.requests_route("room", route(&[9, 3, 4], 4)).unwrap(); drop(near); assert!(announced.try_next().is_none(), "the update is held"); step(Duration::from_millis(1)).await; @@ -10117,8 +10119,8 @@ mod tests { let mut near = Vec::new(); let mut far = Vec::new(); for i in 0..k { - near.push(peer.dynamic_route(format!("room/{i}"), route(&[9, 2], 2)).unwrap()); - far.push(peer.dynamic_route(format!("room/{i}"), route(&[9, 3, 4], 4)).unwrap()); + near.push(peer.requests_route(format!("room/{i}"), route(&[9, 2], 2)).unwrap()); + far.push(peer.requests_route(format!("room/{i}"), route(&[9, 3, 4], 4)).unwrap()); } let mut announced = producer.consume().announced(); for _ in 0..k { @@ -10163,12 +10165,12 @@ mod tests { .with_hops(hops(chain)) .with_via(origin(via)) }; - let near = peer.dynamic_route("room", route(&[9, 2], 2)).unwrap(); + let near = peer.requests_route("room", route(&[9, 2], 2)).unwrap(); announced.expect_next_active("room").await; // The best route changes twice; the consumer sees only the last, once. - let far = peer.dynamic_route("room", route(&[9, 3, 4], 4)).unwrap(); - let farther = peer.dynamic_route("room", route(&[9, 5, 6, 7], 7)).unwrap(); + let far = peer.requests_route("room", route(&[9, 3, 4], 4)).unwrap(); + let farther = peer.requests_route("room", route(&[9, 5, 6, 7], 7)).unwrap(); let start = moq_clock::now(); drop(near); drop(far); @@ -10196,30 +10198,30 @@ mod tests { let route = |chain: &[u64], via| Route::default().with_hops(hops(chain)).with_via(origin(via)); - let near = peer.dynamic_route("named", route(&[9, 2], 2)).unwrap(); + let near = peer.requests_route("named", route(&[9, 2], 2)).unwrap(); announced.expect_next_active("named").await; drop(near); announced.expect_next_ended("named").await; let start = moq_clock::now(); - let _stale = peer.dynamic_route("named", route(&[9, 3, 4], 4)).unwrap(); + let _stale = peer.requests_route("named", route(&[9, 3, 4], 4)).unwrap(); match announced.next().await.unwrap() { AnnounceEvent::Start(announce) => assert_eq!(announce.route.hops, hops(&[9, 3, 4])), other => panic!("should be a start: got {other:?}"), } assert!(moq_clock::now() - start >= RETRACT_QUARANTINE); - let near = peer.dynamic_route("anonymous", route(&[0, 2], 2)).unwrap(); + let near = peer.requests_route("anonymous", route(&[0, 2], 2)).unwrap(); announced.expect_next_active("anonymous").await; drop(near); announced.expect_next_ended("anonymous").await; - let _other = peer.dynamic_route("anonymous", route(&[0, 3, 4], 4)).unwrap(); + let _other = peer.requests_route("anonymous", route(&[0, 3, 4], 4)).unwrap(); announced.expect_next_active("anonymous").await; // Retract and announce cycles at one clock reading queue the prefix once, // whether or not the route names its publisher. let queued = announced.anchoring.len(); for chain in [[9, 2], [0, 2], [9, 2], [0, 2]] { - let churn = peer.dynamic_route("churn", route(&chain, 2)).unwrap(); + let churn = peer.requests_route("churn", route(&chain, 2)).unwrap(); announced.expect_next_active("churn").await; drop(churn); announced.expect_next_ended("churn").await; @@ -10228,7 +10230,7 @@ mod tests { // A quarantine is forgotten once it ends: the cursor wakes to anchor it, // then again to drop it. - let gone = peer.dynamic_route("gone", route(&[9, 2], 2)).unwrap(); + let gone = peer.requests_route("gone", route(&[9, 2], 2)).unwrap(); announced.expect_next_active("gone").await; drop(gone); announced.expect_next_ended("gone").await; @@ -10252,9 +10254,9 @@ mod tests { // Publisher 9 ingests at relay 2; relay 3 relays 2's route. let direct = || Route::default().with_hops(hops(&[9, 2])).with_via(origin(2)); - let first = peer.dynamic_route("room", direct()).unwrap(); + let first = peer.requests_route("room", direct()).unwrap(); let relayed = peer - .dynamic_route("room", Route::default().with_hops(hops(&[9, 2, 3])).with_via(origin(3))) + .requests_route("room", Route::default().with_hops(hops(&[9, 2, 3])).with_via(origin(3))) .unwrap(); announced.expect_next_active("room").await; announced.assert_next_wait(); @@ -10265,7 +10267,7 @@ mod tests { announced.assert_next_wait(); // Relay 2 announces again, and the relayed copy is live with it. - let second = peer.dynamic_route("room", direct()).unwrap(); + let second = peer.requests_route("room", direct()).unwrap(); announced.expect_next_active("room").await; assert!(producer.shared.lock().withdrawn.is_empty()); @@ -10285,8 +10287,8 @@ mod tests { let peer = producer.clone().with_link(Link::Peer); let mut announced = producer.consume().announced(); let route = Route::default().with_hops(hops(&[9, 2])); - let first = peer.dynamic_route("room", route.clone()).unwrap(); - let second = peer.dynamic_route("room", route.clone()).unwrap(); + let first = peer.requests_route("room", route.clone()).unwrap(); + let second = peer.requests_route("room", route.clone()).unwrap(); announced.expect_next_active("room").await; announced.assert_next_wait(); let remaining = if restart { @@ -10312,10 +10314,10 @@ mod tests { let producer = origin(1).produce(); let peer = producer.clone().with_link(Link::Peer); let direct = peer - .dynamic_route("room", Route::default().with_hops(hops(&[9, 2]))) + .requests_route("room", Route::default().with_hops(hops(&[9, 2]))) .unwrap(); let _relayed = peer - .dynamic_route("room", Route::default().with_hops(hops(&[9, 2, 3]))) + .requests_route("room", Route::default().with_hops(hops(&[9, 2, 3]))) .unwrap(); let path = Path::new("room/video"); let id = producer @@ -10345,14 +10347,14 @@ mod tests { .with_epoch(epoch()) .with_hops(hops(&[7])) .with_via(origin(7)); - let _forwarded = peer.dynamic_route("room", route.clone()).unwrap(); + let _forwarded = peer.requests_route("room", route.clone()).unwrap(); assert_eq!( announced.expect_next_active("room").await.source(), Source::Peer(origin(7)) ); // The newest identical route wins, so the local twin takes over. - let local = producer.dynamic_route("room", route).unwrap(); + let local = producer.requests_route("room", route).unwrap(); match announced.next().await.expect("no next") { AnnounceEvent::Update(announce) => assert_eq!(announce.route.source(), Source::Local), other => panic!("should be an update: got {other:?}"), @@ -10372,7 +10374,7 @@ mod tests { let mut local = producer.consume().local().announced(); let _forwarded = peer - .dynamic_route("remote", Route::default().with_hops(hops(&[7])).with_via(origin(7))) + .requests_route("remote", Route::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); local.assert_next_wait(); @@ -10380,10 +10382,10 @@ mod tests { // and retracts from the local view when the local route goes, even though // the peer's still covers it. let _shadow = peer - .dynamic_route("both", Route::default().with_hops(hops(&[7])).with_via(origin(7))) + .requests_route("both", Route::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); let ingest = producer - .dynamic_route( + .requests_route( "both", Route::default().with_hops(hops(&[5])).with_via(origin(5)).with_cost(9), ) @@ -10424,7 +10426,7 @@ mod tests { let mut everyone = producer.consume().announced(); let _core = core - .dynamic_route("core", Route::default().with_hops(hops(&[7])).with_via(origin(7))) + .requests_route("core", Route::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); assert_eq!( everyone.expect_next_active("core").await.source(), @@ -10433,7 +10435,7 @@ mod tests { toward_core.assert_next_wait(); let _mesh = peer - .dynamic_route("mesh", Route::default().with_hops(hops(&[8])).with_via(origin(8))) + .requests_route("mesh", Route::default().with_hops(hops(&[8])).with_via(origin(8))) .unwrap(); assert_eq!( toward_core.expect_next_active("mesh").await.source(), @@ -10443,10 +10445,10 @@ mod tests { // A path both an upstream and a mesh peer reach: the upstream route is // cheaper, yet the upstream view selects the mesh peer's. let _cheap = core - .dynamic_route("both", Route::default().with_hops(hops(&[7])).with_via(origin(7))) + .requests_route("both", Route::default().with_hops(hops(&[7])).with_via(origin(7))) .unwrap(); let mesh = peer - .dynamic_route( + .requests_route( "both", Route::default().with_hops(hops(&[8])).with_via(origin(8)).with_cost(9), ) @@ -10485,7 +10487,7 @@ mod tests { async fn handler_rejection_is_final() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let pending = consumer.request_broadcast("room/alice", None); let request = queued(&server).await; @@ -10511,7 +10513,7 @@ mod tests { async fn claim_answer_is_hidden_until_published_with_its_epoch() { let producer = origin(1).produce(); let consumer = producer.consume(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); let epoch = crate::Epoch::mint(); let mut pending = Box::pin(consumer.request_broadcast("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); @@ -10535,7 +10537,7 @@ mod tests { async fn a_prepared_answer_cannot_announce_itself() { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); announced.assert_next_active("room"); let pending = producer.consume().request_broadcast("room/alice", None); let epoch = crate::Epoch::mint(); @@ -10557,7 +10559,7 @@ mod tests { #[cfg(feature = "async")] async fn a_refused_prepared_answer_cannot_announce_later() { let producer = origin(1).produce(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); let _pending = producer.consume().request_broadcast("room/alice", None); let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); let answer = prepared.broadcast().clone(); @@ -10572,7 +10574,7 @@ mod tests { async fn dropping_a_prepared_claim_answer_refuses_the_request() { let producer = origin(1).produce(); let consumer = producer.consume(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); let pending = consumer.request_broadcast("room/alice", None); let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); drop(prepared); @@ -10585,7 +10587,7 @@ mod tests { async fn a_prepared_answer_for_an_abandoned_claim_is_never_announced() { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); announced.assert_next_active("room"); let _pending = producer.consume().request_broadcast("room/alice", None); let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); @@ -10602,7 +10604,7 @@ mod tests { async fn a_prepared_answer_for_a_released_request_is_never_announced() { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); announced.assert_next_active("room"); let _pending = producer.consume().request_broadcast("room/alice", None); let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); @@ -10622,7 +10624,7 @@ mod tests { async fn a_pinned_request_resolves_only_through_the_published_epoch() { let producer = origin(1).produce(); let consumer = producer.consume(); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); let epoch = crate::Epoch::mint(); let unknown = consumer.request_broadcast("room/alice", crate::Epoch::mint()); assert!(matches!(unknown.await, Err(Error::Unroutable))); @@ -10644,9 +10646,9 @@ mod tests { async fn refusal_never_falls_through() { let producer = origin(1).produce(); let consumer = producer.consume(); - let catch_all = producer.dynamic_route("", Route::default()).unwrap(); - let winner = producer.dynamic_route("room", Route::default()).unwrap(); - let sibling = producer.dynamic_route("room", Route::default().with_cost(2)).unwrap(); + let catch_all = producer.requests_route("", Route::default()).unwrap(); + let winner = producer.requests_route("room", Route::default()).unwrap(); + let sibling = producer.requests_route("room", Route::default().with_cost(2)).unwrap(); let mut pending = Box::pin(consumer.request_broadcast("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); @@ -10672,7 +10674,7 @@ mod tests { async fn superseded_refusal_does_not_end_the_request() { let producer = origin(1).produce(); let consumer = producer.consume(); - let stale = producer.dynamic_route("room", Route::default()).unwrap(); + let stale = producer.requests_route("room", Route::default()).unwrap(); let mut pending = Box::pin(consumer.request_broadcast("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); @@ -10699,13 +10701,13 @@ mod tests { async fn refusal_from_a_beaten_route_asks_the_new_winner() { let producer = origin(1).produce(); let consumer = producer.consume(); - let stale = producer.dynamic_route("room", Route::default()).unwrap(); + let stale = producer.requests_route("room", Route::default()).unwrap(); let mut pending = Box::pin(consumer.request_broadcast("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); let request = queued(&stale).await; - let winner = producer.dynamic_route("room/alice", Route::default()).unwrap(); + let winner = producer.requests_route("room/alice", Route::default()).unwrap(); request.reject(Error::NotFound); let source = broadcast::Info::new().produce(); queued(&winner).await.accept(&source); @@ -10726,7 +10728,7 @@ mod tests { async fn routed_broadcast_waits_out_a_rejection() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let mut resolving = Box::pin(consumer.routed_broadcast("room/alice")); assert!((&mut resolving).now_or_never().is_none()); @@ -10755,7 +10757,7 @@ mod tests { async fn routed_broadcast_reports_teardown_as_closed() { let (producer, driver) = Producer::new(Config::new(origin(1))); let consumer = producer.consume(); - let _server = producer.dynamic_route("room", Route::default()).unwrap(); + let _server = producer.requests_route("room", Route::default()).unwrap(); // Park on the covering route, past the loop's closed check. let mut resolving = Box::pin(consumer.routed_broadcast("room/alice")); @@ -10778,7 +10780,7 @@ mod tests { async fn routed_broadcast_wakes_for_a_local_broadcast() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let mut resolving = Box::pin(consumer.routed_broadcast("room/alice")); assert!((&mut resolving).now_or_never().is_none()); @@ -10802,7 +10804,7 @@ mod tests { async fn late_track_on_a_served_front_replays() { let producer = origin(1).produce(); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let source = broadcast::Info::new().produce(); for name in ["a", "b"] { @@ -10842,7 +10844,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); - let broad_server = producer.dynamic_route("", Route::default()).unwrap(); + let broad_server = producer.requests_route("", Route::default()).unwrap(); // A narrow advertise-only claim: requests under it must NOT route to the // broad server; they fall through to the (absent) fallback handler. let _narrow = producer.announce(".dash", Route::default()).unwrap(); @@ -10867,7 +10869,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let mut announced = consumer.announced(); - let dynamic = producer.dynamic_route("", Route::default()).unwrap(); + let dynamic = producer.requests_route("", Route::default()).unwrap(); // The root claim is advertised like any other prefix. announced.expect_next_active("").await; @@ -10901,7 +10903,7 @@ mod tests { #[cfg(feature = "async")] async fn out_of_scope_request_never_reaches_the_dynamic_handler() { let producer = origin(1).produce(); - let dynamic = producer.dynamic_route("", Route::default()).unwrap(); + let dynamic = producer.requests_route("", Route::default()).unwrap(); let scoped = producer.consume().scope("", &scopes(&["tenant-a"])).unwrap(); // `tenant-a-other` shares a character prefix but not a segment, so this @@ -10986,7 +10988,7 @@ mod tests { let mut announced = consumer.announced(); announced.expect_next_active("room").await; - let _server = producer.dynamic_route("served", Route::default()).unwrap(); + let _server = producer.requests_route("served", Route::default()).unwrap(); let pending = consumer.request_broadcast("served/path", None); drop(driver); @@ -11031,7 +11033,7 @@ mod tests { #[test] fn internal_route_requests_are_closed_on_teardown() { let (producer, mut driver) = Producer::new(Config::new(origin(1))); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); let _pending = producer.consume().request_broadcast("room/alice", None); driver.poll(Instant::now(), &kio::Waiter::noop()).unwrap(); let Poll::Ready(Ok(request)) = server.poll_requested_broadcast(&kio::Waiter::noop()) else { @@ -11058,12 +11060,12 @@ mod tests { /// Announce a served route with `first` as its first hop, materialize /// "room/alice" through it with a one-group "before" track, and subscribe. #[cfg(feature = "async")] - async fn new(first: &[u64]) -> (Self, Dynamic, broadcast::Producer) { + async fn new(first: &[u64]) -> (Self, Requests, broadcast::Producer) { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(first))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(first))) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); @@ -11104,9 +11106,9 @@ mod tests { /// Stand up a second served route with `first` as its first hop and hand /// back its handle, ready to answer the front's re-request. - fn standby(&self, first: &[u64]) -> Dynamic { + fn standby(&self, first: &[u64]) -> Requests { self.producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(first))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(first))) .unwrap() } } @@ -11116,7 +11118,7 @@ mod tests { /// rig's subscription resumes onto it: the successor group is delivered on /// the same subscription, at the group boundary. #[cfg(feature = "async")] - async fn assert_resumes(rig: &mut ResumeRig, server: &Dynamic) { + async fn assert_resumes(rig: &mut ResumeRig, server: &Requests) { let request = queued(server).await; let replacement = broadcast::Info::new().produce(); let track = replacement.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11160,7 +11162,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let first = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10])).with_cost(5)) + .requests_route("room", Route::default().with_hops(hops(&[10])).with_cost(5)) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); let source = broadcast::Info::new().produce(); @@ -11171,7 +11173,7 @@ mod tests { deliver(&track, &mut subscription, b"first").await; let second = producer - .dynamic_route("room", Route::default().with_hops(hops(&[11])).with_cost(1)) + .requests_route("room", Route::default().with_hops(hops(&[11])).with_cost(1)) .unwrap(); deliver(&track, &mut subscription, b"pinned").await; @@ -11285,7 +11287,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let old = producer - .dynamic_route("room", Route::default().with_epoch(crate::Epoch::mint())) + .requests_route("room", Route::default().with_epoch(crate::Epoch::mint())) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); let request = queued(&old).await; @@ -11315,7 +11317,7 @@ mod tests { let consumer = producer.consume(); let old = crate::Epoch::mint(); let dynamic = producer - .dynamic_route("room", Route::default().with_epoch(old.clone())) + .requests_route("room", Route::default().with_epoch(old.clone())) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); let pinned = consumer.request_broadcast("room/alice", old); @@ -11371,7 +11373,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let dynamic = producer - .dynamic_route("room", Route::default().with_epoch(crate::Epoch::mint())) + .requests_route("room", Route::default().with_epoch(crate::Epoch::mint())) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); queued(&dynamic).await.reject(Error::NotFound); @@ -11396,7 +11398,7 @@ mod tests { let consumer = producer.consume(); let old = crate::Epoch::mint(); let dynamic = producer - .dynamic_route("room", Route::default().with_epoch(old.clone())) + .requests_route("room", Route::default().with_epoch(old.clone())) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); let request = queued(&dynamic).await; @@ -11426,7 +11428,7 @@ mod tests { async fn losing_an_epoch_is_another_instance() { let producer = origin(1).produce(); let consumer = producer.consume(); - let dynamic = producer.dynamic_route("room", Route::default()).unwrap(); + let dynamic = producer.requests_route("room", Route::default()).unwrap(); let pending = consumer.request_broadcast("room/alice", None); let old = broadcast::Info::new().produce(); let _old_track = old.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11614,7 +11616,7 @@ mod tests { drop(source); rig.incumbent_track.clone().abort(Error::Dropped).unwrap(); let replacement = broadcast::Info::new().produce(); - let mut handler = replacement.dynamic(); + let mut handler = replacement.requests(); queued(&standby).await.accept(&replacement); let request = handler.requested_track().await.unwrap(); assert_eq!(request.subscription().unwrap().start(), Some(5)); @@ -11673,11 +11675,11 @@ mod tests { None => producer.consume(), }; let incumbent = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10]))) .unwrap(); // The same publisher, reached through the requesting peer. let echo = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10, 7]))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10, 7]))) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); @@ -11740,7 +11742,7 @@ mod tests { // Session A: an assigned anonymous hop serving the path. let server_a = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); // Session C: a third anonymous session, excluding the hop the server @@ -11796,7 +11798,7 @@ mod tests { // Session B attaches at the same path. Its front is served immediately. let server_b = producer - .dynamic_route("room", Route::default().with_hops(hops(&[20]))) + .requests_route("room", Route::default().with_hops(hops(&[20]))) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); let request = queued(&server_b).await; @@ -11969,7 +11971,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let pending = consumer.request_broadcast("room/alice", None); @@ -12017,7 +12019,7 @@ mod tests { async fn a_closed_source_is_not_requested_again_from_its_standing_route() { let producer = origin(1).produce(); let server = producer - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let pending = producer.consume().request_broadcast("room/alice", None); let source = broadcast::Info::new().produce(); @@ -12104,7 +12106,7 @@ mod tests { async fn a_held_group_continues_after_its_track_goes_unread() { let producer = origin(1).produce(); let server = producer - .dynamic_route("live", Route::default().with_hops(hops(&[10]))) + .requests_route("live", Route::default().with_hops(hops(&[10]))) .unwrap(); let pending = producer.consume().request_broadcast("live", None); let source = broadcast::Info::new().produce(); @@ -12193,7 +12195,7 @@ mod tests { let mid = origin(2).produce(); let mid_server = mid - .dynamic_route("room", Route::default().with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_hops(hops(&[10]))) .unwrap(); let mid_pending = mid.consume().request_broadcast("room/alice", None); queued(&mid_server).await.accept(&leaf_front); @@ -12201,7 +12203,7 @@ mod tests { let edge = origin(3).produce(); let edge_server = edge - .dynamic_route("room", Route::default().with_hops(hops(&[20]))) + .requests_route("room", Route::default().with_hops(hops(&[20]))) .unwrap(); let edge_pending = edge.consume().request_broadcast("room/alice", None); queued(&edge_server).await.accept(&mid_resolved); @@ -12324,14 +12326,14 @@ mod tests { #[cfg(feature = "async")] async fn remote_front() -> ( Producer, - Dynamic, + Requests, broadcast::Producer, track::Producer, track::Subscriber, ) { let producer = origin(1).produce(); let server = producer - .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10]))) + .requests_route("room", Route::default().with_epoch(epoch()).with_hops(hops(&[10]))) .unwrap(); let pending = producer.consume().request_broadcast("room/alice", None); let upstream = broadcast::Info::new().produce(); @@ -12356,7 +12358,7 @@ mod tests { let (producer, _server, _upstream, old, subscription) = remote_front().await; let newcomer = producer.create_broadcast("room/alice").unwrap(); - let mut handler = newcomer.dynamic(); + let mut handler = newcomer.requests(); newcomer.announce(Route::default().with_epoch(epoch())).unwrap(); let _unanswered = moq_clock::timeout(Duration::from_secs(1), handler.requested_track()) .await @@ -12606,7 +12608,7 @@ mod tests { drop(group); let second = producer.create_broadcast("room/alice").unwrap(); - let mut handler = second.dynamic(); + let mut handler = second.requests(); second.announce(Route::default().with_epoch(epoch())).unwrap(); let request = moq_clock::timeout(Duration::from_secs(1), handler.requested_track()) .await @@ -12719,7 +12721,7 @@ mod tests { assert!(matches!(driver.poll(Instant::now(), &waiter), Err(Error::Closed))); } - /// A handler holding only its `Dynamic` keeps serving once every producer + /// A handler holding only its `Requests` keeps serving once every producer /// handle drops, and the driver finishes once the handler is gone too. #[moq_clock::test] #[cfg(feature = "async")] @@ -12727,7 +12729,7 @@ mod tests { let (producer, driver) = Producer::new(Config::new(origin(1))); let run = moq_clock::sim::spawn(crate::time::run(driver)); let consumer = producer.consume(); - let server = producer.dynamic_route("room", Route::default()).unwrap(); + let server = producer.requests_route("room", Route::default()).unwrap(); drop(producer); let pending = consumer.request_broadcast("room/alice", None); @@ -12832,7 +12834,7 @@ mod tests { async fn source_selection_leases_survive_reprices_but_never_revive_pending_work() { let producer = origin(1).produce(); let broad = producer - .dynamic_route("room", Route::default().with_via(origin(2))) + .requests_route("room", Route::default().with_via(origin(2))) .unwrap(); let selected = broad.request_validity(&Path::new("room/a")).unwrap(); let pending = selected.current(); @@ -12841,11 +12843,11 @@ mod tests { assert!(!pending.is_closed()); // Another peer's winning route cannot change what a request on peer 2 names. let other = producer - .dynamic_route("room/a", Route::default().with_via(origin(3))) + .requests_route("room/a", Route::default().with_via(origin(3))) .unwrap(); assert!(!pending.is_closed()); let replacement = producer - .dynamic_route("room/a", Route::default().with_via(origin(2))) + .requests_route("room/a", Route::default().with_via(origin(2))) .unwrap(); assert!(pending.is_closed()); assert!(selected.current().is_closed()); @@ -12864,7 +12866,7 @@ mod tests { async fn source_selection_unregisters_without_retaining_the_source() { let producer = origin(1).produce(); let route = producer - .dynamic_route("room", Route::default().with_via(origin(2))) + .requests_route("room", Route::default().with_via(origin(2))) .unwrap(); let path = Path::new("room/a"); let selected = route.request_validity(&path).unwrap(); @@ -12920,7 +12922,7 @@ mod tests { ..Default::default() }); let consumer = producer.consume(); - let _served = producer.dynamic_route("room", Route::default()).unwrap(); + let _served = producer.requests_route("room", Route::default()).unwrap(); // A consumer outlives its producer by design, so the task set can refuse // submissions while the origin is still open. The front's task is then // dropped on the spot, taking its `Watch` with it: the request must not diff --git a/rs/moq-net/src/model/requests.rs b/rs/moq-net/src/model/request_queue.rs similarity index 92% rename from rs/moq-net/src/model/requests.rs rename to rs/moq-net/src/model/request_queue.rs index 7561ce68529..ac27eefc763 100644 --- a/rs/moq-net/src/model/requests.rs +++ b/rs/moq-net/src/model/request_queue.rs @@ -1,4 +1,4 @@ -//! A coalescing request queue drained by dynamic handlers. +//! A coalescing request queue drained by request handlers. use std::{ borrow::Borrow, collections::{HashMap, VecDeque}, @@ -7,7 +7,7 @@ use std::{ /// Requests keyed by `K`, drained in FIFO order by a pool of handlers. /// -/// This is the state shared between requesters and the `Dynamic` handlers that +/// This is the state shared between requesters and the `Requests` handlers that /// serve them (origin broadcasts, broadcast tracks, track fetches), typically /// inside a [`kio::Shared`] so both sides work under one lock. It encodes two /// invariants in one place: @@ -20,7 +20,7 @@ use std::{ /// [`Self::pop`] hands out only the key: the entry stays pending (still /// joinable) until the caller explicitly removes it, on resolution or via /// [`Self::drain_queued`] when the last handler leaves. -pub(super) struct Requests { +pub(super) struct RequestQueue { // Every pending request: queued or already handed to a handler. pending: HashMap>, // The queued (not yet popped) subset, FIFO. Every key here is in `pending`. @@ -36,7 +36,7 @@ struct Entry { queued: bool, } -impl Default for Requests { +impl Default for RequestQueue { fn default() -> Self { Self { pending: HashMap::new(), @@ -46,7 +46,7 @@ impl Default for Requests { } } -impl Requests { +impl RequestQueue { /// Join the pending request for `key`, queued or already handed out. pub fn join(&mut self, key: &Q) -> Option<&mut V> where @@ -180,7 +180,7 @@ mod test { #[test] fn insert_gated_on_handlers() { - let mut requests = Requests::::default(); + let mut requests = RequestQueue::::default(); assert_eq!(requests.insert(1, "a"), Err("a")); requests.add_handler(); @@ -194,7 +194,7 @@ mod test { #[test] fn popped_stays_joinable_until_removed() { - let mut requests = Requests::::default(); + let mut requests = RequestQueue::::default(); requests.add_handler(); assert!(requests.insert(1, "a").is_ok()); @@ -210,7 +210,7 @@ mod test { #[test] fn remove_if_guards_identity() { - let mut requests = Requests::::default(); + let mut requests = RequestQueue::::default(); requests.add_handler(); assert!(requests.insert(1, "old").is_ok()); @@ -224,7 +224,7 @@ mod test { #[test] fn take_after_pop_keeps_the_queue_order() { - let mut requests = Requests::::default(); + let mut requests = RequestQueue::::default(); requests.add_handler(); for (key, value) in [(1, "a"), (2, "b"), (3, "c")] { assert!(requests.insert(key, value).is_ok()); @@ -238,7 +238,7 @@ mod test { #[test] fn drain_queued_spares_popped() { - let mut requests = Requests::::default(); + let mut requests = RequestQueue::::default(); requests.add_handler(); assert!(requests.insert(1, "handed out").is_ok()); assert!(requests.insert(2, "queued").is_ok()); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 2db144d166c..74f180f25ba 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -2055,7 +2055,7 @@ mod test { assert_eq!(read(&mut held), Some(b"first".to_vec())); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); let mut newer = b.create_group(group::Info { sequence: 1 }).unwrap(); newer.write_frame(ts(1), b"newer".as_ref()).unwrap(); newer.finish().unwrap(); @@ -2529,7 +2529,7 @@ mod test { open.abort(Error::Dropped).unwrap(); let b = compressed(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); @@ -2608,7 +2608,7 @@ mod test { drop(a); let b = track::Producer::new(broadcast.clone(), "video", info.clone()); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); @@ -2690,7 +2690,7 @@ mod test { } let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none(), "fails={fails}"); let _pending = dynamic.requested_fill().now_or_never().unwrap().unwrap(); @@ -2722,7 +2722,7 @@ mod test { ); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); b.drop_groups(0..1, StreamError::Old); assert!(fetched.read_frame().now_or_never().is_none()); @@ -2765,7 +2765,7 @@ mod test { ); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert_eq!(read(&mut reading), Some(b"b".to_vec())); assert!(reading.read_frame().now_or_never().is_none()); @@ -2802,7 +2802,7 @@ mod test { assert_eq!(read(&mut reading), Some(b"a".to_vec())); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); @@ -2835,7 +2835,7 @@ mod test { open.abort(Error::Evicted).unwrap(); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); @@ -4705,7 +4705,7 @@ mod test { fn a_pending_fetch_wakes_and_moves_to_the_new_route() { let routes = Producer::new(); let a = copy(); - let dynamic = a.dynamic(); + let dynamic = a.requests(); routes.serve(a.consume()); let logical = logical(&routes); let mut fetching = logical.consume().fetch_group(0, None); @@ -4736,7 +4736,7 @@ mod test { let mut reading = recv(&mut sub); assert_eq!(read(&mut reading), Some(b"a".to_vec())); let b = copy(); - let _dynamic = b.dynamic(); + let _dynamic = b.requests(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); for sequence in 1..=4 { @@ -5008,7 +5008,7 @@ mod test { fn a_fetched_group_outlives_its_delivery_giving_up() { let routes = Producer::new(); let a = copy(); - let dynamic = a.dynamic(); + let dynamic = a.requests(); routes.serve(a.consume()); let mut arriving = a.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5062,7 +5062,7 @@ mod test { let routes = Producer::new(); let logical = logical(&routes); let a = copy(); - let dynamic = a.dynamic(); + let dynamic = a.requests(); routes.serve(a.consume()); let mut sub = subscribe(&logical, HOUR); let mut open = a.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5098,7 +5098,7 @@ mod test { let routes = Producer::new(); let logical = logical(&routes); let a = copy(); - let a_requests = a.dynamic(); + let a_requests = a.requests(); routes.serve(a.consume()); let mut sub = subscribe(&logical, HOUR); let mut open = a.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5110,7 +5110,7 @@ mod test { assert_eq!(read(&mut reading), Some(b"a".to_vec())); let b = copy(); - let b_requests = b.dynamic(); + let b_requests = b.requests(); routes.serve(b.consume()); let mut repeat = b.create_group(group::Info { sequence: 0 }).unwrap(); repeat.write_frame(ts(0), b"a".as_ref()).unwrap(); @@ -5149,7 +5149,7 @@ mod test { assert_eq!(read(&mut reading), Some(b"a".to_vec())); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); open.abort(Error::Stream(code.clone())).unwrap(); assert!(reading.read_frame().now_or_never().is_none(), "{code:?}"); @@ -5206,7 +5206,7 @@ mod test { assert_eq!(read(&mut fetched), Some(b"a".to_vec())); let b = copy(); - let dynamic = b.dynamic(); + let dynamic = b.requests(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); @@ -5513,7 +5513,7 @@ mod test { fn ending_the_front_releases_a_pending_fetch() { let routes = Producer::new(); let a = copy(); - let _dynamic = a.dynamic(); + let _dynamic = a.requests(); routes.serve(a.consume()); let logical = logical(&routes); let mut fetching = logical.consume().fetch_group(0, None); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index bbc5a44ea84..5ef78340617 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -469,7 +469,7 @@ pub(crate) struct TrackState { subscriptions: kio::Lock, // The fills readers queued for groups they miss (see [`Fill`]), same reasoning: a miss - // queues here and a `Dynamic` drains. + // queues here and a `Requests` drains. fills: kio::Shared, // A front's logical track: read straight from its routes' copies rather than this @@ -1458,7 +1458,7 @@ impl TrackState { /// Resolve a one-shot fetch from the track side: the cached group, or an [`Error`] /// once it can never be served. A missing group is a failure ([`Error::NotFound`]), not an - /// end-of-stream. The handler side (a refusal, or no [`Dynamic`] at all) lives + /// end-of-stream. The handler side (a refusal, or no [`Requests`] at all) lives /// in the track's fills; [`Fetching`] polls both. fn poll_fetch_cached(&self, sequence: u64, frame_start: u64) -> Poll> { // A group aborted between the lookup and the consume (an abandoned fetch cut @@ -2687,7 +2687,7 @@ pub struct Producer { broadcast: Arc, state: kio::Producer, prev_subscription: Option, - // Shared with every clone and every `Dynamic`: its `Drop` is the teardown. + // Shared with every clone and every `Requests`: its `Drop` is the teardown. alive: Arc, // Ingress stats scope, inherited from a tagged [`broadcast::Producer`]. Bumped as // one subscription on tag and closed when the last producer clone drops. Empty @@ -3395,11 +3395,11 @@ impl Producer { poll_combined_changed(&subs, bound, &mut self.prev_subscription, waiter).map(Ok) } - /// Create a [`Dynamic`] handle that serves on-demand fetches of uncached + /// Create a [`Requests`] handle that serves on-demand fetches of uncached /// (old) groups. Most producers never need this; a relay creates one to fetch /// past groups from upstream. - pub fn dynamic(&self) -> Dynamic { - Dynamic::new(self.name.clone(), self.state.clone(), self.alive.clone()) + pub fn requests(&self) -> Requests { + Requests::new(self.name.clone(), self.state.clone(), self.alive.clone()) } fn modify(&self) -> Result> { @@ -3408,15 +3408,15 @@ impl Producer { } /// Serves the groups a track's readers miss, the group-level analogue of -/// [`broadcast::Dynamic`]. +/// [`broadcast::Requests`]. /// /// Most tracks never serve old content, so this capability lives on a dedicated /// handle rather than [`Producer`]: a relay creates one (via -/// [`Producer::dynamic`] or [`Request::dynamic`]) to pull past groups +/// [`Producer::requests`] or [`Request::requests`]) to pull past groups /// from upstream. While at least one is alive a reader's miss queues a [`Fill`] for it /// to serve; with none, the miss is named dropped, and a cache-miss /// [`Consumer::fetch_group`] on an accepted track fails fast with [`Error::NotFound`]. -pub struct Dynamic { +pub struct Requests { name: Arc, // Kept to insert served groups into the cache and observe track abort. state: kio::Producer, @@ -3427,7 +3427,7 @@ pub struct Dynamic { alive: Arc, } -impl Dynamic { +impl Requests { fn new(name: Arc, state: kio::Producer, alive: Arc) -> Self { let fills = state.read().fills.clone(); fills.lock().add_handler(); @@ -3480,9 +3480,9 @@ impl Dynamic { } } -impl Clone for Dynamic { +impl Clone for Requests { fn clone(&self) -> Self { - // Count each live handle (mirrors `broadcast::Dynamic`). + // Count each live handle (mirrors `broadcast::Requests`). self.fills.lock().add_handler(); Self { name: self.name.clone(), @@ -3493,9 +3493,9 @@ impl Clone for Dynamic { } } -impl Drop for Dynamic { +impl Drop for Requests { fn drop(&mut self) { - // Unlike `broadcast::Dynamic`, dropping the last handle doesn't abort the track: + // Unlike `broadcast::Requests`, dropping the last handle doesn't abort the track: // a live `Producer` may still be serving the subscription. It just stops serving // fills: a queued one no handler will ever take closes, so its readers stop // waiting on it, while one already taken stays with its `Fill`. @@ -3503,7 +3503,7 @@ impl Drop for Dynamic { } } -/// Ends the track when the last [`Producer`] or [`Dynamic`] drops. +/// Ends the track when the last [`Producer`] or [`Requests`] drops. /// /// A refcount rather than a "am I the last one?" check inside `Drop`: that answer is a /// snapshot, and acting on it is exactly what invalidates it. The track state's own @@ -3516,7 +3516,7 @@ struct Alive { state: kio::Producer, // Set when a `Producer` is first minted, so a `Request` nobody accepted (its - // `Dynamic` holds this guard too) isn't reported as an abandoned publisher. + // `Requests` holds this guard too) isn't reported as an abandoned publisher. published: AtomicBool, // Ingress subscription for this track, opened by the tagged producer that claimed @@ -3776,7 +3776,7 @@ impl super::WeakEntry for TrackWeak { /// A cloneable, watch-only handle to a track's subscriber demand. /// -/// Obtained from [`Producer::demand`], [`Request::demand`], or [`Dynamic::demand`]. +/// Obtained from [`Producer::demand`], [`Request::demand`], or [`Requests::demand`]. /// A publisher uses it to learn which group anyone subscribed, querying, or fetching /// wants (on-demand capture / encoding) without being able to publish frames or close the /// track. A bare [`Consumer`] is not demand. It's a weak handle, so it neither keeps @@ -4459,12 +4459,12 @@ impl Consumer { /// Fetching a single past group, without holding a live subscription. /// /// Returns a [`kio::Pending`] that resolves to the [`group::Consumer`]: - /// immediately if the group is cached, otherwise once a [`Dynamic`] serves + /// immediately if the group is cached, otherwise once a [`Requests`] serves /// the request (a wire FETCH for a relay). `options` accepts `None`, a [`group::Fetch`], /// or `group::Fetch::default()`. /// /// The returned future resolves to [`Error::NotFound`] when the group can never be served - /// (past the final sequence, or no [`Dynamic`] on the track), [`Error::NotFetchable`] + /// (past the final sequence, or no [`Requests`] on the track), [`Error::NotFetchable`] /// instead when this track sent the sequence as a datagram still buffered, the handler's /// rejection (a relay's upstream miss is [`StreamError::NotFound`](StreamError::NotFound)), /// or the track's abort error if it's already closed. A miss queues a one-group [`Fill`], @@ -7209,7 +7209,7 @@ pub struct Staged { producer: Option, // The request's fetch claim, held until the track is published like an unanswered // request's: a cache miss meanwhile waits rather than racing its own handler. - _dynamic: Dynamic, + _dynamic: Requests, } impl Staged { @@ -7249,7 +7249,7 @@ impl Drop for Staged { /// A subscriber asked for a track this broadcast doesn't have yet. /// -/// Yielded by [`poll_requested_track`](crate::broadcast::Dynamic::poll_requested_track), +/// Yielded by [`poll_requested_track`](crate::broadcast::Requests::poll_requested_track), /// or created up front with [`broadcast::Producer::reserve_track`](crate::broadcast::Producer::reserve_track). /// Subscribers block until the request is /// resolved: call [`accept`](Self::accept) to serve it with a [`Producer`] (or @@ -7268,7 +7268,7 @@ pub struct Request { // The previous subscription that was combined, used to detect changes. prev_subscription: Option, - // Shared with the accepted [`Producer`] and every [`Dynamic`]: its `Drop` is the + // Shared with the accepted [`Producer`] and every [`Requests`]: its `Drop` is the // teardown, and it stays inert until a producer is minted. alive: Arc, @@ -7276,7 +7276,7 @@ pub struct Request { // birth: a consumer's cache-miss `fetch_group` waits to be served instead of // racing the producer (e.g. a relay) into creating its own handler. Released // when the request is accepted or dropped; by then the relay holds its own. - _dynamic: Dynamic, + _dynamic: Requests, // Ingress stats scope, threaded into the accepted [`Producer`]. Empty (no-op) // unless this request was reserved on a tagged broadcast. @@ -7301,7 +7301,7 @@ impl Request { let name = name.into(); let state = TrackState::spawn(broadcast.clone()); let alive = Alive::new(name.clone(), state.clone()); - let dynamic = Dynamic::new(name.clone(), state.clone(), alive.clone()); + let dynamic = Requests::new(name.clone(), state.clone(), alive.clone()); Self { name, broadcast, @@ -7369,11 +7369,11 @@ impl Request { Consumer::new(self.name.clone(), self.state.weak().consume_weak()) } - /// Create a [`Dynamic`] handle that serves on-demand fetches of uncached + /// Create a [`Requests`] handle that serves on-demand fetches of uncached /// groups, before [`Self::accept`] is even called. A relay creates one to fetch /// past groups from upstream while (or instead of) serving a live subscription. - pub fn dynamic(&self) -> Dynamic { - Dynamic::new(self.name.clone(), self.state.clone(), self.alive.clone()) + pub fn requests(&self) -> Requests { + Requests::new(self.name.clone(), self.state.clone(), self.alive.clone()) } /// Watch subscriber demand without keeping the track alive. @@ -8810,7 +8810,7 @@ mod test { #[cfg(feature = "async")] fn the_last_sequence_misses_with_a_handler() { let producer = track_producer("test", None); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let fetch = consumer.fetch_group(u64::MAX, None).now_or_never(); assert!(matches!(fetch, Some(Err(Error::NotFound)))); @@ -11388,7 +11388,7 @@ mod test { #[test] fn cold_fetch_expires_without_waiting_for_the_upstream_group() { let mut producer = track_producer("test", Info::timed()); - let _dynamic = producer.dynamic(); + let _dynamic = producer.requests(); let mut successor = producer.create_group(group::Info { sequence: 1 }).unwrap(); successor .write_frame(Timestamp::from_millis(1000).unwrap(), bytes::Bytes::from_static(b"x")) @@ -11474,7 +11474,7 @@ mod test { fn a_fetch_only_cache_is_not_live() { let waiter = kio::Waiter::noop(); let request = Request::new(Arc::new(broadcast::Info::default()), "test"); - let dynamic = request.dynamic(); + let dynamic = request.requests(); let consumer = request.consume(); let mut pending = consumer.fetch_group(2, None); let Poll::Ready(Ok(req)) = dynamic.poll_requested_fill_group(&waiter) else { @@ -11512,7 +11512,7 @@ mod test { #[cfg(feature = "async")] async fn fetched_group_is_not_a_live_drift_edge() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); append_at(&mut producer, 0); @@ -12188,7 +12188,7 @@ mod test { #[cfg(feature = "async")] async fn abort_ends_an_open_fetched_head() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut live = producer.create_group(group::Info { sequence: 0 }).unwrap(); live.start_at(1).unwrap(); @@ -12777,7 +12777,7 @@ mod test { async fn a_fetched_group_does_not_fill_a_pending_tail() { let mut producer = track_producer("test", None); producer.received(); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut arrival = producer.subscribe(None); producer.start_at(0).unwrap(); @@ -14176,7 +14176,7 @@ mod test { // A cached group resolves immediately and never queues a request. `peek_group` // also returns it synchronously. - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); assert!(consumer.peek_group(0).is_some()); let mut g = consumer.fetch_group(0, None).await.unwrap(); @@ -14191,7 +14191,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_miss_signals_dynamic() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); // A cache miss isn't in `peek_group`, but a dynamic handler exists, so @@ -14225,7 +14225,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_miss_rejects() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let pending = consumer.fetch_group(5, None); @@ -14245,7 +14245,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_miss_drop_rejects() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let pending = consumer.fetch_group(5, None); @@ -14265,7 +14265,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_request_unused_once_every_fetch_leaves() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(5, None); @@ -14299,7 +14299,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_abandoned_while_queued_never_reaches_the_handler() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); drop(consumer.fetch_group(5, None)); @@ -14314,7 +14314,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_after_the_last_caller_left_is_not_dropped() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(5, None); @@ -14344,7 +14344,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_accept_after_withdrawal_caches_the_group() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(5, None); @@ -14389,7 +14389,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_hit_keeps_the_group_wanted() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(5, None); @@ -14417,7 +14417,7 @@ mod test { #[cfg(feature = "async")] async fn accepted_group_starts_at_the_request() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let _tail = consumer.fetch_group( @@ -14450,7 +14450,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_misses_an_abandoned_group() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(5, None); @@ -14477,7 +14477,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_reject_does_not_poison_retry() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let pending = consumer.fetch_group(5, None); @@ -14512,7 +14512,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_ignores_a_group_that_starts_too_late() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); // The live subscription resumed mid-group, so only the tail is cached. @@ -14567,7 +14567,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_fills_the_head_of_a_live_group() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut live = producer.create_group(group::Info { sequence: 0 }).unwrap(); @@ -14612,7 +14612,7 @@ mod test { #[cfg(feature = "async")] async fn a_refused_end_leaves_every_copy_uncapped() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); // The live copy holds frames 0 and 1, the fetched head frames 0 to 2. @@ -14642,7 +14642,7 @@ mod test { /// A live group with a fetched head: group 0, fed from frame 1, plus a two-frame head. #[cfg(feature = "async")] async fn live_group_with_head(producer: &Producer) -> (group::Producer, group::Producer) { - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut live = producer.create_group(group::Info { sequence: 0 }).unwrap(); @@ -14668,7 +14668,7 @@ mod test { #[cfg(feature = "async")] async fn a_fetched_head_holds_the_largest_open() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut reader = consumer.clone(); let noop = kio::Waiter::noop(); @@ -14766,7 +14766,7 @@ mod test { #[cfg(feature = "async")] async fn pressure_evicts_a_headed_slot(abort_live: bool) -> bool { let (producer, _pool) = pooled_producer(10_000); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut live = producer.create_group(group::Info { sequence: 0 }).unwrap(); @@ -14819,7 +14819,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_widens_or_fails_cleanly() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let _narrow = consumer.fetch_group( @@ -14883,7 +14883,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_coalesces_concurrent() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); // Two fetches for the same uncached group produce ONE handler request, @@ -14936,7 +14936,7 @@ mod test { #[cfg(feature = "async")] async fn a_late_looser_fetch_asks_again_after_old() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let tight = consumer.fetch_group(5, group::Fetch::default().with_max_delay(Duration::from_secs(1))); @@ -14963,7 +14963,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_coalesced_reject_fails_all() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(5, None); @@ -14993,7 +14993,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_queued_fails_when_handlers_leave() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); // Queued but never popped: the last handler leaving fails it fast. @@ -15010,7 +15010,7 @@ mod test { #[moq_clock::test] #[cfg(feature = "async")] async fn fetch_miss_no_dynamic_not_found() { - // A track with no `Dynamic` can't serve old content, so a cache miss + // A track with no `Requests` can't serve old content, so a cache miss // resolves to NotFound instead of blocking forever. let producer = track_producer("test", Info::timed()); let _open = producer.append_group().unwrap(); // seq 0, but we miss on seq 5 @@ -15027,7 +15027,7 @@ mod test { // A group at or past the final sequence can never exist, even with a handler, // so it resolves to NotFound. - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); assert!(matches!(consumer.fetch_group(5, None).await, Err(Error::NotFound))); @@ -15298,7 +15298,7 @@ mod test { ..Default::default() }; let request = Request::new(Arc::new(broadcast), "test"); - let dynamic = request.dynamic(); + let dynamic = request.requests(); let consumer = request.consume(); // Serve a backfill before the track is accepted, then grow it. @@ -15405,12 +15405,12 @@ mod test { assert!(!Arc::ptr_eq(&a, &b), "each track owns its account"); } - /// A `Dynamic` still serving fetches keeps the track alive, so the publisher + /// A `Requests` still serving fetches keeps the track alive, so the publisher /// letting go isn't an abrupt teardown: the handler can still serve the cache. #[test] fn a_dynamic_defers_teardown() { let (mut producer, pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); finished_group(&mut producer, 100); drop(producer); @@ -15492,7 +15492,7 @@ mod test { ..Default::default() }; let request = Request::new(Arc::new(broadcast), "test"); - let dynamic = request.dynamic(); + let dynamic = request.requests(); let consumer = request.consume(); // Serve backfill seq 0 before the track is accepted. @@ -15553,7 +15553,7 @@ mod test { #[cfg(feature = "async")] async fn refreshed_front_does_not_starve_expiry() { let (producer, _pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); producer.create_group(10u64.into()).unwrap().finish().unwrap(); @@ -15833,7 +15833,7 @@ mod test { #[cfg(feature = "async")] async fn refilled_sequence_stays_out_of_subscriptions() { let (producer, _pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); producer.create_group(0u64.into()).unwrap().finish().unwrap(); @@ -15870,7 +15870,7 @@ mod test { #[cfg(feature = "async")] async fn expired_backfill_behind_refreshed_reclaimed() { let (producer, _pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); producer.create_group(5u64.into()).unwrap().finish().unwrap(); @@ -15929,7 +15929,7 @@ mod test { #[cfg(feature = "async")] async fn refetched_latest_stays_protected() { let (producer, _pool) = pooled_producer(10_000); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let straggler = producer.append_group().unwrap(); // seq 0 @@ -15971,7 +15971,7 @@ mod test { #[cfg(feature = "async")] async fn eviction_allows_refetch() { let (mut producer, _pool) = pooled_producer(10_000); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); finished_group(&mut producer, 10_000); // seq 0 finished_group(&mut producer, 10_000); // seq 1: demotes seq 0 @@ -16040,7 +16040,7 @@ mod test { #[cfg(feature = "async")] async fn a_lowered_floor_reoffers_live_groups_but_not_fetched_ones() { let (producer, _pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); producer.create_group(5u64.into()).unwrap().finish().unwrap(); let mut subscriber = producer.subscribe(replay().with_range(5..)); @@ -16084,7 +16084,7 @@ mod test { #[cfg(feature = "async")] async fn fetched_backfill_not_subscribed() { let (producer, _pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); // The publisher starts at seq 5; earlier groups exist only upstream. @@ -16120,7 +16120,7 @@ mod test { #[cfg(feature = "async")] async fn expired_backfill_reclaimed() { let (producer, pool) = pooled_producer(1 << 40); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); producer.create_group(5u64.into()).unwrap().finish().unwrap(); @@ -16302,7 +16302,7 @@ mod test { #[cfg(feature = "async")] fn request_and_dynamic_share_weak_track_demand() { let request = Request::new(Arc::new(broadcast::Info::default()), "demand"); - let dynamic = request.dynamic(); + let dynamic = request.requests(); let demand = request.demand(); let consumer = request.consume(); assert!(!demand.is_used(), "a bare handle is not demand"); @@ -16326,7 +16326,7 @@ mod test { #[test] fn fill_demand_counts_every_joined_caller() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let first = consumer.fetch_group(3, None); let second = consumer.fetch_group(3, None); @@ -16352,7 +16352,7 @@ mod test { #[test] fn accepted_fill_resolves_its_fetch() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); @@ -16371,9 +16371,9 @@ mod test { /// A feed declared to start at group 2, with groups 2 and 3 written, read from group 0. #[cfg(feature = "async")] - fn started_late() -> (Producer, Dynamic, Subscriber) { + fn started_late() -> (Producer, Requests, Subscriber) { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let subscriber = producer.subscribe(replay().with_range(0..)); producer.start_at(2).unwrap(); write_group(&producer, 2); @@ -16441,7 +16441,7 @@ mod test { fn filled_groups_are_read_in_the_readers_order() { for order in [Order::Descending, Order::Ascending] { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..).with_order(order)); producer.start_at(2).unwrap(); write_group(&producer, 2); @@ -16498,7 +16498,7 @@ mod test { #[cfg(feature = "async")] fn a_late_fresh_group_keeps_its_stretch_of_a_stalled_fill() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..)); producer.start_at(10).unwrap(); write_group(&producer, 10); @@ -16540,7 +16540,7 @@ mod test { #[cfg(feature = "async")] fn an_aborted_fetched_group_stays_wanted_by_its_replacement_fill() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); producer.start_at(10).unwrap(); write_group(&producer, 10); let waiter = kio::Waiter::noop(); @@ -16571,7 +16571,7 @@ mod test { #[cfg(feature = "async")] fn a_refused_group_is_asked_again_by_a_later_reader() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); for sequence in 0..5 { write_group(&producer, sequence); } @@ -16608,7 +16608,7 @@ mod test { #[cfg(feature = "async")] fn a_source_miss_is_asked_again_by_a_later_reader() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); for sequence in 0..5 { write_group(&producer, sequence); } @@ -16639,7 +16639,7 @@ mod test { #[cfg(feature = "async")] fn a_backfill_cached_before_its_answer_stays_owed() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); let mut subscriber = producer.subscribe(ascending(0..)); producer.start_at(10).unwrap(); @@ -16667,7 +16667,7 @@ mod test { #[cfg(feature = "async")] fn an_ordered_reader_leaves_a_filled_group_it_takes() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..1)); producer.start_at(10).unwrap(); write_group(&producer, 10); @@ -16695,7 +16695,7 @@ mod test { #[cfg(feature = "async")] fn a_narrowed_reader_leaves_the_rest_of_its_fill() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); let mut subscriber = producer.subscribe(ascending(0..)); producer.start_at(10).unwrap(); @@ -16738,7 +16738,7 @@ mod test { #[cfg(feature = "async")] fn a_filled_group_is_wanted_until_taken() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); let mut subscriber = producer.subscribe(ascending(0..)); producer.start_at(10).unwrap(); @@ -16771,7 +16771,7 @@ mod test { #[cfg(feature = "async")] fn an_accepted_groups_readers_leave_before_its_fill() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); let mut subscriber = producer.subscribe(ascending(0..)); producer.start_at(10).unwrap(); @@ -16799,7 +16799,7 @@ mod test { #[cfg(feature = "async")] fn a_time_start_before_the_cache_asks_nothing_below_it() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let at = |millis| Timestamp::from_millis(millis).unwrap(); let since = Range { start: Anchor::Time(at(5)), @@ -16837,7 +16837,7 @@ mod test { #[cfg(feature = "async")] fn a_wide_fill_forgets_refusals_its_readers_took() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..).with_order(Order::Ascending)); producer.start_at(100).unwrap(); write_group(&producer, 100); @@ -16861,7 +16861,7 @@ mod test { #[cfg(feature = "async")] fn a_later_reader_asks_afresh_for_what_a_fill_stopped_owing() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); let mut narrowed = producer.subscribe(ascending(0..)); producer.start_at(10).unwrap(); @@ -16890,7 +16890,7 @@ mod test { #[cfg(feature = "async")] fn a_descending_reader_asks_its_newest_miss_first() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_ranges([Range::groups(0..2), Range::groups(4..6), Range::groups(8..)])); producer.start_at(8).unwrap(); @@ -16911,7 +16911,7 @@ mod test { #[cfg(feature = "async")] fn a_reader_forgets_misses_behind_its_window() { let mut producer = track_producer("test", Info::timed()); - let _dynamic = producer.dynamic(); + let _dynamic = producer.requests(); let ascending = |groups| Subscription::default().with_range(groups).with_order(Order::Ascending); let mut subscriber = producer.subscribe(ascending(0..)); producer.start_at(0).unwrap(); @@ -16942,7 +16942,7 @@ mod test { #[cfg(feature = "async")] fn a_descending_reader_asks_its_newest_fragment_first() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut middle = producer.subscribe(replay().with_range(5..6)); producer.start_at(8).unwrap(); write_group(&producer, 8); @@ -17049,7 +17049,7 @@ mod test { fn a_wide_fill_its_handler_left_settles_by_run() { const WIDE: u64 = 1 << 40; let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..)); producer.start_at(WIDE).unwrap(); write_group(&producer, WIDE); @@ -17072,7 +17072,7 @@ mod test { const WIDE: u64 = 1 << 40; const REFUSED: u64 = 100_000; let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..)); producer.start_at(WIDE).unwrap(); write_group(&producer, WIDE); @@ -17094,7 +17094,7 @@ mod test { #[test] fn a_descending_reader_turns_a_queued_fill_newest_first() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut ascending = producer.subscribe(replay().with_range(0..).with_order(Order::Ascending)); let mut descending = producer.subscribe(replay().with_range(0..)); producer.start_at(4).unwrap(); @@ -17154,7 +17154,7 @@ mod test { } /// Fetch group 3 and drop it through the fill it queues, returning how the fetch ended. - fn drop_through_fill(producer: &Producer, dynamic: &Dynamic, drop_all: bool) -> Error { + fn drop_through_fill(producer: &Producer, dynamic: &Requests, drop_all: bool) -> Error { let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); @@ -17187,7 +17187,7 @@ mod test { #[moq_clock::test] async fn dropped_fill_group_fails_its_fetch() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); write_group(&producer, 5); assert!(matches!(drop_through_fill(&producer, &dynamic, true), Error::NotFound)); assert!(drops(&producer).is_empty(), "the feed may still deliver 3"); @@ -17201,7 +17201,7 @@ mod test { #[moq_clock::test] async fn fill_dropped_unanswered_names_the_rest_dropped() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); write_group(&producer, 5); moq_clock::sim::advance(Duration::from_secs(60)).await; assert!(matches!(drop_through_fill(&producer, &dynamic, false), Error::Dropped)); @@ -17212,7 +17212,7 @@ mod test { #[cfg(feature = "async")] async fn fetch_aborts_with_track() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut pending = consumer.fetch_group(3, None); @@ -17557,7 +17557,7 @@ mod test { #[cfg(feature = "async")] async fn a_live_range_ignores_a_fetched_group_past_the_newest_group() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut early = producer.subscribe(replay().with_ranges([Range::live()])); let _fetching = consumer.fetch_group(5, None); @@ -18039,7 +18039,7 @@ mod test { #[cfg(feature = "async")] async fn a_drop_spares_a_fetched_head_still_arriving() { let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let consumer = producer.consume(); let mut live = producer.create_group(group::Info { sequence: 0 }).unwrap(); diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 392e62d9db2..5a05ca62bb4 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -1,4 +1,4 @@ -//! Fills: the groups readers miss, asked of a [`Dynamic`](super::Dynamic) handler by range. +//! Fills: the groups readers miss, asked of a [`Requests`](super::Requests) handler by range. //! //! A miss is a sequence a reader wants that the cache doesn't hold and the live feed will //! never deliver. A reader joins the fill already covering it, or queues a new one, and a @@ -21,7 +21,7 @@ use crate::{Error, Result, StreamError, group}; /// The fills a track's readers asked for, queued or taken by a handler, under one lock. #[derive(Default)] pub(in crate::model) struct FillState { - // Live `Dynamic` handles: a fill queues only while one can take it. + // Live `Requests` handles: a fill queues only while one can take it. handlers: usize, next: u64, // Every fill not yet ended, queued or taken, by id. @@ -526,7 +526,7 @@ pub(super) fn poll_requested( } } -/// A run of groups readers miss, for a [`Dynamic`](super::Dynamic) handler to serve. +/// A run of groups readers miss, for a [`Requests`](super::Requests) handler to serve. /// /// It names `start.group` up to the exclusive [`end`](Self::end), the first group from /// [`start`](Self::start)'s frame. The handler calls [`accept`](Self::accept) for each group diff --git a/rs/moq-net/src/util.rs b/rs/moq-net/src/util.rs index d7634a56d64..b453946afd8 100644 --- a/rs/moq-net/src/util.rs +++ b/rs/moq-net/src/util.rs @@ -79,7 +79,7 @@ pub(crate) struct Tasks { } /// A claim on a [`TaskSet`]'s lifetime with no submission queue: what a -/// broadcast published on an origin and an `origin::Dynamic` hold, so the +/// broadcast published on an origin and an `origin::Requests` hold, so the /// driver outlives the producer handles a session was given and dropped. pub(crate) struct Keepalive { _alive: kio::Producer<()>, diff --git a/rs/moq-net/tests/integration/catalog_acquisition.rs b/rs/moq-net/tests/integration/catalog_acquisition.rs index ca4bc66cf77..03b914e524e 100644 --- a/rs/moq-net/tests/integration/catalog_acquisition.rs +++ b/rs/moq-net/tests/integration/catalog_acquisition.rs @@ -28,7 +28,7 @@ async fn acquisition(version: &str, carries_epoch: bool) { let publisher = origin(1); let subscriber = origin(2); let broadcast = publisher.create_broadcast("live").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) .unwrap(); diff --git a/rs/moq-net/tests/integration/drained_claim.rs b/rs/moq-net/tests/integration/drained_claim.rs index de3ba3d33ee..b6a67404fda 100644 --- a/rs/moq-net/tests/integration/drained_claim.rs +++ b/rs/moq-net/tests/integration/drained_claim.rs @@ -2,7 +2,7 @@ //! A relay's front for a path ends once unread, end to end over real sessions. //! -//! Two workers each claim the `pool` prefix with `origin::Producer::dynamic` and +//! Two workers each claim the `pool` prefix with `origin::Producer::requests` and //! announce it to relay `R` on their own sessions, the way a transcode pool does. A //! viewer on `R` reads `pool/p` from worker `A`, over the cheaper link, which then //! closes that output and drains its session. Nothing tells `R` the output closed, so @@ -46,7 +46,7 @@ async fn settle() { /// A worker claiming `pool`, answering each request with an output whose `video` /// track carries the worker's name. struct Worker { - _claim: Rc, + _claim: Rc, /// Each output with its track, which would end once its producer drops. outputs: Rc>>, origin: origin::Producer, @@ -55,7 +55,7 @@ struct Worker { impl Worker { fn new(hop: u64, name: &'static str) -> Self { let origin = produce_origin(hop); - let claim = Rc::new(origin.dynamic("pool").unwrap()); + let claim = Rc::new(origin.requests("pool").unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (claim.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index 1609c164f85..bbd0caad7f9 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -48,7 +48,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage } let broadcast = nodes[0].create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); moq_clock::sleep(Duration::from_secs(1)).await; @@ -65,7 +65,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage futures::future::Either::Left((request, _)) => request.expect("the track request reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the track"), }; - let groups = request.dynamic(); + let groups = request.requests(); let _track = request.accept(timed()); let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), @@ -170,7 +170,7 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { let _pair = peer(version, &publisher, &relay).await; let broadcast = publisher.create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); moq_clock::sleep(Duration::from_secs(1)).await; @@ -180,7 +180,7 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { futures::future::Either::Left((request, _)) => request.expect("the track request reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the track"), }; - let groups = request.dynamic(); + let groups = request.requests(); let _track = request.accept(timed()); let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), diff --git a/rs/moq-net/tests/integration/ietf_conformance.rs b/rs/moq-net/tests/integration/ietf_conformance.rs index 07c18ad865e..7b6e01ab1a4 100644 --- a/rs/moq-net/tests/integration/ietf_conformance.rs +++ b/rs/moq-net/tests/integration/ietf_conformance.rs @@ -24,7 +24,7 @@ async fn routing(version: &str, cluster: bool) { let publisher = origin(1); let relay = origin(2); let reader = origin(4); - let prefix = Arc::new(moq_net::fuzz::dynamic_route(&publisher, PREFIX, Default::default()).unwrap()); + let prefix = Arc::new(moq_net::fuzz::requests_route(&publisher, PREFIX, Default::default()).unwrap()); let broadcast = broadcast::Info::new().produce(); let track = broadcast.create_track("test", moq_net::track::Info::default()).unwrap(); let serving = prefix.clone(); diff --git a/rs/moq-net/tests/integration/restart.rs b/rs/moq-net/tests/integration/restart.rs index cec0496f1a9..87a1498f6ab 100644 --- a/rs/moq-net/tests/integration/restart.rs +++ b/rs/moq-net/tests/integration/restart.rs @@ -176,14 +176,14 @@ relay_chain_tests! { /// the hidden `fuzz` module as a session lands it, so its answers carry no exact route. struct Worker { origin: origin::Producer, - _claim: Rc, + _claim: Rc, _outputs: Rc>>, } impl Worker { fn new(hop: u64) -> Self { let origin = produce_origin(hop); - let claim = Rc::new(moq_net::fuzz::dynamic_route(&origin, "pool", origin::Route::default()).unwrap()); + let claim = Rc::new(moq_net::fuzz::requests_route(&origin, "pool", origin::Route::default()).unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (claim.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { @@ -260,7 +260,7 @@ async fn prefix_pool_lite07() { /// claim carries an epoch or re-prices, so it lands through the hidden `fuzz` module as /// a session lands it. struct Claim { - dynamic: Rc, + dynamic: Rc, outputs: Rc>>, } @@ -268,7 +268,7 @@ impl Claim { fn new(origin: &origin::Producer, epoch: Option) -> Self { let mut route = origin::Route::default(); route.epoch = epoch; - let dynamic = Rc::new(moq_net::fuzz::dynamic_route(origin, "live", route).unwrap()); + let dynamic = Rc::new(moq_net::fuzz::requests_route(origin, "live", route).unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (dynamic.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { @@ -604,14 +604,14 @@ async fn a_more_specific_source_cannot_fill_a_held_covering_track() { /// A worker claiming `pool` through the public API: each request is prepared under a /// fresh epoch and published at its exact path, and `outputs[path]` holds the latest. struct Pool { - _claim: Rc, + _claim: Rc, outputs: Rc>>, answered: Rc>, } impl Pool { fn new(origin: &origin::Producer) -> Self { - let claim = Rc::new(origin.dynamic("pool").unwrap()); + let claim = Rc::new(origin.requests("pool").unwrap()); let outputs = Rc::new(RefCell::new(std::collections::HashMap::new())); let answered = Rc::new(RefCell::new(0)); let (handler, served, count) = (claim.clone(), outputs.clone(), answered.clone()); diff --git a/rs/moq-net/tests/integration/route_change.rs b/rs/moq-net/tests/integration/route_change.rs index 82787cf5888..fd085c59590 100644 --- a/rs/moq-net/tests/integration/route_change.rs +++ b/rs/moq-net/tests/integration/route_change.rs @@ -1111,7 +1111,7 @@ async fn producer_replaced(version: &str) { let subscriber = produce_origin(3); let broadcast = publisher.create_broadcast("live").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) .unwrap(); diff --git a/rs/moq-net/tests/integration/track_info_cancel.rs b/rs/moq-net/tests/integration/track_info_cancel.rs index 6fcb5e55306..d702fe91ee2 100644 --- a/rs/moq-net/tests/integration/track_info_cancel.rs +++ b/rs/moq-net/tests/integration/track_info_cancel.rs @@ -31,7 +31,7 @@ async fn abandoned_request_reaches_the_publisher(version: &str, relays: u64) { } let broadcast = nodes[0].create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); moq_clock::sleep(Duration::from_secs(1)).await; @@ -75,7 +75,7 @@ async fn withdrawn_broadcast_releases_track_info(version: &str, answered: bool, let publisher = produce_origin(1); let reader = produce_origin(2); let broadcast = publisher.create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); let mut route = origin::Route::default(); if explicit { route.epoch = Some("019a1234-5678-7000-8000-000000000001".parse().unwrap()); diff --git a/rs/moq-net/tests/integration/track_stream_demand.rs b/rs/moq-net/tests/integration/track_stream_demand.rs index 21d941b643d..6ba4b4e513f 100644 --- a/rs/moq-net/tests/integration/track_stream_demand.rs +++ b/rs/moq-net/tests/integration/track_stream_demand.rs @@ -101,7 +101,7 @@ async fn one_used_edge(version: &str, relays: usize, reorder: bool) { let reader: &MockSession = &links.last().unwrap().client_transport; let broadcast = nodes[0].create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let broadcast_edges = record!(broadcast.demand()); @@ -204,7 +204,7 @@ async fn bare_handle_is_lazy(version: &str, relays: usize) { } let broadcast = nodes[0].create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); let broadcast_edges = record!(broadcast.demand()); diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index f80e65755cd..ee175f42649 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -238,7 +238,7 @@ async fn a_fetched_untimed_frame_arrives_untimed() { for version in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-19"] { let chain = Chain::new(&[version]).await; let broadcast = chain.nodes[0].create_broadcast("room").unwrap(); - let mut dynamic = broadcast.dynamic(); + let mut dynamic = broadcast.requests(); broadcast.announce(Default::default()).unwrap(); moq_clock::sleep(Duration::from_secs(1)).await; @@ -248,7 +248,7 @@ async fn a_fetched_untimed_frame_arrives_untimed() { futures::future::Either::Left((request, _)) => request.expect("the track request reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the track"), }; - let groups = request.dynamic(); + let groups = request.requests(); let _track = request.accept(untimed()); let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), diff --git a/rs/moq-net/tests/integration/wide_hop.rs b/rs/moq-net/tests/integration/wide_hop.rs index ccb7e4ac848..82657f75e04 100644 --- a/rs/moq-net/tests/integration/wide_hop.rs +++ b/rs/moq-net/tests/integration/wide_hop.rs @@ -28,7 +28,7 @@ async fn widest_hop_crosses_each_varint_form() { let upstream: Version = "moq-transport-18".parse().unwrap(); let _upstream = peer(upstream, &source, &relay).await; let _downstream = peer(downstream.parse().unwrap(), &relay, &sink).await; - let _route = source.dynamic("room").unwrap(); + let _route = source.requests("room").unwrap(); moq_clock::sim::idle().await; let ids = |route: origin::Route| route.hops.iter().map(|hop| hop.id()).collect::>(); diff --git a/rs/moq-net/tests/loom.rs b/rs/moq-net/tests/loom.rs index ba0807b9ebf..51976f80523 100644 --- a/rs/moq-net/tests/loom.rs +++ b/rs/moq-net/tests/loom.rs @@ -462,7 +462,7 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { model(|| { let broadcast = broadcast::Info::new().produce(); let track = broadcast.create_track("video", None).expect("create track"); - let dynamic = track.dynamic(); + let dynamic = track.requests(); let consumer = track.consume(); let first = consumer.fetch_group(5, None); @@ -523,7 +523,7 @@ fn a_request_never_joins_a_retiring_front() { fn a_refused_answer_is_never_announced() { model(|| { let (producer, mut driver) = origin::Producer::new(origin::Config::new(Hop::new(1).unwrap())); - let claim = producer.dynamic("room").unwrap(); + let claim = producer.requests("room").unwrap(); let pending = producer.consume().request_broadcast("room/alice", None); driver .poll(moq_net::time::Instant::now(), &kio::Waiter::noop()) diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index 9b3fea6c687..ad14c232f51 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -1242,7 +1242,7 @@ mod tests { assert_eq!(written(&mut writes).await.as_ref(), b"low-0"); // The better rendition lives in a broadcast whose request is never answered. - let _handler = origin.dynamic("missing").unwrap(); + let _handler = origin.requests("missing").unwrap(); let mut config = VideoConfig::new(VideoCodec::VP8); config.container = hang::catalog::Container::Legacy; config.coded_width = Some(1920); diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 5e062239b8e..3bebdd0792e 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -611,7 +611,7 @@ struct NodePublisher { /// Holds the broadcast's request queue open, so a subscriber asking for a /// presence or prefix track parks until the next drain serves it instead /// of being rejected `NotFound` on the spot. - dynamic: broadcast::Dynamic, + dynamic: broadcast::Requests, local: HashMap>, totals: TotalsTrack, /// Held presence tracks, keyed by stats label. @@ -1028,7 +1028,7 @@ impl NodePublisher { } } - let dynamic = broadcast.dynamic(); + let dynamic = broadcast.requests(); Some(Self { broadcast, diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 189387835fd..996e55458f9 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -75,7 +75,7 @@ pub struct Transcoder { config: Config, derived: moq_mux::catalog::Producer, // Consumers asking for a rung before (or after) it exists queue here. - dynamic: moq_net::broadcast::Dynamic, + dynamic: moq_net::broadcast::Requests, active: active::Producer, } @@ -92,7 +92,7 @@ impl Transcoder { // The catalog starts empty and fills in during `run`, exactly like a // media importer that hasn't seen parameter sets yet. let derived = moq_mux::catalog::Producer::new(&mut output, moq_mux::catalog::Config::default())?; - let dynamic = output.dynamic(); + let dynamic = output.requests(); Ok(Self { source, @@ -1348,7 +1348,7 @@ mod tests { /// The query held to resolve the track is demand that starts the live path, /// and the live path serves the same sequences from the same /// source, so the source publishes no group at all and serves this one - /// through a [`moq_net::track::Dynamic`] instead. That handle also parks the + /// through a [`moq_net::track::Requests`] instead. That handle also parks the /// fetch at exactly the point in question: past the rung's handler, before /// its `Fill::accept`. #[tokio::test] @@ -1356,7 +1356,7 @@ mod tests { let mut source = source_catalog(320, 240); // The source track carries no live groups; this serves them on demand, so // the test decides when the rung's fetch gets past its source read. - let source_fetches = source._track.dynamic(); + let source_fetches = source._track.requests(); let config = Config { ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(), @@ -1441,7 +1441,7 @@ mod tests { async fn an_abandoned_fetch_encodes_nothing() { let source = source_catalog(320, 240); // Held, not served, until the abandoned fetch has let go of it. - let source_fetches = source._track.dynamic(); + let source_fetches = source._track.requests(); let config = Config { ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(), @@ -1530,7 +1530,7 @@ mod tests { let mut source_demand = source._track.demand(); // Parks the rung's own fetch of the group below, so that fetch is demand // for as long as the caller holds it and nothing else serves it. - let _source_fetches = source._track.dynamic(); + let _source_fetches = source._track.requests(); // Two frames, so the output's largest moves once both are encoded. let mut group = source._track.create_group(0u64.into()).unwrap(); @@ -1623,7 +1623,7 @@ mod tests { let mut source_demand = source._track.demand(); // Parks the rung's own fetch of the group below, so that fetch is // demand for as long as the caller holds it and nothing else serves it. - let _source_fetches = source._track.dynamic(); + let _source_fetches = source._track.requests(); let mut encoder = moq_video::encode::Encoder::new(&{ let mut config = moq_video::encode::Config::new(320, 240, moq_video::FrameRate::new(30, 1).unwrap()); diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 65997f936c9..c1a84eb3906 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -182,7 +182,7 @@ enum Ended { pub(crate) fn serve(rung: Rung, request: moq_net::track::Request) -> impl Future> { // Grab the group-request handle before accepting: a Request is dynamic from // birth, so a fetch racing the acceptance queues instead of failing. - let dynamic = request.dynamic(); + let dynamic = request.requests(); let info = hang::container::track_info(hang::catalog::PRIORITY.video); let mut producer = request.accept(info); let serving = Serving(Some(producer.clone())); @@ -454,7 +454,7 @@ async fn live(rung: &Rung, producer: &mut moq_net::track::Producer) -> Result, ) -> Result<(), Error> { let limit = Arc::new(Semaphore::new(MAX_CONCURRENT_FETCHES)); @@ -961,12 +961,12 @@ mod tests { async fn a_group_nobody_reads_releases_its_fill() { use std::task::Poll; let (rung, source, _retire) = idle_rung(); - let source_fills = source.create_track("video", None).unwrap().dynamic(); + let source_fills = source.create_track("video", None).unwrap().requests(); let output = moq_net::broadcast::Info::default().produce(); let timed = moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI); let mut track = output.create_track("video/120p", timed).unwrap(); - let fills = track.dynamic(); + let fills = track.requests(); let ascending = |groups| { moq_net::track::Subscription::default() .with_max_delay(None) @@ -1009,7 +1009,7 @@ mod tests { async fn cancelling_an_idle_rung_reports_cancel() { let (rung, _source, _retire) = idle_rung(); let output = moq_net::broadcast::Info::default().produce(); - let mut dynamic = output.dynamic(); + let mut dynamic = output.requests(); let view = output.consume(); let subscribing = tokio::spawn(async move { view.track("rung").unwrap().subscribe(None).await.unwrap() }); let request = dynamic.requested_track().await.unwrap(); @@ -1045,7 +1045,7 @@ mod tests { async fn an_unpolled_rung_reports_cancel() { let (rung, _source, _retire) = idle_rung(); let output = moq_net::broadcast::Info::default().produce(); - let mut dynamic = output.dynamic(); + let mut dynamic = output.requests(); let view = output.consume(); let subscribing = tokio::spawn(async move { view.track("rung").unwrap().subscribe(None).await.unwrap() }); let request = dynamic.requested_track().await.unwrap(); diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 998978ed32a..654c2fcb7d1 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are `requests()` (#6752) + +Rename `dynamic()` on `OriginProducer`, `BroadcastProducer`, `TrackProducer`, and `TrackRequest` to `requests()`. +Their handles `OriginDynamic`, `BroadcastDynamic`, and `TrackDynamic` are `OriginRequests`, `BroadcastRequests`, and `TrackRequests`; their methods are unchanged. + ### Connection state `Client.connect(to:)` returns a connecting native session. Replace `status()`, `connects()`, and both `failure()` polls with `state()` and `stateChanged(after: state.revision)`. Use `ConnectionState.phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift index 9f963e88303..8e0063b876c 100644 --- a/swift/Sources/Moq/Broadcast.swift +++ b/swift/Sources/Moq/Broadcast.swift @@ -69,9 +69,9 @@ public final class BroadcastProducer: Sendable { } /// Accept subscriptions to tracks that are not published yet. Hold and iterate - /// the returned `BroadcastDynamic` while such requests should be served. - public func dynamic() throws -> BroadcastDynamic { - BroadcastDynamic(try ffi.dynamic()) + /// the returned `BroadcastRequests` while such requests should be served. + public func requests() throws -> BroadcastRequests { + BroadcastRequests(try ffi.requests()) } /// Advertise this broadcast's exact path as a route. diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift index 4b5c0f0cda3..3eee7a651aa 100644 --- a/swift/Sources/Moq/Origin.swift +++ b/swift/Sources/Moq/Origin.swift @@ -24,10 +24,10 @@ public final class OriginProducer: Sendable { /// /// A route claims `prefix` and every path beneath it (`""` claims every path). /// A service that only serves some of them rejects the rest as they are - /// requested. Create, `dynamic` if tracks are served on demand, populate, + /// requested. Create, `requests` if tracks are served on demand, populate, /// then `BroadcastProducer.announce`. - public func dynamic(prefix: String) throws -> OriginDynamic { - OriginDynamic(try ffi.dynamic(prefix: prefix)) + public func requests(prefix: String) throws -> OriginRequests { + OriginRequests(try ffi.requests(prefix: prefix)) } /// Create a broadcast at `path`, returning the producer that feeds it. @@ -85,15 +85,15 @@ public final class BroadcastPrepared: Sendable { } /// A stream of broadcasts requested by consumers. Iterate directly: -/// `for try await request in dynamic { ... }`. Hold this while missing +/// `for try await request in requests { ... }`. Hold this while missing /// broadcasts should be served; cancelling the consuming task stops serving. -public final class OriginDynamic: AsyncSequence, Sendable { +public final class OriginRequests: AsyncSequence, Sendable { /// The broadcast request emitted by this sequence. public typealias Element = BroadcastRequest - let ffi: MoqOriginDynamic + let ffi: MoqOriginRequests - init(_ ffi: MoqOriginDynamic) { + init(_ ffi: MoqOriginRequests) { self.ffi = ffi } diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Requests.swift similarity index 91% rename from swift/Sources/Moq/Dynamic.swift rename to swift/Sources/Moq/Requests.swift index f31621b7536..3aa5c3d262f 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Requests.swift @@ -27,8 +27,8 @@ public final class TrackRequest: Sendable { } /// Create a fetch handler before accepting this requested track. - public func dynamic() throws -> TrackDynamic { - TrackDynamic(try ffi.dynamic()) + public func requests() throws -> TrackRequests { + TrackRequests(try ffi.requests()) } /// Reject the request with an application error code, failing the subscriber. @@ -37,7 +37,7 @@ public final class TrackRequest: Sendable { } } -/// A run of groups readers miss, for a `TrackDynamic` to serve. +/// A run of groups readers miss, for a `TrackRequests` to serve. /// /// Accept each group you have and drop the rest; `next()` names what is still owed, in the /// fill's order. Releasing the fill names whatever is left as dropped while a reader still @@ -120,13 +120,13 @@ public final class Fill: Sendable { } /// A stream of the fills readers of one track need: runs of groups the cache lacks. -public final class TrackDynamic: AsyncSequence, Sendable { +public final class TrackRequests: AsyncSequence, Sendable { /// The fill emitted by this sequence. public typealias Element = Fill - let ffi: MoqTrackDynamic + let ffi: MoqTrackRequests - init(_ ffi: MoqTrackDynamic) { + init(_ ffi: MoqTrackRequests) { self.ffi = ffi } @@ -149,16 +149,16 @@ public final class TrackDynamic: AsyncSequence, Sendable { } /// A stream of track requests from subscribers for tracks that are not published -/// yet. Iterate directly: `for try await request in dynamic { ... }`. Hold this +/// yet. Iterate directly: `for try await request in requests { ... }`. Hold this /// while such requests should be served; the sequence ends (throwing `Closed`) /// when the broadcast closes, and cancelling the consuming task stops serving. -public final class BroadcastDynamic: AsyncSequence, Sendable { +public final class BroadcastRequests: AsyncSequence, Sendable { /// The track request emitted by this sequence. public typealias Element = TrackRequest - let ffi: MoqBroadcastDynamic + let ffi: MoqBroadcastRequests - init(_ ffi: MoqBroadcastDynamic) { + init(_ ffi: MoqBroadcastRequests) { self.ffi = ffi } diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index efd34bfe137..37910d77a39 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -162,8 +162,8 @@ public final class TrackProducer: Sendable { } /// Serve fetches for groups that are not currently cached. - public func dynamic() throws -> TrackDynamic { - TrackDynamic(try ffi.dynamic()) + public func requests() throws -> TrackRequests { + TrackRequests(try ffi.requests()) } /// Append a new group, returning a producer for its frames. diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index af8e2deeaba..2ab8992f15b 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -160,7 +160,7 @@ final class SmokeTests: XCTestCase { func testOriginProducerIsConstructible() throws { let origin = OriginProducer(cacheCapacityBytes: 4096) _ = origin.consume() - _ = try origin.dynamic(prefix: "") + _ = try origin.requests(prefix: "") } func testBroadcastIsReachableOnlyWhileAnnounced() async throws { @@ -235,7 +235,7 @@ final class SmokeTests: XCTestCase { func testDynamicServesARequestUnderAPrefix() async throws { let origin = OriginProducer() - let dynamic = try origin.dynamic(prefix: "live") + let dynamic = try origin.requests(prefix: "live") let pending = Task { try await origin.consume().requestBroadcast(path: "live/cam") } diff --git a/test/drill/mutations/relay-withdraws-lost-publisher.patch b/test/drill/mutations/relay-withdraws-lost-publisher.patch index 38c8bf4b571..36362fa0fd1 100644 --- a/test/drill/mutations/relay-withdraws-lost-publisher.patch +++ b/test/drill/mutations/relay-withdraws-lost-publisher.patch @@ -16,14 +16,14 @@ diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber. /// without recomputing the chain. route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. -- dynamic: crate::origin::Dynamic, +- dynamic: crate::origin::Requests, + // MUTATION: never dropped, so a route outlives the session that announced it. -+ dynamic: std::mem::ManuallyDrop, ++ dynamic: std::mem::ManuallyDrop, /// Closes as the route goes, which closes every source it minted. live: kio::Producer<()>, /// Whether the GOAWAY drain already re-priced this route. @@ -3584,7 +3585,7 @@ - fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic, wake: Arc) -> Self { + fn new(route: crate::origin::Route, dynamic: crate::origin::Requests, wake: Arc) -> Self { Self { route, - dynamic, diff --git a/test/drill/mutations/subscriber-leaks-broadcasts.patch b/test/drill/mutations/subscriber-leaks-broadcasts.patch index 75e14022e92..3bdc2a16b3a 100644 --- a/test/drill/mutations/subscriber-leaks-broadcasts.patch +++ b/test/drill/mutations/subscriber-leaks-broadcasts.patch @@ -37,7 +37,7 @@ diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber. - source: crate::model::broadcast::SourceGuard, + // MUTATION: never dropped, so a session that ends keeps every source it minted alive. + source: std::mem::ManuallyDrop, - dynamic: crate::broadcast::Dynamic, + dynamic: crate::broadcast::Requests, route: kio::Consumer<()>, // A dedicated close-watch handle, since each pending operation needs its own. @@ -1523,7 +1525,7 @@ impl SourceServe { From b353951cf8b62603db4458b974e9ec56e332fe2d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 01:53:21 -0700 Subject: [PATCH 09/58] feat(net)!: group closed() settles on finish; abort after finish only frees cache Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 6 +- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/src/model/group.rs | 114 +++++++++++++++++++++++++----- rs/moq-net/src/model/track.rs | 5 +- 4 files changed, 103 insertions(+), 24 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 62997e3c9d4..4d0f7b20c0b 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -4482,7 +4482,7 @@ impl Subscriber { if let Poll::Ready(err) = track.poll_closed(waiter) { return Poll::Ready(Err(err)); } - if let Poll::Ready(err) = observing.poll_closed(waiter) { + if let Poll::Ready(err) = observing.poll_aborted(waiter) { return Poll::Ready(Err(err)); } ingest.poll(stream, &mut producer, waiter) @@ -5633,7 +5633,7 @@ impl Subscriber { if let Poll::Ready(res) = waiter.poll_future(receive.as_mut()) { return Poll::Ready(res); } - closed.poll_closed(waiter).map(Err) + closed.poll_aborted(waiter).map(Err) }) .await }; @@ -16518,7 +16518,7 @@ mod joining_fetch_tests { } if !complete { assert!( - matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Error::Cancel)), + matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Err(Error::Cancel))), "partial group must abort" ); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1ab43e86d9b..b53289c27f1 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1058,7 +1058,7 @@ impl GroupRecv { if let Poll::Ready(err) = track.poll_closed(waiter) { break 'serve Err(err); } - if let Poll::Ready(err) = group.handle().poll_closed(waiter) { + if let Poll::Ready(err) = group.handle().poll_aborted(waiter) { break 'serve Err(err); } match ingest.poll(&mut self.reader, group, waiter) { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 02cae7143af..6559583386d 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -721,6 +721,16 @@ impl GroupState { } } + /// The group's outcome once final: `Ok` once finished, which a later abort only frees + /// the cache of, or the abort error. + fn poll_outcome(&self) -> Poll> { + match (self.fin, &self.abort) { + (Some(_), _) => Poll::Ready(Ok(())), + (None, Some(err)) => Poll::Ready(Err(err.clone())), + (None, None) => Poll::Pending, + } + } + /// Resolve whether a reader at `index` can still make progress, answering the same /// question as a read without consuming anything. fn poll_end(&self, index: usize) -> Poll> { @@ -1643,6 +1653,9 @@ impl Producer { /// Consumes the writer. Drops the cached frames so a stale [`Consumer`] can't pin /// their buffers in memory forever; consumers that haven't drained yet surface the /// abort error instead of the leftover cache. + /// + /// After [`finish`](Self::finish) the outcome is already settled, so this only releases + /// the cache: the group stays finished, and a reader that drained it ends cleanly. pub fn abort(self, err: Error) -> Result<()> { self.handle.close_aborted(err) } @@ -1654,9 +1667,10 @@ impl Producer { Error::GroupTooLarge } - /// Block until the group is aborted, including by eviction. Finishing does not close it. + /// Block until the group's outcome is final: `Ok` once finished, or the abort error, + /// including eviction. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { + pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.handle.poll_closed(waiter)).await } /// The non-writing observer used by the cache and built-in sessions. @@ -1701,9 +1715,9 @@ impl Producer { self.handle.consume() } - /// Poll until the group is aborted, including by eviction; ready with the cause. - /// Finishing does not close it. - pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error, + /// including eviction. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { self.handle.poll_closed(waiter) } @@ -1865,13 +1879,17 @@ impl Handle { fn commit_abort(&self, mut guard: kio::Mut<'_, GroupState>, err: Error) { let policy = matches!(err, Error::Old | Error::Timeout | Error::Evicted); + // A finished group's outcome is settled: an abort only releases its cache, so it + // neither failed nor was cut. + let settled = guard.fin.is_some(); // Cache policy frees the frames, as does an oversized group whichever write found // it; a copy that failed or was given up lends them on. let salvage = !policy && !matches!(err, Error::GroupTooLarge); // A reset carrying a policy code is the upstream's cache policy, a cancel is a reader // no longer wanting the group, and a fill refused as unfetchable abandons backfill: // deliberate holes, never a cut readers see. The publisher giving the group up is. - let cut = !policy + let cut = !settled + && !policy && !matches!( err, Error::Cancel @@ -1887,7 +1905,7 @@ impl Handle { if cut { let _ = self.alive.cut.set(err.clone()); } - if !policy { + if !settled && !policy { self.alive.failed.store(true, Ordering::Release); } guard.abort = Some(err); @@ -2148,10 +2166,16 @@ impl Handle { } } - /// Poll until the group is aborted, including by eviction; ready with the cause. - /// Finishing does not close it. + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error. // Sessions observe a group while its sole writer belongs to a frame. - pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + settle(self.state.poll_ref(waiter, |state| state.poll_outcome())) + } + + /// Poll until the group is aborted, including by eviction and after it finished; ready + /// with the cause. Finishing alone does not end it. + // Sessions and track scans watch for an abort that frees a group, finished or not. + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { self.state.poll_closed(waiter).map(|()| self.abort_reason()) } @@ -2176,10 +2200,22 @@ impl Handle { } } +/// The outcome of a group's state polled from a handle: the channel closing short of one +/// (every writer gone without a finish or abort) reads as [`Error::Dropped`]. +fn settle(poll: Poll, kio::Ref<'_, GroupState>>>) -> Poll> { + Poll::Ready(match ready!(poll) { + Ok(outcome) => outcome, + Err(closed) => match closed.poll_outcome() { + Poll::Ready(outcome) => outcome, + Poll::Pending => Err(Error::Dropped), + }, + }) +} + /// A cloneable, watch-only handle to a group's readers. /// -/// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps -/// its abort reason. +/// Once every handle to a finished group is gone, demand waits end with +/// [`Error::Dropped`]; an aborted group's keep its abort reason. #[derive(Clone)] pub struct Demand { sequence: u64, @@ -2219,11 +2255,15 @@ impl Demand { self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) } - /// Wait until the group closes, returning its cause. + /// Wait until the group's outcome is final: `Ok` once finished, or the abort error. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - self.state.closed().await; - self.abort_reason() + pub async fn closed(&self) -> Result<()> { + kio::wait(|waiter| self.poll_closed(waiter)).await + } + + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + settle(self.state.consume_weak().poll(waiter, |state| state.poll_outcome())) } fn abort_reason(&self) -> Error { @@ -4585,10 +4625,50 @@ mod test { )); assert!(matches!( handle.poll_closed(&kio::Waiter::noop()), - Poll::Ready(Error::FrameTooLarge) + Poll::Ready(Err(Error::FrameTooLarge)) )); } + /// `closed` settles on a finish, and an abort after it only frees the cache: the + /// outcome stays clean, a drained reader ends cleanly, and nothing records a cut. + #[test] + fn abort_after_finish_keeps_the_clean_outcome() { + let mut producer = Info { sequence: 0 }.produce(); + let handle = producer.handle(); + let demand = producer.demand(); + let mut consumer = producer.consume(); + let waiter = kio::Waiter::noop(); + assert!(producer.poll_closed(&waiter).is_pending()); + assert!(demand.poll_closed(&waiter).is_pending()); + + producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap(); + producer.finish().unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(handle.poll_aborted(&waiter).is_pending(), "a finish alone frees nothing"); + assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_some()); + + producer.abort(Error::Cancel).unwrap(); + assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(handle.poll_aborted(&waiter), Poll::Ready(Error::Cancel))); + assert!(handle.cut_error().is_none()); + assert!(!handle.fate().failed()); + assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none()); + } + + /// An abort before the finish is the outcome, on every observer. + #[test] + fn abort_before_finish_is_the_outcome() { + let producer = Info { sequence: 0 }.produce(); + let handle = producer.handle(); + let demand = producer.demand(); + producer.abort(Error::Timeout).unwrap(); + let waiter = kio::Waiter::noop(); + assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + } + /// Completing a frame charges whatever it wrote since the last notify. #[test] fn frame_commit_charges_the_tail() { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3267f8f9e37..7be13bf2839 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1336,7 +1336,7 @@ impl TrackState { ) -> Option<&'a Slot> { loop { match select(self) { - Some(slot) if slot.group.poll_closed(waiter).is_ready() && slot.group.is_aborted() => continue, + Some(slot) if slot.group.poll_aborted(waiter).is_ready() => continue, slot => return slot, } } @@ -1494,8 +1494,7 @@ impl TrackState { // edge again, so each pass resolves a lower one. if let Some(live) = edge.presentation && let Some(slot) = self.lookup.get(&live.sequence) - && slot.group.poll_closed(waiter).is_ready() - && slot.group.is_aborted() + && slot.group.poll_aborted(waiter).is_ready() { continue; } From 94994b2670b83d8af750dcb409437739317cb68a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 01:54:59 -0700 Subject: [PATCH 10/58] refactor(net)!: group::Demand says wanted Co-Authored-By: Claude Opus 5.5 --- rs/moq-e2ee/src/tests.rs | 6 +- rs/moq-ffi/src/producer.rs | 9 ++- rs/moq-ffi/src/test.rs | 2 +- rs/moq-mux/src/container/consumer.rs | 4 +- rs/moq-net/src/model/group.rs | 84 +++++++++++++------- rs/moq-net/tests/integration/fetch_cancel.rs | 7 +- 6 files changed, 72 insertions(+), 40 deletions(-) diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 33fcd9b14f9..7150b654a87 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -808,7 +808,7 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { let mut good = recv_group(&mut pair.consumer); assert_eq!(read_frame(&mut good).plaintext, &b"ok"[..]); let mut forged = recv_group(&mut pair.consumer); - assert!(bad_demand.is_used()); + assert!(bad_demand.wanted()); // Sibling frame, next group, and datagram reads are already parked. Nothing // further is published. @@ -840,7 +840,7 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { !demand.wanted().is_some(), "track demand stayed live after authentication failure" ); - assert!(!bad_demand.is_used(), "failed group demand stayed live"); + assert!(!bad_demand.wanted(), "failed group demand stayed live"); assert!(!pair.net.is_closed(), "publisher must stay open"); assert_eq!( @@ -850,7 +850,7 @@ fn grouped_failure_wakes_pending_reads_and_releases_demand() { Poll::Ready("authentication".to_string()) ); assert!( - !open_demand.is_used(), + !open_demand.wanted(), "sibling group demand stayed live after its woken read" ); assert_eq!( diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 1ee3187f961..fab91b0a79b 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -796,10 +796,11 @@ impl MoqGroupProducer { #[cfg(test)] impl MoqGroupProducer { - /// Wait until a consumer has this group. - pub(crate) async fn used(&self) -> Result<(), MoqError> { - let demand = self.inner.lock().unwrap().as_ref().ok_or(MoqError::Closed)?.demand(); - Ok(demand.used().await?) + /// Wait until a consumer wants this group. + pub(crate) async fn wanted(&self) -> Result<(), MoqError> { + let mut demand = self.inner.lock().unwrap().as_ref().ok_or(MoqError::Closed)?.demand(); + while !demand.wanted_changed().await? {} + Ok(()) } } diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index c0bede188d1..e1e4f8c598a 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -129,7 +129,7 @@ async fn wait_for_config_error( /// Wait until `read_frame` has taken `group` off the ordered cursor. async fn wait_group_acquired(group: &MoqGroupProducer) { - tokio::time::timeout(TIMEOUT, group.used()) + tokio::time::timeout(TIMEOUT, group.wanted()) .await .expect("timed out waiting for the group to be acquired") .unwrap(); diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 633ba8a1b69..c846c04ad94 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -2804,8 +2804,8 @@ mod tests { let read = read_past_frames(&mut consumer).await; assert!(matches!(read, Err(crate::Error::UnknownFormat(_))), "{read:?}"); assert!(track.demand().wanted().is_none(), "the ended reader still subscribes"); - assert!(!malformed.demand().is_used(), "the ended reader still holds its group"); - assert!(!later.demand().is_used(), "the ended reader still holds a later group"); + assert!(!malformed.demand().wanted(), "the ended reader still holds its group"); + assert!(!later.demand().wanted(), "the ended reader still holds a later group"); } /// A malformed object mid-group, found while looking ahead past a blocked group, ends the diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 02cae7143af..a92605fdae7 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2161,6 +2161,7 @@ impl Handle { Demand { sequence: self.info.sequence, state: self.state.weak(), + prev: None, } } @@ -2179,11 +2180,23 @@ impl Handle { /// A cloneable, watch-only handle to a group's readers. /// /// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps -/// its abort reason. -#[derive(Clone)] +/// its abort reason. Each clone watches on its own, so its first +/// [`poll_wanted_changed`](Self::poll_wanted_changed) reports the current value. pub struct Demand { sequence: u64, state: kio::ProducerWeak, + // The value `poll_wanted_changed` last returned, or `None` before it returned any. + prev: Option, +} + +impl Clone for Demand { + fn clone(&self) -> Self { + Self { + sequence: self.sequence, + state: self.state.clone(), + prev: None, + } + } } impl Demand { @@ -2193,30 +2206,34 @@ impl Demand { } /// Whether the group is open and has readers right now. - pub fn is_used(&self) -> bool { + pub fn wanted(&self) -> bool { !self.state.is_closed() && self.state.is_used() } - /// Wait until at least one reader needs the group. - #[cfg(feature = "async")] - pub async fn used(&self) -> Result<()> { - kio::wait(|waiter| self.poll_used(waiter)).await - } - - /// Wait until no reader needs the group. + /// Return [`wanted`](Self::wanted) once it differs from what this handle last returned: + /// immediately on the first call, then on each change. Errors once the group is closed. #[cfg(feature = "async")] - pub async fn unused(&self) -> Result<()> { - kio::wait(|waiter| self.poll_unused(waiter)).await + pub async fn wanted_changed(&mut self) -> Result { + kio::wait(|waiter| self.poll_wanted_changed(waiter)).await } - /// Poll until at least one reader needs the group. - pub fn poll_used(&self, waiter: &kio::Waiter) -> Poll> { - self.state.poll_used(waiter).map_err(|_| self.abort_reason()) - } - - /// Poll until no reader needs the group. - pub fn poll_unused(&self, waiter: &kio::Waiter) -> Poll> { - self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) + /// Poll for [`wanted`](Self::wanted) to differ from what this handle last returned. + pub fn poll_wanted_changed(&mut self, waiter: &kio::Waiter) -> Poll> { + loop { + if self.state.is_closed() { + return Poll::Ready(Err(self.abort_reason())); + } + let wanted = self.state.is_used(); + if self.prev != Some(wanted) { + self.prev = Some(wanted); + return Poll::Ready(Ok(wanted)); + } + // Wait for the opposite; closing fails either wait, which errors above. + let _ = ready!(match wanted { + true => self.state.poll_unused(waiter), + false => self.state.poll_used(waiter), + }); + } } /// Wait until the group closes, returning its cause. @@ -3327,22 +3344,31 @@ mod test { } #[test] - #[cfg(feature = "async")] fn demand_watches_readers_without_keeping_the_group_alive() { + let waiter = kio::Waiter::noop(); let producer = Info { sequence: 7 }.produce(); - let demand = producer.demand(); + let mut demand = producer.demand(); assert_eq!(demand.sequence(), 7); - assert!(!demand.is_used()); + assert!(!demand.wanted()); + assert!(matches!(demand.poll_wanted_changed(&waiter), Poll::Ready(Ok(false)))); + assert!(demand.poll_wanted_changed(&waiter).is_pending()); + let first = producer.consume(); let second = first.clone(); - assert!(demand.is_used()); + assert!(demand.wanted()); + assert!(matches!(demand.poll_wanted_changed(&waiter), Poll::Ready(Ok(true)))); drop(first); - assert!(demand.poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(demand.poll_wanted_changed(&waiter).is_pending()); + + // A clone reports the current value first, whatever the original last returned. + let mut clone = demand.clone(); + assert!(matches!(clone.poll_wanted_changed(&waiter), Poll::Ready(Ok(true)))); + drop(second); - assert!(matches!(demand.poll_unused(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_wanted_changed(&waiter), Poll::Ready(Ok(false)))); drop(producer); - assert!(!demand.is_used()); - assert!(matches!(demand.used().now_or_never(), Some(Err(Error::Dropped)))); + assert!(!demand.wanted()); + assert!(matches!(demand.poll_wanted_changed(&waiter), Poll::Ready(Err(Error::Dropped)))); } #[test] @@ -4563,7 +4589,7 @@ mod test { let group = frame.group(); assert_eq!(group.info().sequence, 3); assert_eq!(group.frame_count(), 2, "the open frame counts"); - assert!(group.demand().is_used()); + assert!(group.demand().wanted()); assert!(group.poll_closed(&kio::Waiter::noop()).is_pending()); frame.write(Bytes::from_static(b"b")).unwrap(); diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index 1609c164f85..bf81a4ef9d7 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -89,7 +89,12 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage drop(fetched); // The publisher stops serving the group: nobody reads it any more. - moq_clock::timeout(Duration::from_secs(5), group.demand().unused()) + let mut demand = group.demand(); + let unwanted = async move { + while demand.wanted_changed().await? {} + Ok::<_, moq_net::Error>(()) + }; + moq_clock::timeout(Duration::from_secs(5), unwanted) .await .unwrap_or_else(|_| panic!("{ctx}: the publisher still serves the fetch")) .unwrap(); From 16d74ae2a50af892750f74dad136328999cb9c32 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:00:39 -0700 Subject: [PATCH 11/58] refactor(net)!: auth::Credential in Rust, JS, and the FFI MoqAuthToken is MoqCredential, and each wrapper's AuthToken is Credential. Co-Authored-By: Claude Opus 5.5 --- cpp/obs/src/moq-output.cpp | 2 +- cpp/obs/src/moq-output.h | 2 +- dart/moq/lib/src/aliases.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 76 ++++++++++--------- doc/lib/cpp/index.md | 2 +- doc/lib/dart/index.md | 2 +- doc/lib/go/index.md | 2 +- doc/lib/js/net.md | 2 +- doc/lib/kt/index.md | 2 +- doc/lib/py/index.md | 2 +- doc/lib/swift/index.md | 2 +- go/wrapper/session.go | 18 ++--- js/net/src/auth.ts | 8 +- js/net/src/auth_session.ts | 10 +-- js/net/src/hop.ts | 6 +- js/net/src/ietf/publisher.test.ts | 10 +-- js/net/src/ietf/publisher.ts | 14 ++-- .../kotlin/dev/moq/Aliases.kt | 2 +- py/moq-rs/README.md | 2 +- py/moq-rs/docs/index.md | 2 +- py/moq-rs/moq/__init__.py | 4 +- py/moq-rs/moq/session.py | 12 +-- quest/m1/auth/README.md | 2 +- rs/moq-ffi/src/auth.rs | 18 ++--- rs/moq-net/benches/auth.rs | 2 +- rs/moq-net/src/auth.rs | 42 +++++----- rs/moq-net/src/client.rs | 2 +- rs/moq-net/src/ietf/publisher.rs | 12 +-- rs/moq-net/src/ietf/subscriber.rs | 8 +- rs/moq-net/src/lite/session.rs | 2 +- rs/moq-net/src/model/origin.rs | 8 +- rs/moq-relay/benches/auth.rs | 2 +- swift/Sources/Moq/Auth.swift | 12 +-- 33 files changed, 148 insertions(+), 146 deletions(-) diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 0792dd17939..a0f081fc656 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -534,7 +534,7 @@ bool MoQOutput::PresentToken(const std::string &token) // Replacing `presenting` cancels a presentation still in flight, which the // newer token supersedes. attempt->presenting = attempt->session->auth()->add(token).then( - worker.Executor(), Current>>( + worker.Executor(), Current>>( attempt, [this](const std::shared_ptr ¤t, auto result) { if (!result) { const std::string reason = result.error().to_string(); diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index 036a7d580bb..7e43b3b980f 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -107,7 +107,7 @@ class MoQOutput { // The latest token PresentToken() got accepted, presented on every reconnect // until it is replaced or the attempt is dropped, and the call presenting the // next one. - std::shared_ptr token; + std::shared_ptr token; std::optional presenting; }; diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 9544a11d095..e716beccb81 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -154,7 +154,7 @@ typedef ConnectionStats = MoqConnectionStats; typedef Auth = MoqAuth; /// One token presented with [Auth.add]; cancel it to withdraw the token. -typedef AuthToken = MoqAuthToken; +typedef Credential = MoqCredential; /// What the peer lets this side do: publish and subscribe patterns, and an expiry. typedef Grant = MoqGrant; diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 1ccba994936..302f8a47fb7 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,7 +8,9 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; + import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -8525,7 +8527,7 @@ class FfiConverterMoqAudioProducer { } abstract class MoqAuthInterface { - UniffiRustFuture add({required String token}); + UniffiRustFuture add({required String token}); MoqGrant? grant(); UniffiRustFuture grantChanged(); } @@ -8559,7 +8561,7 @@ class MoqAuth implements MoqAuthInterface { rustCall((status) => uniffi_moq_ffi_fn_free_moqauth(_ptr, status)); } - UniffiRustFuture add({required String token}) { + UniffiRustFuture add({required String token}) { return uniffiRustCallAsync( () => uniffi_moq_ffi_fn_method_moqauth_add( uniffiClonePointer(), @@ -8569,7 +8571,7 @@ class MoqAuth implements MoqAuthInterface { ffi_moq_ffi_rust_future_complete_u64, ffi_moq_ffi_rust_future_cancel_u64, ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqAuthToken.lift(Pointer.fromAddress(ptr)), + (ptr) => FfiConverterMoqCredential.lift(Pointer.fromAddress(ptr)), moqExceptionErrorHandler, ); } @@ -8623,31 +8625,31 @@ class FfiConverterMoqAuth { } } -abstract class MoqAuthTokenInterface { +abstract class MoqCredentialInterface { void cancel(); UniffiRustFuture closed(); MoqGrant? grant(); } -final _MoqAuthTokenFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqauthtoken(ptr, status)); +final _MoqCredentialFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqcredential(ptr, status)); }); -class MoqAuthToken implements MoqAuthTokenInterface { +class MoqCredential implements MoqCredentialInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqAuthToken._(this._ptr) { - _MoqAuthTokenFinalizer.attach(this, _ptr, detach: this); + MoqCredential._(this._ptr) { + _MoqCredentialFinalizer.attach(this, _ptr, detach: this); } - factory MoqAuthToken.lift(Pointer ptr) { - return MoqAuthToken._(ptr); + factory MoqCredential.lift(Pointer ptr) { + return MoqCredential._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqauthtoken(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqcredential(_ptr, status), ); } @@ -8656,13 +8658,13 @@ class MoqAuthToken implements MoqAuthTokenInterface { return; } _isDisposed = true; - _MoqAuthTokenFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqauthtoken(_ptr, status)); + _MoqCredentialFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqcredential(_ptr, status)); } void cancel() { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqauthtoken_cancel( + uniffi_moq_ffi_fn_method_moqcredential_cancel( uniffiClonePointer(), status, ); @@ -8671,7 +8673,7 @@ class MoqAuthToken implements MoqAuthTokenInterface { UniffiRustFuture closed() { return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqauthtoken_closed(uniffiClonePointer()), + () => uniffi_moq_ffi_fn_method_moqcredential_closed(uniffiClonePointer()), ffi_moq_ffi_rust_future_poll_void, ffi_moq_ffi_rust_future_complete_void, ffi_moq_ffi_rust_future_cancel_void, @@ -8683,7 +8685,7 @@ class MoqAuthToken implements MoqAuthTokenInterface { MoqGrant? grant() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqauthtoken_grant( + (status) => uniffi_moq_ffi_fn_method_moqcredential_grant( uniffiClonePointer(), status, ), @@ -8693,26 +8695,26 @@ class MoqAuthToken implements MoqAuthTokenInterface { } } -class FfiConverterMoqAuthToken { - static MoqAuthToken lift(Pointer ptr) { - return MoqAuthToken.lift(ptr); +class FfiConverterMoqCredential { + static MoqCredential lift(Pointer ptr) { + return MoqCredential.lift(ptr); } - static Pointer lower(MoqAuthToken value) { + static Pointer lower(MoqCredential value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqAuthToken value) { + static int allocationSize(MoqCredential value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqAuthToken.lift(pointer), 8); + return LiftRetVal(MoqCredential.lift(pointer), 8); } - static int write(MoqAuthToken value, Uint8List buf) { + static int write(MoqCredential value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -16631,7 +16633,7 @@ external Pointer uniffi_moq_ffi_fn_method_moqauth_grant_changed( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqauthtoken( +external Pointer uniffi_moq_ffi_fn_clone_moqcredential( Pointer handle, Pointer uniffiStatus, ); @@ -16639,7 +16641,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqauthtoken( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqauthtoken( +external void uniffi_moq_ffi_fn_free_moqcredential( Pointer handle, Pointer uniffiStatus, ); @@ -16647,20 +16649,20 @@ external void uniffi_moq_ffi_fn_free_moqauthtoken( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqauthtoken_cancel( +external void uniffi_moq_ffi_fn_method_moqcredential_cancel( Pointer ptr, Pointer uniffiStatus, ); @Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqauthtoken_closed( +external Pointer uniffi_moq_ffi_fn_method_moqcredential_closed( Pointer ptr, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqauthtoken_grant( +external RustBuffer uniffi_moq_ffi_fn_method_moqcredential_grant( Pointer ptr, Pointer uniffiStatus, ); @@ -19451,13 +19453,13 @@ external int uniffi_moq_ffi_checksum_method_moqauth_grant(); external int uniffi_moq_ffi_checksum_method_moqauth_grant_changed(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqauthtoken_cancel(); +external int uniffi_moq_ffi_checksum_method_moqcredential_cancel(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqauthtoken_closed(); +external int uniffi_moq_ffi_checksum_method_moqcredential_closed(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqauthtoken_grant(); +external int uniffi_moq_ffi_checksum_method_moqcredential_grant(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbandwidth_reserve(); @@ -20150,7 +20152,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqaudioproducer_write() != 48796) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauth_add() != 1074) { + if (uniffi_moq_ffi_checksum_method_moqauth_add() != 56582) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauth_grant() != 14588) { @@ -20159,13 +20161,13 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 38858) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauthtoken_cancel() != 59324) { + if (uniffi_moq_ffi_checksum_method_moqcredential_cancel() != 47778) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauthtoken_closed() != 40912) { + if (uniffi_moq_ffi_checksum_method_moqcredential_closed() != 21378) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauthtoken_grant() != 31654) { + if (uniffi_moq_ffi_checksum_method_moqcredential_grant() != 23328) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbandwidth_reserve() != 27807) { diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index d26680b96e6..9cc27f99429 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -205,7 +205,7 @@ Obtain a dynamic track request’s `demand()` before accepting it to watch subsc ## Authorization -On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to an `AuthToken` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. +On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to a `Credential` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. ## Stats diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index f9aafe9df3b..a20551d32d5 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -144,7 +144,7 @@ shows which module holds each. - **Closing.** `await moq.close()` drains for up to one second and reports its result. `session.shutdown()` requests the drain synchronously; `await session.closed()` waits for its result. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Awaiting cancellation of an announcement, grant, or request subscription releases its pending native read. Announcements also release their cursor; authorization and server listeners leave their shared owner usable. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns a `Credential` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 703a20b27c7..95a8b62e64c 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -134,7 +134,7 @@ shows which module holds each. - **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown()` requests that drain immediately; `session.Closed(ctx)` waits for its result, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `Finish()` on its `media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.Close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol. `Dial` returns a connecting session; read `Session().State()` and wait with `StateChanged(ctx, state.Revision)` to observe first-dial failures and reconnects. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Rate()` reads the current estimate in bits per second and `RateChanged(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns a `Credential` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `Preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `Demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index bb0294939f4..960bef56124 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -47,7 +47,7 @@ broadcast.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 781f0fb57f7..f459b22b6c7 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -104,7 +104,7 @@ shows which module holds each. - **Closing.** `moq.shutdown()` requests a drain immediately; suspending `moq.session.closed()` waits up to one second and reports its result. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.end()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns a `Credential` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 1799e4881d2..d870b900bea 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -118,7 +118,7 @@ shows which module holds each. - **Closing.** `session.shutdown()` requests a drain immediately; `await session.closed()` waits up to one second and reports its result. `cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `moq.media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `Bandwidth.Consumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rate_changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns a `Credential` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index b237f59c900..c173654d6f7 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -104,7 +104,7 @@ shows which module holds each. - **Closing.** `session.shutdown()` requests a drain immediately; `try await session.closed()` waits up to one second and reports its result. `session.cancel(code:)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `try finish()` on its `Media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `Bandwidth.Consumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns a `Credential` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/go/wrapper/session.go b/go/wrapper/session.go index b631459a981..84868b3b679 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -105,35 +105,35 @@ func (a *Auth) GrantChanged(ctx context.Context) (*Grant, error) { // ProtocolError carrying its code; a session that cannot carry another token // returns an error for which errors.Is(err, ErrUnsupported) holds. Cancelling ctx // withdraws the token. -func (a *Auth) Add(ctx context.Context, token string) (*AuthToken, error) { - inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqAuthToken, error) { +func (a *Auth) Add(ctx context.Context, token string) (*Credential, error) { + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqCredential, error) { return a.inner.Add(ctx, token) }) if err != nil { return nil, err } - return &AuthToken{inner: inner}, nil + return &Credential{inner: inner}, nil } -// AuthToken is one token presented with Auth.Add. -type AuthToken struct { - inner *ffi.MoqAuthToken +// Credential is one token presented with Auth.Add. +type Credential struct { + inner *ffi.MoqCredential } // Grant returns this token's own grant, or nil after it ended. -func (t *AuthToken) Grant() *Grant { +func (t *Credential) Grant() *Grant { return grantFromFFI(t.inner.Grant()) } // Closed blocks until the token ends, returning the peer's revocation or the end // of a client's connection or a server's session, or nil when the peer closed it // without an error. A reconnect does not end it. -func (t *AuthToken) Closed(ctx context.Context) error { +func (t *Credential) Closed(ctx context.Context) error { return bridge.CallErr(ctx, nil, t.inner.Closed) } // Close withdraws the token, removing its grant from the union. -func (t *AuthToken) Close() { t.inner.Cancel() } +func (t *Credential) Close() { t.inner.Cancel() } // Bandwidth divides one connection's send estimate among the tracks sharing it. type Bandwidth struct { diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 0a4b2685a09..622271afe13 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -8,11 +8,11 @@ * undefined. There {@link Handle.add} rejects with {@link Unsupported} on moq-lite, and on * moq-transport keeps the token, with no grant, for the announcements whose route names it. * {@link Handle.grant} is the union of every token this side presented, and {@link Handle.add} - * presents another without reconnecting, returning its {@link Auth}. + * presents another without reconnecting, returning its {@link Credential}. * * A {@link Handle} passed to `connect` as `auth`, or the one a reconnecting `Connection` owns, * outlives its sessions: each presents every token it holds, so a route naming one of its - * {@link Auth}s keeps it across a reconnect. Only the peer's verdict ends such a token; a + * {@link Credential}s keeps it across a reconnect. Only the peer's verdict ends such a token; a * session ending leaves it for the next. Mirrors the Rust `moq_net::auth`. * * @module @@ -40,9 +40,9 @@ export interface Grant { /** * A token this side presented, which a route can name, on every session of its handle. - * {@link Auth.close} withdraws it. + * {@link Credential.close} withdraws it. */ -export interface Auth { +export interface Credential { /** * This token's own grant on the live session: undefined until its peer answers, while no * session is live, and again once it ends. diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 351f5d57347..f944ce9b985 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -1,6 +1,6 @@ import { Instant, Milli, Nano, now, sleep, wall } from "@moq/clock"; import { type Dispose, type Getter, Once, race, Signal } from "@moq/signals"; -import type { Auth, Grant, Issued, Request, Requests } from "./auth.ts"; +import type { Credential, Grant, Issued, Request, Requests } from "./auth.ts"; import { closeReason, error, ProtocolViolation, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import * as Path from "./path.ts"; import type { Reader, Stream, Writer } from "./stream.ts"; @@ -147,7 +147,7 @@ class Presented { } /** A token the application added to a {@link Handle}, presented on every session of it. */ -class Token implements Auth { +class Token implements Credential { readonly grant = new Signal(undefined); readonly closed: Promise; readonly answered: Promise; @@ -319,7 +319,7 @@ export function ended(handle: Handle): Error | undefined { * * A session made without one creates its own, returned by `Established.auth`, which ends with * that session. Pass one to `connect` as `auth` instead and it outlives its sessions: each - * presents every token the handle holds, so an {@link Auth} it returned keeps riding a route's + * presents every token the handle holds, so a {@link Credential} it returned keeps riding a route's * announcements across a reconnect. It backs one active session at a time, plus the ones * draining after a GOAWAY or a `close()`. Mirrors the Rust `moq_net::auth::Handle`. */ @@ -347,7 +347,7 @@ export class Handle { * handle's end. On moq-transport without AUTH it resolves at once with no grant: only a * request whose route names it carries it. */ - async add(token: string | Uint8Array): Promise { + async add(token: string | Uint8Array): Promise { // A copy, so the caller reusing its buffer never changes what a request carries. const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); const added = stateOf(this).add(bytes); @@ -673,7 +673,7 @@ export class AuthSession { * The bytes a request carries for `token`: undefined for none, for one another handle * issued, or for one that ended here. */ - carried(token: Auth | undefined): Uint8Array | undefined { + carried(token: Credential | undefined): Uint8Array | undefined { if (!this.#requests || !(token instanceof Token)) return undefined; const presented = this.#byToken.get(token); if (!presented || presented.ended || presented.withdrawn) return undefined; diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index 4cd49d41d13..a978f13ae71 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -8,7 +8,7 @@ * @module */ import * as z from "zod/mini"; -import type { Auth } from "./auth.ts"; +import type { Credential } from "./auth.ts"; import type * as Epoch from "./epoch.ts"; import { isLeadingOnes, type StreamVersion } from "./stream.ts"; @@ -116,7 +116,7 @@ export interface Route { * Swapping it renews the request with REQUEST_UPDATE from draft 16, or ends and makes it * again before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. */ - auth?: Auth; + auth?: Credential; } /** Constructors and helpers for {@link Route}. */ @@ -125,7 +125,7 @@ export const Route = { default: { hops: [], cost: Cost.zero } as Route, /** Fill route defaults and saturate the static price at the wire ceiling. */ - normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Auth } = {}): Route { + normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Credential } = {}): Route { const cost = route.cost ?? Cost.zero; if (typeof cost !== "bigint" || cost < 0n) throw new RangeError("route cost must be a non-negative bigint"); const max = 2n ** 62n - 1n; diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index d125d687742..6d48ad1e388 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1,7 +1,7 @@ import { expect, jest, onTestFinished, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { Signal } from "@moq/signals"; -import type { Auth, Grant } from "../auth.ts"; +import type { Credential, Grant } from "../auth.ts"; import { Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { error, ProtocolViolation, SessionCode, SessionError, StreamCode, unauthorized } from "../error.ts"; @@ -1968,10 +1968,10 @@ function republish(origin: OriginProducer, old: BroadcastProducer) { /** The tokens one session issued, each carried as its own bytes. */ class Tokens { readonly ended = new Signal(0); - #issued = new Map(); + #issued = new Map(); - issue(value: string): Auth { - const token: Auth = { + issue(value: string): Credential { + const token: Credential = { grant: new Signal(undefined), closed: new Promise(() => {}), close: () => { @@ -1983,7 +1983,7 @@ class Tokens { return token; } - carried(token: Auth | undefined): Uint8Array | undefined { + carried(token: Credential | undefined): Uint8Array | undefined { return token && this.#issued.get(token); } } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 882537e4bff..399ab989021 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,6 +1,6 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { type Dispose, type Getter, Once, race, Signal } from "@moq/signals"; -import type { Auth, Grant } from "../auth.ts"; +import type { Credential, Grant } from "../auth.ts"; import { Gate } from "../auth_gate.ts"; import type { AuthSession } from "../auth_session.ts"; import { enforceGrant } from "../auth_session.ts"; @@ -128,7 +128,7 @@ interface Reprice { from?: Cluster.Advert; to?: Cluster.Advert; /** The token the request should carry instead, when it changed. */ - token?: Auth; + token?: Credential; } /** Whether two hop paths list the same hops in the same order. */ @@ -170,7 +170,7 @@ type NamespaceRequest = { path: Path.Valid; requestId: bigint; stream: RequestStream; - auth?: Auth; + auth?: Credential; reply?: Promise; updating?: boolean; }; @@ -192,7 +192,7 @@ interface Namespaces { /** The bytes a request carries for a token this session issued. */ interface Carrier { - carried(token: Auth | undefined): Uint8Array | undefined; + carried(token: Credential | undefined): Uint8Array | undefined; /** Bumps whenever one of the session's tokens ends, so a request carrying it drops it. */ readonly ended: Getter; } @@ -216,10 +216,10 @@ interface Target { withdraw(key: Path.Valid): Promise; /** The token the peer holds a namespace under, or undefined when its advertisement carries none. */ - held(key: Path.Valid): Auth | undefined; + held(key: Path.Valid): Credential | undefined; /** The token an advertisement naming `auth` carries: only a request can carry one. */ - carry(auth: Auth | undefined): Auth | undefined; + carry(auth: Credential | undefined): Credential | undefined; } /** What {@link Publisher.runGroup} needs to serve one group. */ @@ -1872,7 +1872,7 @@ export class Publisher { } /** `auth` when a request of this session can carry it. */ - #carry(auth: Auth | undefined): Auth | undefined { + #carry(auth: Credential | undefined): Credential | undefined { return this.#auth?.carried(auth) === undefined ? undefined : auth; } diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index b10eba5aa94..a755b05cb2e 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -158,7 +158,7 @@ typealias ConnectionStats = uniffi.moq.MoqConnectionStats /** A session's authorization: the grant its peer gave it, and more tokens to present. */ typealias Auth = uniffi.moq.MoqAuth /** One token presented with [Auth.add]; cancel it to withdraw the token. */ -typealias AuthToken = uniffi.moq.MoqAuthToken +typealias Credential = uniffi.moq.MoqCredential /** What the peer lets this side do: publish and subscribe patterns, and an expiry. */ typealias Grant = uniffi.moq.MoqGrant /** A connection lifecycle transition reported by [Session.state]. */ diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index b64c16d640e..c2480e80e85 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -159,7 +159,7 @@ client = moq.Client( - `.stats() → ConnectionStats`. Snapshot RTT, bandwidth estimates, and byte/packet counters. - `.state()` and `await .state_changed(after)`. Read one lifecycle snapshot and wait with its revision; first-dial failures and reconnects share the same session. - `.bandwidth() → Bandwidth`. Divide the send estimate between encoders and app-owned tracks. - - `.auth() → Auth`. On `moq-lite-07-wip` and on moq-transport draft-17+ with the MoQ Auth extension, `.grant()` reads the peer's `Grant` and `await .add(token) → AuthToken` presents a fresh token without reconnecting. + - `.auth() → Auth`. On `moq-lite-07-wip` and on moq-transport draft-17+ with the MoQ Auth extension, `.grant()` reads the peer's `Grant` and `await .add(token) → Credential` presents a fresh token without reconnecting. ### Publishing diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 2e05799eede..3cce33c2f80 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -45,7 +45,7 @@ asyncio.run(main()) Request Transport Auth - AuthToken + Credential Grant ``` diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 4121392f893..3f7e04a5b71 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -41,7 +41,7 @@ TrackRequest, ) from .server import Request, Server, Transport -from .session import Auth, AuthToken, Bandwidth, Reservation, Session +from .session import Auth, Bandwidth, Credential, Reservation, Session from .subscribe import ( BroadcastConsumer, GroupConsumer, @@ -95,7 +95,6 @@ "AudioEncoderInput", "AudioSampleFormat", "Auth", - "AuthToken", "Backoff", "Bandwidth", "BroadcastConsumer", @@ -109,6 +108,7 @@ "ConnectionStats", "ConnectionPhase", "ConnectionState", + "Credential", "Datagram", "Error", "ErrorScope", diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index a0fe61bfd61..83deaf68fce 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -2,7 +2,7 @@ from __future__ import annotations -from moq_ffi import MoqAuth, MoqAuthToken, MoqBandwidth, MoqBandwidthConsumer, MoqReservation, MoqSession +from moq_ffi import MoqAuth, MoqCredential, MoqBandwidth, MoqBandwidthConsumer, MoqReservation, MoqSession from .origin import OriginConsumer, OriginProducer from .publish import TrackDemand @@ -143,19 +143,19 @@ async def grant_changed(self) -> Grant | None: session ended. A reconnect is not an end.""" return Grant._from_ffi(await self._inner.grant_changed()) - async def add(self, token: str) -> AuthToken: + async def add(self, token: str) -> Credential: """Present another token, returning once the peer accepts it. - Its grant joins the union until :meth:`AuthToken.cancel`. The peer's refusal + Its grant joins the union until :meth:`Credential.cancel`. The peer's refusal raises ``moq.Error`` carrying its code; a session that cannot carry another token raises ``moq.Error.Unsupported``.""" - return AuthToken(await self._inner.add(token)) + return Credential(await self._inner.add(token)) -class AuthToken: +class Credential: """One token presented with :meth:`Auth.add`, withdrawn by :meth:`cancel`.""" - def __init__(self, inner: MoqAuthToken) -> None: + def __init__(self, inner: MoqCredential) -> None: self._inner = inner def grant(self) -> Grant | None: diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index d5f3f2864fb..cd5cbe30a49 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -132,7 +132,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the IETF binding of lite's routing. - **Naming.** Messages are AUTH / AUTH_OK / AUTH_ERROR like SUBSCRIBE / SUBSCRIBE_OK. Rust is `moq_net::auth` with `auth::Grant`, `auth::Handle`, - `auth::Token`, and `auth::Request`; JS mirrors as `connection.auth`. + `auth::Credential`, and `auth::Request`; JS mirrors as `connection.auth`. `Session::auth()` is additive. The relay derives the grant from the origin handles it already scopes. Decided 2026-10-05: wire diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index a39917dabb1..c194c5af92e 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -33,7 +33,7 @@ impl From for MoqGrant { } /// Present `token` on every session of `handle`, resolving once a peer accepts it. -async fn add(handle: moq_net::auth::Handle, token: String) -> Result { +async fn add(handle: moq_net::auth::Handle, token: String) -> Result { // Without AUTH, moq-transport accepts a token only for the requests that name it, but // nothing here can name one, so it would never be sent. The answer says how the peer // accepted it, which a grant missing after its session drops cannot. @@ -104,17 +104,17 @@ impl MoqAuth { /// Present another token, resolving once the peer accepts it. /// - /// Its grant joins the union until [`MoqAuthToken::cancel`]. A client session presents it + /// Its grant joins the union until [`MoqCredential::cancel`]. A client session presents it /// again on every reconnect. The peer's refusal is a [`MoqError::Protocol`] carrying its code. /// /// # Errors /// /// [`MoqError::Unsupported`] when the session cannot carry another token: a version /// without AUTH, or moq-transport without the MoQ Auth extension. - pub async fn add(&self, token: String) -> Result, MoqError> { + pub async fn add(&self, token: String) -> Result, MoqError> { let handle = self.handle.clone(); let token = crate::ffi::detached(add(handle, token)).await?; - Ok(Arc::new(MoqAuthToken::new(token))) + Ok(Arc::new(MoqCredential::new(token))) } } @@ -122,14 +122,14 @@ impl MoqAuth { /// /// It stays presented until [`cancel`](Self::cancel), or until the handle is released. #[derive(uniffi::Object)] -pub struct MoqAuthToken { +pub struct MoqCredential { /// The watch behind [`grant`](Self::grant), which does not keep the token presented. peek: moq_net::auth::Watch, - token: Task, + token: Task, } -impl MoqAuthToken { - fn new(token: moq_net::auth::Auth) -> Self { +impl MoqCredential { + fn new(token: moq_net::auth::Credential) -> Self { Self { peek: token.grant(), token: Task::new(token), @@ -138,7 +138,7 @@ impl MoqAuthToken { } #[uniffi::export] -impl MoqAuthToken { +impl MoqCredential { /// This token's own grant, or `None` while the peer has not answered it or after it ended. pub fn grant(&self) -> Option { let _guard = crate::ffi::enter(); diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 584fbdefece..586bd3ced01 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -188,7 +188,7 @@ fn renewal(c: &mut Criterion) { let broadcasts: Vec<_> = (0..routes) .map(|route| origin.create_broadcast(format!("room/{route}").as_str()).unwrap()) .collect(); - let mut swap = |auth: &auth::Auth, touched: usize| { + let mut swap = |auth: &auth::Credential, touched: usize| { for broadcast in broadcasts.iter().take(touched) { broadcast .announce(origin::Route::default().with_auth(auth.clone())) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 92bab3af116..a61279b6f7d 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -5,12 +5,12 @@ //! setup, and learns the [`Grant`] it earned. `Session::auth` //! returns the [`Handle`]: [`grant`](Handle::grant) is the union of every token //! this side presented, and `add` presents another without -//! reconnecting. The returned [`Auth`] is cloneable; dropping its last clone withdraws it. +//! reconnecting. The returned [`Credential`] is cloneable; dropping its last clone withdraws it. //! A [`Route`](crate::origin::Route) can name it, so the request advertising the route //! carries it too, and swapping it renews that request in place. //! //! A handle passed to `Client::with_auth` outlives its sessions: each session built on it -//! presents every token it holds, so a route naming one of its [`Auth`]s keeps it across a +//! presents every token it holds, so a route naming one of its [`Credential`]s keeps it across a //! reconnect. Only the peer's verdict ends such a token; a session ending leaves it for the //! next. It backs one active session at a time, plus the ones draining after a GOAWAY. //! @@ -237,7 +237,7 @@ struct Slot { retry: Option, /// Why the token ended on this session, once it has. ended: Option, - /// The last [`Auth`] clone dropped: the driver closes the stream. + /// The last [`Credential`] clone dropped: the driver closes the stream. withdrawn: bool, } @@ -334,7 +334,7 @@ impl Attached { self.recompute(); } - /// The last [`Auth`] clone for token `id` dropped. + /// The last [`Credential`] clone for token `id` dropped. fn withdraw(&mut self, id: u64) { let Some(slot) = self.slots.get_mut(&id) else { return; @@ -496,7 +496,7 @@ impl Permit { /// /// A session made without one creates its own, returned by `Session::auth`, which ends /// with that session. Pass one to `Client::with_auth` instead and it outlives its -/// sessions: each presents every token the handle holds, so an [`Auth`] it issued keeps +/// sessions: each presents every token the handle holds, so a [`Credential`] it issued keeps /// riding a route's announcements across a reconnect. It backs one active session at a /// time, plus the ones draining after a GOAWAY. /// @@ -522,7 +522,7 @@ impl Handle { /// Present another token on every session of this handle, resolving once a peer accepts it /// with how it did. /// - /// The grant joins the union while any clone of the returned [`Auth`] lives. With no + /// The grant joins the union while any clone of the returned [`Credential`] lives. With no /// session live it waits for the next one. A refusal that may pass, such as an /// unreachable auth server, is presented again after a backoff. On moq-transport /// without AUTH there is no answer to wait for: it resolves at once with @@ -535,14 +535,14 @@ impl Handle { /// (moq-lite without AUTH, or a peer that takes no tokens in band), the peer's refusal /// as [`Error::Session`], or the handle's end. #[cfg(feature = "async")] - pub async fn add(&self, token: impl Into) -> Result<(Auth, Answer)> { + pub async fn add(&self, token: impl Into) -> Result<(Credential, Answer)> { let auth = self.present(token.into())?; let answer = kio::wait(|waiter| auth.poll_answered(waiter)).await?; Ok((auth, answer)) } /// Register a token and queue it on every open session. - pub(crate) fn present(&self, token: Bytes) -> Result { + pub(crate) fn present(&self, token: Bytes) -> Result { let mut guard = self.state.lock(); let state = &mut *guard; if let Some(err) = &state.closed { @@ -570,7 +570,7 @@ impl Handle { for answer in answers { state.answer(id, answer.map(|()| Answer::Requests)); } - Ok(Auth { + Ok(Credential { presented: std::sync::Arc::new(Presented { state: self.state.clone(), id, @@ -831,7 +831,7 @@ impl Link { /// Present a token: the session's own credential at setup, presented on this session /// alone, or else one of the handle's, presented on every session. - pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { + pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { if !setup { return self.handle.present(token); } @@ -853,7 +853,7 @@ impl Link { state.next_id += 1; Ok(id) })?; - Ok(Auth { + Ok(Credential { presented: std::sync::Arc::new(Presented { state: self.state().clone(), id, @@ -865,7 +865,7 @@ impl Link { /// The token a request carries for `auth`, or `None` when the request goes out unnamed /// under the session's scope: `auth` came from another handle, it ended here, or /// requests carry no token on this version. - pub(crate) fn token(&self, auth: &Auth) -> Option { + pub(crate) fn token(&self, auth: &Credential) -> Option { if !auth.presented.state.same_channel(self.state()) { return None; } @@ -1270,19 +1270,19 @@ fn attached(links: &mut BTreeMap, id: u64) -> &mut Attached { /// removes its grant from the union. A [`Route`](crate::origin::Route) holding one keeps /// it presented while the route stands, across the handle's sessions. #[derive(Clone)] -pub struct Auth { +pub struct Credential { presented: std::sync::Arc, } #[cfg(test)] -impl Auth { +impl Credential { /// The id the session presents this credential under. pub(crate) fn id(&self) -> u64 { self.presented.id } } -/// The token every clone of one [`Auth`] shares, withdrawn when the last drops. +/// The token every clone of one [`Credential`] shares, withdrawn when the last drops. struct Presented { state: kio::Shared, id: u64, @@ -1300,7 +1300,7 @@ impl Presented { } } -impl Auth { +impl Credential { /// This credential's own grant on the live session: `None` until its peer answers, /// while no session is live, forever on moq-transport without AUTH, and again once /// the credential ends. @@ -1342,18 +1342,18 @@ impl Auth { } } -impl PartialEq for Auth { +impl PartialEq for Credential { fn eq(&self, other: &Self) -> bool { std::sync::Arc::ptr_eq(&self.presented, &other.presented) } } -impl Eq for Auth {} +impl Eq for Credential {} -impl std::fmt::Debug for Auth { +impl std::fmt::Debug for Credential { // Never the token: routes are logged, and a credential must not be. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("Auth").field(&self.presented.id).finish() + f.debug_tuple("Credential").field(&self.presented.id).finish() } } @@ -1418,7 +1418,7 @@ impl Selector { /// Watches a grant: the live session's union, or one credential's. /// -/// Created by [`Handle::grant`] and [`Auth::grant`]. +/// Created by [`Handle::grant`] and [`Credential::grant`]. pub struct Watch { state: kio::Shared, selector: Selector, diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index e19b326bef8..8dbfe4b7778 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -118,7 +118,7 @@ impl Client { } /// Build every session on `auth`, so each presents the tokens it holds and a route - /// naming one of its [`Auth`](crate::auth::Auth)s keeps it across a reconnect. Omit to + /// naming one of its [`Credential`](crate::auth::Credential)s keeps it across a reconnect. Omit to /// give each session a handle of its own, which ends with it. /// /// The handle backs one active session at a time, plus the ones draining after a diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 8eef9575a41..0f4ac6f4a2c 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -3500,7 +3500,7 @@ impl Publisher { path: &crate::PathOwned, suffix: crate::PathOwned, params: ietf::AdvertParams, - auth: Option, + auth: Option, ) -> Result { // Bounded, because an advertisement holds its stream for as long as the namespace // lives: a peer whose concurrent-stream limit we have filled refuses this open, and @@ -3620,7 +3620,7 @@ impl Publisher { ns: &Namespaces, suffix: &crate::PathOwned, path: &crate::PathOwned, - ) -> Option<(Advert, Option)> { + ) -> Option<(Advert, Option)> { let watch = ns.watched.get(suffix)?; let advert = match ns.permitted(path) { true => self.select(&watch.route, &ns.peer, ns.epochs), @@ -3639,7 +3639,7 @@ impl Publisher { } /// The `AUTHORIZATION TOKEN` a request names `auth` with, by value. - fn request_token(&self, auth: &crate::auth::Auth) -> Option { + fn request_token(&self, auth: &crate::auth::Credential) -> Option { let token = self.auth.token(auth)?; Some(super::token::RequestToken(crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, @@ -5731,7 +5731,7 @@ struct NamespaceRequest { messages: super::request_stream::Messages, /// The auth the peer holds the request under: the one it opened with, or the one /// its last update carried. Held, so the credential stays presented while it does. - auth: Option, + auth: Option, /// Waiting in [`Namespaces::updates`] for a request ID. queued: bool, /// When to give up on the update in flight, which has no answer yet. @@ -12467,8 +12467,8 @@ mod serve_tests { /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` /// falls to its request token. The presented credential is kept alive by the returned - /// [`crate::auth::Auth`]. - fn auth_covering_other() -> (crate::auth::Link, crate::auth::Requests, crate::auth::Auth) { + /// [`crate::auth::Credential`]. + fn auth_covering_other() -> (crate::auth::Link, crate::auth::Requests, crate::auth::Credential) { let auth = crate::auth::Link::new(true); let requests = auth.handle().requests(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e58bd3dc828..3be2cc57589 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -7299,7 +7299,7 @@ mod tests { Subscriber, crate::lite::test_transport::Log, crate::auth::Link, - Vec, + Vec, crate::util::TaskSet, ) { let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); @@ -7472,7 +7472,7 @@ mod tests { /// A subscriber whose grant from the peer covers everything until `auth.ended(0, ..)` /// revokes it, with the token handle to hold meanwhile. - fn revocable_auth() -> (crate::auth::Link, crate::auth::Auth) { + fn revocable_auth() -> (crate::auth::Link, crate::auth::Credential) { let auth = crate::auth::Link::new(true); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); @@ -10731,7 +10731,7 @@ mod tests { ) -> ( Subscriber, crate::auth::Requests, - crate::auth::Auth, + crate::auth::Credential, origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { @@ -10749,7 +10749,7 @@ mod tests { ) -> ( Subscriber, crate::auth::Requests, - crate::auth::Auth, + crate::auth::Credential, origin::Consumer, S, ) { diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 22fc3155791..d006fb372e3 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -515,7 +515,7 @@ struct Present { /// Whether the first poll decided who answers the peer's tokens. started: bool, /// The connection's own credential, held for the life of the session. - _setup: Option, + _setup: Option, } impl Present { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7bb068b1879..7168357476e 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -513,7 +513,7 @@ pub struct Route { /// REQUEST_UPDATE from draft 16, or ends and makes it again before. A handle passed to /// `Client::with_auth` presents it on every session it backs, so the route keeps it /// across a reconnect. Local, like [`Self::source`]: a relay never forwards it. - pub auth: Option, + pub auth: Option, /// The announcing session's declared or assigned identity. /// @@ -574,7 +574,7 @@ impl Route { } /// Set the credential the advertising request carries; see [`Self::auth`]. - pub fn with_auth(mut self, auth: crate::auth::Auth) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Credential) -> Self { self.auth = Some(auth); self } @@ -698,7 +698,7 @@ fn route_order<'a>( /// The `(hops, cost, source, epoch, auth)` metadata an announce cursor delivers alongside a /// prefix. -type RouteMeta = (Hops, Cost, Source, Option, Option); +type RouteMeta = (Hops, Cost, Source, Option, Option); /// The earlier of two optional deadlines. fn earliest(a: Option, b: Option) -> Option { @@ -1230,7 +1230,7 @@ struct RouteEntry { hops: Hops, cost: Cost, /// The auth the request advertising the route carries; see [`Route::auth`]. - auth: Option, + auth: Option, /// The rank of the link the entry arrived on; see [`Producer::with_preference`]. preference: Preference, /// The announcing session's declared or assigned identity. Split-horizon diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index d0a404be3ae..33e673c33d4 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -16,7 +16,7 @@ fn grant(prefix: &str) -> Grant { struct Fixture { _connections: Vec, - _tokens: Vec, + _tokens: Vec, producers: Vec, selected: usize, watch: moq_net::auth::Watch, diff --git a/swift/Sources/Moq/Auth.swift b/swift/Sources/Moq/Auth.swift index 67c55b1c4e9..c32281ce32c 100644 --- a/swift/Sources/Moq/Auth.swift +++ b/swift/Sources/Moq/Auth.swift @@ -27,20 +27,20 @@ public final class Auth: Sendable { /// Present another token, returning once the peer accepts it. /// - /// Its grant joins the union until `AuthToken.cancel()`; a client session presents + /// Its grant joins the union until `Credential.cancel()`; a client session presents /// it again on every reconnect. The peer's refusal throws `MoqError.Protocol` /// carrying its code, and a session that cannot carry another token throws /// `MoqError.Unsupported`. - public func add(token: String) async throws -> AuthToken { - AuthToken(try await ffi.add(token: token)) + public func add(token: String) async throws -> Credential { + Credential(try await ffi.add(token: token)) } } /// One token presented with `Auth.add(token:)`, withdrawn by `cancel()`. -public final class AuthToken: Sendable { - let ffi: MoqAuthToken +public final class Credential: Sendable { + let ffi: MoqCredential - init(_ ffi: MoqAuthToken) { self.ffi = ffi } + init(_ ffi: MoqCredential) { self.ffi = ffi } /// This token's own grant, or `nil` after it ended. public func grant() -> Grant? { ffi.grant() } From 0963860a54a8e1e20ae32cfb1b707816e35c85b5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:02:11 -0700 Subject: [PATCH 12/58] refactor(net)!: track::Consumer::info returns Pending Mirrors JS Track.Consumer.info(). Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/tests.rs | 6 ++-- rs/moq-cli/tests/integration/epochs.rs | 2 +- rs/moq-hls/src/export/rendition.rs | 4 +-- rs/moq-mux/src/catalog/producer.rs | 2 +- rs/moq-mux/src/import/track.rs | 4 +-- rs/moq-net/src/ietf/publisher.rs | 6 ++-- rs/moq-net/src/ietf/subscriber.rs | 4 +-- rs/moq-net/src/lite/ietf.rs | 6 ++-- rs/moq-net/src/lite/publisher.rs | 4 +-- rs/moq-net/src/model/origin.rs | 12 ++++---- rs/moq-net/src/model/track.rs | 28 +++++++++---------- rs/moq-net/tests/integration/compress.rs | 2 +- rs/moq-net/tests/integration/fetch_only.rs | 2 +- rs/moq-net/tests/integration/restart.rs | 4 +-- rs/moq-net/tests/integration/session_close.rs | 2 +- .../tests/integration/track_info_cancel.rs | 2 +- .../tests/integration/track_stream_demand.rs | 2 +- rs/moq-net/tests/loom.rs | 10 +++---- rs/moq-rtc/src/ingest.rs | 2 +- rs/moq-rtmp/src/server.rs | 2 +- rs/moq-srt/src/ts.rs | 2 +- rs/moq-transcode/src/lib.rs | 8 +++--- test/interop/clients/rust/tail.rs | 2 +- 23 files changed, 59 insertions(+), 59 deletions(-) diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index 63707ff281d..505ad2ad613 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -220,7 +220,7 @@ async fn a_recording_round_trips() { let (broadcast, _run) = serve(&store).await; let served = broadcast.consume().track("video").unwrap(); - let info = served.query().await.unwrap(); + let info = served.info().await.unwrap(); assert_eq!(info.priority, 3); assert_eq!(info.timescale, Some(Timescale::MILLI)); assert_eq!(info.max_age, None); @@ -240,7 +240,7 @@ async fn a_recording_round_trips() { assert_eq!(reader.finished().await.unwrap(), Position::group(6)); // The untimed track keeps its datagram a datagram. - let info = broadcast.consume().track("log").unwrap().query().await.unwrap(); + let info = broadcast.consume().track("log").unwrap().info().await.unwrap(); assert_eq!((info.priority, info.timescale), (9, None)); assert_eq!(fetch(&broadcast, "log", 0).await.unwrap(), ["a"]); assert!(matches!( @@ -431,7 +431,7 @@ async fn a_growing_archive_is_followed() { assert_eq!(next(&mut b).await.unwrap(), (51, expected(true, 51, 0..2))); assert!(matches!( - broadcast.consume().track("c").unwrap().query().await, + broadcast.consume().track("c").unwrap().info().await, Err(moq_net::Error::NotFound) )); archive.track("c", 0, false).await; diff --git a/rs/moq-cli/tests/integration/epochs.rs b/rs/moq-cli/tests/integration/epochs.rs index e55907363ba..4a17c0dc7b6 100644 --- a/rs/moq-cli/tests/integration/epochs.rs +++ b/rs/moq-cli/tests/integration/epochs.rs @@ -240,7 +240,7 @@ async fn replacement(version: &str) { let mut cached = media.fetch_group(0, None).await.unwrap(); assert_eq!(cached.read_frame().await.unwrap().unwrap().payload.as_ref(), b"A:0"); if let Ok(late) = pinned.track("late") - && let Ok(info) = late.query().await + && let Ok(info) = late.info().await { assert_ne!(info.priority, 17, "retired handle resolved B's metadata"); } diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index e6630cd7ec5..07299103917 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -125,7 +125,7 @@ struct Media { struct Handle { binding: Arc, /// The query holding the track instance alive without subscribing to its media. - track: Option, + track: Option, /// The bound sibling ended, so only its retained rows drain. ended: bool, } @@ -137,7 +137,7 @@ impl Handle { { // The lookup is lazy: the query installs the instance, and reads pin to it through // the query, since End may refuse any lookup made after this one. - self.track = Some(track.query().into_inner()); + self.track = Some(track.info().into_inner()); } } } diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index d3e39bdd12f..267647dca11 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -1563,7 +1563,7 @@ mod test { "video.timeline.z", ] { let track = consumer.track(name).expect("track"); - let info = track.query().await.expect("info"); + let info = track.info().await.expect("info"); assert_eq!(info.priority, PRIORITY.catalog, "{name} should rank with the catalog"); } } diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 2df16450b0a..227d8fe09e0 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -1145,10 +1145,10 @@ mod tests { let request = broadcast.reserve_track("video").unwrap(); let _video = Track::video(request, catalog.reserve(), VideoInit::new(VideoFormat::Vp8, Vec::new())).unwrap(); - let audio = consumer.track("audio").unwrap().query().await.unwrap(); + let audio = consumer.track("audio").unwrap().info().await.unwrap(); assert_eq!(audio.priority, PRIORITY.audio); - let video = consumer.track("video").unwrap().query().await.unwrap(); + let video = consumer.track("video").unwrap().info().await.unwrap(); assert_eq!(video.priority, PRIORITY.video); } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 0f4ac6f4a2c..c9c5107294d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -190,7 +190,7 @@ async fn read_fetch( priority: i8, compression: bool, ) -> Result { - let info = track.query().await?; + let info = track.info().await?; let cached = track.peek_group(sequence); let wire = cached.as_ref().and_then(|group| group.wire()); if wire.as_ref().is_some_and(|wire| !wire.fetchable) { @@ -3192,7 +3192,7 @@ impl Publisher { // The requester abandoning the request is owed nothing. let info = { - let query = track.query(); + let query = track.info(); let mut finished = false; let mut messages = super::request_stream::Messages::new(self.session.clone(), self.control.client()); kio::wait(|waiter| { @@ -10836,7 +10836,7 @@ mod serve_tests { let _track = broadcast.create_track("video", info).unwrap(); let (downstream, _sessions) = crate::lite::ietf::test_hop(&upstream, "room", [true, true]).await; let broadcast = downstream.consume().request_broadcast("room", None).await.unwrap(); - broadcast.track("video").unwrap().query().await.unwrap() + broadcast.track("video").unwrap().info().await.unwrap() } /// Draft 19 assigns 0x06 to SUBGROUP_DELIVERY_TIMEOUT, so a track property 0x06 crosses diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 3be2cc57589..4843ff6a17f 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -8659,7 +8659,7 @@ mod tests { if fetching { track.fetch_group(0, None).await.map(|_| ()) } else { - track.query().await.map(|_| ()) + track.info().await.map(|_| ()) } }); assert!(futures::poll!(pending.as_mut()).is_pending()); @@ -8742,7 +8742,7 @@ mod tests { let consumer = producer.consume(); let track = consumer.track("video").unwrap(); // Demand with nobody subscribing learns the track from TRACK_STATUS. - let mut query = std::pin::pin!(track.query()); + let mut query = std::pin::pin!(track.info()); assert!(futures::poll!(query.as_mut()).is_pending()); let request = dynamic.requested_track().await.expect("no track requested"); diff --git a/rs/moq-net/src/lite/ietf.rs b/rs/moq-net/src/lite/ietf.rs index 6745a124a3c..240984617b8 100644 --- a/rs/moq-net/src/lite/ietf.rs +++ b/rs/moq-net/src/lite/ietf.rs @@ -1238,7 +1238,7 @@ mod hop_tests { let frame = next(&mut received).await.unwrap(); assert_eq!(&frame.payload[..], b"b"); assert!(next(&mut received).await.is_none()); - let info = moq_clock::timeout(TIMEOUT, copy.query()).await.unwrap().unwrap(); + let info = moq_clock::timeout(TIMEOUT, copy.info()).await.unwrap().unwrap(); assert_eq!( (info.max_age, info.timescale, info.priority, info.refresh), ( @@ -1391,7 +1391,7 @@ mod hop_tests { .unwrap(); let (downstream, sessions) = test_hop(&upstream, "room", [true, true]).await; let (copy, mut subscriber) = subscribe(&downstream).await; - let info = moq_clock::timeout(TIMEOUT, copy.query()).await.unwrap().unwrap(); + let info = moq_clock::timeout(TIMEOUT, copy.info()).await.unwrap().unwrap(); assert!(info.properties.is_none()); let mut group = track.append_group().unwrap(); @@ -1451,7 +1451,7 @@ mod hop_tests { }; assert_eq!((listed.timescale, listed.ietf()), (None, None), "{offer:?}"); let copy = broadcast.track("video").unwrap(); - let info = moq_clock::timeout(TIMEOUT, copy.query()).await.unwrap().unwrap(); + let info = moq_clock::timeout(TIMEOUT, copy.info()).await.unwrap().unwrap(); assert_eq!(info.timescale, negotiated.then_some(Timescale::MILLI), "{offer:?}"); assert_eq!( info.ietf(), diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index c6760a44464..119717bfa1f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1797,7 +1797,7 @@ impl Drop for RequestServe { /// The open stream is interest in the track: the query that answered it stays until the /// requester closes its side, so demand holds while the requester moves on to SUBSCRIBE. struct TrackInfoServe { - querying: track::Querying, + querying: track::Resolving, } impl Request for TrackInfoServe { @@ -1816,7 +1816,7 @@ impl Request for TrackInfoServe { } fn start(_: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { - let querying = broadcast.track(&msg.track)?.query().into_inner(); + let querying = broadcast.track(&msg.track)?.info().into_inner(); Ok(Self { querying }) } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7168357476e..4497b066b22 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2997,7 +2997,7 @@ struct Asked { copy: track::Consumer, /// The front's own demand on the copy, held for as long as the front holds the copy: a /// bare handle is no demand, so this stands in for the readers' queries and fetches. - query: track::Querying, + query: track::Resolving, info: Option, /// Some sessions only learn a track's info by subscribing (moq-transport's /// SUBSCRIBE_OK), and read the demand only then, so the readers' demand rides the @@ -3027,7 +3027,7 @@ struct TrackIo { staged: Option, /// The source whose copy serves the track, the copy, and the query holding it; plus the /// subscription that query made. - copy: Option<(u64, track::Consumer, track::Querying)>, + copy: Option<(u64, track::Consumer, track::Resolving)>, held: Option>, /// The last copy ended because its session closed locally: a close, not an /// error, so the track ends cleanly if nothing takes over. @@ -3098,7 +3098,7 @@ struct InFlight { // Kept for its readers: the logical track, the query holding its copy, and the copy's // subscription. _accepted: Option, - _copy: Option, + _copy: Option, _held: Option>, } @@ -3484,8 +3484,8 @@ async fn serve_front(task: FrontTask) -> Vec { lookup.subscribe(io.routes.subscription(demand, subscribers)) }); let query = match &sub { - Some(sub) => sub.query(), - None => lookup.query().into_inner(), + Some(sub) => sub.info(), + None => lookup.info().into_inner(), }; let copy = query.track(); io.query = Some(Asked { @@ -11569,7 +11569,7 @@ mod tests { // Reopening the aborted logical track must not forget the broadcast's metadata. let reopened = rig.resolved.track("video").unwrap(); - assert!(matches!(reopened.query().await, Err(Error::Unsupported))); + assert!(matches!(reopened.info().await, Err(Error::Unsupported))); assert!(matches!(reopened.subscribe(None).await, Err(Error::Unsupported))); } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 206482615fe..a9313a6cea1 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4154,8 +4154,8 @@ impl ArrivalRead<'_> { /// A lazy handle to a single track within a broadcast. /// /// Obtained from [`broadcast::Consumer::track`]. Holding it sends nothing and is not -/// [`Demand`]: the track is requested, and counts as demanded, only while a -/// [`query`](Self::query), [`subscribe`](Self::subscribe) (a live, ongoing stream of +/// [`Demand`]: the track is requested, and counts as demanded, only while an +/// [`info`](Self::info), [`subscribe`](Self::subscribe) (a live, ongoing stream of /// groups), or [`fetch_group`](Self::fetch_group) (one group) made from it is held. The /// same handle can be used any number of times, and clones are cheap. #[derive(Clone)] @@ -4660,8 +4660,8 @@ impl Consumer { /// for the producer to [`Request::accept`] the track (a wire TRACK_INFO round-trip /// for a relay), and errors with the track's abort error if it closes first. /// [`Subscriber::info`] is the already-resolved counterpart. - pub fn query(&self) -> kio::Pending { - kio::Pending::new(Querying { + pub fn info(&self) -> kio::Pending { + kio::Pending::new(Resolving { name: self.name.clone(), broadcast: self.broadcast.clone(), state: self.resolve(None).0, @@ -4787,9 +4787,9 @@ pub struct Subscribing { } impl Subscribing { - /// Query the same track with this subscription already registered. - pub(super) fn query(&self) -> Querying { - Querying { + /// Resolve the same track's info with this subscription already registered. + pub(super) fn info(&self) -> Resolving { + Resolving { name: self.name.clone(), broadcast: self.broadcast.clone(), state: self.state.clone(), @@ -4842,19 +4842,19 @@ impl kio::Task for Subscribing { } } -/// The pollable state of a [`Consumer::query`]; awaited via the +/// The pollable state of a [`Consumer::info`]; awaited via the /// [`kio::Pending`] wrapper. /// /// Counts as the track's [`Demand`] while held, answered or not. -pub struct Querying { +pub struct Resolving { name: Arc, broadcast: Arc, state: kio::Consumer, stats: stats::Scope, } -impl Querying { - /// A handle on the track instance this query asked, whatever its broadcast serves later. +impl Resolving { + /// A handle on the track instance this resolves, whatever its broadcast serves later. pub fn track(&self) -> Consumer { Consumer { name: self.name.clone(), @@ -4874,7 +4874,7 @@ impl Querying { } } -impl kio::Task for Querying { +impl kio::Task for Resolving { type Output = Result; fn poll(&mut self, waiter: &kio::Waiter) -> Poll { @@ -6500,7 +6500,7 @@ fn refuse_untimed(info: &Info, subscription: &Subscription) -> Result<()> { impl Subscriber { /// The track's [`Info`], resolved when the subscription was established. /// - /// Free, unlike [`Consumer::query`]: subscribing already waited for the info + /// Free, unlike [`Consumer::info`]: subscribing already waited for the info /// (SUBSCRIBE_OK on the wire), so a subscriber always has it. pub fn info(&self) -> &Info { &self.info @@ -16435,7 +16435,7 @@ mod test { let demand = request.demand(); let consumer = request.consume(); assert!(!demand.is_used(), "a bare handle is not demand"); - let query = consumer.query(); + let query = consumer.info(); assert!(demand.is_used()); assert!(dynamic.demand().is_used()); drop(query); diff --git a/rs/moq-net/tests/integration/compress.rs b/rs/moq-net/tests/integration/compress.rs index 8f6b53cca01..c1969319639 100644 --- a/rs/moq-net/tests/integration/compress.rs +++ b/rs/moq-net/tests/integration/compress.rs @@ -419,7 +419,7 @@ async fn ietf_fetch_keeps_the_stored_form() { origin.consume().routed("bcast").await.unwrap(); let remote = origin.consume().request_broadcast("bcast", None).await.unwrap(); let consumer = remote.track("data").unwrap(); - assert_eq!(consumer.query().await.unwrap().compress, version != "moq-transport-16"); + assert_eq!(consumer.info().await.unwrap().compress, version != "moq-transport-16"); let mut fetched = consumer.fetch_group(0, moq_net::group::Fetch::default()).await.unwrap(); for index in 0..3 { assert_eq!( diff --git a/rs/moq-net/tests/integration/fetch_only.rs b/rs/moq-net/tests/integration/fetch_only.rs index 70d7bd7fffb..6ebc07ce3bc 100644 --- a/rs/moq-net/tests/integration/fetch_only.rs +++ b/rs/moq-net/tests/integration/fetch_only.rs @@ -181,7 +181,7 @@ async fn a_subscribe_after_track_status_keeps_timestamps() { let remote = resolve(&client).await; let copy = remote.track("video").unwrap(); - let info = copy.query().await.expect("TRACK_STATUS resolves the track"); + let info = copy.info().await.expect("TRACK_STATUS resolves the track"); assert!(info.timescale.is_some(), "{version}: the copy is untimed"); assert!(track.subscription().is_none(), "{version}: the query subscribed"); diff --git a/rs/moq-net/tests/integration/restart.rs b/rs/moq-net/tests/integration/restart.rs index cec0496f1a9..5b1c519170b 100644 --- a/rs/moq-net/tests/integration/restart.rs +++ b/rs/moq-net/tests/integration/restart.rs @@ -485,12 +485,12 @@ async fn retired_fetch(version: &str, explicit: bool) { write(&old_track, "old:1"); assert_eq!(read(&mut subscription).await, "old:1"); if let Ok(late) = held.track("late") - && let Ok(info) = late.query().await + && let Ok(info) = late.info().await { assert_ne!(info.priority, 17, "{version}: late track resolved replacement"); } // A held query keeps reading the retired instance; a bare handle would not. - let _reading = track.query(); + let _reading = track.info(); drop(subscription); settle().await; let resumed = track.subscribe(None).await; diff --git a/rs/moq-net/tests/integration/session_close.rs b/rs/moq-net/tests/integration/session_close.rs index 6b6cbe01cee..a80012c76fd 100644 --- a/rs/moq-net/tests/integration/session_close.rs +++ b/rs/moq-net/tests/integration/session_close.rs @@ -120,7 +120,7 @@ async fn track_info_interest_precedes_a_subscription() { consumer.routed("bcast").await.unwrap(); let remote = consumer.request_broadcast("bcast", None).await.unwrap(); let copy = remote.track("video").unwrap(); - copy.query().await.expect("TRACK_INFO"); + copy.info().await.expect("TRACK_INFO"); crate::support::wanted(track.demand()) .await .expect("TRACK_INFO holds demand"); diff --git a/rs/moq-net/tests/integration/track_info_cancel.rs b/rs/moq-net/tests/integration/track_info_cancel.rs index 6fcb5e55306..dcc171298bf 100644 --- a/rs/moq-net/tests/integration/track_info_cancel.rs +++ b/rs/moq-net/tests/integration/track_info_cancel.rs @@ -86,7 +86,7 @@ async fn withdrawn_broadcast_releases_track_info(version: &str, answered: bool, options.client_subscribe = Some(reader.clone()); let pair = connect_mock(options).await; let remote = reader.consume().routed_broadcast("room").await.unwrap(); - let mut query = remote.track("video").unwrap().query(); + let mut query = remote.track("video").unwrap().info(); let request = match futures::future::select(std::pin::pin!(dynamic.requested_track()), &mut query).await { futures::future::Either::Left((request, _)) => request.expect("TRACK reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered TRACK"), diff --git a/rs/moq-net/tests/integration/track_stream_demand.rs b/rs/moq-net/tests/integration/track_stream_demand.rs index 21d941b643d..2684deaf400 100644 --- a/rs/moq-net/tests/integration/track_stream_demand.rs +++ b/rs/moq-net/tests/integration/track_stream_demand.rs @@ -235,7 +235,7 @@ async fn bare_handle_is_lazy(version: &str, relays: usize) { assert!(broadcast_edges.borrow().is_empty(), "{label}: a bare handle is demand"); // A held query reaches the publisher, and holds the track there until dropped. - let mut query = handle.query(); + let mut query = handle.info(); moq_clock::timeout(TIMEOUT, &mut query) .await .unwrap_or_else(|_| panic!("{label}: query stalled")) diff --git a/rs/moq-net/tests/loom.rs b/rs/moq-net/tests/loom.rs index ba0807b9ebf..00511a88eef 100644 --- a/rs/moq-net/tests/loom.rs +++ b/rs/moq-net/tests/loom.rs @@ -273,13 +273,13 @@ fn a_front_sees_a_reader_come_and_go() { let room = block_on(pending).expect("resolves"); // A first reader gets the source asked, then leaves. - let first = room.track("video").expect("track").query(); + let first = room.track("video").expect("track").info(); driver.poll(now, &noop).unwrap(); assert!(demand.wanted().is_some(), "the front never asked the source"); drop(first); let racing = room.clone(); - let reader = thread::spawn(move || drop(racing.track("video").expect("track").query())); + let reader = thread::spawn(move || drop(racing.track("video").expect("track").info())); driver.poll(now, &noop).unwrap(); reader.join().unwrap(); @@ -314,17 +314,17 @@ fn a_front_sees_a_reader_return() { let room = block_on(pending).expect("resolves"); // A first reader comes and goes, which parks the track. - let first = room.track("video").expect("track").query(); + let first = room.track("video").expect("track").info(); driver.poll(now, &noop).unwrap(); drop(first); driver.poll(now, &noop).unwrap(); assert!(demand.wanted().is_none(), "the front kept the source for nobody"); - let leaving = room.track("video").expect("track").query(); + let leaving = room.track("video").expect("track").info(); let racing = room.clone(); let reader = thread::spawn(move || { drop(leaving); - racing.track("video").expect("track").query() + racing.track("video").expect("track").info() }); driver.poll(now, &noop).unwrap(); let returned = reader.join().unwrap(); diff --git a/rs/moq-rtc/src/ingest.rs b/rs/moq-rtc/src/ingest.rs index 859f3703e31..fc145c45862 100644 --- a/rs/moq-rtc/src/ingest.rs +++ b/rs/moq-rtc/src/ingest.rs @@ -106,7 +106,7 @@ mod tests { ) .unwrap(); - let info = broadcast.consume().track("0.avc3").unwrap().query().await.unwrap(); + let info = broadcast.consume().track("0.avc3").unwrap().info().await.unwrap(); assert_eq!(info.max_age, Some(Duration::from_secs(3))); } diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index d30a6eb3e45..ef2bd5e6693 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -2091,7 +2091,7 @@ mod tests { let consumer = origin.consume(); consumer.routed("live/cam0").await.unwrap(); let broadcast = consumer.request_broadcast("live/cam0", None).await.unwrap(); - let info = broadcast.track("0.flv-v").unwrap().query().await.unwrap(); + let info = broadcast.track("0.flv-v").unwrap().info().await.unwrap(); assert_eq!(info.max_age, Some(Duration::from_secs(3))); } diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index 23fde702674..5cadac54188 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -610,7 +610,7 @@ mod tests { let consumer = origin.consume(); consumer.routed("live/cam0").await.unwrap(); let broadcast = consumer.request_broadcast("live/cam0", None).await.unwrap(); - let info = broadcast.track("0.avc3").unwrap().query().await.unwrap(); + let info = broadcast.track("0.avc3").unwrap().info().await.unwrap(); assert_eq!(info.max_age, Some(Duration::from_secs(3))); } diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index cc853107b1d..c9fa3689fdb 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1245,7 +1245,7 @@ mod tests { // wait until the live path has claimed group 0, so the fetch below can only // resolve from the track cache and retirement finds that live group open. let rung = consumer.track("video/120p").unwrap(); - let mut query = rung.query(); + let mut query = rung.info(); (&mut query).await.unwrap(); let mut edge = rung.clone(); assert_eq!(edge.largest().await.map(|position| position.group), Some(0)); @@ -1312,7 +1312,7 @@ mod tests { // A held query is demand, so the rung starts encoding and subscribes to the // source. let rung = consumer.track("video/120p").unwrap(); - let mut query = rung.query(); + let mut query = rung.info(); (&mut query).await.unwrap(); let wanted = async { while demand.wanted_changed().await?.is_none() {} @@ -1384,7 +1384,7 @@ mod tests { // Resolving the info waits for the transcoder to accept the track, so the // fetch below reaches a rung that is already serving. let rung = consumer.track("video/120p").unwrap(); - let mut query = rung.query(); + let mut query = rung.info(); (&mut query).await.unwrap(); assert!( source._track.subscription_changed().await.unwrap().is_some(), @@ -1468,7 +1468,7 @@ mod tests { // No live groups, so group 7 misses and reaches the fetch handler. let rung = consumer.track("video/120p").unwrap(); - rung.query().await.unwrap(); + rung.info().await.unwrap(); let fetching = rung.fetch_group(7, None); let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) diff --git a/test/interop/clients/rust/tail.rs b/test/interop/clients/rust/tail.rs index ac55b7946bd..2f0f04f2e3d 100644 --- a/test/interop/clients/rust/tail.rs +++ b/test/interop/clients/rust/tail.rs @@ -134,7 +134,7 @@ async fn main() -> anyhow::Result<()> { if status { // Query before subscribing so draft-18+ must use TRACK_STATUS, // rather than learn metadata from SUBSCRIBE_OK as a side effect. - let info = remote.query().await?; + let info = remote.info().await?; ensure!(info.timescale.is_none(), "IETF track should be untimed"); ensure!(info.priority == 2, "TRACK_STATUS lost publisher priority"); eprintln!("track status answered"); From ba5721ceb85c7092307cc8088316a636674954c5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:02:48 -0700 Subject: [PATCH 13/58] feat(net)!: track closed() returns the outcome and settles on a reached end Co-Authored-By: Claude Opus 5.5 --- rs/moq-audio/src/encode/producer.rs | 2 +- rs/moq-e2ee/src/tests.rs | 2 +- rs/moq-flate/src/stream/producer.rs | 4 +- rs/moq-net/src/ietf/subscriber.rs | 26 ++++---- rs/moq-net/src/lite/subscriber.rs | 4 +- rs/moq-net/src/model/track.rs | 97 +++++++++++++++++++++++++---- rs/moq-net/src/model/track/fill.rs | 2 +- rs/moq-transcode/src/rung.rs | 2 +- 8 files changed, 107 insertions(+), 32 deletions(-) diff --git a/rs/moq-audio/src/encode/producer.rs b/rs/moq-audio/src/encode/producer.rs index 97e0f6d1a83..6b067bc9a74 100644 --- a/rs/moq-audio/src/encode/producer.rs +++ b/rs/moq-audio/src/encode/producer.rs @@ -634,7 +634,7 @@ mod tests { let closed = tokio::time::timeout(Duration::from_secs(1), demand.closed()) .await .expect("producer closed"); - assert!(matches!(closed, moq_net::Error::Dropped)); + assert!(closed.is_ok(), "{closed:?}"); } /// A finished publication may live as long as its broadcast; the codec and input diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 33fcd9b14f9..ee925dd21e1 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -521,7 +521,7 @@ fn refused_track_is_aborted() { ); assert!(matches!( net.poll_closed(&kio::Waiter::noop()), - Poll::Ready(moq_net::Error::Cancel) + Poll::Ready(Err(moq_net::Error::Cancel)) )); } diff --git a/rs/moq-flate/src/stream/producer.rs b/rs/moq-flate/src/stream/producer.rs index 678a0035c5a..4b74dd82a34 100644 --- a/rs/moq-flate/src/stream/producer.rs +++ b/rs/moq-flate/src/stream/producer.rs @@ -98,8 +98,8 @@ impl Inner { // A closed track refuses every append, so say so before the budget: `GroupTooLarge` promises // the log is still writable. - if let Poll::Ready(err) = self.track.poll_closed(&kio::Waiter::noop()) { - return Err(err.into()); + if let Poll::Ready(outcome) = self.track.poll_closed(&kio::Waiter::noop()) { + return Err(outcome.err().unwrap_or(moq_net::Error::Closed).into()); } // A timestamp that doesn't match the track is refused before anything changes: found by diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4d0f7b20c0b..815595cccfe 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3780,7 +3780,7 @@ impl Subscriber { } let resume = loop { let step = kio::wait(|waiter| { - if idle.track.poll_closed(waiter).is_ready() { + if idle.track.poll_aborted(waiter).is_ready() { return Poll::Ready(Step::Closed); } if gate.poll_denied(waiter).is_ready() { @@ -3994,7 +3994,7 @@ impl Subscriber { let mut opening = this.session.clone(); let mut stream = kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } Stream::poll_open_request(&mut opening, this.version, &mut waiter.context()) @@ -4004,7 +4004,7 @@ impl Subscriber { let mut timeout = crate::time::Deadline::after(&this.runtime, Control::REQUEST_ID_TIMEOUT); let fetch_id = kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } this.control.poll_credit(waiter, &mut timeout) @@ -4425,7 +4425,7 @@ impl Subscriber { &mut reading ))); kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } waiter.poll_future(open.as_mut()) @@ -4479,7 +4479,7 @@ impl Subscriber { let mut ingest = GroupIngest::new(self, &group, timescale); let res = { kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } if let Poll::Ready(err) = observing.poll_aborted(waiter) { @@ -4829,7 +4829,7 @@ impl Subscriber { ) -> Result, Error> { reading.park(); let settled = kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } @@ -4995,7 +4995,7 @@ impl Subscriber { let mut serving = track.clone(); let mut serve = std::pin::pin!(crate::util::sendable(self.run_fill(stream, &mut serving, timescale))); kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } waiter.poll_future(serve.as_mut()) @@ -5492,7 +5492,7 @@ impl Subscriber { Poll::Pending => {} } } - if let Poll::Ready(err) = track::TrackState::poll_closed(&owner, waiter) { + if let Poll::Ready(err) = track::TrackState::poll_aborted(&owner, waiter) { // Completed history stays cached. Only this FETCH's unfinished group is // ours to abort; closing a track never cascades into cached siblings. if !matches!(*slot.read(), GroupFetch::Done) { @@ -13629,7 +13629,7 @@ mod stitch_tests { assert!( matches!( futures::FutureExt::now_or_never(h.track.closed()), - Some(Error::Unsupported) + Some(Err(Error::Unsupported)) ), "{objects:?}: the subscription must end with the refusal" ); @@ -14631,7 +14631,7 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_fill(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); } /// A subgroup object without a Timestamp on a timed track makes the whole track @@ -14660,7 +14660,7 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_group(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); } /// END_OF_TRACK after a group's last object ends the track right after that group. @@ -14719,7 +14719,7 @@ mod stitch_tests { assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); assert!(matches!( h.track.closed().now_or_never(), - Some(Error::ProtocolViolation) + Some(Err(Error::ProtocolViolation)) )); } @@ -15403,7 +15403,7 @@ mod stitch_tests { assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); assert!(matches!( futures::FutureExt::now_or_never(track.closed()), - Some(Error::MalformedTrack) + Some(Err(Error::MalformedTrack)) )); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index b53289c27f1..f710438e36c 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1055,7 +1055,7 @@ impl GroupRecv { // The track or group dying cancels the stream; the peer's own close // arrives through the ingest's reads. let res = 'serve: { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { break 'serve Err(err); } if let Poll::Ready(err) = group.handle().poll_aborted(waiter) { @@ -2986,7 +2986,7 @@ mod tests { } _ => { assert!(matches!(res, Err(Error::ProtocolViolation)), "{version:?}: {res:?}"); - assert!(matches!(track.closed().now_or_never(), Some(Error::ProtocolViolation))); + assert!(matches!(track.closed().now_or_never(), Some(Err(Error::ProtocolViolation)))); } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7be13bf2839..a3b1c9952ff 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2553,14 +2553,38 @@ impl TrackState { producer.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) } - /// Observe a shared track's terminal cause without extending its publisher lifetime. + /// Observe a shared track's abort, or every producer leaving, without extending its + /// publisher lifetime. A finish alone does not end it: lower groups may still arrive. // Sessions retain shared state while a FETCH owns its group writer. - pub(crate) fn poll_closed(producer: &kio::Producer, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_aborted(producer: &kio::Producer, waiter: &kio::Waiter) -> Poll { producer .poll_closed(waiter) .map(|()| producer.read().abort.clone().unwrap_or(Error::Dropped)) } + /// The track's outcome once final: `Ok` once its declared end is reached, which a later + /// abort keeps, or the abort error. + fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { + if self.is_complete() { + Poll::Ready(Ok(())) + } else if let Some(err) = &self.abort { + Poll::Ready(Err(err.clone())) + } else { + self.watch_tail(waiter); + Poll::Pending + } + } + + /// [`Self::poll_outcome`] read through a handle: the channel closing short of an + /// outcome (every producer gone without an end or abort) reads as [`Error::Dropped`]. + fn settle(poll: Poll, kio::Ref<'_, Self>>>) -> Poll> { + Poll::Ready(match ready!(poll) { + Ok(outcome) => outcome, + Err(closed) if closed.is_complete() => Ok(()), + Err(closed) => Err(closed.abort.clone().unwrap_or(Error::Dropped)), + }) + } + /// Abort shared track state without extending its publisher lifetime. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn abort(producer: &kio::Producer, err: Error) -> Result<()> { @@ -3292,15 +3316,22 @@ impl Producer { Ok(()) } - /// Block until the track is closed or aborted, returning the cause. + /// Block until the track's outcome is final: `Ok` once its declared end is reached, or + /// the abort error, or [`Error::Dropped`] once every producer left without either. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { + pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the track is closed or aborted; ready with the cause. - pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - TrackState::poll_closed(&self.state, waiter) + /// Poll until the track's outcome is final; see [`Self::closed`]. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + TrackState::settle(self.state.poll_ref(waiter, |state| state.poll_outcome(waiter))) + } + + /// Poll until the track is aborted, or every producer left; ready with the cause. + // Sessions stop serving on an abort, but keep taking lower groups past a finish. + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { + TrackState::poll_aborted(&self.state, waiter) } /// Return true if the track has been closed. @@ -3942,11 +3973,17 @@ impl Demand { Poll::Ready(Ok(next)) } - /// Block until the track is closed or aborted, returning the cause. + /// Block until the track's outcome is final; see [`Producer::closed`]. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - self.state.closed().await; - self.abort_reason() + pub async fn closed(&self) -> Result<()> { + kio::wait(|waiter| { + TrackState::settle( + self.state + .consume_weak() + .poll(waiter, |state| state.poll_outcome(waiter)), + ) + }) + .await } /// The publisher's tie-break priority, as set in [`Info::priority`]. @@ -13045,6 +13082,44 @@ mod test { assert_eq!(consumer.resume_position(), Some(Position { group: 1, frame: 2 })); } + /// `closed` settles once the declared end is reached, not when it is declared, and an + /// abort after that keeps the clean outcome. + #[test] + #[cfg(feature = "async")] + fn closed_settles_on_a_reached_end() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", Info::timed()); + let demand = producer.demand(); + producer.append_group().unwrap().finish().unwrap(); + producer.finish_at(Position::group(2)).unwrap(); + assert!(producer.poll_closed(&waiter).is_pending(), "group 1 is still to come"); + assert!(demand.closed().now_or_never().is_none()); + + producer.append_group().unwrap().finish().unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + assert!(producer.poll_aborted(&waiter).is_pending(), "lower groups may still arrive"); + + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + } + + /// An abort short of the end, or the last producer leaving without one, is the outcome. + #[test] + #[cfg(feature = "async")] + fn closed_reports_an_unfinished_end() { + let waiter = kio::Waiter::noop(); + let aborted = track_producer("aborted", Info::timed()); + aborted.clone().abort(Error::Timeout).unwrap(); + assert!(matches!(aborted.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + + let dropped = track_producer("dropped", Info::timed()); + let demand = dropped.demand(); + drop(dropped); + assert!(matches!(demand.closed().now_or_never(), Some(Err(Error::Dropped)))); + } + /// An abort short of the end keeps the groups that finished for a reader that has not /// pulled them yet: it gets them, then the abort. The open group nobody will finish /// is gone, on both cursors. diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 392e62d9db2..2ff54b1399d 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -980,7 +980,7 @@ impl GroupFill { /// Poll until the track closes, with its abort error. pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - TrackState::poll_closed(&self.inner.state, waiter) + TrackState::poll_aborted(&self.inner.state, waiter) } /// Whether a reader still waits on the fill. diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 92d8e38898c..2ec3e166f56 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -1036,7 +1036,7 @@ mod tests { ); source.close(); assert!(matches!(running.await, Ok(Ended::Closed))); - assert!(matches!(observed.closed().await, moq_net::Error::Cancel)); + assert!(matches!(observed.closed().await, Err(moq_net::Error::Cancel))); } /// The track is admitted before the serving future is spawned, so it also owns cleanup From 5154cc024132c79e865c0661c13cf0f552b7ca38 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:05:45 -0700 Subject: [PATCH 14/58] refactor(net)!: JS Track.Producer.createGroup(sequence), as in Rust writeGroup inserted a caller-built group; createGroup builds it, like Rust's create_group and e2ee's createGroup. Sharing one group between tracks goes. Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/e2ee.test.ts | 18 +- js/e2ee/src/track.ts | 3 +- js/hang/bench/slow-group.ts | 3 +- js/hang/src/container/consumer.holes.test.ts | 15 +- .../container/consumer.nonsequential.test.ts | 6 +- .../src/container/consumer.outoforder.test.ts | 12 +- js/hang/src/container/consumer.skips.test.ts | 3 +- .../src/container/consumer.startup.test.ts | 27 +- js/hang/src/container/consumer.test.ts | 123 +++----- js/net/bench/resume.ts | 3 +- js/net/bench/subscribe-drop.ts | 4 +- js/net/bench/track.ts | 3 +- js/net/src/broadcast.test.ts | 15 +- js/net/src/flate/group.test.ts | 7 +- js/net/src/group.ts | 1 - js/net/src/ietf/publisher.test.ts | 32 +- js/net/src/ietf/subscriber.ts | 3 +- js/net/src/integration.test.ts | 4 +- js/net/src/internal.ts | 5 - js/net/src/lite/publisher.test.ts | 95 +++--- js/net/src/lite/subscriber.ts | 8 +- js/net/src/origin.test.ts | 11 +- js/net/src/parked-expiry.test.ts | 7 +- js/net/src/resume.test.ts | 30 +- js/net/src/track.test.ts | 287 ++++++------------ js/net/src/track.ts | 37 +-- js/watch/src/audio/decoder.test.ts | 3 +- js/watch/src/audio/playout.test.ts | 3 +- js/watch/src/audio/replay.ts | 3 +- js/watch/src/video/decoder.test.ts | 6 +- test/interop/clients/js/open-gop/main.ts | 3 +- test/interop/track-status.ts | 3 +- test/wasm/baseline/publisher.ts | 3 +- 33 files changed, 272 insertions(+), 514 deletions(-) diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index a14ad142370..3bb3e252d80 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -385,8 +385,7 @@ test("grouped authentication failure ends the track", async () => { group.close(); // A forged second group, written straight to the net track. - const bad = new NetGroup.Producer(1); - p.net.writeGroup(bad); + const bad = p.net.createGroup(1); bad.writeFrame({ timestamp: ms(2), payload: await sealed(p, GROUP, 1, 0, "ok", true) }); bad.close(); @@ -403,12 +402,10 @@ test("grouped failure wakes pending reads and releases demand", async () => { expect(demand.wanted.peek()).toBeDefined(); // One authentic frame, then silence. The group and the track stay open. - const open = new NetGroup.Producer(0); - p.net.writeGroup(open); + const open = p.net.createGroup(0); open.writeFrame({ timestamp: ms(1), payload: await sealed(p, GROUP, 0, 0, "ok") }); - const bad = new NetGroup.Producer(1); + const bad = p.net.createGroup(1); const badDemand = bad.demand(); - p.net.writeGroup(bad); bad.writeFrame({ timestamp: ms(2), payload: await sealed(p, GROUP, 1, 0, "nope", true) }); const good = await recvGroup(p.consumer); @@ -434,8 +431,7 @@ test("grouped failure wakes pending reads and releases demand", async () => { test("a grouped failure ahead of a datagram open rejects the datagram", async () => { const p = await pair("audio"); - const bad = new NetGroup.Producer(0); - p.net.writeGroup(bad); + const bad = p.net.createGroup(0); bad.writeFrame({ timestamp: ms(1), payload: await sealed(p, GROUP, 0, 0, "nope", true) }); p.net.insertDatagram(0, ms(2), await sealed(p, DATAGRAM, 0, 0, "late")); const forged = await recvGroup(p.consumer); @@ -778,11 +774,9 @@ test("a timestamp out of range at the track's timescale spends nothing", async ( test("a grouped failure skips opens queued behind it", async () => { const p = await pair("video"); - const bad = new NetGroup.Producer(0); - p.net.writeGroup(bad); + const bad = p.net.createGroup(0); bad.writeFrame({ timestamp: ms(1), payload: await sealed(p, GROUP, 0, 0, "nope", true) }); - const good = new NetGroup.Producer(1); - p.net.writeGroup(good); + const good = p.net.createGroup(1); good.writeFrame({ timestamp: ms(2), payload: await sealed(p, GROUP, 1, 0, "ok") }); p.net.insertDatagram(0, ms(3), await sealed(p, DATAGRAM, 0, 0, "late")); const forged = await recvGroup(p.consumer); diff --git a/js/e2ee/src/track.ts b/js/e2ee/src/track.ts index 9d3360fdf44..55dc2a79581 100644 --- a/js/e2ee/src/track.ts +++ b/js/e2ee/src/track.ts @@ -58,8 +58,7 @@ export class Producer { this.#writable(); this.#reserve(sequence); this.#next = sequence + 1; - const inner = new NetGroup.Producer(sequence); - this.#inner.writeGroup(inner); + const inner = this.#inner.createGroup(sequence); return Group.Producer.make(inner, this.#writer); } diff --git a/js/hang/bench/slow-group.ts b/js/hang/bench/slow-group.ts index 127e8405449..7d7c3e7f08f 100644 --- a/js/hang/bench/slow-group.ts +++ b/js/hang/bench/slow-group.ts @@ -15,9 +15,8 @@ for (const count of [1, 16, 256, 1024]) { maxDelay, }); const write = (sequence: number) => { - const group = new Group.Producer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: Varint.encode(sequence * 1000), timestamp: Time.Timestamp.now() }); - track.writeGroup(group); }; const buffered = async (timestamp: number) => { while (consumer.buffered.peek().at(-1)?.end !== timestamp) await consumer.buffered.changed(); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index fe77724c73d..afe7803fe9a 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -23,14 +23,12 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms } })(); try { - const first = new Group.Producer(0); - track.writeGroup(first); + const first = track.createGroup(0); first.writeFrame({ payload: encodeFrame(new Uint8Array([1]), Micro(10_000)), timestamp: Time.Timestamp.now() }); first.close(); await flush(); expect(results).toHaveLength(1); - const marker = new Group.Producer(2); - track.writeGroup(marker); + const marker = track.createGroup(2); marker.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.now() }); marker.close(); await flush(); @@ -69,8 +67,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as } })(); const write = (sequence: number, timestamp?: number) => { - const group = new Group.Producer(sequence); - track.writeGroup(group); + const group = track.createGroup(sequence); group.writeFrame({ payload: timestamp === undefined ? new Uint8Array() : encodeFrame(new Uint8Array([1]), Micro(timestamp)), timestamp: Time.Timestamp.fromMicros(timestamp ?? 20_000), @@ -120,8 +117,7 @@ test("the holes a stalled source left behind share one budget", async () => { } })(); const write = (sequence: number) => { - const group = new Group.Producer(sequence); - track.writeGroup(group); + const group = track.createGroup(sequence); group.writeFrame({ payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)), timestamp: Time.Timestamp.fromMicros(sequence * 10_000), @@ -161,8 +157,7 @@ test("a hole is late from the earliest arrival past it", async () => { } })(); const write = (sequence: number) => { - const group = new Group.Producer(sequence); - track.writeGroup(group); + const group = track.createGroup(sequence); group.writeFrame({ payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)), timestamp: Time.Timestamp.fromMicros(sequence * 10_000), diff --git a/js/hang/src/container/consumer.nonsequential.test.ts b/js/hang/src/container/consumer.nonsequential.test.ts index 492a8b03a80..0e26b421463 100644 --- a/js/hang/src/container/consumer.nonsequential.test.ts +++ b/js/hang/src/container/consumer.nonsequential.test.ts @@ -41,13 +41,12 @@ test("Consumer waits on a non-sequential hang group because it has no per-frame }); // Group A at a large sequence, completed so the cursor advances past it. - const a = new Group.Producer(1_000_000); + const a = track.createGroup(1_000_000); a.writeFrame({ payload: encodeLegacyFrame(0 as Micro, new Uint8Array([0x01])), timestamp: Time.Timestamp.now(), }); a.close(); - track.writeGroup(a); // Drain A's frame. const firstFrame = await nextFrame(consumer); @@ -59,8 +58,7 @@ test("Consumer waits on a non-sequential hang group because it has no per-frame // Open group B at a large jump (+90_000, NOT +1) with a frame far past A's last PTS, and leave it // open. Legacy has no duration, so A's end is unknown -- B could be continuous, or group 1_000_001 // could still be coming. We cannot tell, so B must NOT surface yet. - const b = new Group.Producer(1_090_000); - track.writeGroup(b); + const b = track.createGroup(1_090_000); b.writeFrame({ payload: encodeLegacyFrame(1_000_000 as Micro, new Uint8Array([0x02])), timestamp: Time.Timestamp.now(), diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index 927c61caf46..a71cda5fc7b 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -32,8 +32,7 @@ function publishFrame(group: Group.Producer, millis: number, tag: number) { /** Publish one closed group carrying a single frame at `millis`. */ function publish(track: Track.Producer, sequence: number, millis: number) { - const group = new Group.Producer(sequence); - track.writeGroup(group); + const group = track.createGroup(sequence); publishFrame(group, millis, sequence); group.close(); } @@ -94,14 +93,12 @@ test("a below-cursor group still downloading is not truncated", async () => { }); // The live edge group arrives first and starts delivery (still open). - const live = new Group.Producer(3); - track.writeGroup(live); + const live = track.createGroup(3); publishFrame(live, 3000, 30); expect((await nextFrame(consumer))?.frame.payload[0]).toBe(30); // A backlog group lands below the cursor, one frame at a time (mid-download). - const backlog = new Group.Producer(1); - track.writeGroup(backlog); + const backlog = track.createGroup(1); publishFrame(backlog, 1000, 10); const first = await nextFrame(consumer); expect(first?.group).toBe(1); @@ -134,8 +131,7 @@ test("delayed older groups survive a floor established by two newer groups", asy publish(track, 3, 30); expect((await nextFrame(consumer))?.group).toBe(3); - const latest = new Group.Producer(4); - track.writeGroup(latest); + const latest = track.createGroup(4); publishFrame(latest, 40, 4); expect((await nextFrame(consumer))?.group).toBe(4); diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index b5f6e1ef64f..d74d11f66ec 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -15,9 +15,8 @@ for (const count of [1, 16, 256]) { maxDelay, }); const write = (sequence: number, timestamp: number) => { - const group = new Group.Producer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: Varint.encode(timestamp), timestamp: Time.Timestamp.now() }); - track.writeGroup(group); return group; }; const buffered = async (timestamp: number) => { diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 5bb4417e4e2..6ea4edbf9df 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -61,12 +61,10 @@ for (const emptySuccessor of [false, true]) { `Consumer starts across a hole after an empty reset (empty successor: ${emptySuccessor}, maxDelay: %s)`, async (maxDelay) => { const { track, results, warn } = setup(maxDelay); - const stale = new Group.Producer(16); - track.writeGroup(stale); + const stale = track.createGroup(16); await flush(); if (emptySuccessor) { - const empty = new Group.Producer(17); - track.writeGroup(empty); + const empty = track.createGroup(17); empty.close(); await flush(); } @@ -75,8 +73,7 @@ for (const emptySuccessor of [false, true]) { expect(results).toEqual([]); const sequence = emptySuccessor ? 19 : 18; - const live = new Group.Producer(sequence); - track.writeGroup(live); + const live = track.createGroup(sequence); for (let i = 0; i < 5; i++) write(live, 1_000_000 + i * 33_333); await flush(); @@ -94,15 +91,13 @@ for (const emptySuccessor of [false, true]) { test("Consumer preserves a declared marker before its first media across a hole", async () => { const { track, results, warn } = setup(); - const marker = new Group.Producer(16); - track.writeGroup(marker); + const marker = track.createGroup(16); write(marker, 0, true); marker.close(); await flush(); expect(items(results)).toEqual(["end 16@0"]); - const live = new Group.Producer(18); - track.writeGroup(live); + const live = track.createGroup(18); write(live, 50_000); await flush(); // Group 17 never arrived, so the media after the marker is past a gap. It spans the declared @@ -114,32 +109,28 @@ test("Consumer preserves a declared marker before its first media across a hole" test.each([false, true])("Consumer still waits for a gap after media (playhead reset: %s)", async (reset) => { const { track, consumer, results, warn } = setup(1000); - const first = new Group.Producer(16); - track.writeGroup(first); + const first = track.createGroup(16); write(first, 0); first.close(); await flush(); expect(items(results)).toEqual(["16@0"]); if (reset) { - const marker = new Group.Producer(17); - track.writeGroup(marker); + const marker = track.createGroup(17); write(marker, 0, true); marker.close(); await flush(); expect(items(results)).toEqual(["16@0", "end 17@0"]); } const sequence = reset ? 20 : 19; - const later = new Group.Producer(sequence); - track.writeGroup(later); + const later = track.createGroup(sequence); write(later, 50_000); await flush(); expect(consumer.buffered.peek().length).toBeGreaterThan(0); expect(frames(results)).toHaveLength(1); // Supply the missing, timestamp-contiguous bridge and prove the queued frame survives. - const bridge = new Group.Producer(sequence - 1); - track.writeGroup(bridge); + const bridge = track.createGroup(sequence - 1); write(bridge, 0); bridge.close(); await flush(); diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 5c70cffa5b2..ce4385275b6 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -503,22 +503,20 @@ function writeGroupWithLegacyFrames( timestamps: Micro[], stamp: (ts: Micro) => Time.Timestamp = (ts) => Time.Timestamp.fromMicros(ts), ) { - const group = new Group.Producer(sequence); + const group = track.createGroup(sequence); for (const ts of timestamps) { group.writeFrame({ payload: encodeLegacy(ts), timestamp: stamp(ts) }); } group.close(); - track.writeGroup(group); } function writeMarkerGroup(track: Track.Producer, sequence: number, timestamp: Micro) { - const group = new Group.Producer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: encodeLegacyFrame(timestamp, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(timestamp), }); group.close(); - track.writeGroup(group); } async function drainFrames( @@ -595,11 +593,10 @@ test("Consumer index spans MoQ frames for keyframe detection", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: multiFormat, maxDelay: 500 as Milli }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // first MoQ frame → 3 samples group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // second MoQ frame → 3 samples group.close(); - track.writeGroup(group); track.close(); const frames = await drainFrames(consumer, 200); @@ -628,17 +625,15 @@ for (const [where, payloads] of [ const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: failingFormat, maxDelay: 500 as Milli }); - const g0 = new Group.Producer(0); + const g0 = track.createGroup(0); for (const byte of payloads) g0.writeFrame({ payload: new Uint8Array([byte]), timestamp: Time.Timestamp.now() }); g0.close(); - track.writeGroup(g0); // A later group that decodes cleanly is never reached. - const g1 = new Group.Producer(1); + const g1 = track.createGroup(1); g1.writeFrame({ payload: new Uint8Array([0x04]), timestamp: Time.Timestamp.now() }); g1.close(); - track.writeGroup(g1); track.close(); const frames: number[] = []; @@ -662,11 +657,10 @@ test("Consumer delivers everything ahead of a decode error", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 2, [100_000 as Micro]); - const malformed = new Group.Producer(3); + const malformed = track.createGroup(3); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.fromMicros(200_000) }); malformed.close(); - track.writeGroup(malformed); track.close(); const items: string[] = []; @@ -690,10 +684,9 @@ test("Consumer keeps the first of several decode errors", async () => { const consumer = new Consumer(replay(track), { format: failingFormat, maxDelay: 500 as Milli }); for (const sequence of [0, 1]) { - const group = new Group.Producer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: new Uint8Array([sequence]), timestamp: Time.Timestamp.now() }); group.close(); - track.writeGroup(group); } track.close(); await settle(); @@ -720,13 +713,11 @@ test("Consumer releases its open groups when an error ends it", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: failingFormat, maxDelay: 500 as Milli }); - const malformed = new Group.Producer(0); + const malformed = track.createGroup(0); malformed.writeFrame({ payload: new Uint8Array([0xff]), timestamp: Time.Timestamp.now() }); malformed.close(); - track.writeGroup(malformed); - const later = new Group.Producer(1); + const later = track.createGroup(1); later.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); - track.writeGroup(later); await open; expect(later.demand().used.peek()).toBe(true); @@ -754,14 +745,13 @@ test("Consumer keeps frames decoded before a group reset (truncated GoP)", async const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format, maxDelay: 500 as Milli }); - const g0 = new Group.Producer(0); + const g0 = track.createGroup(0); for (const timestamp of [1, 2]) { g0.writeFrame({ payload: encodeLegacyFrame(timestamp as Micro, new Uint8Array([1])), timestamp: Time.Timestamp.fromMicros(timestamp), }); } - track.writeGroup(g0); await decoded; g0.close(new NetError.Stream(StreamCode.Cancel)); @@ -948,7 +938,7 @@ test("Consumer keeps a trailing duration marker internal", async () => { maxDelay: 500 as Milli, }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: encodeLegacyFrame(0 as Micro, new Uint8Array([0xde, 0xad])), timestamp: Time.Timestamp.now(), @@ -958,7 +948,6 @@ test("Consumer keeps a trailing duration marker internal", async () => { timestamp: Time.Timestamp.now(), }); group.close(); - track.writeGroup(group); track.close(); await settle(); @@ -978,7 +967,7 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra maxDelay: 500 as Milli, }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: encodeLegacyFrame(20_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), @@ -988,7 +977,6 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra timestamp: Time.Timestamp.now(), }); group.close(); - track.writeGroup(group); track.close(); expect(await consumer.next()).toEqual({ kind: "end", group: 0, end: 20_000 as Micro }); @@ -1098,11 +1086,10 @@ test("Consumer throws the first error in delivery order", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 30_000 as Milli }); writeGroupWithLegacyFrames(track, 0, [10_000_000 as Micro], wallClock); - const malformed = new Group.Producer(1); + const malformed = track.createGroup(1); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: wallClock() }); malformed.close(); - track.writeGroup(malformed); writeGroupWithLegacyFrames(track, 2, [0 as Micro, 100_000 as Micro], wallClock); track.close(); @@ -1127,13 +1114,12 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn }, }; const write = (sequence: number, timestamps: number[]) => { - const group = new Group.Producer(sequence); + const group = track.createGroup(sequence); // Transport stamps follow the sequence, so a lower group may land after a higher one. group.writeFrame({ payload: new TextEncoder().encode(JSON.stringify(timestamps)), timestamp: Time.Timestamp.fromMicros(sequence * 1000), }); - track.writeGroup(group); return group; }; @@ -1240,14 +1226,13 @@ test("Consumer reads no gap when retiring a group holding only its endpoint", as const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: new LegacyFormat("video"), maxDelay: 150 as Milli }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: encodeLegacy(0 as Micro), timestamp: Time.Timestamp.fromMicros(0) }); group.writeFrame({ payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(33_000), }); group.close(); - track.writeGroup(group); await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); @@ -1266,9 +1251,8 @@ test("Consumer reads a gap when retiring a truncated group", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: new LegacyFormat("audio"), maxDelay: 150 as Milli }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: encodeLegacy(0 as Micro), timestamp: Time.Timestamp.fromMicros(0) }); - track.writeGroup(group); await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); @@ -1289,9 +1273,8 @@ test("Consumer reads a gap when retiring an open group", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: new LegacyFormat("audio"), maxDelay: 150 as Milli }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: encodeLegacy(0 as Micro), timestamp: Time.Timestamp.fromMicros(0) }); - track.writeGroup(group); await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); @@ -1365,11 +1348,10 @@ test("Consumer handles empty decode result without deadlock", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: emptyThenValid, maxDelay: 500 as Milli }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // empty decode group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // valid decode group.close(); - track.writeGroup(group); track.close(); const frames = await drainFrames(consumer, 300); @@ -1390,10 +1372,9 @@ test("Consumer preserves empty media from formats without endpoint markers", asy }; const track = new Track.Producer("test"); const consumer = new Consumer(track.subscribe(), { format, maxDelay: 500 as Milli }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() }); group.close(); - track.writeGroup(group); track.close(); expect(frameOf(await nextFrame(consumer)).payload).toHaveLength(0); @@ -1409,7 +1390,7 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { maxDelay: 500 as Milli, }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: fragment("key"), timestamp: at(0), @@ -1419,7 +1400,6 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { timestamp: at(3000), }); group.close(); - track.writeGroup(group); track.close(); const frames = await drainFrames(consumer, 200); @@ -1438,11 +1418,10 @@ test("Consumer with CmafFormat times an untimed track from tfdt", async () => { maxDelay: 500 as Milli, }); - const group = new Group.Producer(0); + const group = track.createGroup(0); group.writeFrame({ payload: fragment("untimedKey") }); group.writeFrame({ payload: fragment("untimedDelta") }); group.close(); - track.writeGroup(group); track.close(); const frames = await drainFrames(consumer, 200); @@ -1481,16 +1460,14 @@ test("Consumer duration-skips a stalled group once it is covered", async () => { const consumer = new Consumer(replay(track), { format: durationFormat, maxDelay: 10_000 as Milli }); // Group.Producer 0: one frame at ts=0 lasting 33ms, never closed (stalled). - const g0 = new Group.Producer(0); + const g0 = track.createGroup(0); g0.writeFrame({ payload: new Uint8Array([0]), timestamp: Time.Timestamp.now() }); // Group.Producer 1: closed, starts exactly where group 0's frame ends. - const g1 = new Group.Producer(1); + const g1 = track.createGroup(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); g1.close(); - track.writeGroup(g0); - track.writeGroup(g1); track.close(); const frames = await drainFrames(consumer, 200); @@ -1519,15 +1496,13 @@ test("Consumer does not duration-skip when the gap is not covered", async () => // Group.Producer 0 stays open and later receives a second frame; nothing covers the gap, // so that late frame must survive rather than being skipped. - const g0 = new Group.Producer(0); + const g0 = track.createGroup(0); g0.writeFrame({ payload: new Uint8Array([0]), timestamp: Time.Timestamp.now() }); - const g1 = new Group.Producer(1); + const g1 = track.createGroup(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); g1.close(); - track.writeGroup(g0); - track.writeGroup(g1); // Let the consumer settle on group 0, then extend it before closing. await new Promise((resolve) => setTimeout(resolve, 20)); @@ -1554,13 +1529,12 @@ test("Consumer delivers a PTS-contiguous next group whose sequence jumped (Legac }); // Group A (seq 1_000_000): one 3000-tick sample, so its content ends at 33_333µs (3000/90000 * 1e6). - const a = new Group.Producer(1_000_000); + const a = track.createGroup(1_000_000); a.writeFrame({ payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); a.close(); - track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1570,8 +1544,7 @@ test("Consumer delivers a PTS-contiguous next group whose sequence jumped (Legac // A.end (33_333µs) -- the sub-millisecond skew a genuinely contiguous CMAF boundary shows because // per-sample durations and base-decode-times round to µs independently. It's within the contiguity // tolerance, so the timeline is unbroken and B must deliver immediately even while still open. - const b = new Group.Producer(1_090_000); - track.writeGroup(b); + const b = track.createGroup(1_090_000); b.writeFrame({ payload: encodeLegacyFrame(Math.round((3045 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x02])), timestamp: at(3045), @@ -1599,13 +1572,12 @@ test("Consumer waits on a PTS gap instead of skipping to a later buffered group }); // Group A (seq 1_000_000): content ends at 33_333µs. - const a = new Group.Producer(1_000_000); + const a = track.createGroup(1_000_000); a.writeFrame({ payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); a.close(); - track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1613,8 +1585,7 @@ test("Consumer waits on a PTS gap instead of skipping to a later buffered group // Group C (seq 1_090_000) starts at 90_000 ticks (1_000_000µs) -- far past A.end (33_333µs), a real // gap. An intermediate group may still be in transit, so C must NOT be delivered yet. - const c = new Group.Producer(1_090_000); - track.writeGroup(c); + const c = track.createGroup(1_090_000); c.writeFrame({ payload: encodeLegacyFrame(Math.round((90_000 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x03])), timestamp: at(90_000), @@ -1628,8 +1599,7 @@ test("Consumer waits on a PTS gap instead of skipping to a later buffered group // The missing group B arrives, contiguous with A (first PTS 3000 ticks = 33_333µs = A.end); it // continues the timeline, so delivery resumes with B's frame. - const b = new Group.Producer(1_045_000); - track.writeGroup(b); + const b = track.createGroup(1_045_000); b.writeFrame({ payload: encodeLegacyFrame(Math.round((3000 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x02])), timestamp: at(3000), @@ -1653,8 +1623,7 @@ test("Consumer delivers a contiguous group after one that completed out of order }); // A (seq 1_000_000) stays open so it remains the active group. - const a = new Group.Producer(1_000_000); - track.writeGroup(a); + const a = track.createGroup(1_000_000); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1665,8 +1634,7 @@ test("Consumer delivers a contiguous group after one that completed out of order // B (seq 1_045_000) starts at A's end and is CLOSED while A is still active, so B's own finally // block sees sequence !== #active and never records anything. B ends at 9000 ticks (100_000µs). - const b = new Group.Producer(1_045_000); - track.writeGroup(b); + const b = track.createGroup(1_045_000); b.writeFrame(timedFrame(0x03, 6000, 2)); b.close(); await settle(); @@ -1677,8 +1645,7 @@ test("Consumer delivers a contiguous group after one that completed out of order // C (seq 1_090_000) starts at B's end, so it continues the timeline and must deliver. const pending = consumer.next(); - const c = new Group.Producer(1_090_000); - track.writeGroup(c); + const c = track.createGroup(1_090_000); c.writeFrame(timedFrame(0x04, 9000, 3)); const result = await Promise.race([pending, settle(300).then(() => "timeout" as const)]); @@ -1699,8 +1666,7 @@ test("Consumer plays the head once a waited-out gap exceeds the budget (Legacy)" }); // A (seq 1000): one frame, ends at 3000 ticks (33_333µs). - const a = new Group.Producer(1000); - track.writeGroup(a); + const a = track.createGroup(1000); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); a.close(); @@ -1710,8 +1676,7 @@ test("Consumer plays the head once a waited-out gap exceeds the budget (Legacy)" // B (seq 2000) starts at 90_000 ticks (1_000_000µs), a second past A's end: the missing group // could only ever arrive far beyond the 100ms budget, so B plays from its first frame. - const b = new Group.Producer(2000); - track.writeGroup(b); + const b = track.createGroup(2000); b.writeFrame(timedFrame(0x02, 90_000, 1)); // The missing group's span was never delivered, so a gap from A's end to B comes first. @@ -1731,15 +1696,13 @@ test("Consumer waits on a gap within the budget, then plays the head once it exc }); // A (seq 1000): one frame, ends at 3000 ticks (33_333µs). - const a = new Group.Producer(1000); - track.writeGroup(a); + const a = track.createGroup(1000); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); a.close(); // B (seq 2000) starts at 6000 ticks (66_667µs): a gap, but only 33ms past A's end. - const b = new Group.Producer(2000); - track.writeGroup(b); + const b = track.createGroup(2000); b.writeFrame(timedFrame(0x02, 6000, 1)); const pending = consumer.next(); @@ -1803,8 +1766,7 @@ test("Consumer empty groups mean nothing", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("audio"), maxDelay: 2_000 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro]); - const empty = new Group.Producer(1); - track.writeGroup(empty); + const empty = track.createGroup(1); empty.close(); writeGroupWithLegacyFrames(track, 2, [1_000 as Micro]); await settle(); @@ -1857,8 +1819,7 @@ test("Consumer reads a shed marker group as a gap", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: new LegacyFormat("audio"), maxDelay: 0 as Milli }); - const group0 = new Group.Producer(0); - track.writeGroup(group0); + const group0 = track.createGroup(0); group0.writeFrame({ payload: encodeLegacy(0 as Micro), timestamp: Time.Timestamp.fromMicros(0), @@ -1886,16 +1847,14 @@ test("Consumer yields no gap across a PTS-contiguous group id jump (Legacy)", as maxDelay: 10_000 as Milli, }); - const a = new Group.Producer(1_000_000); - track.writeGroup(a); + const a = track.createGroup(1_000_000); a.writeFrame(timedFrame(0x01, 0, 0)); a.close(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); // Sequence jumps +90_000 but the first PTS meets A's end, so nothing is missing. - const b = new Group.Producer(1_090_000); - track.writeGroup(b); + const b = track.createGroup(1_090_000); b.writeFrame(timedFrame(0x02, 3000, 1)); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x02])); diff --git a/js/net/bench/resume.ts b/js/net/bench/resume.ts index 59b93166500..3171ce2c98c 100644 --- a/js/net/bench/resume.ts +++ b/js/net/bench/resume.ts @@ -48,8 +48,7 @@ async function measure(readers: number, swaps: number | undefined, fork = false) const track = broadcast.createTrack("seq"); // Every route's copy holds the same groups under the same sequences. const write = () => { - const group = new GroupProducer(sequence); - track.writeGroup(group); + const group = track.createGroup(sequence); group.writeFrame({ payload }); group.close(); }; diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index b7bf27d3294..71f4e82db69 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -1,6 +1,5 @@ /** Sweep failed group sends over independent publishers and subscribers. */ import { Milli } from "@moq/clock"; -import { Producer as GroupProducer } from "../src/group.ts"; import { randomHop } from "../src/hop.ts"; import { Publisher } from "../src/lite/publisher.ts"; import { decodeSubscribeResponseMaybe, Subscribe } from "../src/lite/subscribe.ts"; @@ -28,10 +27,9 @@ try { broadcast.announce(); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); for (let sequence = 0; sequence < groups; sequence++) { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame(textFrame("frame")); group.close(); - track.writeGroup(group); } track.close(); return { origin, broadcast }; diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 2a2e00adbc2..651ab9cb645 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -62,8 +62,7 @@ async function measure( // Inserted by sequence, the way the wire hands a subscribed track its groups. const publish = (close: boolean) => { clock += held > 0 ? 1 : CACHE_WINDOW_MS / retained; - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); group.writeFrame({ payload, timestamp: Timestamp.fromMillis(sequence++) }); if (close) group.close(); return group; diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index a3024baaabc..7152e157067 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -2,7 +2,7 @@ import { expect, setSystemTime, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import { GroupTooLarge, NotFound, ProtocolViolation, TimestampMismatch } from "./error.ts"; -import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "./group.ts"; +import { MAX_GROUP_FRAMES } from "./group.ts"; import { TrackSequences } from "./internal.ts"; import { textFrame } from "./mock.ts"; import { Timescale, Timestamp } from "./time.ts"; @@ -107,8 +107,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - const group = new GroupProducer(1); - second.writeGroup(group); + const group = second.createGroup(1); expect(() => group.writeFrame(textFrame("b", Timestamp.fromMillis(500)))).toThrow(TimestampMismatch); group.writeFrame(textFrame("b", Timestamp.fromMillis(1000))); @@ -148,7 +147,7 @@ test("a removed producer writes nothing once its replacement claims the name", a const subscriber = second.subscribe(); second.close(); expect(() => first.appendGroup()).toThrow("replaced"); - expect(() => first.writeGroup(new GroupProducer(1))).toThrow("replaced"); + expect(() => first.createGroup(1)).toThrow("replaced"); expect(() => first.appendDatagram(Timestamp.fromMillis(0), new Uint8Array())).toThrow("replaced"); expect(await subscriber.finished()).toEqual({ group: 1, frame: 0 }); first.close(); @@ -183,7 +182,7 @@ test("a replacement producer refuses to append a sequence the name already wrote const firstProducer = firstRequest.accept({ timescale: Timescale.MILLI }); expect(firstProducer.appendGroup().sequence).toBe(0); expect(firstProducer.appendDatagram(Timestamp.fromMillis(0), new Uint8Array())).toBe(1); - firstProducer.writeGroup(new GroupProducer(8)); + firstProducer.createGroup(8); firstProducer.insertDatagram(12, Timestamp.fromMillis(0), new Uint8Array()); firstSubscriber.close(); firstProducer.close(); @@ -198,7 +197,7 @@ test("a replacement producer refuses to append a sequence the name already wrote ); // An explicit first write picks the start, and appends continue from there. - secondProducer.writeGroup(new GroupProducer(13)); + secondProducer.createGroup(13); expect(secondProducer.appendGroup().sequence).toBe(14); const nextGeneration = new BroadcastProducer(); @@ -274,14 +273,14 @@ test("created and requested tracks share a name's sequences", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); expect(first.appendGroup().sequence).toBe(0); - first.writeGroup(new GroupProducer(8)); + first.createGroup(8); first.insertDatagram(12, Timestamp.fromMillis(0), new Uint8Array()); first.close(); // A replacement refuses to append past nothing, since that would reuse every explicit write. const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); expect(() => second.appendGroup()).toThrow("duplicate sequence"); - second.writeGroup(new GroupProducer(13)); + second.createGroup(13); second.close(); // An on-demand producer checks the same namespace, and so does a later created track. diff --git a/js/net/src/flate/group.test.ts b/js/net/src/flate/group.test.ts index 59dd9d40cb0..47067e2ac5a 100644 --- a/js/net/src/flate/group.test.ts +++ b/js/net/src/flate/group.test.ts @@ -69,13 +69,12 @@ test("declared plain bytes consume the group budget before any inflation", () => } }); -test("empty frames preserve the window and prewritten groups bind without duplicating plain copies", async () => { - const group = new Group(0); +test("empty frames preserve the window", async () => { + const track = new Track("compressed").accept({ compress: true }); + const group = track.createGroup(0); group.writeFrame({ payload }); group.writeFrame({ payload: new Uint8Array() }); const reader = group.mirror(); - const track = new Track("compressed").accept({ compress: true }); - track.writeGroup(group); group.writeFrame({ payload }); group.close(); for (const expected of [payload, new Uint8Array(), payload]) diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 61edb18538a..1f02f27b97d 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -229,7 +229,6 @@ export class Producer { group.#admits ??= []; group.#admits.push(admit); }; - hooks.groupStart = (group) => (group.#state.total.peek() > 0 ? group.#state.timestamp : undefined); hooks.overRefresh = (group, ratio) => { const state = group.#state; const first = state.frames.peek()[0]; diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 6d48ad1e388..e8bec126dfb 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -5,7 +5,7 @@ import type { Credential, Grant } from "../auth.ts"; import { Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { error, ProtocolViolation, SessionCode, SessionError, StreamCode, unauthorized } from "../error.ts"; -import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "../group.ts"; +import { MAX_GROUP_FRAMES } from "../group.ts"; import { type Hop, HopSchema } from "../hop.ts"; import { hooks } from "../internal.ts"; import { createMockTransportPair } from "../mock.ts"; @@ -279,10 +279,9 @@ test("TRACK_STATUS is answered on every draft without subscribing", async () => maxAge: Milli(5000), priority: 2, }); - const group = new GroupProducer(4); + const group = track.createGroup(4); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }); group.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.fromMillis(1) }); - track.writeGroup(group); const subscribed = spyOn(track, "subscribe"); const { stream, wire } = requestStream(version); @@ -797,24 +796,21 @@ test("a blocked group header is reset when the group expires", async () => { server, ); - const old = new GroupProducer(0); + const old = track.createGroup(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); old.close(); - track.writeGroup(old); await headerStarted; - const edge = new GroupProducer(1); + const edge = track.createGroup(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(10_000) }); edge.close(); - track.writeGroup(edge); // A group beyond the edge, so group 0's reach (10s, where group 1 begins) is // provably past the budget. A group is bounded by where its successor starts, so // the successor alone never convicts it: nothing yet proves group 0 ends sooner. - const later = new GroupProducer(2); + const later = track.createGroup(2); later.writeFrame({ payload: new TextEncoder().encode("later"), timestamp: Timestamp.fromMillis(20_000) }); later.close(); - track.writeGroup(later); const resetBeforeRelease = await Promise.race([ streamReset.then(() => true), @@ -883,10 +879,9 @@ test("a group stream whose FIN never completes is reset when the group expires", ); const write = (sequence: number) => { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); group.close(); - track.writeGroup(group); }; write(0); @@ -967,10 +962,9 @@ test("a group stream whose FIN never completes is reset after the cache evicts i // Frozen timestamps, so only the wall clock since group 1 arrived convicts group 0. const write = (sequence: number) => { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); group.close(); - track.writeGroup(group); }; write(0); @@ -1052,10 +1046,9 @@ test("a group that goes stale while its stream opens writes nothing", async () = ); const write = (sequence: number, ms: number) => { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); group.close(); - track.writeGroup(group); }; write(0, 0); await opening; @@ -3304,8 +3297,7 @@ async function subscribeOkLargest(version: IetfVersion): Promise | undefined; if (missing === "gap") { const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const group = new GroupProducer(1); - producer.writeGroup(group); + const group = producer.createGroup(1); group.close(); } else { serving = (async () => { diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index f9c7c5f09d2..eda34230e62 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -512,8 +512,6 @@ export const hooks: { * the group joined accepted it. */ admitGroupStart: (group: GroupProducer, admit: (start: Timestamp) => () => void) => void; - /** Return a group producer's first timestamp, if it already holds a frame. */ - groupStart: (group: GroupProducer) => Timestamp | undefined; /** Whether a group's bytes after its first frame exceed `ratio` times the first frame. */ overRefresh: (group: GroupProducer, ratio: number) => boolean; /** Keep a mirror of a group accepted unverified below its track's eviction watermark out of media-time expiry. */ @@ -683,9 +681,6 @@ export const hooks: { admitGroupStart: () => { throw new Error("group.ts not loaded"); }, - groupStart: () => { - throw new Error("group.ts not loaded"); - }, overRefresh: () => { throw new Error("group.ts not loaded"); }, diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index d827efb477b..98bbddc3c15 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -207,10 +207,9 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF // Finish the track only once it's being served, so the publisher observes a live // track ending rather than resolving a subscribe against an already-closed one. for (const sequence of sequences) { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame(textFrame("hello", Timestamp.fromMillis(sequence))); group.close(); - track.writeGroup(group); // Let the publisher drain this group before the next, so arrival order is the // order given rather than whatever the cache hands over in one batch. A group that @@ -258,7 +257,7 @@ async function groupSendOrders(options: { version?: Version; }) { const { priority, sequences, update, version = Version.DRAFT_05 } = options; - const groups = sequences.map((sequence) => new GroupProducer(sequence)); + const groups: GroupProducer[] = []; const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); @@ -282,15 +281,16 @@ async function groupSendOrders(options: { const opened = pair.client.incomingUnidirectionalStreams.getReader(); try { - for (const [index, group] of groups.entries()) { + for (const [index, sequence] of sequences.entries()) { if (index === 1 && update !== undefined) { await replayUpdate({ priority: update }).encode(client.writer, version); // Wait for the publisher to apply it, rather than assuming it beat the next group. while (track.subscription.peek()?.priority !== update) await track.subscription.changed(); } + const group = track.createGroup(sequence); + groups.push(group); group.writeFrame(textFrame("hello")); - track.writeGroup(group); // One stream per group, in the order given: wait for this one before queuing the next. await servingNextGroup(opened); @@ -370,9 +370,8 @@ test("lite draft-05: a subscribe update re-ranks a group already on the wire", a void publisher.runSubscribe(msg, server); // Leave the group open, so its stream is still being served when the update lands. - const group = new GroupProducer(4); + const group = track.createGroup(4); group.writeFrame(textFrame("hello")); - track.writeGroup(group); const opened = pair.client.incomingUnidirectionalStreams.getReader(); await servingNextGroup(opened); @@ -433,9 +432,8 @@ test("lite draft-05: a subscribe update during the stream open still ranks the g }); void publisher.runSubscribe(msg, server); - const group = new GroupProducer(4); + const group = track.createGroup(4); group.writeFrame(textFrame("hello")); - track.writeGroup(group); try { // The publisher is now parked inside the open, having already read priority 1. @@ -483,13 +481,14 @@ test("lite draft-05: many concurrent groups share one subscription listener", as void publisher.runSubscribe(msg, server); // Leave every group open, so all of their streams are in flight at once. - const groups = Array.from({ length: count }, (_, sequence) => new GroupProducer(sequence)); + const groups: GroupProducer[] = []; const opened = pair.client.incomingUnidirectionalStreams.getReader(); try { - for (const group of groups) { + for (let sequence = 0; sequence < count; sequence++) { + const group = track.createGroup(sequence); + groups.push(group); group.writeFrame(textFrame("hello")); - track.writeGroup(group); await servingNextGroup(opened); } opened.releaseLock(); @@ -524,10 +523,9 @@ test("lite draft-05: the fetch response ranks the publisher's own writes", async const broadcast = publish(origin, Path.from("test")); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const group = new GroupProducer(7); + const group = track.createGroup(7); group.writeFrame(textFrame("hello")); group.close(); - track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_05 }); @@ -560,10 +558,9 @@ test("lite draft-06: an unchecked track's fetched frames carry their send time", const broadcast = publish(origin, Path.from("test")); const track = broadcast.createTrack("video", { timescale: Timescale.MICRO, ietf: IetfVersion.DRAFT_22 }); - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("hello", Timestamp.fromMillis(36_000_000))); group.close(); - track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); const server = await Stream.accept(pair.server, Version.DRAFT_06); @@ -697,10 +694,9 @@ async function servedSubscription( release: gate.release, // Stamped by sequence, so a group served out of order still starts in order. serve(sequence: number) { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); for (const frame of frames) group.writeFrame(textFrame(frame, Timestamp.fromMillis(sequence))); group.close(); - track.writeGroup(group); }, // Makes the next group stream withhold its FIN from the client, the way a stream the // browser never finishes sending looks; resolves with the reason the publisher resets it. @@ -810,8 +806,7 @@ test("lite draft-06: a cut group is dropped early, once", async () => { expect(await sub.servedSequence()).toBe(0); expect("start" in (await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06))).toBe(true); - const cut = new GroupProducer(1); - sub.track.writeGroup(cut); + const cut = sub.track.createGroup(1); cut.close(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); @@ -836,8 +831,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { expect(await sub.servedSequence()).toBe(0); expect("start" in (await decodeSubscribeResponse(sub.client.reader, version))).toBe(true); - const cut = new GroupProducer(1); - sub.track.writeGroup(cut); + const cut = sub.track.createGroup(1); cut.close(new StreamError(StreamCode(70), { message: "cut" })); const resp = await decodeSubscribeResponse(sub.client.reader, version); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); @@ -944,8 +938,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { expect(await sub.servedSequence()).toBe(0); expect("start" in (await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06))).toBe(true); - const cut = new GroupProducer(1); - sub.track.writeGroup(cut); + const cut = sub.track.createGroup(1); cut.close(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); @@ -1156,9 +1149,8 @@ for (const skipped of ["failed", "stale"] as const) { }); try { hooks.startTrack(sub.track, { start: 5, requested: 5 }); - const group = new GroupProducer(5); + const group = sub.track.createGroup(5); group.writeFrame(textFrame("old", Timestamp.fromMillis(5))); - sub.track.writeGroup(group); group.close(skipped === "failed" ? new Error("cut") : undefined); if (skipped === "failed") sub.track.dropGroups(7, 8, new Error("cut")); sub.serve(6); @@ -1617,10 +1609,9 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy ); try { - const first = new GroupProducer(0); + const first = track.createGroup(0); first.writeFrame(textFrame("hello")); first.close(); - track.writeGroup(first); track.finishAt({ group: 2, frame: 0 }); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1629,10 +1620,9 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy const early = await pending; expect("end" in early).toBe(true); - const second = new GroupProducer(1); + const second = track.createGroup(1); second.writeFrame(textFrame("hello")); second.close(); - track.writeGroup(second); track.close(); const resp = await pending; @@ -1671,10 +1661,9 @@ async function heldOpenEnd() { server, ); - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("hello")); group.close(); - track.writeGroup(group); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1753,10 +1742,9 @@ test("lite draft-07: a group whose close fails is dropped", async () => { ); try { - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("hello")); group.close(); - track.writeGroup(group); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1803,10 +1791,9 @@ test("blocked DROP responses stop opening later groups", async () => { await write(chunk); }); const append = (sequence: number) => { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame(textFrame("frame")); group.close(); - track.writeGroup(group); }; for (let sequence = 0; sequence < 8; sequence++) append(sequence); const serving = publisher.runSubscribe( @@ -1879,10 +1866,9 @@ async function serve( void publisher.runSubscribe(msg, server); for (const [sequence, frames] of Object.entries(groups)) { - const group = new GroupProducer(Number(sequence)); + const group = track.createGroup(Number(sequence)); for (const frame of frames) group.writeFrame(textFrame(frame)); group.close(); - track.writeGroup(group); // Let the publisher drain each group before the next, so the streams arrive in // the order written rather than whatever the cache hands over in one batch. @@ -2059,10 +2045,9 @@ async function saturatedGroup() { void publisher.runSubscribe(msg, server); // A finished group still has frames to send, so its own close must not drop it. - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("hello")); group.close(); - track.writeGroup(group); // The group is queued inside the open from here on. await opened; @@ -2140,16 +2125,14 @@ test("lite draft-05: a blocked group header is reset when the group expires", as server, ); - const old = new GroupProducer(0); + const old = track.createGroup(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); old.close(); - track.writeGroup(old); await headerStarted; - const edge = new GroupProducer(1); + const edge = track.createGroup(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(1000) }); edge.close(); - track.writeGroup(edge); const resetBeforeRelease = await Promise.race([ streamReset.then(() => true), @@ -2263,7 +2246,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire if (!server) throw new Error("publisher never accepted the subscribe stream"); const opened = pair.client.incomingUnidirectionalStreams.getReader(); // Left open: its producer never finishes it. - const open = new GroupProducer(0); + const open = track.createGroup(0); try { void publisher.runSubscribe( @@ -2271,7 +2254,6 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire server, ); open.writeFrame(frame(0)); - track.writeGroup(open); const next = await opened.read(); if (next.done) throw new Error("publisher never opened the group stream"); @@ -2283,10 +2265,9 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire // The track runs a second past group 0's successor while group 0 waits for more. for (const ms of [1000, 2000]) { - const group = new GroupProducer(ms / 1000); + const group = track.createGroup(ms / 1000); group.writeFrame(frame(ms)); group.close(); - track.writeGroup(group); } const end = await stream.done().then( @@ -2354,10 +2335,9 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi ); const write = (sequence: number, ms: number) => { - const group = new GroupProducer(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); group.close(); - track.writeGroup(group); }; write(0, 0); await opening; @@ -2636,9 +2616,8 @@ test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () const broadcast = publish(origin, Path.from("live")); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); // Left open, so the fetch is still serving when the grant shrinks. - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("first")); - track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); const server = await Stream.accept(pair.server, Version.DRAFT_06); @@ -2674,9 +2653,8 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const frame = (ms: number) => ({ payload: new TextEncoder().encode("x"), timestamp: Timestamp.fromMillis(ms) }); // Left open, so the fetch is still serving when the track runs on. - const open = new GroupProducer(0); + const open = track.createGroup(0); open.writeFrame(frame(0)); - track.writeGroup(open); const client = await Stream.open(pair.client, { version: Version.DRAFT_07 }); const server = await Stream.accept(pair.server, Version.DRAFT_07); @@ -2695,10 +2673,9 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a // The first frame is on the wire before the track runs a second past its successor. await client.reader.u8(); for (const ms of [1000, 2000]) { - const group = new GroupProducer(ms / 1000); + const group = track.createGroup(ms / 1000); group.writeFrame(frame(ms)); group.close(); - track.writeGroup(group); } await serving; @@ -2720,10 +2697,9 @@ test.each([0, 1])("lite draft-07 reports the cached largest position when starti const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); const broadcast = publish(origin, Path.from("quiet")); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("cached")); group.close(); - track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); if (!server) throw new Error("missing subscribe stream"); @@ -2765,10 +2741,9 @@ test("lite draft-07 reports the source's largest position past what a relay hold const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); // The source answered with group 4 as its largest; only group 0 has landed. hooks.reportedLargest(track, { group: 4, frame: 0 }); - const group = new GroupProducer(0); + const group = track.createGroup(0); group.writeFrame(textFrame("oldest")); group.close(); - track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); if (!server) throw new Error("missing subscribe stream"); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index c7a1cb8e1e7..29d9d9806bb 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1588,7 +1588,7 @@ export class Subscriber { } const { track, timescale, tail } = entry; - const producer = new netGroup.Producer(group.sequence); + let producer: netGroup.Producer | undefined; const read = tail.open(group.sequence); try { @@ -1608,7 +1608,7 @@ export class Subscriber { track.close(violation); throw violation; } - track.writeGroup(producer); + producer = track.createGroup(group.sequence); // Block until the timescale is known; the group's stream can arrive before // TRACK_INFO (or implicit defaults) resolves it on the subscribe stream. @@ -1632,11 +1632,11 @@ export class Subscriber { // A violation ends the session, which the connection does once it escapes; anything // else ends only this group. if (err instanceof ProtocolViolation) { - producer.close(err); + producer?.close(err); throw err; } const e = await sessionCause(this.#quic, err); - producer.close(e); + producer?.close(e); stream.stop(e); } finally { read(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 6717f94274b..875f30da57c 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -4,7 +4,6 @@ import { announcedView } from "./announced.test-util"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import * as Epoch from "./epoch.ts"; import { StreamCode, StreamError } from "./error.ts"; -import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; import { hooks, spreadHash } from "./internal.ts"; import type { Consumer, Table } from "./origin.ts"; @@ -2244,7 +2243,7 @@ test("demand held across an epoch change asks again, unless it named the old epo const track = next.createTrack("video"); hooks.acceptRequest((await it.next()).value, next); const subscription = (await unpinned)?.track("video").subscribe(); - track.writeGroup(new GroupProducer(7)); + track.createGroup(7); expect((await subscription?.recvGroup())?.sequence).toBe(7); subscription?.close(); @@ -2292,7 +2291,7 @@ test("demand held across an epoch round trip still asks again", async () => { const track = next.createTrack("video"); hooks.acceptRequest((await it.next()).value, next); const subscription = (await demanded)?.track("video").subscribe(); - track.writeGroup(new GroupProducer(7)); + track.createGroup(7); expect((await subscription?.recvGroup())?.sequence).toBe(7); subscription?.close(); @@ -2453,7 +2452,7 @@ test.each([ expect(fresh.active.peek()?.epoch).toBe(to); const subscription = fresh.active.peek()?.track("video").subscribe(); const forwarded = (await demanded)?.track("video").subscribe(); - nextTrack.writeGroup(new GroupProducer(7)); + nextTrack.createGroup(7); expect((await subscription?.recvGroup())?.sequence).toBe(7); expect((await forwarded?.recvGroup())?.sequence).toBe(7); @@ -2549,7 +2548,7 @@ test("a session's re-request after an epoch update asks the handler again", asyn const track = next.createTrack("video"); hooks.acceptRequest((await requests.next()).value, next); const subscription = (await after)?.track("video").subscribe(); - track.writeGroup(new GroupProducer(7)); + track.createGroup(7); expect((await subscription?.recvGroup())?.sequence).toBe(7); subscription?.close(); @@ -2586,7 +2585,7 @@ test("an update does not override route selection: another claim at the epoch ke expect(held.active.peek()?.epoch).toBe(EPOCH); expect(fresh.active.peek()?.epoch).toBe(EPOCH); const subscription = fresh.active.peek()?.track("video").subscribe(); - track.writeGroup(new GroupProducer(7)); + track.createGroup(7); expect((await subscription?.recvGroup())?.sequence).toBe(7); subscription?.close(); diff --git a/js/net/src/parked-expiry.test.ts b/js/net/src/parked-expiry.test.ts index a30b7862e54..580b10f39cd 100644 --- a/js/net/src/parked-expiry.test.ts +++ b/js/net/src/parked-expiry.test.ts @@ -1,7 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { StreamCode } from "./error.ts"; -import { Producer as Group } from "./group.ts"; import { randomHop } from "./hop.ts"; import { NativeSession } from "./ietf/adapter.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; @@ -35,10 +34,9 @@ for (const protocol of protocols) { broadcast.announce(); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const write = (sequence: number) => { - const group = new Group(sequence); + const group = track.createGroup(sequence); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }); group.close(); - track.writeGroup(group); }; write(0); @@ -158,12 +156,11 @@ for (const protocol of protocols) { broadcast.announce(); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const write = (sequence: number, frames: number) => { - const group = new Group(sequence); + const group = track.createGroup(sequence); for (let frame = 0; frame < frames; frame++) { group.writeFrame({ payload: new Uint8Array([frame]), timestamp: Timestamp.fromMillis(0) }); } group.close(); - track.writeGroup(group); }; // Two frames, so the second is still unread while the stream's first write waits on credit. diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 5270b05224b..e64f83ddba4 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -51,8 +51,7 @@ function copy(source: BroadcastProducer) { return { producer, write(sequence: number, frames = 1, close = true) { - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); for (let frame = 0; frame < frames; frame++) group.writeFrame({ payload: enc.encode(`${sequence}.${frame}`) }); if (close) group.close(); @@ -348,8 +347,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on for (;;) { const group = await input.recvGroup(); if (!group) return; - const copy = new GroupProducer(group.sequence); - output.writeGroup(copy); + const copy = output.createGroup(group.sequence); try { for (let index = 0; ; index++) { const frame = await group.readFrame(); @@ -611,12 +609,10 @@ test("a fetched group continued from an empty, already-closed copy releases that const sub = front.consumer.track("seq").subscribe(); // Group 5 is empty: the first route fails it, the next holds it closed. - const failing = new GroupProducer(5); - a.createTrack("fetch").writeGroup(failing); + const failing = a.createTrack("fetch").createGroup(5); const group = await front.consumer.track("fetch").fetchGroup(5); const read = group.readFrame(); - const whole = new GroupProducer(5); - b.createTrack("fetch").writeGroup(whole); + const whole = b.createTrack("fetch").createGroup(5); whole.close(); front.serve(b.consume()); await settle(); @@ -655,8 +651,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn expect(drain(sub)).toEqual([0]); const fa = a.createTrack("fetch"); - const failing = new GroupProducer(5); - fa.writeGroup(failing); + const failing = fa.createGroup(5); failing.writeFrame({ payload: enc.encode("5.0") }); const fetching = front.consumer.track("fetch").fetchGroup(5); for (let turn = 0; turn < turns; turn++) await Promise.resolve(); @@ -666,8 +661,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn // The next route holds the fetched group whole; the old one still feeds the reader a // group the new route never has. const fb = b.createTrack("fetch"); - const whole = new GroupProducer(5); - fb.writeGroup(whole); + const whole = fb.createGroup(5); whole.writeFrame({ payload: enc.encode("5.0") }); whole.writeFrame({ payload: enc.encode("5.1") }); whole.close(); @@ -1096,8 +1090,7 @@ test("a failed group on a route whose replacement was refused ends with the refu b.createTrack("seq", { priority: 2 }); front.serve(a.consume()); const sub = front.consumer.track("seq").subscribe(); - const stalled = new GroupProducer(0); - ta.writeGroup(stalled); + const stalled = ta.createGroup(0); stalled.writeFrame({ payload: enc.encode("0.0") }); const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); @@ -1214,8 +1207,7 @@ test("a fetch never reads a replacement refused for its track properties", async const b = new BroadcastProducer(); a.createTrack("seq", { priority: 1 }); const tb = b.createTrack("seq", { priority: 2 }); - const group = new GroupProducer(0); - tb.writeGroup(group); + const group = tb.createGroup(0); group.writeFrame({ payload: enc.encode("b") }); group.close(); front.serve(a.consume()); @@ -1237,8 +1229,7 @@ test("the first fetch of a track says what the track is for later routes", async [b, 2], ] as const) { const producer = source.createTrack("seq", { priority }); - const group = new GroupProducer(0); - producer.writeGroup(group); + const group = producer.createGroup(0); group.writeFrame({ payload: enc.encode(`${priority}`) }); group.close(); } @@ -1636,8 +1627,7 @@ test("a fetch waits for the copy being read to say what the track is", async () // a answers what the track is only when told to. const requests = wireOf(a).requested(); const tb = b.createTrack("seq", { priority: 2 }); - const group = new GroupProducer(0); - tb.writeGroup(group); + const group = tb.createGroup(0); group.writeFrame({ payload: enc.encode("b") }); group.close(); front.serve(a.consume()); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 98712920dd3..65143bd0214 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -194,7 +194,7 @@ test("a fixed start is no join", () => { test("a replacement wants the group past its predecessor", () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("test"); - first.writeGroup(new GroupProducer(7)); + first.createGroup(7); first.close(); const second = broadcast.createTrack("test"); @@ -336,8 +336,8 @@ test("recvDatagram leaves the ordered group cursor alone", async () => { producer.insertDatagram(5, Timestamp.fromMillis(5), enc.encode("x")); expect((await datagrams.recvDatagram())?.sequence).toBe(5); - producer.writeGroup(new GroupProducer(3)); - producer.writeGroup(new GroupProducer(6)); + producer.createGroup(3); + producer.createGroup(6); expect((await track.nextGroup())?.sequence).toBe(3); expect((await track.nextGroup())?.sequence).toBe(6); }); @@ -565,15 +565,15 @@ test("nextGroup skips late arrivals", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe().ordered(); - producer.writeGroup(new GroupProducer(5)); + producer.createGroup(5); const first = await track.nextGroup(); expect(first?.sequence).toBe(5); // Late arrivals with sequence <= last returned are skipped. - producer.writeGroup(new GroupProducer(3)); - producer.writeGroup(new GroupProducer(4)); - producer.writeGroup(new GroupProducer(7)); + producer.createGroup(3); + producer.createGroup(4); + producer.createGroup(7); const next = await track.nextGroup(); expect(next?.sequence).toBe(7); @@ -583,8 +583,8 @@ test("nextGroup returns buffered groups in sequence", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); - producer.writeGroup(new GroupProducer(3)); - producer.writeGroup(new GroupProducer(5)); + producer.createGroup(3); + producer.createGroup(5); expect((await track.nextGroup())?.sequence).toBe(3); expect((await track.nextGroup())?.sequence).toBe(5); @@ -635,7 +635,7 @@ test("the ordered handle carries datagrams", async () => { const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); producer.insertDatagram(5, Timestamp.fromMillis(5), enc.encode("x")); - producer.writeGroup(new GroupProducer(3)); + producer.createGroup(3); expect((await track.recvDatagram())?.sequence).toBe(5); // The datagram did not consume the group cursor. @@ -691,10 +691,9 @@ test("a late-arriving successor bounds a group's reach", async () => { [2, 3000], [1, 200], ]) { - const group = new GroupProducer(sequence); + const group = producer.createGroup(sequence); group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); group.close(); - producer.writeGroup(group); } // Group 0 reaches at most 200ms, where group 1 starts, so it is past the budget. @@ -729,21 +728,18 @@ for (const take of ["nextGroup", "tryNextGroup"] as const) { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); - const zero = new GroupProducer(0); + const zero = producer.createGroup(0); zero.writeFrame(textFrame("0.0")); zero.writeFrame(textFrame("0.1")); zero.close(); - producer.writeGroup(zero); - const one = new GroupProducer(1); + const one = producer.createGroup(1); one.writeFrame(textFrame("1.0")); one.close(); - producer.writeGroup(one); - const two = new GroupProducer(2); + const two = producer.createGroup(2); two.writeFrame(textFrame("2.0")); two.close(); - producer.writeGroup(two); // The frame path enters group 0; a direct take then claims group 1. expect((await track.readFrame())?.group).toBe(0); @@ -762,21 +758,18 @@ test("ordered frame reads skip a late lower-sequence group", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); - const five = new GroupProducer(5); + const five = producer.createGroup(5); five.writeFrame(textFrame("five", Timestamp.fromMillis(5))); five.close(); - producer.writeGroup(five); expect(await track.readString()).toBe("five"); - const three = new GroupProducer(3); + const three = producer.createGroup(3); three.writeFrame(textFrame("three", Timestamp.fromMillis(3))); three.close(); - producer.writeGroup(three); - const six = new GroupProducer(6); + const six = producer.createGroup(6); six.writeFrame(textFrame("six", Timestamp.fromMillis(6))); six.close(); - producer.writeGroup(six); expect(await track.readString()).toBe("six"); }); @@ -816,8 +809,7 @@ test("a late lower group within the budget is delivered", async () => { [6, 2000], [7, 3000], ]) { - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); group.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); group.close(); } @@ -830,8 +822,7 @@ test("a late lower group within the budget is delivered", async () => { // Arriving below everything already delivered is not what makes content stale: the // budget is the only gate, and this straggler's timestamp is within it. A consumer // that needs sequence order reorders (or drops) it itself. - const late = new GroupProducer(4); - producer.writeGroup(late); + const late = producer.createGroup(4); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(500) }); late.close(); @@ -1181,30 +1172,6 @@ test("frozen timestamps still age out by wall clock", () => { } }); -// Arrival is per track: a group joining a second track later doesn't restart the first -// track's clock. -test("a group shared by two tracks ages each from its own arrival", () => { - const clock = manualTime(10_000); - const first = new TrackProducer("first").accept({}); - const second = new TrackProducer("second").accept({}); - try { - first.writeFrame({ payload: enc.encode("0") }); - const shared = new GroupProducer(1); - shared.writeFrame({ payload: enc.encode("1") }); - shared.close(); - first.writeGroup(shared); - clock.set(11_000); - second.writeGroup(shared); - - // Group 0's successor joined the first track a full budget ago. - expect(drain(first.subscribe({ maxDelay: Milli(1000) }))).toEqual([1]); - } finally { - first.close(); - second.close(); - clock.installed.close(); - } -}); - // Groups that arrive at once are judged by media time, which convicts them before the wall // clock can. test("a startup burst ages out by media time", () => { @@ -1377,10 +1344,9 @@ test("a live track retains its newest closed group through idle expiry", async ( try { // A late older group must not replace the protected newest snapshot. for (const sequence of [2, 1]) { - const group = new GroupProducer(sequence); + const group = producer.createGroup(sequence); group.writeFrame({ payload: enc.encode("snapshot"), timestamp: Timestamp.fromMillis(0) }); group.close(); - producer.writeGroup(group); } await clock.advance(10_000 + CACHE_WINDOW_MS + 200); const replay = producer.subscribe({ maxDelay: Milli(100) }); @@ -1604,16 +1570,14 @@ test("latency budget retains a consumed live-edge anchor", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); const track = producer.subscribe(); - const edge = new GroupProducer(2); + const edge = producer.createGroup(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); edge.close(); - producer.writeGroup(edge); expect((await track.recvGroup())?.sequence).toBe(2); - const late = new GroupProducer(1); + const late = producer.createGroup(1); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(0) }); late.close(); - producer.writeGroup(late); producer.close(); expect(track.recvGroup()).resolves.toBeUndefined(); @@ -1623,15 +1587,13 @@ test("an aborted live-edge anchor does not make older content stale", async () = const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); const track = producer.subscribe(); - const edge = new GroupProducer(2); + const edge = producer.createGroup(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); edge.close(new Error("aborted")); - producer.writeGroup(edge); - const older = new GroupProducer(1); + const older = producer.createGroup(1); older.writeFrame({ payload: enc.encode("older"), timestamp: Timestamp.fromMillis(0) }); older.close(); - producer.writeGroup(older); expect((await track.recvGroup())?.sequence).toBe(1); track.close(); @@ -1647,7 +1609,7 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca const ordered = order === "ordered" ? subscriber.ordered() : undefined; const read = () => (ordered ? ordered.nextGroup() : subscriber.recvGroup()); try { - for (let sequence = 0; sequence < 5; sequence++) producer.writeGroup(new GroupProducer(sequence)); + for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence); expect((await everything.recvGroup())?.sequence).toBe(0); expect((await read())?.sequence).toBe(1); expect((await read())?.sequence).toBe(2); @@ -1675,7 +1637,7 @@ test("subscription updates narrow the cursor before its first read", async () => const everything = producer.subscribe({ maxDelay: Milli(5000) }); const track = producer.subscribe({ maxDelay: Milli(5000) }); try { - for (let sequence = 0; sequence < 5; sequence++) producer.writeGroup(new GroupProducer(sequence)); + for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence); track.update({ maxDelay: Milli(5000), groups: { start: { excluded: 2 }, end: { included: 3 } } }); expect((await track.recvGroup())?.sequence).toBe(3); const pending = track.recvGroup(); @@ -1739,7 +1701,7 @@ test("withRange with an excluded end of 0 is the empty range and does not ride a const empty = producer.subscribe({ maxDelay: Milli(5000), groups: { end: { excluded: 0 } } }); empty.withRange({ end: { excluded: 0 } }); - for (let sequence = 0; sequence < 3; sequence++) producer.writeGroup(new GroupProducer(sequence)); + for (let sequence = 0; sequence < 3; sequence++) producer.createGroup(sequence); expect((await everything.recvGroup())?.sequence).toBe(0); const pending = empty.recvGroup(); @@ -1795,7 +1757,7 @@ test("local cursor bounds can skip, pause, and release buffered groups", async ( const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); - for (let sequence = 0; sequence < 5; sequence++) producer.writeGroup(new GroupProducer(sequence)); + for (let sequence = 0; sequence < 5; sequence++) producer.createGroup(sequence); track.withRange({ start: { included: 1 }, end: { excluded: 3 } }); expect((await track.nextGroup())?.sequence).toBe(1); @@ -1815,11 +1777,11 @@ test("recvGroup serves a late arrival after a newer group", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }); - producer.writeGroup(new GroupProducer(2)); + producer.createGroup(2); expect((await track.recvGroup())?.sequence).toBe(2); // Group 1 lands after group 2 was already served. - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(1); expect((await track.recvGroup())?.sequence).toBe(1); // Staleness is the latency window's job, not arrival order's: the track @@ -1834,7 +1796,7 @@ test("withRange parks recvGroup beyond the cap and a raised cap re-offers", asyn const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }); - for (let sequence = 0; sequence < 3; sequence++) producer.writeGroup(new GroupProducer(sequence)); + for (let sequence = 0; sequence < 3; sequence++) producer.createGroup(sequence); track.withRange({ end: { excluded: 2 } }); expect((await track.recvGroup())?.sequence).toBe(0); @@ -1863,9 +1825,9 @@ test("recvGroup serves in-range groups that arrive behind a capped one", async ( track.withRange({ end: { excluded: 2 } }); // Reordered burst: the beyond-cap group arrives first. - producer.writeGroup(new GroupProducer(2)); - producer.writeGroup(new GroupProducer(0)); - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(2); + producer.createGroup(0); + producer.createGroup(1); expect((await track.recvGroup())?.sequence).toBe(0); expect((await track.recvGroup())?.sequence).toBe(1); @@ -1884,42 +1846,39 @@ test("withRange drops groups recvGroup parked at the cap", async () => { const track = producer.subscribe(); track.withRange({ end: { excluded: 1 } }); - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(1); const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); track.withRange({ start: { included: 2 } }); - producer.writeGroup(new GroupProducer(2)); + producer.createGroup(2); // The overtaken parked group is dropped, not re-offered. expect((await pending)?.sequence).toBe(2); }); // A live duplicate would fan out to every subscriber twice (recvGroup has no sequence -// cursor to hide it), so writeGroup rejects it like Rust's claim_sequence. An aborted +// cursor to hide it), so createGroup rejects it like Rust's claim_sequence. An aborted // incarnation is evicted so a fresh group can serve the sequence again. -test("writeGroup rejects a duplicate live sequence", async () => { +test("createGroup rejects a duplicate live sequence", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe(); - const first = new GroupProducer(7); + const first = producer.createGroup(7); first.writeFrame(textFrame("first")); first.close(); - producer.writeGroup(first); // A peer re-sending a live sequence is rejected, not fanned out again. - expect(() => producer.writeGroup(new GroupProducer(7))).toThrow("duplicate group"); + expect(() => producer.createGroup(7)).toThrow("duplicate group"); expect((await track.recvGroup())?.sequence).toBe(7); // An aborted incarnation is replaceable: the retry serves the sequence fresh. - const aborted = new GroupProducer(8); - producer.writeGroup(aborted); + const aborted = producer.createGroup(8); aborted.close(new Error("upstream reset")); - const retry = new GroupProducer(8); + const retry = producer.createGroup(8); retry.writeFrame(textFrame("retry")); retry.close(); - producer.writeGroup(retry); const got = await track.recvGroup(); expect(got?.sequence).toBe(8); @@ -1934,7 +1893,7 @@ test("closing the subscriber releases a recvGroup parked after a clean close", a const track = producer.subscribe(); track.withRange({ end: { excluded: 1 } }); - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(1); const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); @@ -1951,7 +1910,7 @@ test("closing the subscriber releases a recvGroup parked while the producer is l const track = producer.subscribe(); track.withRange({ end: { excluded: 1 } }); - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(1); const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); @@ -1965,12 +1924,12 @@ test("the ordered and arrival cursors are independent", async () => { const ordered = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); const arrival = producer.subscribe({ maxDelay: Milli(5000) }); - producer.writeGroup(new GroupProducer(5)); + producer.createGroup(5); expect((await ordered.nextGroup())?.sequence).toBe(5); // A late seq 3 is skipped by the ordered cursor, which has already passed it, but the // arrival cursor has both buffered and still delivers each exactly once. - producer.writeGroup(new GroupProducer(3)); + producer.createGroup(3); expect((await arrival.recvGroup())?.sequence).toBe(3); expect((await arrival.recvGroup())?.sequence).toBe(5); }); @@ -1982,13 +1941,13 @@ test("the first group read commits the cursor and refuses the other order", asyn // An arrival read commits to arrival order; ordered() is refused afterwards. const arrival = producer.subscribe({ maxDelay: Milli(5000) }); - producer.writeGroup(new GroupProducer(5)); + producer.createGroup(5); expect((await arrival.recvGroup())?.sequence).toBe(5); expect(() => arrival.ordered()).toThrow("arrival order"); // The commitment refuses the other cursor without poisoning this one: a late // lower sequence still flows in arrival order. - producer.writeGroup(new GroupProducer(3)); + producer.createGroup(3); expect((await arrival.recvGroup())?.sequence).toBe(3); // The other direction: ordered() takes the subscription and arrival reads throw. @@ -2011,7 +1970,7 @@ test("recvDatagram does not commit the group cursor", async () => { // Still uncommitted: the subscription can go sequence-ordered. const ordered = track.ordered(); - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(1); expect((await ordered.nextGroup())?.sequence).toBe(1); }); @@ -2030,10 +1989,9 @@ test("a closed track still delivers a group parked above the cap once the cap is const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); for (let sequence = 0; sequence < 3; sequence++) { - const group = new GroupProducer(sequence); + const group = producer.createGroup(sequence); group.writeFrame(textFrame(`frame-${sequence}`)); group.close(); - producer.writeGroup(group); } track.withRange({ end: { excluded: 2 } }); @@ -2064,7 +2022,7 @@ test("end reports the head after the highest produced sequence, datagrams includ const track = producer.subscribe(); expect(track.end()).toBeUndefined(); - producer.writeGroup(new GroupProducer(0)); + producer.createGroup(0); producer.appendDatagram(Timestamp.fromMillis(1), enc.encode("x")); producer.close(); expect(track.end()).toEqual({ group: 2, frame: 0 }); @@ -2174,12 +2132,10 @@ test("a late group below the largest changes nothing", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); - const framed = new GroupProducer(2); - producer.writeGroup(framed); + const framed = producer.createGroup(2); framed.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); framed.close(); - const empty = new GroupProducer(5); - producer.writeGroup(empty); + const empty = producer.createGroup(5); empty.close(); producer.finishAt({ group: 6, frame: 0 }); @@ -2187,8 +2143,7 @@ test("a late group below the largest changes nothing", async () => { expect(await subscriber.largest()).toBeUndefined(); // Late content below the end is still admitted; it just isn't the largest. - const late = new GroupProducer(4); - producer.writeGroup(late); + const late = producer.createGroup(4); late.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); expect(await subscriber.largest()).toBeUndefined(); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 0 }); @@ -2202,8 +2157,7 @@ test("a recreated largest group is reported before the end", async () => { const subscriber = producer.subscribe(); // The recreated group carries the same content, timestamps included. const timestamp = Timestamp.now(); - const first = new GroupProducer(0); - producer.writeGroup(first); + const first = producer.createGroup(0); first.writeFrame({ payload: enc.encode("a"), timestamp }); first.close(new Error("cut")); producer.finishAt({ group: 1, frame: 0 }); @@ -2217,8 +2171,7 @@ test("a recreated largest group is reported before the end", async () => { await settle(); expect(advanced).toBe(false); - const again = new GroupProducer(0); - producer.writeGroup(again); + const again = producer.createGroup(0); for (const payload of ["a", "b"]) again.writeFrame({ payload: enc.encode(payload), timestamp }); expect(await next).toEqual({ group: 0, frame: 1 }); again.close(); @@ -2237,8 +2190,7 @@ test("a datagram raises the largest", async () => { /** Write untimed group `sequence` with `frames` frames, left open. */ function framedGroup(producer: TrackProducer, sequence: number, frames: number): GroupProducer { - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); for (let i = 0; i < frames; i++) group.writeFrame({ payload: enc.encode("x") }); return group; } @@ -2369,7 +2321,7 @@ test("group ranges preserve progress and spell out inclusion", async () => { const track = producer .subscribe({ maxDelay: Milli(5000) }) .withRange({ start: { included: 1 }, end: { included: 1 } }); - for (let sequence = 0; sequence < 4; sequence++) producer.writeGroup(new GroupProducer(sequence)); + for (let sequence = 0; sequence < 4; sequence++) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); expect(track.tryRecvGroup()).toBeUndefined(); track.withRange({ start: { included: 1 }, end: { excluded: 3 } }); @@ -2384,7 +2336,7 @@ test("group ranges preserve progress and spell out inclusion", async () => { test("a lowered range re-offers the unread cached groups", () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 10 } } }); - for (const sequence of [5, 6, 10]) producer.writeGroup(new GroupProducer(sequence)); + for (const sequence of [5, 6, 10]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(10); expect(track.tryRecvGroup()).toBeUndefined(); @@ -2394,7 +2346,7 @@ test("a lowered range re-offers the unread cached groups", () => { expect(track.tryRecvGroup()?.sequence).toBe(5); track.withRange({}); expect(track.tryRecvGroup()).toBeUndefined(); - producer.writeGroup(new GroupProducer(11)); + producer.createGroup(11); expect(track.tryRecvGroup()?.sequence).toBe(11); track.close(); }); @@ -2402,11 +2354,10 @@ test("a lowered range re-offers the unread cached groups", () => { test("a lowered range re-offers a cached cut with its error", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); - const cut = new GroupProducer(0); - producer.writeGroup(cut); + const cut = producer.createGroup(0); const error = new StreamError(StreamCode(70), { message: "cut" }); cut.close(error); - producer.writeGroup(new GroupProducer(1)); + producer.createGroup(1); expect(track.tryRecvGroup()?.sequence).toBe(1); track.withRange({}); @@ -2420,7 +2371,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { test("a lowered range still re-offers after the producer closes", () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); - for (const sequence of [0, 1]) producer.writeGroup(new GroupProducer(sequence)); + for (const sequence of [0, 1]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); producer.close(); @@ -2434,7 +2385,7 @@ for (const abort of [undefined, new Error("gone")]) { test(`a lowered range offers nothing to a reader that ${abort ? "aborted" : "closed"}`, () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); - for (const sequence of [0, 1]) producer.writeGroup(new GroupProducer(sequence)); + for (const sequence of [0, 1]) producer.createGroup(sequence); expect(track.tryRecvGroup()?.sequence).toBe(1); track.close(abort); @@ -2447,7 +2398,7 @@ for (const abort of [undefined, new Error("gone")]) { test("malformed group bounds do not partially advance the cursor", () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }); - producer.writeGroup(new GroupProducer(0)); + producer.createGroup(0); expect(() => track.withRange({ start: { included: 3 }, end: { excluded: -1 } })).toThrow(); expect(track.tryRecvGroup()?.sequence).toBe(0); track.close(); @@ -2455,7 +2406,7 @@ test("malformed group bounds do not partially advance the cursor", () => { test("finishAt refuses an end at or below a produced sequence", () => { const producer = new TrackProducer("test"); - producer.writeGroup(new GroupProducer(5)); + producer.createGroup(5); // Exclusive, so it must be above the highest produced sequence. expect(() => producer.finishAt({ group: 5, frame: 0 })).toThrow(); @@ -2464,7 +2415,7 @@ test("finishAt refuses an end at or below a produced sequence", () => { // A second end is refused, as is any group at or past the first. expect(() => producer.finishAt({ group: 7, frame: 0 })).toThrow(); - expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); + expect(() => producer.createGroup(6)).toThrow(); expect(() => producer.appendGroup()).toThrow(); expect(() => producer.insertDatagram(6, Timestamp.now(), new Uint8Array(1))).toThrow(); producer.close(); @@ -2473,7 +2424,7 @@ test("finishAt refuses an end at or below a produced sequence", () => { test("finishAt declares an end ahead of the live edge without ending the track", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(60_000) }); - producer.writeGroup(new GroupProducer(5)); + producer.createGroup(5); const first = await track.recvGroup(); expect(first?.sequence).toBe(5); @@ -2482,12 +2433,10 @@ test("finishAt declares an end ahead of the live edge without ending the track", expect(await track.finished()).toEqual({ group: 8, frame: 0 }); // Not terminal: groups below the end still arrive, including a straggler. - const late = new GroupProducer(7); + const late = producer.createGroup(7); late.close(); - producer.writeGroup(late); - const straggler = new GroupProducer(6); + const straggler = producer.createGroup(6); straggler.close(); - producer.writeGroup(straggler); expect((await track.recvGroup())?.sequence).toBe(6); expect((await track.recvGroup())?.sequence).toBe(7); @@ -2509,9 +2458,8 @@ for (const ordered of [false, true]) { try { producer.finishAt({ group: 3, frame: 0 }); const waiting = receive(); - const group = new GroupProducer(2); + const group = producer.createGroup(2); group.writeFrame(textFrame("tail")); - producer.writeGroup(group); const received = await waiting; expect(received?.sequence).toBe(2); expect(await received?.readString()).toBe("tail"); @@ -2549,9 +2497,8 @@ for (const channel of ["group", "datagram"]) { const groupRead = track.recvGroup(); const datagramRead = track.recvDatagram(); if (channel === "group") { - const group = new GroupProducer(2); + const group = producer.createGroup(2); group.close(); - producer.writeGroup(group); } else { producer.insertDatagram(2, Timestamp.fromMillis(0), enc.encode("tail")); } @@ -2834,23 +2781,6 @@ test("a frame whose timedness disagrees with its track is refused", async () => expect(frame?.timestamp).toBeUndefined(); }); -test("a group joining a track it disagrees with is refused", () => { - const producer = new TrackProducer("test").accept({}); - const group = new GroupProducer(0); - group.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(1) }); - expect(() => producer.writeGroup(group)).toThrow(TimestampMismatch); -}); - -test("an empty group bound to a timed track can't join an untimed one", () => { - const timed = new TrackProducer("timed").accept({ timescale: Timescale.MILLI }); - const untimed = new TrackProducer("untimed").accept({}); - const group = timed.appendGroup(); - expect(() => untimed.writeGroup(group)).toThrow(TimestampMismatch); - // Still held to the timed track it was bound to first. - expect(() => group.writeFrame({ payload: enc.encode("x") })).toThrow(TimestampMismatch); - group.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(1) }); -}); - test("a datagram whose timedness disagrees with its track is refused", async () => { const timed = new TrackProducer("timed").accept({ timescale: Timescale.MILLI }); expect(() => timed.appendDatagram(undefined, enc.encode("x"))).toThrow(TimestampMismatch); @@ -2966,8 +2896,7 @@ test("explicit and producer-cut drops survive outside the group cache", async () producer.dropGroups(3, 4, DROPPED); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 3, end: 4, error: DROPPED }); - const group = new GroupProducer(4); - producer.writeGroup(group); + const group = producer.createGroup(4); const cut = new Error("cut"); group.close(cut); await settle(); @@ -2988,8 +2917,7 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () new TooFarBehind(), ]; for (const [sequence, hole] of holes.entries()) { - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); group.close(hole); } await settle(); @@ -3002,11 +2930,9 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () test("an upstream drop ends a group still arriving with its error", async () => { const producer = new TrackProducer("video").accept({}); const reader = producer.subscribe({ maxDelay: Milli(60_000) }); - const arriving = new GroupProducer(3); - producer.writeGroup(arriving); + const arriving = producer.createGroup(3); arriving.writeFrame({ payload: enc.encode("3") }); - const finished = new GroupProducer(4); - producer.writeGroup(finished); + const finished = producer.createGroup(4); finished.writeFrame({ payload: enc.encode("4") }); finished.close(); @@ -3041,13 +2967,11 @@ test("a drop below the window is reported once the window lowers to it", () => { test("a re-created cut group is served, not dropped", async () => { const producer = new TrackProducer("video").accept({}); const reader = producer.subscribe({ maxDelay: Milli(60_000) }); - const cut = new GroupProducer(4); - producer.writeGroup(cut); + const cut = producer.createGroup(4); cut.close(new Error("cut")); await settle(); - const retry = new GroupProducer(4); - producer.writeGroup(retry); + producer.createGroup(4); await settle(); const recv = hooks.tryRecvGroup(reader, true); expect(recv.kind === "group" ? recv.group.sequence : recv).toBe(4); @@ -3098,8 +3022,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => const producer = new TrackProducer("video").accept({}); try { producer.finishAt({ group: 5, frame: 5 }); - const group = new GroupProducer(5); - producer.writeGroup(group); + const group = producer.createGroup(5); for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); group.close(); producer.close(); @@ -3137,8 +3060,7 @@ test("a wire end repeats or refines but never moves", () => { // end says does not exist. test("a mid-group end refuses to cut existing frames", () => { const producer = new TrackProducer("video").accept({}); - const group = new GroupProducer(5); - producer.writeGroup(group); + const group = producer.createGroup(5); for (let i = 0; i < 6; i++) group.writeFrame({ payload: new Uint8Array([i]) }); expect(() => producer.finishAt({ group: 5, frame: 5 })).toThrow(ProtocolViolation); expect(producer.subscribe().end()).toBeUndefined(); @@ -3151,27 +3073,20 @@ test.each([true, false])("a mid-group end caps later writes (declared first: %s) const producer = new TrackProducer("video").accept({}); const end = { group: 5, frame: 2 }; if (early) producer.finishAt(end); - const group = new GroupProducer(5); + const group = producer.createGroup(5); group.writeFrame({ payload: new Uint8Array([0]) }); - producer.writeGroup(group); if (!early) producer.finishAt(end); group.writeFrame({ payload: new Uint8Array([1]) }); expect(() => group.writeFrame({ payload: new Uint8Array([2]) })).toThrow("past the track's end"); expect(group.consume().frameCount).toBe(2); - expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); - // A group handed over already past the end is refused before it is cached. - const overfull = new GroupProducer(5); - for (let i = 0; i < 3; i++) overfull.writeFrame({ payload: new Uint8Array([i]) }); - group.close(new Error("replaced")); - expect(() => producer.writeGroup(overfull)).toThrow(ProtocolViolation); + expect(() => producer.createGroup(6)).toThrow(); producer.close(); }); // A closed group `sequence` written into `producer` whose one frame starts at `ms`, aborted // as ingest does when that frame is refused. function stampGroup(producer: TrackProducer, sequence: number, ms: number): GroupProducer { - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); try { group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } catch (err) { @@ -3193,8 +3108,7 @@ test("a re-created group is handed out once", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(30_000) }); const subscriber = producer.subscribe({ maxDelay: Milli(30_000) }); stampGroup(producer, 0, 0); - const aborted = new GroupProducer(1); - producer.writeGroup(aborted); + const aborted = producer.createGroup(1); expect(drainGroups(subscriber)).toEqual([0, 1]); aborted.close(new Error("aborted")); @@ -3298,14 +3212,12 @@ test("a rewind is refused whichever group lands first", () => { // that start as the watermark, and no later group can start before it. test("a replaced aborted group still bounds later ones", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); - const first = new GroupProducer(0); - producer.writeGroup(first); + const first = producer.createGroup(0); first.writeFrame({ payload: enc.encode("1000"), timestamp: Timestamp.fromMillis(1000) }); first.close(new Error("aborted")); - const replacement = new GroupProducer(0); + const replacement = producer.createGroup(0); replacement.writeFrame({ payload: enc.encode("500"), timestamp: Timestamp.fromMillis(500) }); - producer.writeGroup(replacement); replacement.close(); expect(() => stampGroup(producer, 1, 600)).toThrow(TimestampMismatch); stampGroup(producer, 1, 1000); @@ -3328,12 +3240,13 @@ test("a rewind across large timescales is refused", () => { ).toThrow(TimestampMismatch); }); -test("a group stamped before it is written is checked on write", () => { +test("a group's first frame is checked against the group below it", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); stampGroup(producer, 1, 6000); - const group = new GroupProducer(2); - group.writeFrame({ payload: enc.encode("early"), timestamp: Timestamp.fromMillis(5000) }); - expect(() => producer.writeGroup(group)).toThrow(TimestampMismatch); + const group = producer.createGroup(2); + expect(() => group.writeFrame({ payload: enc.encode("early"), timestamp: Timestamp.fromMillis(5000) })).toThrow( + TimestampMismatch, + ); }); test("an evicted group still bounds, and a late group below it is unverified", async () => { @@ -3378,8 +3291,7 @@ test("an evicted group still bounds, and a late group below it is unverified", a test("an untimed track takes groups in any order", () => { const producer = new TrackProducer("test").accept({}); for (const sequence of [5, 3, 4]) { - const group = new GroupProducer(sequence); - producer.writeGroup(group); + const group = producer.createGroup(sequence); group.writeFrame({ payload: enc.encode(`${sequence}`) }); group.close(); } @@ -3393,9 +3305,8 @@ function pendingTail(end: number, maxDelay = Milli(60_000)) { hooks.startTrack(producer, { start: 0 }); hooks.endTrack(producer, { group: end, frame: 0 }); const write = (sequence: number) => { - const group = new GroupProducer(sequence); + const group = producer.createGroup(sequence); group.close(); - producer.writeGroup(group); }; return { producer, reader, write }; } @@ -3446,9 +3357,8 @@ test("without a declared start, the first arrival stands in for it", async () => const producer = new TrackProducer("test"); const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.endTrack(producer, { group: 3, frame: 0 }); - const group = new GroupProducer(2); + const group = producer.createGroup(2); group.close(); - producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); producer.close(); @@ -3487,9 +3397,8 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" const reader = producer.subscribe({ maxDelay: Milli(60_000), groups: { start: { included: 1 } } }); hooks.startTrack(producer, { start: 3, requested: 1 }); hooks.endTrack(producer, { group: 4, frame: 0 }); - const group = new GroupProducer(3); + const group = producer.createGroup(3); group.close(); - producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(3); expect(await reader.recvGroup()).toBeUndefined(); @@ -3506,18 +3415,16 @@ test("an end after the highest group holds the reader at the hole", async () => const producer = new TrackProducer("test"); const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.startTrack(producer, { start: 0 }); - const high = new GroupProducer(2); + const high = producer.createGroup(2); high.close(); - producer.writeGroup(high); expect((await reader.recvGroup())?.sequence).toBe(2); const next = reader.recvGroup(); hooks.endTrack(producer, { group: 3, frame: 0 }); expect(await settled(next)).toBe(false); for (const sequence of [0, 1]) { - const low = new GroupProducer(sequence); + const low = producer.createGroup(sequence); low.close(); - producer.writeGroup(low); } expect((await next)?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 6f5062b84f9..010fa115c74 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1351,10 +1351,9 @@ export class Producer { this.#pruneTimerAt = at; } - // Retain a source group and fan it out to every live sink. A group that already holds - // frame 0 passes its checked `start` and whether it is `late`; any other checks frame 0 + // Retain a source group and fan it out to every live sink; its frame 0 is checked // against this track when it lands. - #publish(group: GroupProducer, start?: Timestamp, late = false): void { + #publish(group: GroupProducer): void { const entry: CachedGroup = { group, mirrors: new Map(), arrived: now() }; this.#undrop(group.sequence); group.closed.then((closed) => { @@ -1367,8 +1366,6 @@ export class Producer { const sequence = group.sequence; if (this.#unchecked()) { // Its timestamps may rewind, so they are not checked. - } else if (start) { - this.#record(entry, start, late); } else { hooks.admitGroupStart(group, (start) => { const late = this.#admit(sequence, start); @@ -1462,7 +1459,7 @@ export class Producer { * Append a new group with the next sequence number: one past this producer's own writes. * * Throws if an earlier producer of the name in this broadcast already wrote that sequence. - * A replacement picks where it starts by writing its first group with {@link writeGroup}. + * A replacement picks where it starts with {@link createGroup}. * * Its frames must match the track's {@link Info.timescale}: a timestamp on every frame of * a timed track and none on an untimed one, or the write throws {@link TimestampMismatch}. @@ -1478,35 +1475,31 @@ export class Producer { } /** - * Insert an existing group into the track. + * Create a new group with the given sequence number. * * Throws on a sequence that is still cached: a live duplicate would fan out to every * subscriber twice. An aborted incarnation is evicted so a fresh group can serve the * sequence again. Best effort (mirrors Rust): nothing remembers a sequence whose cache - * entry is already gone, so a long-evicted sequence is accepted as new. Throws - * {@link TimestampMismatch} if the group already holds a frame whose timedness - * disagrees with the track's {@link Info.timescale}, or whose first frame starts before - * the group below it or after the one above it. + * entry is already gone, so a long-evicted sequence is accepted as new. Its frames are + * checked like {@link appendGroup}'s. */ - writeGroup(group: GroupProducer) { - this.#writable(group.sequence); - this.#bind(group); - this.#cap(group); - const existing = this.#cached.get(group.sequence); + createGroup(sequence: number): GroupProducer { + this.#writable(sequence); + const existing = this.#cached.get(sequence); if (existing) { if (!(existing.group.closed.peek() instanceof Error)) { - throw new Error(`duplicate group: sequence=${group.sequence}`); + throw new Error(`duplicate group: sequence=${sequence}`); } // Subscribers may have seen the aborted incarnation's start, so it stays a watermark. this.#evict(existing); this.#uncache(existing, true); } - // A group written before it joined is checked here, before it is cached or fanned out. - const start = this.#unchecked() ? undefined : hooks.groupStart(group); - const late = start !== undefined && this.#admit(group.sequence, start); - - this.#publish(group, start, late); + const group = new GroupProducer(sequence); + this.#bind(group); + this.#cap(group); + this.#publish(group); + return group; } // Whether this track's timestamps may rewind (see {@link Info.ietf}). diff --git a/js/watch/src/audio/decoder.test.ts b/js/watch/src/audio/decoder.test.ts index 1c449b169a0..6faa0dfe850 100644 --- a/js/watch/src/audio/decoder.test.ts +++ b/js/watch/src/audio/decoder.test.ts @@ -963,10 +963,9 @@ async function livePlayback(burst: number) { held.push(frame * FRAME); if (held.length < burst) continue; for (const timestamp of held) { - const group = new Moq.Group.Producer(sequence++); + const group = track.createGroup(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(timestamp)))); group.close(); - track.writeGroup(group); } held = []; } diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index b97c17ef421..1cdb2fbd110 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -98,10 +98,9 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< clock = t[next].arrival; for (; next < t.length && t[next].arrival === clock; next++) { const media = Micro.fromMilli(t[next].media as Milli); - const group = new Group.Producer(next); + const group = track.createGroup(next); group.writeFrame({ payload: encode(media), timestamp: Time.Timestamp.fromMicros(media) }); group.close(); - track.writeGroup(group); } // A macrotask boundary, so the group readers have observed everything written above. diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 82a9b91d633..8dc3c033906 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -236,9 +236,8 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato } let group = groups.get(arrival.group); if (!group) { - group = new Group.Producer(arrival.group); + group = track.createGroup(arrival.group); groups.set(arrival.group, group); - track.writeGroup(group); } const timestamp = micros(arrival.timestamp); group.writeFrame({ diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index e9927a0d5f7..1be32d3d32a 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -900,10 +900,9 @@ function liveVideo(clock: ReturnType, track: Moq.Track.Producer held.push(frame * FRAME); if (held.length < this.burst) continue; for (const media of held) { - const group = new Moq.Group.Producer(sequence++); + const group = track.createGroup(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(media)))); group.close(); - track.writeGroup(group); } held = []; } @@ -1016,9 +1015,8 @@ it("a video track read from another path does not inherit the outgoing delay", a it("buffered video holds its poster until its own span reaches the floor", async () => { const clock = manualAt(0); const playback = await guardedPlayback("legacy", [], [], true, 1000); - const group = new Moq.Group.Producer(0); + const group = playback.track.createGroup(0); if (!playback.track) throw new Error("missing video track"); - playback.track.writeGroup(group); try { group.writeFrame(legacyFrame(Micro(0))); group.writeFrame(legacyFrame(Micro(40_000))); diff --git a/test/interop/clients/js/open-gop/main.ts b/test/interop/clients/js/open-gop/main.ts index d851fc3dd8c..eead7c800e0 100644 --- a/test/interop/clients/js/open-gop/main.ts +++ b/test/interop/clients/js/open-gop/main.ts @@ -65,8 +65,7 @@ window.runRecovery = async (packets) => { for (const packet of packets) { if (group?.sequence !== packet.group) { group?.close(); - group = new Moq.Group.Producer(packet.group); - track.writeGroup(group); + group = track.createGroup(packet.group); } const timestamp = Micro(packet.timestamp); const payload = Uint8Array.from(atob(packet.data), (c) => c.charCodeAt(0)); diff --git a/test/interop/track-status.ts b/test/interop/track-status.ts index 87f8bac7663..4f05ce8f1ca 100644 --- a/test/interop/track-status.ts +++ b/test/interop/track-status.ts @@ -23,10 +23,9 @@ const origin = new OriginProducer(); const broadcast = origin.createBroadcast(Path.from("room")); broadcast.announce(); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: Milli(5000), priority: 2 }); -const group = new GroupProducer(4); +const group = track.createGroup(4); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }); group.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.fromMillis(1) }); -track.writeGroup(group); const publisher = new Publisher({ quic: pair.server, diff --git a/test/wasm/baseline/publisher.ts b/test/wasm/baseline/publisher.ts index 2a0ade8def1..9310f5ee4cc 100644 --- a/test/wasm/baseline/publisher.ts +++ b/test/wasm/baseline/publisher.ts @@ -28,11 +28,10 @@ async function prepare(config: Workload): Promise { while (demand.peek() === undefined && track.closed.peek() === undefined) await Promise.race([demand.changed(), track.closed]); if (track.closed.peek() !== undefined) return; - const group = new Moq.Group.Producer(config.sequence); + const group = track.createGroup(config.sequence); for (const [frame, bytes] of fixtures.entries()) group.writeFrame({ payload: bytes, timestamp: Moq.Time.Timestamp.fromMillis(frame) }); group.close(); - track.writeGroup(group); // Keep the track and broadcast served until every reader completes. No timing arm or tail-close race. })(), ); From 314f20a7d9896b905479b87a57ee227a139a511c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:05:54 -0700 Subject: [PATCH 15/58] feat(net)!: split JS Group.Producer.close into finish and abort Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/e2ee.test.ts | 2 +- js/e2ee/src/group.ts | 4 +- js/flate/src/snapshot/producer.ts | 2 +- js/flate/src/snapshot/snapshot.test.ts | 4 +- js/flate/src/stream/producer.ts | 4 +- js/flate/src/stream/stream.test.ts | 2 +- js/hang/src/container/consumer.holes.test.ts | 10 +- .../container/consumer.nonsequential.test.ts | 2 +- .../src/container/consumer.outoforder.test.ts | 6 +- js/hang/src/container/consumer.rewind.test.ts | 2 +- .../src/container/consumer.startup.test.ts | 12 +- js/hang/src/container/consumer.test.ts | 82 +++++----- js/hang/src/container/legacy.ts | 7 +- js/json/src/snapshot/producer.ts | 11 +- js/json/src/snapshot/snapshot.test.ts | 2 +- js/json/src/stream/producer.ts | 4 +- js/json/src/window/producer.ts | 9 +- js/json/src/window/window.test.ts | 4 +- js/net/bench/compression.ts | 2 +- js/net/bench/drift.ts | 2 +- js/net/bench/frames.ts | 2 +- js/net/bench/refresh.ts | 6 +- js/net/bench/resume.ts | 8 +- js/net/bench/serve-browser-entry.ts | 2 +- js/net/bench/subscribe-drop.ts | 2 +- js/net/bench/track.ts | 4 +- js/net/src/broadcast.test.ts | 20 +-- js/net/src/connection/reload.test.ts | 4 +- js/net/src/consume.test.ts | 2 +- js/net/src/flate/group.test.ts | 14 +- js/net/src/group.test.ts | 34 ++-- js/net/src/group.ts | 22 ++- js/net/src/ietf/publisher.test.ts | 30 ++-- js/net/src/ietf/subscribe_tracks.test.ts | 6 +- js/net/src/ietf/subscriber.ts | 10 +- js/net/src/integration.test.ts | 34 ++-- js/net/src/lite/compression.test.ts | 2 +- js/net/src/lite/connection.test.ts | 8 +- js/net/src/lite/group.test.ts | 6 +- js/net/src/lite/publisher.test.ts | 59 +++---- js/net/src/lite/refresh.test.ts | 2 +- js/net/src/lite/subscriber.ts | 20 +-- js/net/src/order.test.ts | 10 +- js/net/src/origin.test.ts | 6 +- js/net/src/parked-expiry.test.ts | 4 +- js/net/src/resume.test-util.ts | 2 +- js/net/src/resume.test.ts | 70 ++++---- js/net/src/retention.test.ts | 2 +- js/net/src/track.test.ts | 152 +++++++++--------- js/net/src/track.ts | 13 +- js/net/src/untimed.test.ts | 8 +- js/net/src/zod.test.ts | 4 +- js/publish/src/broadcast.test.ts | 6 +- js/watch/src/audio/decoder.test.ts | 2 +- js/watch/src/audio/playout.test.ts | 2 +- js/watch/src/audio/replay.ts | 2 +- js/watch/src/video/decoder.test.ts | 12 +- 57 files changed, 392 insertions(+), 373 deletions(-) diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index a14ad142370..9b028fc2649 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -388,7 +388,7 @@ test("grouped authentication failure ends the track", async () => { const bad = new NetGroup.Producer(1); p.net.writeGroup(bad); bad.writeFrame({ timestamp: ms(2), payload: await sealed(p, GROUP, 1, 0, "ok", true) }); - bad.close(); + bad.finish(); expect(await readText(await recvGroup(p.consumer))).toBe("ok"); const forged = await recvGroup(p.consumer); diff --git a/js/e2ee/src/group.ts b/js/e2ee/src/group.ts index a08466d085a..395dd11fbb7 100644 --- a/js/e2ee/src/group.ts +++ b/js/e2ee/src/group.ts @@ -96,14 +96,14 @@ export class Producer { } this.#closed = true; void this.#writer.pump.run(() => { - if (!this.#aborted) this.#inner.close(); + if (!this.#aborted) this.#inner.finish(); }); } #abort(error: Error): void { this.#closed = true; this.#aborted ??= error; - this.#inner.close(error); + this.#inner.abort(error); } } diff --git a/js/flate/src/snapshot/producer.ts b/js/flate/src/snapshot/producer.ts index c9c8e6250c5..7b6a1997453 100644 --- a/js/flate/src/snapshot/producer.ts +++ b/js/flate/src/snapshot/producer.ts @@ -61,7 +61,7 @@ export class Producer { } finally { // The group is already visible on the track, so leaving it open on a failed write would // strand a subscriber that advanced into it with nothing to read and no end. - group.close(); + group.finish(); } } diff --git a/js/flate/src/snapshot/snapshot.test.ts b/js/flate/src/snapshot/snapshot.test.ts index 8e94be4bd12..71309dcf65e 100644 --- a/js/flate/src/snapshot/snapshot.test.ts +++ b/js/flate/src/snapshot/snapshot.test.ts @@ -151,11 +151,11 @@ test("latest lets a newer group preempt an open one", async () => { // A complete newer value arrives while the previous group is still open. const fresh = track.appendGroup(); fresh.writeFrame({ payload: bytes(2), timestamp: Time.Timestamp.now() }); - fresh.close(); + fresh.finish(); expect((await consumer.latest())?.value).toEqual(bytes(2)); - stale.close(); + stale.finish(); track.close(); }); diff --git a/js/flate/src/stream/producer.ts b/js/flate/src/stream/producer.ts index fb023d26d98..b8ca9a6d532 100644 --- a/js/flate/src/stream/producer.ts +++ b/js/flate/src/stream/producer.ts @@ -88,7 +88,7 @@ export class Producer { // end. The group reaches a reader already inside it, which keeps its own handle and would // otherwise get a generic error when ours is dropped. const abort = err instanceof Error ? err : new Error(String(err)); - this.#group?.close(abort); + this.#group?.abort(abort); this.#group = undefined; this.#track.close(abort); throw err; @@ -100,7 +100,7 @@ export class Producer { /** Finish the track, closing the group. */ finish(): void { - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; this.#track.close(); } diff --git a/js/flate/src/stream/stream.test.ts b/js/flate/src/stream/stream.test.ts index e3983a7e07e..6140647e128 100644 --- a/js/flate/src/stream/stream.test.ts +++ b/js/flate/src/stream/stream.test.ts @@ -75,7 +75,7 @@ test("a second group is a rolled log, not a continuation", async () => { for (const pair of [payloads(2), payloads(2)]) { const group = track.appendGroup(); for (const payload of pair) group.writeFrame({ payload, timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); } track.close(); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index fe77724c73d..25044194521 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -26,13 +26,13 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms const first = new Group.Producer(0); track.writeGroup(first); first.writeFrame({ payload: encodeFrame(new Uint8Array([1]), Micro(10_000)), timestamp: Time.Timestamp.now() }); - first.close(); + first.finish(); await flush(); expect(results).toHaveLength(1); const marker = new Group.Producer(2); track.writeGroup(marker); marker.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.now() }); - marker.close(); + marker.finish(); await flush(); if (budget > 0) { expect(results).toHaveLength(1); @@ -75,7 +75,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as payload: timestamp === undefined ? new Uint8Array() : encodeFrame(new Uint8Array([1]), Micro(timestamp)), timestamp: Time.Timestamp.fromMicros(timestamp ?? 20_000), }); - group.close(); + group.finish(); }; try { write(0, 0); @@ -126,7 +126,7 @@ test("the holes a stalled source left behind share one budget", async () => { payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)), timestamp: Time.Timestamp.fromMicros(sequence * 10_000), }); - group.close(); + group.finish(); }; try { write(0); @@ -167,7 +167,7 @@ test("a hole is late from the earliest arrival past it", async () => { payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)), timestamp: Time.Timestamp.fromMicros(sequence * 10_000), }); - group.close(); + group.finish(); }; try { write(0); diff --git a/js/hang/src/container/consumer.nonsequential.test.ts b/js/hang/src/container/consumer.nonsequential.test.ts index 492a8b03a80..5544943625d 100644 --- a/js/hang/src/container/consumer.nonsequential.test.ts +++ b/js/hang/src/container/consumer.nonsequential.test.ts @@ -46,7 +46,7 @@ test("Consumer waits on a non-sequential hang group because it has no per-frame payload: encodeLegacyFrame(0 as Micro, new Uint8Array([0x01])), timestamp: Time.Timestamp.now(), }); - a.close(); + a.finish(); track.writeGroup(a); // Drain A's frame. diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index 927c61caf46..fd6b00a0b98 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -35,7 +35,7 @@ function publish(track: Track.Producer, sequence: number, millis: number) { const group = new Group.Producer(sequence); track.writeGroup(group); publishFrame(group, millis, sequence); - group.close(); + group.finish(); } /** Every frame the consumer will hand over right now, as [group, payload] pairs. */ @@ -111,13 +111,13 @@ test("a below-cursor group still downloading is not truncated", async () => { // rather than shift the group out, so the second frame still arrives. const pending = nextFrame(consumer); publishFrame(backlog, 1500, 11); - backlog.close(); + backlog.finish(); const second = await pending; expect(second?.group).toBe(1); expect(second?.frame.payload[0]).toBe(11); - live.close(); + live.finish(); track.close(); consumer.close(); }); diff --git a/js/hang/src/container/consumer.rewind.test.ts b/js/hang/src/container/consumer.rewind.test.ts index 358c9b2d701..67f01adfc2b 100644 --- a/js/hang/src/container/consumer.rewind.test.ts +++ b/js/hang/src/container/consumer.rewind.test.ts @@ -33,7 +33,7 @@ function group(track: Track.Producer, timestamps: number[], end?: number) { payload: encodeFrame(new Uint8Array(), Micro(end)), }); } - group.close(); + group.finish(); } async function frame(consumer: Consumer) { diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 5bb4417e4e2..3a46b7c3ae7 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -67,10 +67,10 @@ for (const emptySuccessor of [false, true]) { if (emptySuccessor) { const empty = new Group.Producer(17); track.writeGroup(empty); - empty.close(); + empty.finish(); await flush(); } - stale.close(new NetError.Stream(StreamCode.Old)); + stale.abort(new NetError.Stream(StreamCode.Old)); await flush(); expect(results).toEqual([]); @@ -97,7 +97,7 @@ test("Consumer preserves a declared marker before its first media across a hole" const marker = new Group.Producer(16); track.writeGroup(marker); write(marker, 0, true); - marker.close(); + marker.finish(); await flush(); expect(items(results)).toEqual(["end 16@0"]); @@ -117,7 +117,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const first = new Group.Producer(16); track.writeGroup(first); write(first, 0); - first.close(); + first.finish(); await flush(); expect(items(results)).toEqual(["16@0"]); @@ -125,7 +125,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const marker = new Group.Producer(17); track.writeGroup(marker); write(marker, 0, true); - marker.close(); + marker.finish(); await flush(); expect(items(results)).toEqual(["16@0", "end 17@0"]); } @@ -141,7 +141,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const bridge = new Group.Producer(sequence - 1); track.writeGroup(bridge); write(bridge, 0); - bridge.close(); + bridge.finish(); await flush(); // The bridge closes the hole, so nothing reads as a gap. expect(items(results)).toEqual(["16@0", ...(reset ? ["end 17@0"] : []), `${sequence - 1}@0`, `${sequence}@50000`]); diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 5c70cffa5b2..18535ee70aa 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -48,7 +48,7 @@ function writeLoc(track: Track.Producer, groups: [Micro, number[]][][]) { timestamp: Time.Timestamp.fromMicros(timestamp), }); } - group.close(); + group.finish(); } track.close(); } @@ -395,7 +395,7 @@ test("Consumer refuses a data marker that shares its group", async () => { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.fromMicros(0) }); group.writeFrame({ payload: second, timestamp: Time.Timestamp.fromMicros(10_000) }); - group.close(); + group.finish(); // A finished track resolves the read instead of hanging when the reader fails to end. track.close(); await expect(nextFrame(consumer)).rejects.toThrow("shares its group"); @@ -507,7 +507,7 @@ function writeGroupWithLegacyFrames( for (const ts of timestamps) { group.writeFrame({ payload: encodeLegacy(ts), timestamp: stamp(ts) }); } - group.close(); + group.finish(); track.writeGroup(group); } @@ -517,7 +517,7 @@ function writeMarkerGroup(track: Track.Producer, sequence: number, timestamp: Mi payload: encodeLegacyFrame(timestamp, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(timestamp), }); - group.close(); + group.finish(); track.writeGroup(group); } @@ -598,7 +598,7 @@ test("Consumer index spans MoQ frames for keyframe detection", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // first MoQ frame → 3 samples group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // second MoQ frame → 3 samples - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -631,13 +631,13 @@ for (const [where, payloads] of [ const g0 = new Group.Producer(0); for (const byte of payloads) g0.writeFrame({ payload: new Uint8Array([byte]), timestamp: Time.Timestamp.now() }); - g0.close(); + g0.finish(); track.writeGroup(g0); // A later group that decodes cleanly is never reached. const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([0x04]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g1); track.close(); @@ -665,7 +665,7 @@ test("Consumer delivers everything ahead of a decode error", async () => { const malformed = new Group.Producer(3); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.fromMicros(200_000) }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); track.close(); @@ -692,7 +692,7 @@ test("Consumer keeps the first of several decode errors", async () => { for (const sequence of [0, 1]) { const group = new Group.Producer(sequence); group.writeFrame({ payload: new Uint8Array([sequence]), timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); track.writeGroup(group); } track.close(); @@ -722,7 +722,7 @@ test("Consumer releases its open groups when an error ends it", async () => { const malformed = new Group.Producer(0); malformed.writeFrame({ payload: new Uint8Array([0xff]), timestamp: Time.Timestamp.now() }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); const later = new Group.Producer(1); later.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); @@ -763,7 +763,7 @@ test("Consumer keeps frames decoded before a group reset (truncated GoP)", async } track.writeGroup(g0); await decoded; - g0.close(new NetError.Stream(StreamCode.Cancel)); + g0.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [4 as Micro]); track.close(); @@ -957,7 +957,7 @@ test("Consumer keeps a trailing duration marker internal", async () => { payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); await settle(); @@ -987,7 +987,7 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra payload: encodeLegacyFrame(20_000 as Micro, new Uint8Array([0xde, 0xad])), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1101,7 +1101,7 @@ test("Consumer throws the first error in delivery order", async () => { const malformed = new Group.Producer(1); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: wallClock() }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); writeGroupWithLegacyFrames(track, 2, [0 as Micro, 100_000 as Micro], wallClock); track.close(); @@ -1140,7 +1140,7 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: batchFormat, maxDelay: 500 as Milli }); - write(0, [1_000_000]).close(); + write(0, [1_000_000]).finish(); expect((await nextFrame(consumer))?.frame?.timestamp).toBe(1_000_000 as Micro); // The newer group arrives first and stays open, so the older one is the oldest buffered group @@ -1148,10 +1148,10 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn const newest = write(3, [3_000_000]); await later; // Its first sample is fine; the second rewinds below the delivered group's start. - write(2, [2_000_000, 0]).close(); + write(2, [2_000_000, 0]).finish(); // Read only once the skip has dropped it, before the newer group's frame is delivered. await rewound; - newest.close(); + newest.finish(); track.close(); const read = async () => { @@ -1246,7 +1246,7 @@ test("Consumer reads no gap when retiring a group holding only its endpoint", as payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(33_000), }); - group.close(); + group.finish(); track.writeGroup(group); await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); @@ -1272,7 +1272,7 @@ test("Consumer reads a gap when retiring a truncated group", async () => { await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); - group.close(new NetError.Stream(StreamCode.Cancel)); + group.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [100_000 as Micro]); writeGroupWithLegacyFrames(track, 2, [200_000 as Micro]); await settle(); @@ -1303,7 +1303,7 @@ test("Consumer reads a gap when retiring an open group", async () => { expect(await consumer.next()).toEqual({ kind: "gap", timestamp: 0 as Micro, duration: 100_000 as Micro }); expect(frameOf(await consumer.next()).timestamp).toBe(100_000 as Micro); - group.close(); + group.finish(); consumer.close(); }); @@ -1368,7 +1368,7 @@ test("Consumer handles empty decode result without deadlock", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // empty decode group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // valid decode - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1392,7 +1392,7 @@ test("Consumer preserves empty media from formats without endpoint markers", asy const consumer = new Consumer(track.subscribe(), { format, maxDelay: 500 as Milli }); const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1418,7 +1418,7 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { payload: fragment("delta"), timestamp: at(3000), }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1441,7 +1441,7 @@ test("Consumer with CmafFormat times an untimed track from tfdt", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: fragment("untimedKey") }); group.writeFrame({ payload: fragment("untimedDelta") }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1487,7 +1487,7 @@ test("Consumer duration-skips a stalled group once it is covered", async () => { // Group.Producer 1: closed, starts exactly where group 0's frame ends. const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g0); track.writeGroup(g1); @@ -1524,7 +1524,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g0); track.writeGroup(g1); @@ -1532,7 +1532,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => // Let the consumer settle on group 0, then extend it before closing. await new Promise((resolve) => setTimeout(resolve, 20)); g0.writeFrame({ payload: new Uint8Array([20]), timestamp: Time.Timestamp.now() }); - g0.close(); + g0.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1559,7 +1559,7 @@ test("Consumer delivers a PTS-contiguous next group whose sequence jumped (Legac payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); - a.close(); + a.finish(); track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1604,7 +1604,7 @@ test("Consumer waits on a PTS gap instead of skipping to a later buffered group payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); - a.close(); + a.finish(); track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1668,10 +1668,10 @@ test("Consumer delivers a contiguous group after one that completed out of order const b = new Group.Producer(1_045_000); track.writeGroup(b); b.writeFrame(timedFrame(0x03, 6000, 2)); - b.close(); + b.finish(); await settle(); - a.close(); + a.finish(); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x02])); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x03])); @@ -1703,7 +1703,7 @@ test("Consumer plays the head once a waited-out gap exceeds the budget (Legacy)" track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); - a.close(); + a.finish(); // The reader is parked on the missing 1001, as a decoder is while it waits for the next group. const pending = consumer.next(); @@ -1735,7 +1735,7 @@ test("Consumer waits on a gap within the budget, then plays the head once it exc track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); - a.close(); + a.finish(); // B (seq 2000) starts at 6000 ticks (66_667µs): a gap, but only 33ms past A's end. const b = new Group.Producer(2000); @@ -1805,7 +1805,7 @@ test("Consumer empty groups mean nothing", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); const empty = new Group.Producer(1); track.writeGroup(empty); - empty.close(); + empty.finish(); writeGroupWithLegacyFrames(track, 2, [1_000 as Micro]); await settle(); @@ -1873,7 +1873,7 @@ test("Consumer reads a shed marker group as a gap", async () => { // The zero budget sheds the marker unread, and what it declared is lost with it. expect(await consumer.next()).toEqual({ kind: "gap", timestamp: 0 as Micro, duration: 500 as Micro }); expect(frameOf(await consumer.next()).timestamp).toBe(500 as Micro); - group0.close(); + group0.finish(); consumer.close(); }); @@ -1889,7 +1889,7 @@ test("Consumer yields no gap across a PTS-contiguous group id jump (Legacy)", as const a = new Group.Producer(1_000_000); track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); - a.close(); + a.finish(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1926,7 +1926,7 @@ test("a live video frame is delivered before its duration marker arrives", async payload: encodeLegacyFrame(15_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.close(); // The marker is the frame's duration, not an item. expect(await consumer.next()).toBeUndefined(); @@ -1958,7 +1958,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre timestamp: Time.Timestamp.now(), }); await settle(); - group.close(new NetError.Stream(code)); + group.abort(new NetError.Stream(code)); await settle(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([1])); const next = track.appendGroup(); @@ -1966,7 +1966,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre payload: encodeLegacyFrame(1 as Micro, new Uint8Array([2])), timestamp: Time.Timestamp.now(), }); - next.close(); + next.finish(); // The reset cut the group's tail. expect(await consumer.next()).toEqual({ kind: "gap", @@ -2011,12 +2011,12 @@ test("a group reset does not hide a later container decode failure", async () => try { const reset = track.appendGroup(); await settle(); - reset.close(new NetError.Stream(StreamCode.Cancel)); + reset.abort(new NetError.Stream(StreamCode.Cancel)); await settle(); const malformed = track.appendGroup(); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.now() }); - malformed.close(); + malformed.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("buffer too short"); } finally { diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index a4dc2d2326b..5916111b02e 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -146,7 +146,7 @@ export class Producer { payload: this.#format.kind === "data" ? new Uint8Array() : encodeFrame(new Uint8Array(), timestamp), timestamp: Time.Timestamp.fromMicros(timestamp), }); - group.close(); + group.finish(); this.#liveEdge = this.#liveEdge === undefined ? timestamp : (Math.max(this.#liveEdge, timestamp) as Micro); this.#marked = true; } @@ -169,7 +169,7 @@ export class Producer { : undefined; // A presentation endpoint cannot bound the decode-order tail after reordering. this.#format.finishGroup(this.#group, this.#reordered ? undefined : end); - this.#group.close(); + this.#group.finish(); this.#group = undefined; if (this.#end !== undefined) { this.#liveEdge = this.#liveEdge === undefined ? this.#end : (Math.max(this.#liveEdge, this.#end) as Micro); @@ -182,7 +182,8 @@ export class Producer { /** Close the track and current group, optionally with an error. */ close(err?: Error) { if (!err) this.#close(); - this.#group?.close(err); + if (err) this.#group?.abort(err); + else this.#group?.finish(); this.#track.close(err); } } diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index 638c40e59ad..f25254a82e0 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -80,7 +80,7 @@ export class Producer { const group = this.#group; this.#group = undefined; - group.close(); + group.finish(); } /** @@ -111,7 +111,7 @@ export class Producer { if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle // before opening the next one, so a failure below doesn't leave a closed group behind. - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; const group = this.#track.appendGroup(); @@ -122,7 +122,7 @@ export class Producer { // The group carries no frames, so close it rather than leaving it open on the track. A // rejected frame doesn't close the track, and a consumer that already advanced into this // group would otherwise block until some later update opened a newer one. - group.close(); + group.finish(); throw err; } @@ -131,7 +131,7 @@ export class Producer { this.#group = group; } else { // Deltas disabled: one frame per group, identical to a plain JSON track. - group.close(); + group.finish(); this.#group = undefined; } return; @@ -186,7 +186,8 @@ export class Producer { #close(abort?: Error): void { this.#unwatch?.(); this.#unwatch = undefined; - this.#group?.close(abort); + if (abort) this.#group?.abort(abort); + else this.#group?.finish(); this.#group = undefined; // The open group goes with the track, so the encoder must not keep emitting deltas into it. diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index cbe6ba971e7..1b08fdcb545 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -634,7 +634,7 @@ test("snapshot consumer propagates a non-gap frame failure", async () => { const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode('{"ok":true}'), timestamp: Time.Timestamp.now() }); expect((await consumer.next())?.value).toEqual({ ok: true }); - group.close(new NetError.Stream(StreamCode.Internal)); + group.abort(new NetError.Stream(StreamCode.Internal)); await expect(consumer.next()).rejects.toMatchObject({ code: StreamCode.Internal }); }); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index be17354c7df..5e5c7612ce1 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -87,14 +87,14 @@ export class Producer { // Close the group with the same error first. A consumer that already pulled it holds its own // handle, which the track's close no longer reaches, so dropping ours would leave a reader // sitting in the group with a generic error instead of the failure that ended the log. - this.#group?.close(abort); + this.#group?.abort(abort); this.#group = undefined; this.#track.close(abort); } /** Finish the track, closing the group. */ finish(): void { - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; this.#track.close(); } diff --git a/js/json/src/window/producer.ts b/js/json/src/window/producer.ts index fdecbd3e6bd..de269416096 100644 --- a/js/json/src/window/producer.ts +++ b/js/json/src/window/producer.ts @@ -114,7 +114,7 @@ export class Producer { if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle // before opening the next one, so a failure below doesn't leave a closed group behind. - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; const group = this.#track.appendGroup(); @@ -124,7 +124,7 @@ export class Producer { } catch (err) { // The group carries no frames, so close it rather than leaving it open on the track. A // consumer that already advanced into it would otherwise block with nothing to read. - group.close(); + group.finish(); throw err; } @@ -153,7 +153,7 @@ export class Producer { const group = this.#group; this.#group = undefined; - group.close(); + group.finish(); } /** Finish the track, closing any open group. */ @@ -167,7 +167,8 @@ export class Producer { this.#unwatch?.(); this.#unwatch = undefined; - this.#group?.close(abort); + if (abort) this.#group?.abort(abort); + else this.#group?.finish(); this.#group = undefined; this.#track.close(abort); diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index b5a08fb9c39..78187201e1f 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -403,13 +403,13 @@ test("consumer resumes at a checkpoint after losing a group", async () => { group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); expect((await consumer.next())?.value).toEqual({ push: { index: 0, value: { n: 0 } } }); - group.close(error); + group.abort(error); frame = encoder.push({ n: 1 }); group = track.appendGroup(); group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); - group.close(); + group.finish(); track.close(); expect((await consumer.next())?.value).toEqual({ push: { index: 1, value: { n: 1 } } }); diff --git a/js/net/bench/compression.ts b/js/net/bench/compression.ts index 8c5cbe91ea4..42d1b882b97 100644 --- a/js/net/bench/compression.ts +++ b/js/net/bench/compression.ts @@ -22,7 +22,7 @@ for (const publishers of [1, 4, 16]) { let started = performance.now(); for (const group of groups) { for (let index = 0; index < frames; index++) group.writeFrame({ payload }); - group.close(); + group.finish(); } publish += performance.now() - started; stored = groups.reduce((bytes, group) => bytes + hooks.groupCacheBytes(group), 0); diff --git a/js/net/bench/drift.ts b/js/net/bench/drift.ts index a4b74268e2e..efc820f3300 100644 --- a/js/net/bench/drift.ts +++ b/js/net/bench/drift.ts @@ -20,7 +20,7 @@ export async function run(): Promise { for (let sequence = 0; sequence < retained; sequence++) { const group = track.appendGroup(); group.writeFrame({ payload }); - group.close(); + group.finish(); } const subscribers = Array.from({ length: readers }, () => track.subscribe({ maxDelay: Milli(60_000) }), diff --git a/js/net/bench/frames.ts b/js/net/bench/frames.ts index 22aa863798b..0ba69284490 100644 --- a/js/net/bench/frames.ts +++ b/js/net/bench/frames.ts @@ -54,7 +54,7 @@ const lite: Protocol = { const producer = new Producer(0); const done = readFrames(new Reader(stream, undefined, LiteVersion.DRAFT_06), producer, { scale: 1_000_000, - }).then(() => producer.close()); + }).then(() => producer.finish()); return { group: Promise.resolve(producer.consume()), done }; }; return { open, close: () => {} }; diff --git a/js/net/bench/refresh.ts b/js/net/bench/refresh.ts index 106f3f3b89d..4e3842f7cce 100644 --- a/js/net/bench/refresh.ts +++ b/js/net/bench/refresh.ts @@ -39,7 +39,7 @@ for (const retained of [1, 64, 1024]) for (let i = 0; i < tracks; i++) { const group = producers[i].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); - group.close(); + group.finish(); const received = await baseline[i].recvGroup(); if (!received) throw new Error("baseline group missing"); await received.readFrame(); @@ -50,7 +50,7 @@ for (const retained of [1, 64, 1024]) for (let old = 1; old < retained; old++) { const group = producers[0].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); - group.close(); + group.finish(); const received = await baseline[0].recvGroup(); if (!received) throw new Error("historical group missing"); await received.readFrame(); @@ -62,7 +62,7 @@ for (const retained of [1, 64, 1024]) const group = producers[0].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); group.writeFrame({ payload: new Uint8Array(4) }); - group.close(); + group.finish(); const received = await baseline[0].recvGroup(); if (!received) throw new Error("measured group missing"); await received.readFrame(); diff --git a/js/net/bench/resume.ts b/js/net/bench/resume.ts index 59b93166500..8e68cd64e48 100644 --- a/js/net/bench/resume.ts +++ b/js/net/bench/resume.ts @@ -51,7 +51,7 @@ async function measure(readers: number, swaps: number | undefined, fork = false) const group = new GroupProducer(sequence); track.writeGroup(group); group.writeFrame({ payload }); - group.close(); + group.finish(); }; sources.push({ broadcast, write }); return broadcast; @@ -171,7 +171,7 @@ async function measureFlaps(readers: number, flaps: number): Promise { for (const group of held) group.close(); for (const subscriber of subscribers) subscriber.close(); front.consumer.close(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); for (const handle of handles) handle.close(); for (const source of sources) source.close(); await flush(); @@ -215,7 +215,7 @@ async function measureReplay(readers: number, replayed: number): Promise }), }); } - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); const start = performance.now(); await Promise.all(consumers.map((consumer) => consumer.readable())); const elapsed = performance.now() - start; @@ -225,7 +225,7 @@ async function measureReplay(readers: number, replayed: number): Promise checksum += frame.payload.byteLength; consumer.close(); } - replacement.close(); + replacement.finish(); await flush(); return (elapsed * 1000) / readers; } diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index b511b53453f..aa8accfa877 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -98,7 +98,7 @@ async function measure(protocol: string, groups: number, viewers: number) { payload: new Uint8Array(32), timestamp: Timestamp.fromMicros(sequence * 2500 + Math.floor((frame * 2500) / 128)), }); - group.close(); + group.finish(); } let deadline: ReturnType | undefined; try { diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index b7bf27d3294..8f59a911f59 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -30,7 +30,7 @@ try { for (let sequence = 0; sequence < groups; sequence++) { const group = new GroupProducer(sequence); group.writeFrame(textFrame("frame")); - group.close(); + group.finish(); track.writeGroup(group); } track.close(); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 2a2e00adbc2..356e1e28ca3 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -65,7 +65,7 @@ async function measure( const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload, timestamp: Timestamp.fromMillis(sequence++) }); - if (close) group.close(); + if (close) group.finish(); return group; }; @@ -101,7 +101,7 @@ async function measure( const elapsed = now() - start; for (const done of opened) done(); - for (const group of open) group.close(); + for (const group of open) group.finish(); await Promise.all(reads); // Completion wakes every subscriber independently of producer closure. const ending = subscribers.map((subscriber) => subscriber.recvGroup()); diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index a3024baaabc..103ed74d353 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -126,7 +126,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { test("an empty replacement ends past its predecessor's groups", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - first.appendGroup().close(); + first.appendGroup().finish(); first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); @@ -141,7 +141,7 @@ test("an empty replacement ends past its predecessor's groups", async () => { test("a removed producer writes nothing once its replacement claims the name", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - first.appendGroup().close(); + first.appendGroup().finish(); broadcast.removeTrack("media"); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); @@ -162,7 +162,7 @@ test("a removed producer's abort ignores its replacement's groups", async () => broadcast.removeTrack("media"); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - for (let i = 0; i < 3; i++) second.appendGroup().close(); + for (let i = 0; i < 3; i++) second.appendGroup().finish(); const abort = new Error("cut off"); first.close(abort); expect(first.closed.peek()).toBe(abort); @@ -325,7 +325,7 @@ test("names that never wrote leave nothing behind", () => { test("a written name refuses to reuse its sequences after many other names", () => { const broadcast = new BroadcastProducer(); const written = broadcast.createTrack("written"); - written.appendGroup().close(); + written.appendGroup().finish(); written.close(); for (let i = 0; i < 1000; i++) broadcast.createTrack(`unused-${i}`).close(); @@ -606,7 +606,7 @@ test("subscribe serves a statically inserted track without a request", async () const broadcast = new BroadcastProducer(); const track1 = broadcast.createTrack("track1", { timescale: Timescale.MILLI }); - track1.appendGroup().close(); + track1.appendGroup().finish(); // The track already exists, so subscribe resolves immediately (no requested()). const sub1 = broadcast.track("track1").subscribe().ordered(); @@ -619,7 +619,7 @@ test("subscribe serves a statically inserted track without a request", async () const track2 = broadcast.createTrack("track2", { timescale: Timescale.MILLI }); const sub2 = broadcast.track("track2").subscribe().ordered(); - track2.appendGroup().close(); + track2.appendGroup().finish(); expect((await sub2.nextGroup())?.sequence).toBe(0); }); @@ -677,7 +677,7 @@ test("a read throws GroupTooLarge on an overflow, then resyncs to the next group // Group 1 is clean. const g1 = producer.appendGroup(); g1.writeFrame({ payload: new TextEncoder().encode("ok"), timestamp: Timestamp.now() }); - g1.close(); + g1.finish(); // The reader hits the abort in group 0 (error, not a silent skip), then the next // read resyncs from group 1. @@ -812,11 +812,11 @@ test("a fetch waits for a group still to come", async () => { const first = track.appendGroup(); first.writeFrame({ payload: new TextEncoder().encode("0"), timestamp: Timestamp.now() }); - first.close(); + first.finish(); const second = track.appendGroup(); second.writeFrame({ payload: new TextEncoder().encode("1"), timestamp: Timestamp.now() }); - second.close(); + second.finish(); const group = await pending; expect(group.sequence).toBe(1); @@ -835,7 +835,7 @@ test("fetch maxDelay tightens publisher retention without changing omission", as payload: new TextEncoder().encode(String(millis)), timestamp: Timestamp.fromMillis(millis), }); - group.close(); + group.finish(); } const omitted = await wireOf(broadcast).fetchGroup("video", 0); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index e3f144b38ed..3ec036869ee 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -686,7 +686,7 @@ for (const [name, teardown] of Object.entries(teardowns)) { fin.hold?.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); producer.close(); teardown.end(handle, win); await clock.advance(Nano.fromMilli(teardown.linger)); @@ -903,7 +903,7 @@ test("closing the loop keeps the tokens until the session drains", async () => { fin.hold?.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); producer.close(); reload.close(); diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index a3efb9b3503..ecea5231327 100644 --- a/js/net/src/consume.test.ts +++ b/js/net/src/consume.test.ts @@ -31,7 +31,7 @@ test("cache identity follows the serving announcement, preserving held tracks", const shared = cache.get(job, cache.instance(job)); expect(shared?.closed).toBe(fresh.closed); // Cache replacement never transfers or closes the track an old handle is reading. - track.appendGroup().close(); + track.appendGroup().finish(); expect((await ordered.nextGroup())?.sequence).toBe(0); cache.withdraw(job, specific); expect(cache.get(job, cache.instance(job))).toBeUndefined(); diff --git a/js/net/src/flate/group.test.ts b/js/net/src/flate/group.test.ts index 59dd9d40cb0..58da8efaf4c 100644 --- a/js/net/src/flate/group.test.ts +++ b/js/net/src/flate/group.test.ts @@ -16,7 +16,7 @@ test("synchronous writers store slices; plain readers share one charged inflated const raw = group.mirror(); group.writeFrame({ payload }); group.writeFrame({ payload }); - group.close(); + group.finish(); const stored = hooks.groupCacheBytes(group); expect(stored).toBeLessThan(payload.length); const inflating = spyOn(Decoder.prototype, "frame"); @@ -44,7 +44,7 @@ test("received slices are forwarded byte-for-byte and exact declarations fail al hooks.bindGroupCompression(group, true); const slice = new Encoder().frame(payload); hooks.writeGroupFrame(group, { payload: slice }, payload.length + 1); - group.close(); + group.finish(); const raw = await hooks.readGroupFrame(group.mirror(), true); expect(raw?.frame.payload).toBe(slice); const bad = group.mirror(); @@ -65,7 +65,7 @@ test("declared plain bytes consume the group budget before any inflation", () => expect(hooks.groupCacheBytes(group)).toBe(0); } finally { inflating.mockRestore(); - group.close(); + group.finish(); } }); @@ -77,7 +77,7 @@ test("empty frames preserve the window and prewritten groups bind without duplic const track = new Track("compressed").accept({ compress: true }); track.writeGroup(group); group.writeFrame({ payload }); - group.close(); + group.finish(); for (const expected of [payload, new Uint8Array(), payload]) expect((await reader.readFrame())?.payload).toEqual(expected); track.close(); @@ -89,7 +89,7 @@ test("a cut compressed group preserves its committed frames before returning its const reader = group.mirror(); group.writeFrame({ payload }); const cut = new Error("cut"); - group.close(cut); + group.abort(cut); expect((await reader.readFrame())?.payload).toEqual(payload); await expect(reader.readFrame()).rejects.toThrow(cut); track.close(); @@ -103,7 +103,7 @@ test("a shared plain read cannot change a later synchronous write's admission", expect((await reader.readFrame())?.payload.length).toBe(MAX_GROUP_CACHE_BYTES); // The plain-byte budget is full, but another empty frame still fits, regardless of readers. expect(() => group.writeFrame({ payload: new Uint8Array() })).not.toThrow(); - group.close(); + group.finish(); expect((await reader.readFrame())?.payload.length).toBe(0); track.close(); }); @@ -148,7 +148,7 @@ test("refresh measures stored slices without inflating a compressed group", () = expect(inflating).toHaveBeenCalledTimes(0); } finally { inflating.mockRestore(); - group.close(); + group.finish(); track.close(); } }); diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index fcfa1c5b3cd..56979bfd8d4 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -91,7 +91,7 @@ test("a closed group rejects a further write without discarding committed frames for (let i = 0; i < MAX_GROUP_FRAMES; i++) { producer.writeFrame({ payload: new Uint8Array([i & 0xff]), timestamp: Timestamp.now() }); } - producer.close(); + producer.finish(); expect(() => producer.writeFrame({ payload: new Uint8Array([0]), timestamp: Timestamp.now() })).toThrow( "group is closed", @@ -118,7 +118,7 @@ test("a group with no eviction reads every frame without error", async () => { const { producer, consumer } = pair(0); producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); - producer.close(); + producer.finish(); expect((await consumer.readFrame())?.payload[0]).toBe(1); expect((await consumer.readFrame())?.payload[0]).toBe(2); @@ -163,7 +163,7 @@ test("done distinguishes a finished group from one that is merely empty", () => producer.writeFrame(textFrame("a")); // Buffered but closed: still not done until the frame is drained. - producer.close(); + producer.finish(); expect(consumer.done).toBe(false); consumer.tryReadFrame(); @@ -176,7 +176,7 @@ test("a previously obtained closed handle peeks source closure synchronously", ( const { producer, consumer } = pair(0); const closed = consumer.closed; - producer.close(); + producer.finish(); expect(closed.peek()).toBeNull(); }); @@ -201,7 +201,7 @@ test("readable resolves once a frame is buffered", async () => { test("readable resolves once the group closes, even with nothing buffered", async () => { const { producer, consumer } = pair(0); const readable = consumer.readable(); - producer.close(); + producer.finish(); await readable; // resolves on close so callers don't wait forever expect(consumer.tryReadFrame()).toBeUndefined(); }); @@ -246,8 +246,8 @@ test("readable waits past replayed frames after adopting an open replacement", a await readable; expect(consumer.tryReadFrameSequence()).toMatchObject({ sequence: 1, payload: textFrame("one").payload }); consumer.close(); - producer.close(); - replacement.close(); + producer.finish(); + replacement.finish(); }); test("done ignores replayed frames an adopted copy buffers", async () => { @@ -279,17 +279,17 @@ test("done ignores replayed frames an adopted copy buffers", async () => { await new Promise((resolve) => setImmediate(resolve)); // The adopted copy ends having repeated only the frame already read. replacement.writeFrame(textFrame("zero")); - replacement.close(); + replacement.finish(); expect(consumer.done).toBe(true); await readable; expect(consumer.tryReadFrame()).toBeUndefined(); - producer.close(); + producer.finish(); }); test("buffered frames are still readable after the group closes", async () => { const { producer, consumer } = pair(0); producer.writeFrame(textFrame("a")); - producer.close(); + producer.finish(); // Closing doesn't discard buffered frames; the blocking reader drains them before ending. expect(await consumer.readString()).toBe("a"); @@ -306,7 +306,7 @@ test("a frame larger than the cache is rejected rather than silently dropped", ( // Nothing was appended, so the group is still empty rather than holding a phantom frame. const consumer = producer.consume(); - producer.close(); + producer.finish(); expect(consumer.tryReadFrame()).toBeUndefined(); }); @@ -332,7 +332,7 @@ test("a group that ends on its own abandons the replacement it asked for", async await new Promise((resolve) => setTimeout(resolve, 0)); expect(signal?.aborted).toBe(false); - producer.close(); + producer.finish(); expect(await read).toBeUndefined(); expect(signal?.aborted).toBe(true); }); @@ -381,7 +381,7 @@ test.each([false, true])("a group ending beside its replacement discards it (con consumer.close(); expect(released).toBe(1); } else { - producer.close(); + producer.finish(); } expect(await read).toBeUndefined(); expect(adopted).toBe(false); @@ -409,13 +409,13 @@ test("a group recovered twice reads on and settles", async () => { await consumer.readFrame(); // Two failures in a row: each moves the group to the next copy. - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); replacements[0].writeFrame({ payload: new Uint8Array([0]) }); replacements[0].writeFrame({ payload: new Uint8Array([1]) }); expect((await consumer.readFrame())?.payload[0]).toBe(1); - replacements[0].close(new Error("route failed")); + replacements[0].abort(new Error("route failed")); for (const payload of [0, 1, 2]) replacements[1].writeFrame({ payload: new Uint8Array([payload]) }); - replacements[1].close(); + replacements[1].finish(); expect((await consumer.readFrame())?.payload[0]).toBe(2); expect(await consumer.readFrame()).toBeUndefined(); expect(await consumer.closed).toBeNull(); @@ -438,7 +438,7 @@ test("a stalled group whose copy then fails asks again with the failure", async route.set(1); const read = consumer.readFrame(); await new Promise((resolve) => setTimeout(resolve, 0)); - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); await expect(read).rejects.toBe(verdict); }); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 61edb18538a..d9e34e2c251 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -385,7 +385,7 @@ export class Producer { mirror.frames.set([]); mirror.cacheBytes = 0; } - this.close(error); + this.abort(error); throw error; } @@ -394,15 +394,27 @@ export class Producer { return this.#state.closed.peek() !== undefined; } - /** Closes the group, optionally with an error to abort readers. */ - close(abort?: Error) { + /** Marks the group complete: no more frames will be written. Idempotent. */ + finish() { + this.#end(null); + } + + /** + * Ends the group with `error`, which readers that have not drained it see. A no-op once the + * group finished or aborted: the outcome is already settled. + */ + abort(error: Error) { + this.#end(error); + } + + #end(outcome: Error | null) { if (this.#state.closed.peek() !== undefined) return; this.#state.flate?.finish(); - this.#state.closed.set(abort ?? null); + this.#state.closed.set(outcome); if (this.#mirrors) { for (const mirror of this.#mirrors) { - if (mirror.closed.peek() === undefined) mirror.closed.set(abort ?? null); + if (mirror.closed.peek() === undefined) mirror.closed.set(outcome); } this.#mirrors.clear(); } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index d125d687742..c1236f09bb4 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -799,13 +799,13 @@ test("a blocked group header is reset when the group expires", async () => { const old = new GroupProducer(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); - old.close(); + old.finish(); track.writeGroup(old); await headerStarted; const edge = new GroupProducer(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(10_000) }); - edge.close(); + edge.finish(); track.writeGroup(edge); // A group beyond the edge, so group 0's reach (10s, where group 1 begins) is @@ -813,7 +813,7 @@ test("a blocked group header is reset when the group expires", async () => { // the successor alone never convicts it: nothing yet proves group 0 ends sooner. const later = new GroupProducer(2); later.writeFrame({ payload: new TextEncoder().encode("later"), timestamp: Timestamp.fromMillis(20_000) }); - later.close(); + later.finish(); track.writeGroup(later); const resetBeforeRelease = await Promise.race([ @@ -885,7 +885,7 @@ test("a group stream whose FIN never completes is reset when the group expires", const write = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -969,7 +969,7 @@ test("a group stream whose FIN never completes is reset after the cache evicts i const write = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -1054,7 +1054,7 @@ test("a group that goes stale while its stream opens writes nothing", async () = const write = (sequence: number, ms: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); track.writeGroup(group); }; write(0, 0); @@ -2858,7 +2858,7 @@ function writeGroup(track: TrackProducer, frames: number): void { for (let i = 0; i < frames; i++) { group.writeFrame({ payload: new TextEncoder().encode(`${group.sequence}.${i}`), timestamp: Timestamp.now() }); } - group.close(); + group.finish(); } /** Send `msg` on a fresh subscribe stream and read the publisher's SUBSCRIBE_OK. */ @@ -3143,7 +3143,7 @@ test("draft-20: a fill serves the current group's head on a fetch stream", async // Everything past the snapshot belongs to the subscription, not the fill. group.writeFrame({ payload: new TextEncoder().encode("0.2"), timestamp: Timestamp.now() }); - group.close(); + group.finish(); const live = await nextUni(fx.uni); if (!live) throw new Error("the subscription never served the live tail"); @@ -3309,7 +3309,7 @@ async function subscribeOkLargest(version: IetfVersion): Promise Frame.decode(c, header.flags, undefined)); @@ -3702,7 +3702,7 @@ test("draft-20: the subscriber leaving ends a fill still reading its group", asy const served = await reading; expect(served.reset?.message).toContain("unsubscribed"); } finally { - open.close(); + open.finish(); fx.close(); client.close(); } @@ -3757,7 +3757,7 @@ test("draft-20: a fill works on a dynamically requested track", async () => { reset: undefined, }); } finally { - group.close(); + group.finish(); fx.close(); client.close(); } @@ -4172,7 +4172,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async const write = (text: string) => { const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode(text), timestamp: Timestamp.now() }); - group.close(); + group.finish(); }; const { client } = await runSubscribe( fx, diff --git a/js/net/src/ietf/subscribe_tracks.test.ts b/js/net/src/ietf/subscribe_tracks.test.ts index 6786fbd2ea5..a2e3140f685 100644 --- a/js/net/src/ietf/subscribe_tracks.test.ts +++ b/js/net/src/ietf/subscribe_tracks.test.ts @@ -150,7 +150,7 @@ for (const version of drafts) { const track = broadcast.createTrack("video"); const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const listing = await fx.list(); await ok(listing, version); const { stream, publish } = await fx.offered(); @@ -499,7 +499,7 @@ for (const version of [Version.DRAFT_20, Version.DRAFT_22]) { for (let i = 0; i < 2; i++) { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([i]) }); - group.close(); + group.finish(); } await ok(await fx.list(), version); const { stream } = await fx.offered(); @@ -518,7 +518,7 @@ for (const version of [Version.DRAFT_20, Version.DRAFT_22]) { await update(stream, version, 4n, true); const fresh = track.appendGroup(); fresh.writeFrame({ payload: new Uint8Array([2]) }); - fresh.close(); + fresh.finish(); const uni = fx.pair.client.incomingUnidirectionalStreams.getReader(); const next = await uni.read(); if (!next.value) throw new Error("missing filtered group"); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 62147a9ccbd..bf458da1aff 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1597,7 +1597,7 @@ export class Subscriber { // the track ends with it, and at object 0 it ends before it. if (producer && frame.idDelta !== 0) throw new ObjectIdGap(frame.idDelta); const end = { group: group.groupId, frame: nextObject + frame.idDelta }; - producer?.close(); + producer?.finish(); try { hooks.endTrack(track, end); } catch (err: unknown) { @@ -1620,7 +1620,7 @@ export class Subscriber { } // A group with no objects still exists. - open().close(); + open().finish(); } catch (err: unknown) { if (err instanceof ProtocolViolation) this.#fail(err.code, err.message); const e = await sessionCause(this.#quic, err); @@ -1647,20 +1647,20 @@ export class Subscriber { if (e instanceof ObjectIdGap) console.warn(`unsupported object gap: ${reason(e)}`); if (e instanceof EndOfTrackProperties) { // The group before the marker is complete; only the track's end can't be carried. - producer?.close(); + producer?.finish(); track.close(e); } else if ( e instanceof ProtocolViolation || (e instanceof StreamError && e.code === StreamCode.MalformedTrack) ) { // The publisher broke the track's end or its content, which no later group can repair. - producer?.close(e); + producer?.abort(e); track.close(e); } else { // A stream that fails before its first object still names a group, which the // reader sees fail rather than silently go missing. try { - open().close(e); + open().abort(e); } catch { // The track has already closed or ended below this group. } diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 16a5681dd6d..f72d19eb684 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -350,7 +350,7 @@ test("integration: lite applies initial and updated group bounds", async () => { const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); + for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().finish(); const remote = wireOf(client).consume(Path.from("test")); const subscriber = remote @@ -401,7 +401,7 @@ test("integration: lite refuses an empty requested range on open and on update", const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); + for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().finish(); const remote = wireOf(client).consume(Path.from("test")); const video = remote.track("video"); @@ -871,7 +871,7 @@ test("integration: a group reset carries the peer's code to the subscriber", asy expect(await consumer.readString()).toBe("frame"); // Reset the group stream the way a peer that dropped the group does. - group.close(new Reset(2)); + group.abort(new Reset(2)); // The subscriber reads the code back off a typed error rather than whichever shape the // transport produced, so nothing has to feature-detect a browser global. @@ -912,7 +912,7 @@ test("integration: a locally raised group error reaches the peer as its own code // The publisher's own cache dropped the rest of the group. Nothing hand-builds a transport // error here, which is the point: the condition is raised the way the library raises it. - group.close(new TooFarBehind()); + group.abort(new TooFarBehind()); const err = await consumer.readFrame().then( () => undefined, @@ -1006,11 +1006,11 @@ test("integration: lite draft-07 fetches a cached group", async () => { const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); - group0.close(); + group0.finish(); const group1 = producer.appendGroup(); group1.writeFrame({ payload: enc.encode("newer"), timestamp: Timestamp.fromMillis(20) }); - group1.close(); + group1.finish(); // Fetch group 0 without holding a live subscription; the timestamps round-trip. const remote = wireOf(client).consume(Path.from("test")); @@ -1045,7 +1045,7 @@ test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at % const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = new GroupProducer(1); producer.writeGroup(group); - group.close(); + group.finish(); } else { serving = (async () => { for (;;) { @@ -1105,14 +1105,14 @@ test.each(["frame", "FIN"])("integration: lite fetch waits for the publisher's f await sleep(0); expect(settled).toBe(0); if (answer === "frame") group.writeFrame(textFrame("accepted")); - else group.close(); + else group.finish(); const consumers = await Promise.all([a, b]); for (const consumer of consumers) { expect(await consumer.readString()).toBe(answer === "frame" ? "accepted" : undefined); consumer.close(); } } finally { - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1138,7 +1138,7 @@ test("integration: lite draft-05 coalesces concurrent fetches of one group", asy const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); - group0.close(); + group0.finish(); const remote = wireOf(client).consume(Path.from("test")); const trackConsumer = remote.track("video"); @@ -1195,7 +1195,7 @@ test("integration: lite draft-07 fetches an in-progress group", async () => { expect(dec.decode(second?.payload)).toBe("beta"); expect(second?.timestamp?.asMillis()).toBe(15); - group0.close(); + group0.finish(); expect(await fetched.readFrame()).toBeUndefined(); broadcast.close(); @@ -1260,7 +1260,7 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", const firstTrack = first.createTrack("video", { timescale: Timescale.MILLI }); const firstGroup = firstTrack.appendGroup(); firstGroup.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); - firstGroup.close(); + firstGroup.finish(); // Prime the publisher's TRACK_INFO cache against the predecessor. const remote = wireOf(client).consume(path); @@ -1270,7 +1270,7 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", const secondTrack = second.createTrack("video", { timescale: Timescale.MICRO }); const secondGroup = secondTrack.appendGroup(); secondGroup.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMicros(1234) }); - secondGroup.close(); + secondGroup.finish(); // A millisecond timescale would round this to 1000us. const fetched = await remote.track("video").fetchGroup(0); @@ -1535,7 +1535,7 @@ test("integration: lite fetch teardown when the reader abandons an open group", fetched.close(); await waitUntil(() => !group.demand().used.peek()); - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1648,7 +1648,7 @@ test("integration: lite coalesced fetch stays until every reader abandons the op f2.close(); await waitUntil(() => !group.demand().used.peek()); - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1690,7 +1690,7 @@ test("integration: lite fetch re-armed by a late reader keeps every frame", asyn expect(`${delay}: ${more}`).toBe(`${delay}: more`); f2.close(); - group.close(); + group.finish(); } broadcast.close(); @@ -1714,7 +1714,7 @@ test("integration: lite fetch delivers every frame of a finite multi-frame group const group = video.appendGroup(); const count = 50; for (let i = 0; i < count; i++) group.writeFrame(textFrame(`f${i}`)); - group.close(); // finite + group.finish(); // finite const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(group.sequence); diff --git a/js/net/src/lite/compression.test.ts b/js/net/src/lite/compression.test.ts index e24a9f0978a..3a41af7f540 100644 --- a/js/net/src/lite/compression.test.ts +++ b/js/net/src/lite/compression.test.ts @@ -45,7 +45,7 @@ for (const downstream of [ALPN_06, ALPN_07_WIP]) { const group = track.appendGroup(); const payloads = [new Uint8Array(4096).fill(7), new Uint8Array(), new Uint8Array(4096).fill(7)]; for (const payload of payloads) group.writeFrame({ payload }); - group.close(); + group.finish(); const received = await reader.recvGroup(); if (!received) throw new Error("missing group"); expect(received.sequence).toBe(0); diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 700d86adf71..26ef77d27ec 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -244,7 +244,7 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { fin.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); producer.close(); const closing = server.close(); @@ -285,7 +285,7 @@ async function servedRawSubscription() { const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const subscriber = await Stream.open(pair.client, { version }); await subscriber.writer.u53(StreamId.Subscribe); @@ -386,7 +386,7 @@ test("close waits for a served FETCH to be acknowledged", async () => { const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const server = new Connection({ url: new URL("https://relay.example/"), quic: pair.server, @@ -438,7 +438,7 @@ test("close waits for a request served while withdrawals are in flight", async ( const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const url = new URL("https://localhost/test"); const [client, server] = await Promise.all([ connect({ url, transport: pair.client, consume: destination }), diff --git a/js/net/src/lite/group.test.ts b/js/net/src/lite/group.test.ts index b0f36716873..cf5019c0cd6 100644 --- a/js/net/src/lite/group.test.ts +++ b/js/net/src/lite/group.test.ts @@ -58,7 +58,7 @@ test("frames split at every byte keep their payloads and timestamps", async () = const chunks = Array.from(bytes, (byte) => new Uint8Array([byte])); await readFrames(streamOf(chunks), producer, { scale: SCALE }); - producer.close(); + producer.finish(); let ts = 0; for (const { delta, payload } of frames) { @@ -74,7 +74,7 @@ test("frames without a timescale carry no timestamp prefix", async () => { const producer = new Producer(0); const consumer = producer.consume(); await readFrames(streamOf([encode([{ payload: [7] }, { payload: [8, 9] }])]), producer, { scale: 0 }); - producer.close(); + producer.finish(); expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([7])); expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([8, 9])); @@ -94,6 +94,6 @@ test("stops once the group closes", async () => { // Never ends: only the close can stop the read. const stream = new Reader(new ReadableStream(), undefined, VERSION); const done = readFrames(stream, producer, { scale: SCALE }); - producer.close(); + producer.finish(); await done; }); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index d827efb477b..f76c909fbbf 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -209,7 +209,7 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF for (const sequence of sequences) { const group = new GroupProducer(sequence); group.writeFrame(textFrame("hello", Timestamp.fromMillis(sequence))); - group.close(); + group.finish(); track.writeGroup(group); // Let the publisher drain this group before the next, so arrival order is the @@ -302,7 +302,7 @@ async function groupSendOrders(options: { }); } finally { opened.releaseLock(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); publisher.close(); client.close(); } @@ -394,7 +394,7 @@ test("lite draft-05: a subscribe update re-ranks a group already on the wire", a expect(stream.sendOrder).toBe(sendOrder({ priority: 9 })); } finally { - group.close(); + group.finish(); publisher.close(); client.close(); } @@ -453,7 +453,7 @@ test("lite draft-05: a subscribe update during the stream open still ranks the g expect(pair.server.sendStreams.uni[0]?.sendOrder).toBe(sendOrder({ priority: 9 })); } finally { - group.close(); + group.finish(); publisher.close(); client.close(); } @@ -508,7 +508,7 @@ test("lite draft-05: many concurrent groups share one subscription listener", as expect(pair.server.sendStreams.uni.map((stream) => stream.sendOrder)).toEqual(expected); } finally { - for (const group of groups) group.close(); + for (const group of groups) group.finish(); publisher.close(); client.close(); } @@ -526,7 +526,7 @@ test("lite draft-05: the fetch response ranks the publisher's own writes", async const group = new GroupProducer(7); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_05 }); @@ -562,7 +562,7 @@ test("lite draft-06: an unchecked track's fetched frames carry their send time", const group = new GroupProducer(0); group.writeFrame(textFrame("hello", Timestamp.fromMillis(36_000_000))); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); @@ -699,7 +699,7 @@ async function servedSubscription( serve(sequence: number) { const group = new GroupProducer(sequence); for (const frame of frames) group.writeFrame(textFrame(frame, Timestamp.fromMillis(sequence))); - group.close(); + group.finish(); track.writeGroup(group); }, // Makes the next group stream withhold its FIN from the client, the way a stream the @@ -812,7 +812,7 @@ test("lite draft-06: a cut group is dropped early, once", async () => { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end]).toEqual([1, 1]); @@ -838,7 +838,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new StreamError(StreamCode(70), { message: "cut" })); + cut.abort(new StreamError(StreamCode(70), { message: "cut" })); const resp = await decodeSubscribeResponse(sub.client.reader, version); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end, resp.drop.error]).toEqual([ @@ -946,7 +946,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end]).toEqual([1, 1]); @@ -1159,7 +1159,8 @@ for (const skipped of ["failed", "stale"] as const) { const group = new GroupProducer(5); group.writeFrame(textFrame("old", Timestamp.fromMillis(5))); sub.track.writeGroup(group); - group.close(skipped === "failed" ? new Error("cut") : undefined); + if (skipped === "failed") group.abort(new Error("cut")); + else group.finish(); if (skipped === "failed") sub.track.dropGroups(7, 8, new Error("cut")); sub.serve(6); const response = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); @@ -1619,7 +1620,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy try { const first = new GroupProducer(0); first.writeFrame(textFrame("hello")); - first.close(); + first.finish(); track.writeGroup(first); track.finishAt({ group: 2, frame: 0 }); @@ -1631,7 +1632,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy const second = new GroupProducer(1); second.writeFrame(textFrame("hello")); - second.close(); + second.finish(); track.writeGroup(second); track.close(); @@ -1673,7 +1674,7 @@ async function heldOpenEnd() { const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1755,7 +1756,7 @@ test("lite draft-07: a group whose close fails is dropped", async () => { try { const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1805,7 +1806,7 @@ test("blocked DROP responses stop opening later groups", async () => { const append = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame(textFrame("frame")); - group.close(); + group.finish(); track.writeGroup(group); }; for (let sequence = 0; sequence < 8; sequence++) append(sequence); @@ -1881,7 +1882,7 @@ async function serve( for (const [sequence, frames] of Object.entries(groups)) { const group = new GroupProducer(Number(sequence)); for (const frame of frames) group.writeFrame(textFrame(frame)); - group.close(); + group.finish(); track.writeGroup(group); // Let the publisher drain each group before the next, so the streams arrive in @@ -2061,7 +2062,7 @@ async function saturatedGroup() { // A finished group still has frames to send, so its own close must not drop it. const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); // The group is queued inside the open from here on. @@ -2142,13 +2143,13 @@ test("lite draft-05: a blocked group header is reset when the group expires", as const old = new GroupProducer(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); - old.close(); + old.finish(); track.writeGroup(old); await headerStarted; const edge = new GroupProducer(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(1000) }); - edge.close(); + edge.finish(); track.writeGroup(edge); const resetBeforeRelease = await Promise.race([ @@ -2285,7 +2286,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire for (const ms of [1000, 2000]) { const group = new GroupProducer(ms / 1000); group.writeFrame(frame(ms)); - group.close(); + group.finish(); track.writeGroup(group); } @@ -2296,7 +2297,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire expect(end).toMatchObject({ code: StreamCode.Old }); } finally { opened.releaseLock(); - open.close(); + open.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2356,7 +2357,7 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi const write = (sequence: number, ms: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); track.writeGroup(group); }; write(0, 0); @@ -2657,7 +2658,7 @@ test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () expect(unauthorizedResets(resets)).toContain("unauthorized: live"); } finally { resets.mockRestore(); - group.close(); + group.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2697,7 +2698,7 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a for (const ms of [1000, 2000]) { const group = new GroupProducer(ms / 1000); group.writeFrame(frame(ms)); - group.close(); + group.finish(); track.writeGroup(group); } await serving; @@ -2705,7 +2706,7 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a expect(resets.mock.calls.map(([reason]) => toStreamCode(reason as Error))).toContain(StreamCode.Old); } finally { resets.mockRestore(); - open.close(); + open.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2722,7 +2723,7 @@ test.each([0, 1])("lite draft-07 reports the cached largest position when starti const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = new GroupProducer(0); group.writeFrame(textFrame("cached")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); @@ -2767,7 +2768,7 @@ test("lite draft-07 reports the source's largest position past what a relay hold hooks.reportedLargest(track, { group: 4, frame: 0 }); const group = new GroupProducer(0); group.writeFrame(textFrame("oldest")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); diff --git a/js/net/src/lite/refresh.test.ts b/js/net/src/lite/refresh.test.ts index 64091a24c69..60ae9bcd80b 100644 --- a/js/net/src/lite/refresh.test.ts +++ b/js/net/src/lite/refresh.test.ts @@ -73,7 +73,7 @@ test.each([ALPN_06, ALPN_07_WIP])("refresh joins reach a browser publisher on %s for (const join of joins) join.close(); received.close(); baseline.close(); - group.close(); + group.finish(); } finally { spy.mockRestore(); for (const origin of [b, source]) origin.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index c7a1cb8e1e7..9f7c1a02beb 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1247,7 +1247,7 @@ export class Subscriber { // Arm after admission, before checking: another unanswered token may still // widen the union while we wait, which is not a revocation of this fetch. const disposeGrant = this.#grant?.subscribe(() => { - if (this.#denied(broadcast)) group.close(refused); + if (this.#denied(broadcast)) group.abort(refused); }); void group.closed.then(() => disposeGrant?.()); if (this.#denied(broadcast)) throw refused; @@ -1274,7 +1274,7 @@ export class Subscriber { hooks.bindGroupCompression(group, accepted.info.compress); void this.#runFetchResponse(accepted.stream, group, accepted.info); } catch (err: unknown) { - group.close(error(err)); + group.abort(error(err)); throw err; } } @@ -1375,11 +1375,11 @@ export class Subscriber { hooks.writeGroupFrame(group, frame, frame.inflated); } - group.close(); + group.finish(); stream.close(); } catch (err: unknown) { const e = error(err); - group.close(e); + group.abort(e); stream.abort(e); } } @@ -1616,7 +1616,7 @@ export class Subscriber { while (scale === undefined) { if (track.closed.peek() !== undefined) { // Subscription ended before the scale resolved; nothing to decode. - producer.close(); + producer.finish(); stream.stop(StreamCode.Cancel); return; } @@ -1626,17 +1626,17 @@ export class Subscriber { await readFrames(stream, producer, { scale, timed: hasMediaTime(this.version), compress: entry.compress }); - producer.close(); + producer.finish(); stream.stop(StreamCode.Cancel); } catch (err: unknown) { // A violation ends the session, which the connection does once it escapes; anything // else ends only this group. if (err instanceof ProtocolViolation) { - producer.close(err); + producer.abort(err); throw err; } const e = await sessionCause(this.#quic, err); - producer.close(e); + producer.abort(e); stream.stop(e); } finally { read(); @@ -1807,7 +1807,7 @@ export class Subscriber { // This also releases callers still awaiting acceptance. for (const { group } of this.#fetches.values()) { - group.close(cut); + group.abort(cut); } } } @@ -1841,7 +1841,7 @@ async function untilAbandoned(group: netGroup.Producer, step: Promise): Pr // Close here rather than where the error lands, so no fetch coalesces onto the group // in between only to fail with it. const err = new StreamError(StreamCode.Cancel, { message: "cancel" }); - group.close(err); + group.abort(err); throw err; } } diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index b29247db139..df249ce4fb0 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -105,7 +105,7 @@ async function backlog(protocol: string, order: Order, update?: Order) { const result = { upstream: video.subscription.peek()?.order, orders: sendOrders(transport) }; - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -153,7 +153,7 @@ test.each(["ascending", "descending"] as const)("ietf serves GROUP_ORDER %s", as } expect(sendOrders(transport)).toEqual(expected(order, 3)); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -217,12 +217,12 @@ test("ietf keeps a closing group stream ranked until it settles", async () => { } // The newest group, ranked first, finishes while its FIN is held. - groups[2]?.close(); + groups[2]?.finish(); await fins.closing; expect(sendOrders(transport)).toEqual(expected("descending", 3)); fins.release(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -264,7 +264,7 @@ test("a shared track aggregates descending and serves each subscriber in its own expect(sendOrders(ascending.transport)).toEqual(expected("ascending", 3)); expect(sendOrders(descending.transport)).toEqual(expected("descending", 3)); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); ascendingTrack.close(); descendingTrack.close(); ascendingRemote.close(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 6717f94274b..7f157c7520d 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -2417,7 +2417,7 @@ test.each([ expect(resolved?.epoch).toBe(from); const sticky = resolved?.track("video").subscribe(); - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await sticky?.recvGroup())?.sequence).toBe(0); // A re-price at the same epoch keeps every handle, and the handler is not asked again. @@ -2439,7 +2439,7 @@ test.each([ expect(pinned.active.peek()?.epoch).toBe(from); // The subscription already open stays on the old answer. - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await sticky?.recvGroup())?.sequence).toBe(1); // A re-request, local or from a session, asks the handler again and never reads the @@ -2460,7 +2460,7 @@ test.each([ // Existing request handles can still subscribe, but never join the new answer. const retained = held.active.peek()?.track("video").subscribe(); expect((await retained?.recvGroup())?.sequence).toBe(1); - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await retained?.recvGroup())?.sequence).toBe(2); expect(held.active.peek()).toBe(resolved); diff --git a/js/net/src/parked-expiry.test.ts b/js/net/src/parked-expiry.test.ts index a30b7862e54..707df50e215 100644 --- a/js/net/src/parked-expiry.test.ts +++ b/js/net/src/parked-expiry.test.ts @@ -37,7 +37,7 @@ for (const protocol of protocols) { const write = (sequence: number) => { const group = new Group(sequence); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -162,7 +162,7 @@ for (const protocol of protocols) { for (let frame = 0; frame < frames; frame++) { group.writeFrame({ payload: new Uint8Array([frame]), timestamp: Timestamp.fromMillis(0) }); } - group.close(); + group.finish(); track.writeGroup(group); }; diff --git a/js/net/src/resume.test-util.ts b/js/net/src/resume.test-util.ts index 01bca3d581a..fd3a1a15fd1 100644 --- a/js/net/src/resume.test-util.ts +++ b/js/net/src/resume.test-util.ts @@ -9,7 +9,7 @@ export function readsFrom(active: BroadcastConsumer | undefined, source: Broadca const track = source.createTrack(name); const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const sub = active.track(name).subscribe(); try { return sub.tryRecvGroup() !== undefined; diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 5270b05224b..5dac619b351 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -55,7 +55,7 @@ function copy(source: BroadcastProducer) { producer.writeGroup(group); for (let frame = 0; frame < frames; frame++) group.writeFrame({ payload: enc.encode(`${sequence}.${frame}`) }); - if (close) group.close(); + if (close) group.finish(); return group; }, }; @@ -137,7 +137,7 @@ test("returning to a route reuses its subscription and held group", async () => held.close(); } finally { mirrored.mockRestore(); - open.close(); + open.finish(); sub.close(); front.consumer.close(); for (const handle of handles) handle.close(); @@ -176,7 +176,7 @@ test("a returning route resumes where a fresh copy would", async () => { held.close(); } finally { mirrored.mockRestore(); - open.close(); + open.finish(); sub.close(); front.consumer.close(); for (const handle of handles) handle.close(); @@ -228,7 +228,7 @@ test("a fork continues its open group across a route failure after its parent cl const next = group.readFrame(); tb.write(0, 3); front.serve(b.consume()); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await next)?.payload)).toBe("0.1"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.2"); expect(await group.readFrame()).toBeUndefined(); @@ -357,7 +357,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on if (lagging && group.sequence === 1 && index === 2) await release.promise; copy.writeFrame(frame); } - copy.close(); + copy.finish(); } finally { group.close(); } @@ -397,7 +397,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on sub = active.track("seq").subscribe({ maxDelay: Milli(60_000) }); const first = source.appendGroup(); for (let frame = 0; frame < 4; frame++) first.writeFrame({ payload: enc.encode(`0.${frame}`) }); - first.close(); + first.finish(); const seen: string[] = []; const initial = await sub.recvGroup(); if (!initial) throw new Error("expected group 0"); @@ -413,10 +413,10 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on expect(interrupted.sequence).toBe(1); for (let frame = 0; frame < 2; frame++) seen.push(dec.decode((await interrupted.readFrame())?.payload)); for (let frame = 2; frame < 4; frame++) open.writeFrame({ payload: enc.encode(`1.${frame}`) }); - open.close(); + open.finish(); const last = source.appendGroup(); for (let frame = 0; frame < 4; frame++) last.writeFrame({ payload: enc.encode(`2.${frame}`) }); - last.close(); + last.finish(); // The standby holds the complete original while the incumbent cannot pass 1.1. for (let sequence = 0; sequence < 3; sequence++) { @@ -528,7 +528,7 @@ test("a group whose route fails continues from the replacement", async () => { tb.write(0, 2); await swap(); // The old route's session goes, failing what it was still delivering. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); disposeA(); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); @@ -550,7 +550,7 @@ test("a group no route continues is given up once a newer one supersedes it", as // The new route never had group 0, and its group 1 is past a zero budget's reach for it. tb.write(1); await swap(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); await expect(group.readFrame()).rejects.toBeInstanceOf(StreamError); expect(drain(sub)).toEqual([1]); @@ -617,10 +617,10 @@ test("a fetched group continued from an empty, already-closed copy releases that const read = group.readFrame(); const whole = new GroupProducer(5); b.createTrack("fetch").writeGroup(whole); - whole.close(); + whole.finish(); front.serve(b.consume()); await settle(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(await read).toBeUndefined(); group.close(); await settle(); @@ -660,7 +660,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn failing.writeFrame({ payload: enc.encode("5.0") }); const fetching = front.consumer.track("fetch").fetchGroup(5); for (let turn = 0; turn < turns; turn++) await Promise.resolve(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); await settle(); // The next route holds the fetched group whole; the old one still feeds the reader a @@ -670,7 +670,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn fb.writeGroup(whole); whole.writeFrame({ payload: enc.encode("5.0") }); whole.writeFrame({ payload: enc.encode("5.1") }); - whole.close(); + whole.finish(); b.createTrack("seq"); front.serve(b.consume()); const group = await fetching; @@ -879,7 +879,7 @@ test("a fetched group continues from the next route once its own fails", async ( tb.write(5, 2); front.serve(b.consume()); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("5.1"); expect(await group.readFrame()).toBeUndefined(); @@ -978,7 +978,7 @@ test("a recovery fetch a later route supersedes is abandoned", async () => { // The next route does not have the group yet: recovery fetches it there and waits. front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); await settle(); const fetching = tb.producer.demand().wanted.peek() !== undefined; @@ -1013,7 +1013,7 @@ for (const fails of [true, false]) { const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); - if (fails) stalled.close(new StreamError(StreamCode.SessionClosed)); + if (fails) stalled.abort(new StreamError(StreamCode.SessionClosed)); front.serve(b.consume()); const read = group.readFrame(); @@ -1106,7 +1106,7 @@ test("a failed group on a route whose replacement was refused ends with the refu front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); await expect(group.readFrame()).rejects.toThrow("changed its properties"); sub.close(); @@ -1152,7 +1152,7 @@ test("a failed group whose replacement cannot fetch ends with the failure, or co front.serve(Unfetchable.of(b.consume())); await settle(); const failure = new StreamError(StreamCode.Cancel); - stalled.close(failure); + stalled.abort(failure); if (held) { expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1217,7 +1217,7 @@ test("a fetch never reads a replacement refused for its track properties", async const group = new GroupProducer(0); tb.writeGroup(group); group.writeFrame({ payload: enc.encode("b") }); - group.close(); + group.finish(); front.serve(a.consume()); await front.consumer.track("seq").info(); front.serve(b.consume()); @@ -1240,7 +1240,7 @@ test("the first fetch of a track says what the track is for later routes", async const group = new GroupProducer(0); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${priority}`) }); - group.close(); + group.finish(); } front.serve(a.consume()); (await front.consumer.track("seq").fetchGroup(0)).close(); @@ -1414,7 +1414,7 @@ test.each([ // Both routes hold group 1: the old one whole, the serving one failing after a frame. ta.write(1, 2); const failing = tb.write(1, 1, false); - const fail = () => failing.close(new StreamError(StreamCode.SessionClosed)); + const fail = () => failing.abort(new StreamError(StreamCode.SessionClosed)); if (early) fail(); const group = await next(); if (group?.sequence !== 1) throw new Error("expected group 1"); @@ -1464,7 +1464,7 @@ test.each([true, false])( ta.producer.close(new StreamError(StreamCode.SessionClosed)); const later = next(); await settle(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1491,7 +1491,7 @@ test("a fetched group that fails on the route still serving waits for the next r expect(dec.decode((await group.readFrame())?.payload)).toBe("5.0"); // Its route stays current, so its only copy of group 5 is the failed one. - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); // Timers still run: recovery does not spin on the failed copy. await settle(); @@ -1517,7 +1517,7 @@ test("a group handed out already failed with its route still continues on the ne const sub = front.consumer.track("seq").subscribe(); // The old route fails group 0 before the reader takes it. const failed = ta.write(0, 1, false); - failed.close(new StreamError(StreamCode.SessionClosed)); + failed.abort(new StreamError(StreamCode.SessionClosed)); const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); @@ -1639,7 +1639,7 @@ test("a fetch waits for the copy being read to say what the track is", async () const group = new GroupProducer(0); tb.writeGroup(group); group.writeFrame({ payload: enc.encode("b") }); - group.close(); + group.finish(); front.serve(a.consume()); const sub = front.consumer.track("seq").subscribe(); front.serve(b.consume()); @@ -1934,7 +1934,7 @@ test.each([true, false])( // The old route finishes group 1 only now, and the new route's copy of it fails. ta.write(1, 2); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1979,7 +1979,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival // b's first group lands only now, and c's copy of it fails. tb.write(1, 2); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -2285,7 +2285,7 @@ test("closing with an abort settles a recovery in flight with it", async () => { // The next route does not have the group yet: recovery fetches it there and waits. front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); await settle(); const abort = new Error("aborted"); @@ -2391,7 +2391,7 @@ test("a clean end keeps the serving copy while a group read from a replaced one expect(dec.decode((await group.readFrame())?.payload)).toBe("1.0"); // The old route fails: the group continues from the copy that ended. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); group.close(); @@ -2463,7 +2463,7 @@ test("a clean end keeps the serving copy while a group handed out before the swa expect(sub.closed.peek()).toBeNull(); // The old route fails: the group continues from the copy that ended. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); group.close(); @@ -2681,7 +2681,7 @@ for (const fetched of [false, true]) { if (!hostile) return undefined; const copy = new GroupProducer(sequence); copy.writeFrame({ payload: enc.encode(`${sequence}.0`) }); - setTimeout(() => copy.close(malformed), 0); + setTimeout(() => copy.abort(malformed), 0); return copy.consume(); }); const sub = front.consumer.track("seq").subscribe(); @@ -2692,7 +2692,7 @@ for (const fetched of [false, true]) { asked.length = 0; hostile = true; - stalled.close(malformed); + stalled.abort(malformed); await expect(group.readFrame()).rejects.toBe(malformed); await settle(); expect(asked).toEqual([]); @@ -2720,10 +2720,10 @@ test("a verdict every route repeats moves to the newer route once, then ends the front.serve(routeB.route); await settle(); - stalled.close(malformed); + stalled.abort(malformed); const read = group.readFrame(); expect(await isPending(read)).toBe(true); - repeat.close(malformed); + repeat.abort(malformed); await expect(read).rejects.toBe(malformed); await expect(group.readFrame()).rejects.toBe(malformed); expect([routeA.asked, routeB.asked]).toEqual([[], []]); diff --git a/js/net/src/retention.test.ts b/js/net/src/retention.test.ts index 30b0d771a6c..fbedb2f6296 100644 --- a/js/net/src/retention.test.ts +++ b/js/net/src/retention.test.ts @@ -140,7 +140,7 @@ for (const [name, alpn] of [ for (let i = 0; i < count; i++) { const group = producer.appendGroup(); group.writeFrame(frame(offset + i)); - group.close(); + group.finish(); const consumer = await track.nextGroup(); if (!consumer) throw new Error("no group"); await readFrames(consumer, 1); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 98712920dd3..24a365b6f46 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -106,7 +106,7 @@ function refreshGroup(producer: TrackProducer, delta: number): void { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("key") }); if (delta > 0) group.writeFrame({ payload: new Uint8Array(delta) }); - group.close(); + group.finish(); } test("the first subscriber of a track with no group wants group 0", () => { @@ -602,7 +602,7 @@ test("a long group is not stale while its tail reaches the edge", async () => { for (const ms of [0, 500, 1000, 1500, 2000]) { long.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } - long.close(); + long.finish(); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); expect((await sub.recvGroup())?.sequence).toBe(0); @@ -617,7 +617,7 @@ test("a long group is stale once its successor falls behind", async () => { for (const ms of [0, 500, 1000]) { long.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } - long.close(); + long.finish(); // Group 0 reaches at most 3s (where group 1 starts), a second behind the 4s edge and // so past the budget. A frame's duration is not on the wire, so group 0's own last // timestamp proves nothing about where it ends; only group 1's start bounds it. @@ -693,7 +693,7 @@ test("a late-arriving successor bounds a group's reach", async () => { ]) { const group = new GroupProducer(sequence); group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); producer.writeGroup(group); } @@ -732,17 +732,17 @@ for (const take of ["nextGroup", "tryNextGroup"] as const) { const zero = new GroupProducer(0); zero.writeFrame(textFrame("0.0")); zero.writeFrame(textFrame("0.1")); - zero.close(); + zero.finish(); producer.writeGroup(zero); const one = new GroupProducer(1); one.writeFrame(textFrame("1.0")); - one.close(); + one.finish(); producer.writeGroup(one); const two = new GroupProducer(2); two.writeFrame(textFrame("2.0")); - two.close(); + two.finish(); producer.writeGroup(two); // The frame path enters group 0; a direct take then claims group 1. @@ -764,18 +764,18 @@ test("ordered frame reads skip a late lower-sequence group", async () => { const five = new GroupProducer(5); five.writeFrame(textFrame("five", Timestamp.fromMillis(5))); - five.close(); + five.finish(); producer.writeGroup(five); expect(await track.readString()).toBe("five"); const three = new GroupProducer(3); three.writeFrame(textFrame("three", Timestamp.fromMillis(3))); - three.close(); + three.finish(); producer.writeGroup(three); const six = new GroupProducer(6); six.writeFrame(textFrame("six", Timestamp.fromMillis(6))); - six.close(); + six.finish(); producer.writeGroup(six); expect(await track.readString()).toBe("six"); @@ -819,7 +819,7 @@ test("a late lower group within the budget is delivered", async () => { const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); - group.close(); + group.finish(); } const track = producer.subscribe({ maxDelay: Milli(5000) }); @@ -833,7 +833,7 @@ test("a late lower group within the budget is delivered", async () => { const late = new GroupProducer(4); producer.writeGroup(late); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(500) }); - late.close(); + late.finish(); expect((await track.recvGroup())?.sequence).toBe(4); producer.close(); @@ -957,7 +957,7 @@ test("real time reads a live stream without truncating it", async () => { const next = producer.appendGroup(); next.writeFrame({ payload: enc.encode("key"), timestamp: Timestamp.fromMillis(n * 2000) }); await settle(); - open.close(); + open.finish(); next.writeFrame({ payload: enc.encode("tail"), timestamp: Timestamp.fromMillis(n * 2000 + 1900) }); expect(await end).toMatchObject({ code: StreamCode.Old }); @@ -1094,7 +1094,7 @@ test("a fully read group still reports its expiry", async () => { const track = producer.subscribe({ maxDelay: Milli(1000) }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.fromMillis(0) }); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1191,7 +1191,7 @@ test("a group shared by two tracks ages each from its own arrival", () => { first.writeFrame({ payload: enc.encode("0") }); const shared = new GroupProducer(1); shared.writeFrame({ payload: enc.encode("1") }); - shared.close(); + shared.finish(); first.writeGroup(shared); clock.set(11_000); second.writeGroup(shared); @@ -1271,7 +1271,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn const write = () => { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); }; write(); const group = track.tryRecvGroup(); @@ -1311,7 +1311,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy const track = producer.subscribe({ maxDelay: Milli(budget) }); const held = producer.appendGroup(); held.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); - held.close(); + held.finish(); const group = track.tryRecvGroup(); if (!group) throw new Error("missing group"); expect(await group.readString()).toBe("x"); @@ -1327,10 +1327,10 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy // Past the cache window, so the next write evicts the failed successor at once. const replacement = 10_000 + CACHE_WINDOW_MS + 10_000; await clock.advance(replacement); - failed.close(new Error("upstream failed")); + failed.abort(new Error("upstream failed")); const live = producer.appendGroup(); live.writeFrame({ payload: enc.encode("y"), timestamp: Timestamp.fromMillis(0) }); - live.close(); + live.finish(); await clock.advance(10_000 + budget + 1); expect(stale).toBe(false); @@ -1357,7 +1357,7 @@ test("a run of empty groups ages out by wall clock", () => { producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); for (let sequence = 1; sequence <= RUN; sequence++) { const group = producer.appendGroup(); - if (sequence % 2 === 0) group.close(); + if (sequence % 2 === 0) group.finish(); } producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(10) }); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toHaveLength(RUN + 2); @@ -1379,7 +1379,7 @@ test("a live track retains its newest closed group through idle expiry", async ( for (const sequence of [2, 1]) { const group = new GroupProducer(sequence); group.writeFrame({ payload: enc.encode("snapshot"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); producer.writeGroup(group); } await clock.advance(10_000 + CACHE_WINDOW_MS + 200); @@ -1405,7 +1405,7 @@ test("retention eviction surfaces as a gap for a handed-out group", async () => const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1428,7 +1428,7 @@ test("idle expiry reclaims an ended track's held mirror", async () => { const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1549,7 +1549,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { const track = producer.subscribe({ maxDelay: Milli(100) }); const source = producer.appendGroup(); source.writeFrame(textFrame("only")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1606,13 +1606,13 @@ test("latency budget retains a consumed live-edge anchor", async () => { const edge = new GroupProducer(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); - edge.close(); + edge.finish(); producer.writeGroup(edge); expect((await track.recvGroup())?.sequence).toBe(2); const late = new GroupProducer(1); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(0) }); - late.close(); + late.finish(); producer.writeGroup(late); producer.close(); @@ -1625,12 +1625,12 @@ test("an aborted live-edge anchor does not make older content stale", async () = const edge = new GroupProducer(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); - edge.close(new Error("aborted")); + edge.abort(new Error("aborted")); producer.writeGroup(edge); const older = new GroupProducer(1); older.writeFrame({ payload: enc.encode("older"), timestamp: Timestamp.fromMillis(0) }); - older.close(); + older.finish(); producer.writeGroup(older); expect((await track.recvGroup())?.sequence).toBe(1); @@ -1904,7 +1904,7 @@ test("writeGroup rejects a duplicate live sequence", async () => { const first = new GroupProducer(7); first.writeFrame(textFrame("first")); - first.close(); + first.finish(); producer.writeGroup(first); // A peer re-sending a live sequence is rejected, not fanned out again. @@ -1914,11 +1914,11 @@ test("writeGroup rejects a duplicate live sequence", async () => { // An aborted incarnation is replaceable: the retry serves the sequence fresh. const aborted = new GroupProducer(8); producer.writeGroup(aborted); - aborted.close(new Error("upstream reset")); + aborted.abort(new Error("upstream reset")); const retry = new GroupProducer(8); retry.writeFrame(textFrame("retry")); - retry.close(); + retry.finish(); producer.writeGroup(retry); const got = await track.recvGroup(); @@ -2032,7 +2032,7 @@ test("a closed track still delivers a group parked above the cap once the cap is for (let sequence = 0; sequence < 3; sequence++) { const group = new GroupProducer(sequence); group.writeFrame(textFrame(`frame-${sequence}`)); - group.close(); + group.finish(); producer.writeGroup(group); } @@ -2091,13 +2091,13 @@ test("readFrame does not livelock when a sole group finishes before the next arr // readable() resolves immediately, so the reader must not busy-wait on it (which would starve the // macrotask queue and never observe the next group). const g0 = producer.appendGroup(); - g0.close(); + g0.finish(); // The next group arrives via a macrotask; if the reader livelocks on microtasks it never runs. setTimeout(() => { const g1 = producer.appendGroup(); g1.writeFrame(textFrame("hello")); - g1.close(); + g1.finish(); producer.close(); }, 10); @@ -2122,7 +2122,7 @@ test("largest names the newest frame written", async () => { // The producer's getter holds no cursor, so every caller reads the same edge. expect(producer.largest()).toEqual({ group: 0, frame: 1 }); expect(producer.largest()).toEqual({ group: 0, frame: 1 }); - first.close(); + first.finish(); // A new group raises the largest at its head before any frame, so its first frame names // nothing newer. @@ -2139,7 +2139,7 @@ test("largest names the newest frame written", async () => { expect(advanced).toBe(false); second.writeFrame({ payload: enc.encode("d"), timestamp: Timestamp.now() }); expect(await next).toEqual({ group: 1, frame: 1 }); - second.close(); + second.finish(); // Reading a group does not move the edge: it is what the track produced, not what is left. await subscriber.recvGroup(); @@ -2163,7 +2163,7 @@ test("largest ends at the declared end while the producer stays open", async () const ended = subscriber.largest(); expect(await Promise.race([ended.then(() => "ready"), Promise.resolve("pending")])).toBe("pending"); - group.close(); + group.finish(); expect(await ended).toBeUndefined(); producer.close(); }); @@ -2177,10 +2177,10 @@ test("a late group below the largest changes nothing", async () => { const framed = new GroupProducer(2); producer.writeGroup(framed); framed.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); - framed.close(); + framed.finish(); const empty = new GroupProducer(5); producer.writeGroup(empty); - empty.close(); + empty.finish(); producer.finishAt({ group: 6, frame: 0 }); expect(await subscriber.largest()).toEqual({ group: 5, frame: 0 }); @@ -2205,7 +2205,7 @@ test("a recreated largest group is reported before the end", async () => { const first = new GroupProducer(0); producer.writeGroup(first); first.writeFrame({ payload: enc.encode("a"), timestamp }); - first.close(new Error("cut")); + first.abort(new Error("cut")); producer.finishAt({ group: 1, frame: 0 }); expect(await subscriber.largest()).toEqual({ group: 0, frame: 0 }); @@ -2221,7 +2221,7 @@ test("a recreated largest group is reported before the end", async () => { producer.writeGroup(again); for (const payload of ["a", "b"]) again.writeFrame({ payload: enc.encode(payload), timestamp }); expect(await next).toEqual({ group: 0, frame: 1 }); - again.close(); + again.finish(); expect(await subscriber.largest()).toBeUndefined(); producer.close(); }); @@ -2229,7 +2229,7 @@ test("a recreated largest group is reported before the end", async () => { test("a datagram raises the largest", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); - producer.appendGroup().close(); + producer.appendGroup().finish(); expect(producer.appendDatagram(Timestamp.now(), enc.encode("d"))).toBe(1); expect(await subscriber.largest()).toEqual({ group: 1, frame: 0 }); producer.close(); @@ -2312,7 +2312,7 @@ test("largest follows the bounds as they move", async () => { // it back. test("largest stays ended when the bounds widen", async () => { const producer = new TrackProducer("test").accept({}); - framedGroup(producer, 5, 1).close(); + framedGroup(producer, 5, 1).finish(); producer.close(); const reader = producer.subscribe().withRange({ end: { excluded: 3 } }); @@ -2327,7 +2327,7 @@ test("a live-edge reader starts on an empty largest group", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const first = producer.appendGroup(); first.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); - first.close(); + first.finish(); const empty = producer.appendGroup(); const reader = producer.subscribe({ maxDelay: Milli(30_000) }); @@ -2335,7 +2335,7 @@ test("a live-edge reader starts on an empty largest group", async () => { expect(edge).toEqual({ group: 1, frame: 0 }); reader.withRange({ start: { included: edge?.group ?? 0 } }); - empty.close(); + empty.finish(); const next = producer.appendGroup(); next.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); @@ -2405,7 +2405,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { const cut = new GroupProducer(0); producer.writeGroup(cut); const error = new StreamError(StreamCode(70), { message: "cut" }); - cut.close(error); + cut.abort(error); producer.writeGroup(new GroupProducer(1)); expect(track.tryRecvGroup()?.sequence).toBe(1); @@ -2483,10 +2483,10 @@ test("finishAt declares an end ahead of the live edge without ending the track", // Not terminal: groups below the end still arrive, including a straggler. const late = new GroupProducer(7); - late.close(); + late.finish(); producer.writeGroup(late); const straggler = new GroupProducer(6); - straggler.close(); + straggler.finish(); producer.writeGroup(straggler); expect((await track.recvGroup())?.sequence).toBe(6); expect((await track.recvGroup())?.sequence).toBe(7); @@ -2550,7 +2550,7 @@ for (const channel of ["group", "datagram"]) { const datagramRead = track.recvDatagram(); if (channel === "group") { const group = new GroupProducer(2); - group.close(); + group.finish(); producer.writeGroup(group); } else { producer.insertDatagram(2, Timestamp.fromMillis(0), enc.encode("tail")); @@ -2581,7 +2581,7 @@ test("finished rejects when the track aborts or closes without an end", async () // A clean close is an end, so it resolves. const clean = new TrackProducer("test"); const reader = clean.subscribe(); - clean.appendGroup().close(); + clean.appendGroup().finish(); clean.close(); expect(await reader.finished()).toEqual({ group: 1, frame: 0 }); }); @@ -2594,7 +2594,7 @@ test("an omitted publisher limit still ages idle groups out", async () => { try { const source = producer.appendGroup(); source.writeFrame(textFrame("old")); - source.close(); + source.finish(); clock.set(10_000 + CACHE_WINDOW_MS - 1_000); producer.appendGroup(); const within = producer.subscribe({ maxDelay: Milli(100_000) }); @@ -2619,10 +2619,10 @@ test("a live track does not pin an aborted newest group", () => { try { const good = producer.appendGroup(); good.writeFrame(textFrame("snapshot")); - good.close(); + good.finish(); const reset = producer.appendGroup(); reset.writeFrame(textFrame("partial")); - reset.close(new Error("reset")); + reset.abort(new Error("reset")); clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); const late = producer.subscribe({ maxDelay: Milli(100_000) }); @@ -2641,7 +2641,7 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn const subscriber = producer.subscribe({ maxDelay: Milli(100_000) }); const snapshot = producer.appendGroup(); snapshot.writeFrame(textFrame("snapshot")); - snapshot.close(); + snapshot.finish(); // Idle well past the window, but protected while the track is live. clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); @@ -2665,7 +2665,7 @@ test("a late subscriber to a closed unlimited track is released once the cache a const producer = new TrackProducer("unlimited").accept({ timescale: Timescale.MILLI }); const source = producer.appendGroup(); source.writeFrame(textFrame("last")); - source.close(); + source.finish(); producer.close(); // Counts the map entries subscribing leaves behind, rather than the heap: any entry @@ -2704,7 +2704,7 @@ test("an abort keeps finished groups for a slow reader, then reports it", async for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); - group.close(); + group.finish(); } producer.appendGroup().writeFrame(textFrame("open")); const boom = new Error("boom"); @@ -2728,7 +2728,7 @@ test("an abort after the declared end settles ends clean", async () => { for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); - group.close(); + group.finish(); } producer.finishAt({ group: 2, frame: 0 }); producer.close(new Error("boom")); @@ -2756,7 +2756,7 @@ test("an ended track's buffered groups age out for a stale subscriber", async () const stale = producer.subscribe({ maxDelay: Milli(30) }); const group = producer.appendGroup(); group.writeFrame(textFrame("x")); - group.close(); + group.finish(); producer.close(abort); // Nothing writes after close; the scheduled cleanup can lag by one scan slice. @@ -2826,7 +2826,7 @@ test("a frame whose timedness disagrees with its track is refused", async () => TimestampMismatch, ); other.writeFrame({ payload: enc.encode("y") }); - other.close(); + other.finish(); // The untimed frame reaches the subscriber as it was written: no time filled in. const frame = await (await subscriber.recvGroup())?.readFrame(); @@ -2969,7 +2969,7 @@ test("explicit and producer-cut drops survive outside the group cache", async () const group = new GroupProducer(4); producer.writeGroup(group); const cut = new Error("cut"); - group.close(cut); + group.abort(cut); await settle(); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 4, end: 5, error: cut }); @@ -2990,7 +2990,7 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () for (const [sequence, hole] of holes.entries()) { const group = new GroupProducer(sequence); producer.writeGroup(group); - group.close(hole); + group.abort(hole); } await settle(); for (const sequence of holes.keys()) expect(hooks.givenUp(reader, sequence).error).toBeUndefined(); @@ -3008,7 +3008,7 @@ test("an upstream drop ends a group still arriving with its error", async () => const finished = new GroupProducer(4); producer.writeGroup(finished); finished.writeFrame({ payload: enc.encode("4") }); - finished.close(); + finished.finish(); const group = await reader.recvGroup(); expect(group?.sequence).toBe(3); @@ -3043,7 +3043,7 @@ test("a re-created cut group is served, not dropped", async () => { const reader = producer.subscribe({ maxDelay: Milli(60_000) }); const cut = new GroupProducer(4); producer.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); await settle(); const retry = new GroupProducer(4); @@ -3101,7 +3101,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => const group = new GroupProducer(5); producer.writeGroup(group); for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); - group.close(); + group.finish(); producer.close(); await clock.advance(10_000 + CACHE_WINDOW_MS + 1); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 4 }); @@ -3162,7 +3162,7 @@ test.each([true, false])("a mid-group end caps later writes (declared first: %s) // A group handed over already past the end is refused before it is cached. const overfull = new GroupProducer(5); for (let i = 0; i < 3; i++) overfull.writeFrame({ payload: new Uint8Array([i]) }); - group.close(new Error("replaced")); + group.abort(new Error("replaced")); expect(() => producer.writeGroup(overfull)).toThrow(ProtocolViolation); producer.close(); }); @@ -3175,10 +3175,10 @@ function stampGroup(producer: TrackProducer, sequence: number, ms: number): Grou try { group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } catch (err) { - group.close(err as Error); + group.abort(err as Error); throw err; } - group.close(); + group.finish(); return group; } @@ -3197,7 +3197,7 @@ test("a re-created group is handed out once", () => { producer.writeGroup(aborted); expect(drainGroups(subscriber)).toEqual([0, 1]); - aborted.close(new Error("aborted")); + aborted.abort(new Error("aborted")); stampGroup(producer, 1, 1); stampGroup(producer, 2, 2); expect(drainGroups(subscriber)).toEqual([2]); @@ -3301,12 +3301,12 @@ test("a replaced aborted group still bounds later ones", () => { const first = new GroupProducer(0); producer.writeGroup(first); first.writeFrame({ payload: enc.encode("1000"), timestamp: Timestamp.fromMillis(1000) }); - first.close(new Error("aborted")); + first.abort(new Error("aborted")); const replacement = new GroupProducer(0); replacement.writeFrame({ payload: enc.encode("500"), timestamp: Timestamp.fromMillis(500) }); producer.writeGroup(replacement); - replacement.close(); + replacement.finish(); expect(() => stampGroup(producer, 1, 600)).toThrow(TimestampMismatch); stampGroup(producer, 1, 1000); }); @@ -3381,7 +3381,7 @@ test("an untimed track takes groups in any order", () => { const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${sequence}`) }); - group.close(); + group.finish(); } }); @@ -3394,7 +3394,7 @@ function pendingTail(end: number, maxDelay = Milli(60_000)) { hooks.endTrack(producer, { group: end, frame: 0 }); const write = (sequence: number) => { const group = new GroupProducer(sequence); - group.close(); + group.finish(); producer.writeGroup(group); }; return { producer, reader, write }; @@ -3447,7 +3447,7 @@ test("without a declared start, the first arrival stands in for it", async () => const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.endTrack(producer, { group: 3, frame: 0 }); const group = new GroupProducer(2); - group.close(); + group.finish(); producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); @@ -3488,7 +3488,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" hooks.startTrack(producer, { start: 3, requested: 1 }); hooks.endTrack(producer, { group: 4, frame: 0 }); const group = new GroupProducer(3); - group.close(); + group.finish(); producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(3); expect(await reader.recvGroup()).toBeUndefined(); @@ -3507,7 +3507,7 @@ test("an end after the highest group holds the reader at the hole", async () => const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.startTrack(producer, { start: 0 }); const high = new GroupProducer(2); - high.close(); + high.finish(); producer.writeGroup(high); expect((await reader.recvGroup())?.sequence).toBe(2); const next = reader.recvGroup(); @@ -3516,7 +3516,7 @@ test("an end after the highest group holds the reader at the hole", async () => expect(await settled(next)).toBe(false); for (const sequence of [0, 1]) { const low = new GroupProducer(sequence); - low.close(); + low.finish(); producer.writeGroup(low); } expect((await next)?.sequence).toBe(0); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 6f5062b84f9..57301876f14 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -917,7 +917,7 @@ export class Producer { if (open) { for (const [sequence, entry] of this.#cached) { if (sequence >= start && sequence < end && entry.group.closed.peek() === undefined) { - entry.group.close(error); + entry.group.abort(error); } } } @@ -1162,7 +1162,7 @@ export class Producer { if (!entry.group.isClosed) { const evicted = new TooFarBehind(); EVICTED.add(evicted); - entry.group.close(evicted); + entry.group.abort(evicted); } const mirrors = [...entry.mirrors.values()]; for (const mirror of mirrors) hooks.evictGroup(mirror); @@ -1758,7 +1758,10 @@ export class Producer { closeTrackState(this.#state, abort); this.#sequence.holders--; if (this.#sequence.owner === this) this.#sequence.owner = undefined; - for (const { group } of this.#cache) group.close(abort); + for (const { group } of this.#cache) { + if (abort) group.abort(abort); + else group.finish(); + } for (const entry of open) { this.#unlink(entry); this.#uncache(entry); @@ -1791,10 +1794,10 @@ export class Producer { group.writeFrame(frame); } catch (err) { // Abort the group so no subscriber waits on a group that will never get its frame. - group.close(err instanceof Error ? err : new Error(String(err))); + group.abort(err instanceof Error ? err : new Error(String(err))); throw err; } - group.close(); + group.finish(); } } diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 3946c2f11b0..9170bec2045 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -56,7 +56,7 @@ async function roundTrip(wire: Wire, timescale: Timescale | undefined, timestamp for (const [i, timestamp] of timestamps.entries()) { group.writeFrame({ payload: new Uint8Array([i]), timestamp }); } - group.close(); + group.finish(); } })(); @@ -86,7 +86,7 @@ async function republish(input: BroadcastConsumer): Promise { for (let group = await groups.nextGroup(); group; group = await groups.nextGroup()) { const out = video.appendGroup(); for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) out.writeFrame(frame); - out.close(); + out.finish(); } // Closing the broadcast does not end a track inserted into it. video.close(); @@ -122,7 +122,7 @@ test("a forwarded untimed track stays untimed", async () => { if (!request) break; const group = request.accept({ timescale: Timescale.MICRO }).appendGroup(); group.writeFrame({ payload: new Uint8Array([7]), timestamp: Timestamp.fromMicros(1_234) }); - group.close(); + group.finish(); } })(); @@ -197,7 +197,7 @@ for (const wire of WIRES.filter((wire) => wire.name === "lite-06" || wire.name = // Datagrams aren't cached, so a group read back first proves the subscription is live. const group = producer.appendGroup(); group.writeFrame({ payload: new Uint8Array([0]) }); - group.close(); + group.finish(); expect(await subscriber.recvGroup()).toBeDefined(); producer.appendDatagram(undefined, new Uint8Array([7])); diff --git a/js/net/src/zod.test.ts b/js/net/src/zod.test.ts index ddc8cee2798..95a3cf48b51 100644 --- a/js/net/src/zod.test.ts +++ b/js/net/src/zod.test.ts @@ -9,7 +9,7 @@ test("read validates each frame and ends only at the end of the group", async () const consumer = producer.consume(); // Falsy values are frames like any other, not the end of the stream. for (const json of ["0", "false", "null", '"ok"']) producer.writeFrame(textFrame(json)); - producer.close(); + producer.finish(); const schema = z.union([z.number(), z.boolean(), z.null(), z.string()]); expect(await Zod.read(consumer, schema)).toBe(0); @@ -23,7 +23,7 @@ test("read rejects a frame the schema refuses", async () => { const producer = new Producer(0); const consumer = producer.consume(); producer.writeFrame(textFrame('{"count":"three"}')); - producer.close(); + producer.finish(); await expect(Zod.read(consumer, z.object({ count: z.number() }))).rejects.toThrow(); }); diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 9631e92b857..f9d90dad24d 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -279,7 +279,7 @@ test("a rendition keeps its track across rendition changes", async () => { await settle(); const track = video.track.peek(); if (!track) throw new Error("expected the demanded track"); - track.appendGroup().close(); + track.appendGroup().finish(); // Registering another rendition leaves this one's track alone. const audio = broadcast.audio("audio"); @@ -338,7 +338,7 @@ test("replacing a closed rendition track keeps the broadcast and publishes a fre if (!track) throw new Error("expected the demanded track"); // The encoder wrote, then failed. - track.appendGroup().close(); + track.appendGroup().finish(); track.close(new Error("encoder failed")); video.close(); await settle(); @@ -474,7 +474,7 @@ test("a served decoder identity change moves the rendition to a fresh track name await settle(); const track = video.track.peek(); if (!track) throw new Error("expected the demanded track"); - track.appendGroup().close(); + track.appendGroup().finish(); // Bitrate, frame rate, and coded size move either way on the served track. for (const update of [ diff --git a/js/watch/src/audio/decoder.test.ts b/js/watch/src/audio/decoder.test.ts index 1c449b169a0..4abb76e2486 100644 --- a/js/watch/src/audio/decoder.test.ts +++ b/js/watch/src/audio/decoder.test.ts @@ -965,7 +965,7 @@ async function livePlayback(burst: number) { for (const timestamp of held) { const group = new Moq.Group.Producer(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(timestamp)))); - group.close(); + group.finish(); track.writeGroup(group); } held = []; diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index b97c17ef421..adc680ac30b 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -100,7 +100,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< const media = Micro.fromMilli(t[next].media as Milli); const group = new Group.Producer(next); group.writeFrame({ payload: encode(media), timestamp: Time.Timestamp.fromMicros(media) }); - group.close(); + group.finish(); track.writeGroup(group); } diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 82a9b91d633..58eda7488ba 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -246,7 +246,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato timestamp: Time.Timestamp.fromMicros(timestamp), }); if (ends.get(arrival.group) === next) { - group.close(); + group.finish(); groups.delete(arrival.group); } next++; diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index e9927a0d5f7..dd4c12354a4 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -902,7 +902,7 @@ function liveVideo(clock: ReturnType, track: Moq.Track.Producer for (const media of held) { const group = new Moq.Group.Producer(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(media)))); - group.close(); + group.finish(); track.writeGroup(group); } held = []; @@ -1031,7 +1031,7 @@ it("buffered video holds its poster until its own span reaches the floor", async await clock.advance(40); expect(playback.decoder.out.frame.peek()?.timestamp).toBe(40_000); } finally { - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1055,7 +1055,7 @@ it("bounds startup decoding while another track is missing and releases it on cl expect(playback.pictures.every((frame) => frame.closed)).toBe(true); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1081,7 +1081,7 @@ it("resumes decoding the moment shared startup releases", async () => { expect(playback.submitted).toHaveLength(8); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1130,7 +1130,7 @@ it("continues filling when the shared floor rises while video waits on audio", a expect(playback.decoder.out.frame.peek()?.timestamp).toBeGreaterThanOrEqual(200_000); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1148,7 +1148,7 @@ it("keeps feeding a decoder that has not output its first picture yet", async () await clock.advance(160); expect(playback.decoder.out.frame.peek()?.timestamp).toBe(160_000); } finally { - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); From 8d7a19328f38c8f8bc34eb67791340e35b5fa242 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:08:43 -0700 Subject: [PATCH 16/58] refactor(net)!: JS connection allocator, and migration notes Co-Authored-By: Claude Opus 5.5 --- dart/moq/MIGRATE.md | 4 ++++ go/wrapper/MIGRATE.md | 4 ++++ js/net/MIGRATE.md | 7 +++++++ js/net/src/connection/bandwidth.test.ts | 10 +++++----- js/net/src/connection/pool.ts | 10 +++++----- js/net/src/connection/reload.ts | 6 +++--- js/publish/src/element.ts | 4 ++-- js/room/src/local.test.ts | 2 +- js/room/src/local.ts | 2 +- kt/moq/MIGRATE.md | 4 ++++ py/moq-rs/MIGRATE.md | 4 ++++ rs/moq-ffi/MIGRATE.md | 4 ++++ rs/moq-net/MIGRATE.md | 19 +++++++++++++++++++ rs/moq-tokio/MIGRATE.md | 4 ++++ swift/MIGRATE.md | 4 ++++ 15 files changed, 71 insertions(+), 17 deletions(-) diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index b20e5bbafb8..473edcec354 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Credential` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`AuthToken` is `Credential`; `Auth.add` returns it. + ### Connection state `Moq.connect` returns a connecting native session. Replace status, connect-count, and failure polls with `session.state()` and `session.stateChanged(after: state.revision)`. `ConnectionState` carries `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index c1f214e6a81..8f79fae97a7 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Credential` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`AuthToken` is `Credential`; `Auth.Add` returns it. + ### Connection state `Dial` returns a connecting native client. Replace `Session.Status`, `Connects`, and `Failure` with `State()` and `StateChanged(ctx, state.Revision)`. `ConnectionState` carries `Phase`, `Connects`, `Failure`, and `Version`; `ConnectionPhase` replaces `ConnectionStatus`. Cancelling a state wait only cancels that wait. Cancel the session itself to stop its dial. diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 2bdc084a66e..3d036005977 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Names say one thing ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`Auth.Auth` is `Auth.Credential`, and `Reservation.grant` is `Reservation.rate`. +A connection's `bandwidth` allocator is `allocator`, mirroring Rust's `Session::allocator()`. +`Track.Producer.writeGroup(group)` is `createGroup(sequence)`, which creates the group and returns it like `appendGroup()`: replace `const group = new Group.Producer(n); track.writeGroup(group);` with `const group = track.createGroup(n);`. +A group can no longer be written before it joins a track, or join more than one track. + ### Published names refuse navigation segments Broadcast creation and route publication now refuse literal `.` and `..` segments, including segments in the producer's root. diff --git a/js/net/src/connection/bandwidth.test.ts b/js/net/src/connection/bandwidth.test.ts index 665f35744af..f415cb90857 100644 --- a/js/net/src/connection/bandwidth.test.ts +++ b/js/net/src/connection/bandwidth.test.ts @@ -46,7 +46,7 @@ test("a connection samples the send rate onto the allocator", async () => { const connection = new Connection({ url }); try { await waitUntil(() => connection.status.peek() === "connected"); - const allocator = connection.bandwidth.peek(); + const allocator = connection.allocator.peek(); if (!allocator) throw new Error("missing allocator"); const track = new TrackProducer("video").accept({ priority: 60 }); @@ -68,8 +68,8 @@ test("two publishers on one connection split the estimate by priority", async () await waitUntil(() => first.status.peek() === "connected"); await waitUntil(() => second.status.peek() === "connected"); - const allocator = first.bandwidth.peek(); - expect(allocator).toBe(second.bandwidth.peek()); + const allocator = first.allocator.peek(); + expect(allocator).toBe(second.allocator.peek()); if (!allocator) throw new Error("missing allocator"); const audio = new TrackProducer("audio").accept({ priority: 80 }); @@ -97,7 +97,7 @@ test("an idle track on a live connection claims nothing", async () => { const handle = new Connection({ url }); try { await waitUntil(() => handle.status.peek() === "connected"); - const allocator = handle.bandwidth.peek(); + const allocator = handle.allocator.peek(); if (!allocator) throw new Error("missing allocator"); const watched = new TrackProducer("watched").accept({ priority: 60 }); @@ -124,7 +124,7 @@ test("a private connection still exposes an allocator", async () => { }); try { await waitUntil(() => connection.status.peek() === "connected"); - const allocator = connection.bandwidth.peek(); + const allocator = connection.allocator.peek(); if (!allocator) throw new Error("missing allocator"); const track = new TrackProducer("video").accept({ priority: 60 }); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index e6f09fe9b01..7bcb047962f 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -172,7 +172,7 @@ export class Connection { * against one registry. Borrowed, not owned: the type has no close, since * closing it would starve every other handle. */ - readonly bandwidth: Getter; + readonly allocator: Getter; /** * The origin for the current URL, or undefined while disabled or URL-less. @@ -209,7 +209,7 @@ export class Connection { readonly #established = new Signal(undefined); readonly #probe = new Signal | undefined>(undefined); readonly #origin = new Signal(undefined); - readonly #bandwidth = new Signal(undefined); + readonly #allocator = new Signal(undefined); readonly #auth = new Signal(undefined); readonly #error = new Signal(undefined); #signals: Effect; @@ -239,7 +239,7 @@ export class Connection { this.error = this.#error; this.probe = new Follow(this.#probe); this.origin = this.#origin; - this.bandwidth = this.#bandwidth; + this.allocator = this.#allocator; this.auth = this.#auth; this.transport = this.#signals.computed((effect) => effect.get(this.#established)?.transport); @@ -266,7 +266,7 @@ export class Connection { effect.cleanup(lease.release); effect.set(this.#origin, lease.origin, undefined); - effect.set(this.#bandwidth, lease.connection.bandwidth, undefined); + effect.set(this.#allocator, lease.connection.allocator, undefined); effect.set(this.#auth, lease.connection.auth, undefined); // Proxy the shared loop's outputs, so this handle reads like its own connection. @@ -316,7 +316,7 @@ export class Connection { signals.run((effect) => { if (!effect.get(this.enabled) || !effect.get(href)) return; effect.set(this.#origin, origin, undefined); - effect.set(this.#bandwidth, loop.bandwidth, undefined); + effect.set(this.#allocator, loop.allocator, undefined); effect.set(this.#auth, loop.auth, undefined); }); signals.run((effect) => effect.set(this.#status, effect.get(loop.status), "disconnected")); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 849f663417f..8fb68b837d9 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -152,7 +152,7 @@ export class Reload { * estimate is `undefined` while disconnected, which tells a sender to hold * its rate rather than encode at zero. */ - readonly bandwidth: Allocator; + readonly allocator: Allocator; /** * The auth handle every session of the loop is built on, spanning reconnects. @@ -328,8 +328,8 @@ export class Reload { effect.set(this.#probeSource, effect.get(this.established)?.probe, undefined); }); - this.bandwidth = new Allocator(this.#estimate); - this.#signals.cleanup(() => this.bandwidth.close()); + this.allocator = new Allocator(this.#estimate); + this.#signals.cleanup(() => this.allocator.close()); // The transport has no event for the send-rate estimate, so sample on our // own schedule and skip a tick while the previous snapshot is outstanding. diff --git a/js/publish/src/element.ts b/js/publish/src/element.ts index 356e6f5930c..34c42ce90af 100644 --- a/js/publish/src/element.ts +++ b/js/publish/src/element.ts @@ -231,7 +231,7 @@ export default class MoqPublish extends HTMLElement { broadcast: this.broadcast, capture: this.#capture, enabled: this.#videoEnabled, - bandwidth: this.connection.bandwidth, + bandwidth: this.connection.allocator, }); this.signals.cleanup(() => this.video.close()); @@ -239,7 +239,7 @@ export default class MoqPublish extends HTMLElement { broadcast: this.broadcast, enabled: this.#audioEnabled, capture: audioCapture, - bandwidth: this.connection.bandwidth, + bandwidth: this.connection.allocator, }); this.signals.cleanup(() => this.audio.close()); diff --git a/js/room/src/local.test.ts b/js/room/src/local.test.ts index 63325dce7fe..77d609cbd8b 100644 --- a/js/room/src/local.test.ts +++ b/js/room/src/local.test.ts @@ -40,7 +40,7 @@ test("screen capture stays enabled while pending and resets after a live share e const local = new Local({ connection: { origin: new Signal(undefined), - bandwidth, + allocator: bandwidth, } as never, identity: Path.from("alice"), }); diff --git a/js/room/src/local.ts b/js/room/src/local.ts index ccfe047598f..aea6db869a0 100644 --- a/js/room/src/local.ts +++ b/js/room/src/local.ts @@ -102,7 +102,7 @@ export class Local { this.user = userFields(props.user); const origin = props.connection.origin; - const bandwidth = props.connection.bandwidth; + const bandwidth = props.connection.allocator; this.webcam = new Publish.Source.Camera({ enabled: this.cameraEnabled, diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index c42780b3615..055a168cddd 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Credential` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`AuthToken` is `Credential`; `Auth.add` returns it. + ### Connection state `Moq.connect` returns a connecting native session. Replace `status()`, `connects()`, and failure polls with `session.state()` and `session.stateChanged(state.revision)`. `ConnectionState` carries `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 1ce345d17f7..ebd350e94eb 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Credential` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`moq.AuthToken` is `moq.Credential`; `Auth.add` returns it. + ### Connection state Client entry returns a connecting native session. Replace `Session.status()`, `connects()`, and both `failure()` polls with `state()` and `await state_changed(state.revision)`. The `ConnectionState` fields include `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. Wait for `CONNECTED` when work requires an established transport, or await `closed()` for the terminal result. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 056aa8a1fe4..5455df0a1a4 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,10 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### `MoqCredential` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`MoqAuthToken` is `MoqCredential`, the core API's name for one presented credential. + ### Connection state `MoqClient::connect` returns a connecting session without waiting for its first dial to succeed, natively and in the browser. Read `MoqSession::state()` and pass its `revision` to `state_changed(after)` to observe first-dial failures and reconnects. `MoqConnectionState` carries `phase`, `connects`, `failure`, and `version` together. These replace `status()`, `connects()`, and both `failure()` polls. Cancel the returned session to stop dialing; cancelling or releasing the client does not close it. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 9dea3a51305..5fe6840bb0f 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,25 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Names say one thing ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +Rename these, which kept their behavior: + +| Before | After | +|---|---| +| `Session::bandwidth()` | `Session::allocator()` | +| `Client::with_bandwidth` | `Client::with_allocator` | +| `auth::Auth` | `auth::Credential` | +| `track::Consumer::query()`, `track::Querying` | `track::Consumer::info()`, `track::Resolving` | +| `bandwidth::Reservation::consumer()` | `bandwidth::Reservation::consume()` | +| `stats::Control` | `stats::Message` | +| `stats::Bytes` | `stats::Totals` | +| `transport::Stats`, `transport::poll::Session::stats()` | `transport::Metrics`, `transport::poll::Session::metrics()` | + +`group::Demand` reads like `track::Demand`: replace `is_used()` with `wanted()`, and `used()`, `unused()`, `poll_used`, and `poll_unused` with a loop over `wanted_changed()` (or `poll_wanted_changed`), which returns the new value each time it changes and errors once the group closes. +`transport::Metrics` drops `bytes_sent` and `bytes_received`: a transport reports them through the `transport::Wire` it returns from `wire()`, which `session::Stats` now reads. +`transport::StatsUnavailable` is gone: `metrics()` defaults to reporting nothing, so delete an implementation that returned it. + ### `stats::Refusal` is `stats::RefusalKind`, and `time::now` is gone (#6510) Rename `moq_net::stats::Refusal` to `moq_net::stats::RefusalKind`, so it no longer shares a name with `moq_auth::Refusal`; its variants and `Refusals` are unchanged. diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index 4f4a034a986..c4ef33517d7 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Connection::allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`Connection::bandwidth()` is `Connection::allocator()`, mirroring `moq_net::Session::allocator()`. + ### The connection receive window is 128 MiB, and has a floor (#5878) `quic::DEFAULT_RECEIVE_WINDOW` is 128 MiB instead of 64 MiB, since incomplete messages now hold connection credit until complete. diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 998978ed32a..2632de0b841 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Credential` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`AuthToken` is `Credential`; `Auth.add(token:)` returns it. + ### Connection state `Client.connect(to:)` returns a connecting native session. Replace `status()`, `connects()`, and both `failure()` polls with `state()` and `stateChanged(after: state.revision)`. Use `ConnectionState.phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. From 88e981b88d2e6bf73e106479eb5e3f3d8d070cf9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:08:59 -0700 Subject: [PATCH 17/58] chore(quest): finish quest/m1/api-audit/net-names.md Co-Authored-By: Claude Opus 5.5 --- quest/m1/api-audit/README.md | 1 - quest/m1/api-audit/net-names.md | 47 --------------------------------- 2 files changed, 48 deletions(-) delete mode 100644 quest/m1/api-audit/net-names.md diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 889e9fb6107..db63db33d02 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -72,7 +72,6 @@ Rejected in planning: - [A request's source enforces its validity](/quest/m1/api-audit/answer.md) - a stale source refuses new track requests by itself, with no public additions - [moq-transport stays private](/quest/m1/api-audit/ietf-private.md) - no public moq-net item exists only for moq-transport, and a client names its request target once as a URL - [One lifecycle vocabulary](/quest/m1/api-audit/lifecycle.md) - finish, abort, closed, and close mean one thing on every handle, and Rust and JS observers fire alike -- [moq-net names say one thing](/quest/m1/api-audit/net-names.md) - demand says `wanted`, colliding stats and transport names are renamed, and Rust and JS info lookups mirror - [Request handlers are named requests](/quest/m1/api-audit/requests.md) - `dynamic()` and `Dynamic` become `requests()` and `Requests` at every layer ## Related diff --git a/quest/m1/api-audit/net-names.md b/quest/m1/api-audit/net-names.md deleted file mode 100644 index 9a8c7d58826..00000000000 --- a/quest/m1/api-audit/net-names.md +++ /dev/null @@ -1,47 +0,0 @@ -# [L] moq-net names say one thing - -## Goal - -moq-net and `@moq/net` names don't collide with other items, don't describe -the wrong shape, and mirror each other. Each rename reaches the bindings and -docs that expose it. - -## Plan - -Decided 2026-10-23 (API audit follow-up, maintainer): - -- **Demand says `wanted`.** `group::Demand` drops - `is_used`/`used`/`unused`/`poll_used`/`poll_unused` for `track::Demand`'s - `wanted()` snapshot plus `wanted_changed()` (and its poll). - `broadcast::Demand` is deleted instead - ([net-exports](/quest/m1/api-audit/net-exports.md)). -- **Allocator.** `Session::bandwidth()` returns the send allocator beside - `send_bandwidth()`/`recv_bandwidth()` estimates: rename it `allocator()`, and - `Client::with_bandwidth` `with_allocator`. -- **Stats and transport collisions.** `stats::Control` (vs `track::Control`) - becomes `stats::Message`; `stats::Bytes` (vs `bytes::Bytes`) becomes - `stats::Totals`; the trait `transport::Stats` (vs `session::Stats` and the - `stats` module) becomes `transport::Metrics`. -- **Wire owns bytes.** `transport::Metrics` drops `bytes_sent` and - `bytes_received`: `transport::Wire` already counts them, and - `session::Stats` reads them from it. `StatsUnavailable` goes; the trait - method gets a default. -- **Credential.** `auth::Auth` (JS `Auth.Auth`) is one presented credential: - `auth::Credential` in Rust, JS, and the FFI. Update the - [auth epic](/quest/m1/auth/README.md), which uses the old name. -- **Info.** `track::Consumer::query() -> Pending` becomes - `info() -> Pending`, mirroring JS `Track.Consumer.info()`. - `Pending` stays generic over the task so poll callers and `track()` keep - working; awaiting it still yields `Result`. -- **Reservation.** `bandwidth::Reservation::consumer()` becomes `consume()`; - JS `Reservation.grant` (vs `Auth.Grant`) becomes `rate`, as the bindings - name it. -- **JS groups.** `Track.Producer.writeGroup` becomes `createGroup`, Rust's name. - -Kept, so the next audit skips them: `largest()` stays a snapshot on -`track::Producer` and a stream on readers, and `subscription()` stays the -aggregate on producers and requests: the receiver type tells them apart. -`server::Activity` and `stats::RefusalKind` stay in moq-net. - -Public API: moq-net, `@moq/net`, moq-tokio, moq-ffi and its wrappers renamed. -Wire: none. From 35fa0b98dbd17ade02869235962cb6c84aceb2ec Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:13:49 -0700 Subject: [PATCH 18/58] style: format the renames Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/track.ts | 2 +- js/hang/src/container/consumer.outoforder.test.ts | 2 +- js/hang/src/container/consumer.startup.test.ts | 2 +- js/hang/src/container/consumer.test.ts | 3 +-- js/net/bench/track.ts | 2 +- js/net/src/hop.ts | 4 +++- js/net/src/ietf/subscriber.ts | 2 +- js/net/src/lite/publisher.test.ts | 2 +- js/net/src/track.test.ts | 2 +- js/watch/src/audio/replay.ts | 2 +- py/moq-rs/moq/session.py | 2 +- rs/moq-net/src/model/group.rs | 5 ++++- rs/moq-net/src/model/origin.rs | 8 +++++++- 13 files changed, 24 insertions(+), 14 deletions(-) diff --git a/js/e2ee/src/track.ts b/js/e2ee/src/track.ts index 55dc2a79581..25281cec03b 100644 --- a/js/e2ee/src/track.ts +++ b/js/e2ee/src/track.ts @@ -3,7 +3,7 @@ * * @module */ -import { Group as NetGroup, type Track as NetTrack, type Time } from "@moq/net"; +import type { Group as NetGroup, Track as NetTrack, Time } from "@moq/net"; import type { Event } from "./datagram.ts"; import { checkU53 } from "./derive.ts"; import { Failure } from "./failure.ts"; diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index a71cda5fc7b..d928530df86 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -1,6 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Micro, Milli } from "@moq/clock"; -import { Group, Time, Track, Varint } from "@moq/net"; +import { type Group, Time, Track, Varint } from "@moq/net"; import { Consumer } from "./consumer.ts"; import { Format as LegacyFormat } from "./legacy.ts"; diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 6ea4edbf9df..c848f564728 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -1,6 +1,6 @@ import { expect, jest, onTestFinished, spyOn, test } from "bun:test"; import { Micro, Milli } from "@moq/clock"; -import { Group, Error as NetError, StreamCode, Time, Track } from "@moq/net"; +import { type Group, Error as NetError, StreamCode, Time, Track } from "@moq/net"; import { Consumer, type Read } from "./consumer.ts"; import { encodeFrame, Format } from "./legacy.ts"; diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index ce4385275b6..17cbce2f335 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -2,7 +2,7 @@ import { expect, spyOn, test } from "bun:test"; import { readFileSync } from "node:fs"; import { Manual, type Micro, Milli, Nano } from "@moq/clock"; import { Format as LocFormat } from "@moq/loc"; -import { Group, Error as NetError, SessionCode, StreamCode, Time, Track, Varint } from "@moq/net"; +import { type Group, Error as NetError, SessionCode, StreamCode, Time, Track, Varint } from "@moq/net"; import { decodeInitSegment, type InitSegment } from "../cmaf/decode.ts"; import { Format as CmafFormat } from "../cmaf/format.ts"; import { Consumer, type Read } from "./consumer.ts"; @@ -1503,7 +1503,6 @@ test("Consumer does not duration-skip when the gap is not covered", async () => g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); g1.close(); - // Let the consumer settle on group 0, then extend it before closing. await new Promise((resolve) => setTimeout(resolve, 20)); g0.writeFrame({ payload: new Uint8Array([20]), timestamp: Time.Timestamp.now() }); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 651ab9cb645..44601f99d1e 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -1,7 +1,7 @@ /** Sweep retained groups and subscribers for one group published through a track and received. */ import { Milli } from "@moq/clock"; -import { type Consumer as GroupConsumer, Producer as GroupProducer } from "../src/group.ts"; +import type { Consumer as GroupConsumer, Producer as GroupProducer } from "../src/group.ts"; import { Timescale, Timestamp } from "../src/time.ts"; import { Producer, type Subscriber } from "../src/track.ts"; import { untilGivenUp } from "../src/util/given_up.ts"; diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index a978f13ae71..54d5ba31ed1 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -125,7 +125,9 @@ export const Route = { default: { hops: [], cost: Cost.zero } as Route, /** Fill route defaults and saturate the static price at the wire ceiling. */ - normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Credential } = {}): Route { + normalize( + route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Credential } = {}, + ): Route { const cost = route.cost ?? Cost.zero; if (typeof cost !== "bigint" || cost < 0n) throw new RangeError("route cost must be a non-negative bigint"); const max = 2n ** 62n - 1n; diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 7ba41699d4a..bd12a9f3d0d 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -19,7 +19,7 @@ import { sessionCause, unauthorized, } from "../error.ts"; -import * as netGroup from "../group.ts"; +import type * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { finalSequence, diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 98bbddc3c15..d0492fa8040 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -4,7 +4,7 @@ import { Signal } from "@moq/signals"; import type { Grant } from "../auth.ts"; import { Producer as BroadcastProducer } from "../broadcast.ts"; import { fromTransport, SessionCode, StreamCode, StreamError, toStreamCode } from "../error.ts"; -import { Producer as GroupProducer } from "../group.ts"; +import type { Producer as GroupProducer } from "../group.ts"; import { randomHop } from "../hop.ts"; import { Version as IetfVersion } from "../ietf/version.ts"; import { hooks } from "../internal.ts"; diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 65143bd0214..ef99ef9b100 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -5,7 +5,7 @@ import { Producer as BroadcastProducer } from "./broadcast.ts"; import { ProtocolViolation, StreamCode, StreamError, TimestampMismatch, TooFarBehind } from "./error.ts"; import { Decoder } from "./flate/decoder.ts"; import { Encoder } from "./flate/encoder.ts"; -import { type Consumer as GroupConsumer, Producer as GroupProducer, MAX_GROUP_FRAMES } from "./group.ts"; +import { type Consumer as GroupConsumer, type Producer as GroupProducer, MAX_GROUP_FRAMES } from "./group.ts"; import { Version as IetfVersion } from "./ietf/version.ts"; import { hooks } from "./internal.ts"; import { textFrame } from "./mock.ts"; diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 8dc3c033906..0159f124b21 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -19,7 +19,7 @@ import { Manual, type Micro, Milli, Nano, type Second } from "@moq/clock"; import type * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; -import { Group, Time, Track } from "@moq/net"; +import { type Group, Time, Track } from "@moq/net"; import { Effect, Signal } from "@moq/signals"; import { type Arrival as Diagnostic, gap } from "../arrivals"; import { type Delay, Sync } from "../sync"; diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 83deaf68fce..7f2df1edcae 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -2,7 +2,7 @@ from __future__ import annotations -from moq_ffi import MoqAuth, MoqCredential, MoqBandwidth, MoqBandwidthConsumer, MoqReservation, MoqSession +from moq_ffi import MoqAuth, MoqBandwidth, MoqBandwidthConsumer, MoqCredential, MoqReservation, MoqSession from .origin import OriginConsumer, OriginProducer from .publish import TrackDemand diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index a92605fdae7..d97eec4bddd 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -3368,7 +3368,10 @@ mod test { assert!(matches!(demand.poll_wanted_changed(&waiter), Poll::Ready(Ok(false)))); drop(producer); assert!(!demand.wanted()); - assert!(matches!(demand.poll_wanted_changed(&waiter), Poll::Ready(Err(Error::Dropped)))); + assert!(matches!( + demand.poll_wanted_changed(&waiter), + Poll::Ready(Err(Error::Dropped)) + )); } #[test] diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 4497b066b22..39623e6e23b 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -698,7 +698,13 @@ fn route_order<'a>( /// The `(hops, cost, source, epoch, auth)` metadata an announce cursor delivers alongside a /// prefix. -type RouteMeta = (Hops, Cost, Source, Option, Option); +type RouteMeta = ( + Hops, + Cost, + Source, + Option, + Option, +); /// The earlier of two optional deadlines. fn earliest(a: Option, b: Option) -> Option { From d88213883b44618845ac0c33c85086571ab191d1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:16:32 -0700 Subject: [PATCH 19/58] test: drop unused Group imports after createGroup Co-Authored-By: Claude Opus 5.5 --- js/hang/src/container/consumer.holes.test.ts | 2 +- js/hang/src/container/consumer.nonsequential.test.ts | 2 +- js/hang/src/container/consumer.skips.test.ts | 2 +- js/watch/src/audio/playout.test.ts | 2 +- js/watch/src/video/decoder.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index afe7803fe9a..b6b4e9e4ec2 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Manual, Micro, Milli, Nano } from "@moq/clock"; -import { Group, Time, Track } from "@moq/net"; +import { Time, Track } from "@moq/net"; import { Consumer, type Read } from "./consumer.ts"; import { encodeFrame, Format } from "./legacy.ts"; diff --git a/js/hang/src/container/consumer.nonsequential.test.ts b/js/hang/src/container/consumer.nonsequential.test.ts index 0e26b421463..3ceb8a44bbe 100644 --- a/js/hang/src/container/consumer.nonsequential.test.ts +++ b/js/hang/src/container/consumer.nonsequential.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import type { Micro, Milli } from "@moq/clock"; -import { Group, Time, Track, Varint } from "@moq/net"; +import { Time, Track, Varint } from "@moq/net"; import { Consumer } from "./consumer.ts"; import { Format as LegacyFormat } from "./legacy.ts"; diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index d74d11f66ec..62b8266ee6a 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -1,6 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; -import { Group, Time, Track, Varint } from "@moq/net"; +import { Time, Track, Varint } from "@moq/net"; import { Signal } from "@moq/signals"; import { Consumer } from "./consumer.ts"; import { Format } from "./legacy.ts"; diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index 1cdb2fbd110..7ba7212fd2c 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, spyOn } from "bun:test"; import { Micro, Milli } from "@moq/clock"; import * as Container from "@moq/hang/container"; -import { Group, Time, Track, Varint } from "@moq/net"; +import { Time, Track, Varint } from "@moq/net"; import { AUTO_MAX_DELAY, target } from "./latency"; import { AudioRingBuffer } from "./ring-buffer"; diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index 1be32d3d32a..f8d7e1c399b 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -1015,8 +1015,8 @@ it("a video track read from another path does not inherit the outgoing delay", a it("buffered video holds its poster until its own span reaches the floor", async () => { const clock = manualAt(0); const playback = await guardedPlayback("legacy", [], [], true, 1000); - const group = playback.track.createGroup(0); if (!playback.track) throw new Error("missing video track"); + const group = playback.track.createGroup(0); try { group.writeFrame(legacyFrame(Micro(0))); group.writeFrame(legacyFrame(Micro(40_000))); From 77e3f71fa72a0274de40b983f1bd71b311314e73 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:18:43 -0700 Subject: [PATCH 20/58] test: drop unused GroupProducer import in interop Co-Authored-By: Claude Opus 5.5 --- test/interop/track-status.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/interop/track-status.ts b/test/interop/track-status.ts index 4f05ce8f1ca..6b148bdcdd2 100644 --- a/test/interop/track-status.ts +++ b/test/interop/track-status.ts @@ -1,7 +1,6 @@ // Answer Rust-encoded TRACK_STATUS requests with the JS publisher, returning each response. import assert from "node:assert/strict"; import { Milli } from "@moq/clock"; -import { Producer as GroupProducer } from "../../js/net/src/group.ts"; import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; import { Publisher } from "../../js/net/src/ietf/publisher.ts"; import { TrackStatusRequest } from "../../js/net/src/ietf/track.ts"; From 284515ef03c2fe3f56a865938eab8d69720f7fa3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:19:29 -0700 Subject: [PATCH 21/58] chore: drop unused Group import in hang bench Co-Authored-By: Claude Opus 5.5 --- js/hang/bench/slow-group.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/hang/bench/slow-group.ts b/js/hang/bench/slow-group.ts index 7d7c3e7f08f..a1622c64879 100644 --- a/js/hang/bench/slow-group.ts +++ b/js/hang/bench/slow-group.ts @@ -1,7 +1,7 @@ // Measure warning traffic and catch-up time over the groups a slow consumer skips. import { Milli } from "@moq/clock"; -import { Group, Time, Track, Varint } from "@moq/net"; +import { Time, Track, Varint } from "@moq/net"; import { Signal } from "@moq/signals"; import { Consumer } from "../src/container/consumer.ts"; import { Format } from "../src/container/legacy.ts"; From ec418e19c9260b767f1e805ef0bfcdac9ab731d1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:34:46 -0700 Subject: [PATCH 22/58] feat(net)!: split JS Track.Producer.close into finish, abort, and close finish() declares the end without closing, abort(err) is terminal, and close() releases the handle like a Rust drop: clean with a declared end, otherwise a Cancel abort. Producer.closed and Demand.closed now settle on the outcome, including a reached and settled end; the wire layer watches the release through private hooks. Co-Authored-By: Claude Opus 5.5 --- bench/browser/page/transport.ts | 3 +- demo/web/src/publish.ts | 5 +- js/e2ee/src/e2ee.test.ts | 2 +- js/e2ee/src/generation.ts | 2 +- js/e2ee/src/track.ts | 7 +- js/flate/src/snapshot/producer.ts | 1 + js/flate/src/snapshot/snapshot.test.ts | 3 +- js/flate/src/stream/producer.ts | 3 +- js/flate/src/stream/stream.test.ts | 1 + js/hang/src/container/consumer.holes.test.ts | 4 + .../src/container/consumer.outoforder.test.ts | 2 + js/hang/src/container/consumer.rewind.test.ts | 3 + js/hang/src/container/consumer.skips.test.ts | 1 + .../src/container/consumer.startup.test.ts | 1 + js/hang/src/container/consumer.test.ts | 47 +++++- js/hang/src/container/legacy.ts | 6 +- js/json/src/snapshot/producer.ts | 6 +- js/json/src/stream/codec.test.ts | 1 + js/json/src/stream/producer.ts | 3 +- js/json/src/stream/stream.test.ts | 2 +- js/json/src/window/producer.ts | 6 +- js/json/src/window/window.test.ts | 2 + js/net/bench/broadcasts.ts | 1 + js/net/bench/compression.ts | 2 + js/net/bench/drift.ts | 1 + js/net/bench/serve-browser-entry.ts | 1 + js/net/bench/subscribe-drop.ts | 1 + js/net/bench/subscribe-tracks.ts | 2 +- js/net/bench/track.ts | 1 + js/net/examples/publish.ts | 3 +- js/net/src/bandwidth.test.ts | 1 + js/net/src/bandwidth.ts | 6 +- js/net/src/broadcast.test.ts | 35 +++- js/net/src/broadcast.ts | 25 +-- js/net/src/connection/reload.test.ts | 4 + js/net/src/flate/group.test.ts | 7 + js/net/src/group.test.ts | 11 ++ js/net/src/ietf/publisher.test.ts | 25 ++- js/net/src/ietf/subscribe_tracks.test.ts | 4 + js/net/src/ietf/subscriber.ts | 25 +-- js/net/src/integration.test.ts | 6 +- js/net/src/internal.ts | 13 ++ js/net/src/listing.test.ts | 14 +- js/net/src/listing.ts | 3 +- js/net/src/lite/connection.test.ts | 3 + js/net/src/lite/publisher.test.ts | 15 ++ js/net/src/lite/subscriber.ts | 31 ++-- js/net/src/resume.test-util.ts | 1 + js/net/src/resume.test.ts | 30 +++- js/net/src/resume.ts | 2 +- js/net/src/tail.test.ts | 2 + js/net/src/track.test.ts | 158 ++++++++++++++++-- js/net/src/track.ts | 93 ++++++++--- js/net/src/untimed.test.ts | 3 +- js/net/src/util/demand.ts | 11 +- js/publish/src/audio/capture.test.ts | 1 + js/publish/src/audio/encoder.test.ts | 5 +- js/publish/src/audio/encoder.ts | 4 +- js/publish/src/broadcast.test.ts | 7 +- js/publish/src/broadcast.ts | 11 +- js/publish/src/video/capture.test.ts | 2 + js/publish/src/video/encoder-stall.test.ts | 10 +- js/publish/src/video/encoder.test.ts | 50 +++++- js/publish/src/video/encoder.ts | 2 +- js/room/src/chat.test.ts | 2 +- js/room/src/metadata.ts | 5 +- js/watch/src/audio/decoder.test.ts | 1 + js/watch/src/audio/playout.test.ts | 1 + js/watch/src/audio/replay.ts | 1 + js/watch/src/media.test.ts | 2 +- js/watch/src/video/decoder.test.ts | 1 + .../clients/js/src/capture.test.ts | 18 +- test/interop/clients/js-native/control.ts | 4 +- test/interop/clients/js-native/server.ts | 10 +- test/interop/clients/js-native/tail.ts | 5 +- test/interop/clients/js/open-gop/main.ts | 5 +- test/interop/clients/js/src/finish.ts | 3 +- test/p2p/src/main.ts | 1 + test/receive-credit/sender.ts | 8 +- test/video/src/captions.ts | 2 +- test/video/src/poster.ts | 4 +- test/wasm/baseline/publisher.ts | 3 +- test/wasm/src/main.ts | 7 +- 83 files changed, 653 insertions(+), 173 deletions(-) diff --git a/bench/browser/page/transport.ts b/bench/browser/page/transport.ts index 53f30ffea1a..cd98d3ded76 100644 --- a/bench/browser/page/transport.ts +++ b/bench/browser/page/transport.ts @@ -44,6 +44,7 @@ async function publish(config: Transport): Promise { }); if (connection.transport !== "webtransport") throw new Error(`publisher negotiated ${connection.transport}`); cleanup.push(() => { + track.finish(); track.close(); producer.close(); connection.abort(); @@ -57,7 +58,7 @@ async function publish(config: Transport): Promise { const group = track.appendGroup(); for (let frame = start; frame < Math.min(start + config.group, config.frames); frame++) group.writeFrame({ payload: fixtures[frame], timestamp: Moq.Time.Timestamp.fromMillis(frame) }); - group.close(); + group.finish(); } })(); } diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts index b9f789188f6..dac12cefa54 100644 --- a/demo/web/src/publish.ts +++ b/demo/web/src/publish.ts @@ -408,7 +408,10 @@ meta.run((effect) => { timescale: Net.Time.Timescale.MILLI, maxAge: Milli(86_400_000), }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const producer = new Json.Snapshot.Producer({ track }); producer.update({ value: currentMeta, at: Net.Time.Timestamp.now() }); diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index 9b028fc2649..22c7df742f7 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -751,7 +751,7 @@ test("produce waits for an unaccepted track's timescale", async () => { const video = await generation.name("video"); const closed = new NetTrack.Producer(video); const refused = generation.produce(closed, "video"); - closed.close(new Error("gone")); + closed.abort(new Error("gone")); await expect(refused).rejects.toThrow("gone"); await generation.produce(netTrack(video), "video"); expect(await failure(generation.produce(netTrack(video), "video"))).toBe("reuse"); diff --git a/js/e2ee/src/generation.ts b/js/e2ee/src/generation.ts index 8abc89ab9bc..839c9b059e9 100644 --- a/js/e2ee/src/generation.ts +++ b/js/e2ee/src/generation.ts @@ -132,7 +132,7 @@ export class Producer { if (this.#claims.has(semantic)) throw new Failure("reuse"); } catch (err) { // Both refusals are Failures. - track.close(err as Failure); + track.abort(err as Failure); throw err; } this.#claims.add(semantic); diff --git a/js/e2ee/src/track.ts b/js/e2ee/src/track.ts index 9d3360fdf44..0a16898b9f8 100644 --- a/js/e2ee/src/track.ts +++ b/js/e2ee/src/track.ts @@ -107,11 +107,14 @@ export class Producer { this.#writer.closed = true; if (abort) { this.#writer.aborted = abort; - this.#inner.close(abort); + this.#inner.abort(abort); return; } void this.#writer.pump.run(() => { - if (!this.#writer.aborted) this.#inner.close(); + if (!this.#writer.aborted) { + this.#inner.finish(); + this.#inner.close(); + } }); } diff --git a/js/flate/src/snapshot/producer.ts b/js/flate/src/snapshot/producer.ts index 7b6a1997453..54f3f214731 100644 --- a/js/flate/src/snapshot/producer.ts +++ b/js/flate/src/snapshot/producer.ts @@ -67,6 +67,7 @@ export class Producer { /** Finish the track. */ finish(): void { + this.#track.finish(); this.#track.close(); } } diff --git a/js/flate/src/snapshot/snapshot.test.ts b/js/flate/src/snapshot/snapshot.test.ts index 71309dcf65e..d603f0ecacb 100644 --- a/js/flate/src/snapshot/snapshot.test.ts +++ b/js/flate/src/snapshot/snapshot.test.ts @@ -156,6 +156,7 @@ test("latest lets a newer group preempt an open one", async () => { expect((await consumer.latest())?.value).toEqual(bytes(2)); stale.finish(); + track.finish(); track.close(); }); @@ -181,7 +182,7 @@ test("an aborted track surfaces its error instead of spinning", async () => { const consumer = new Consumer({ track: track.subscribe() }); const boom = new Error("subscription aborted"); - track.close(boom); + track.abort(boom); await expect(consumer.next()).rejects.toThrow("subscription aborted"); }); diff --git a/js/flate/src/stream/producer.ts b/js/flate/src/stream/producer.ts index b8ca9a6d532..1ef3f8ed619 100644 --- a/js/flate/src/stream/producer.ts +++ b/js/flate/src/stream/producer.ts @@ -90,7 +90,7 @@ export class Producer { const abort = err instanceof Error ? err : new Error(String(err)); this.#group?.abort(abort); this.#group = undefined; - this.#track.close(abort); + this.#track.abort(abort); throw err; } @@ -102,6 +102,7 @@ export class Producer { finish(): void { this.#group?.finish(); this.#group = undefined; + this.#track.finish(); this.#track.close(); } } diff --git a/js/flate/src/stream/stream.test.ts b/js/flate/src/stream/stream.test.ts index 6140647e128..b4999ff9993 100644 --- a/js/flate/src/stream/stream.test.ts +++ b/js/flate/src/stream/stream.test.ts @@ -77,6 +77,7 @@ test("a second group is a rolled log, not a continuation", async () => { for (const payload of pair) group.writeFrame({ payload, timestamp: Time.Timestamp.now() }); group.finish(); } + track.finish(); track.close(); const consumer = new Consumer({ track: track.subscribe({ maxDelay: REPLAY_LATENCY }) }); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index 25044194521..958d2e6550e 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -46,6 +46,7 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms ]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); @@ -97,6 +98,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as ]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); @@ -138,6 +140,7 @@ test("the holes a stalled source left behind share one budget", async () => { expect(results.map((read) => read.kind)).toEqual(["frame", "gap", "frame", "gap", "frame", "gap", "frame"]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); @@ -182,6 +185,7 @@ test("a hole is late from the earliest arrival past it", async () => { expect(results.map((read) => read.kind)).toEqual(["frame", "gap", "frame", "gap", "frame"]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index fd6b00a0b98..736e53fcbb6 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -118,6 +118,7 @@ test("a below-cursor group still downloading is not truncated", async () => { expect(second?.frame.payload[0]).toBe(11); live.finish(); + track.finish(); track.close(); consumer.close(); }); @@ -150,6 +151,7 @@ test("delayed older groups survive a floor established by two newer groups", asy await expect(nextFrame(consumer)).rejects.toThrow("below the previous group start"); } finally { consumer.close(); + track.finish(); track.close(); clock.mockRestore(); } diff --git a/js/hang/src/container/consumer.rewind.test.ts b/js/hang/src/container/consumer.rewind.test.ts index 67f01adfc2b..4b3015d8676 100644 --- a/js/hang/src/container/consumer.rewind.test.ts +++ b/js/hang/src/container/consumer.rewind.test.ts @@ -55,6 +55,7 @@ test("Consumer accepts a resuming keyframe before the estimated group end", asyn expect((await frame(consumer))?.frame?.timestamp).toBe(Micro(50_000)); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -88,6 +89,7 @@ test("Consumer accepts leading pictures below the previous group's content", asy expect((await frame(consumer))?.frame?.timestamp).toBe(Micro(150_000)); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -110,6 +112,7 @@ test("Consumer rejects a later frame below the previous group start", async () = await expect(frame(consumer)).rejects.toThrow("below"); } finally { consumer.close(); + track.finish(); track.close(); } }); diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index b5f6e1ef64f..02bb22e1e95 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -45,6 +45,7 @@ for (const count of [1, 16, 256]) { ); } finally { consumer.close(); + track.finish(); track.close(); warn.mockRestore(); } diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 3a46b7c3ae7..c29b9f41daf 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -25,6 +25,7 @@ function setup(maxDelay = 100) { })(); onTestFinished(async () => { consumer.close(); + track.finish(); track.close(); try { await reading; diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 18535ee70aa..38e583dbdb0 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -50,6 +50,7 @@ function writeLoc(track: Track.Producer, groups: [Micro, number[]][][]) { } group.finish(); } + track.finish(); track.close(); } @@ -397,10 +398,12 @@ test("Consumer refuses a data marker that shares its group", async () => { group.writeFrame({ payload: second, timestamp: Time.Timestamp.fromMicros(10_000) }); group.finish(); // A finished track resolves the read instead of hanging when the reader fails to end. + track.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("shares its group"); } finally { consumer.close(); + track.finish(); track.close(); } } @@ -558,6 +561,7 @@ test("Consumer delivers frames from a single group", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 500 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -572,6 +576,7 @@ test("Consumer forces keyframe true at index 0", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 500 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -600,6 +605,7 @@ test("Consumer index spans MoQ frames for keyframe detection", async () => { group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // second MoQ frame → 3 samples group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -639,6 +645,7 @@ for (const [where, payloads] of [ g1.writeFrame({ payload: new Uint8Array([0x04]), timestamp: Time.Timestamp.now() }); g1.finish(); track.writeGroup(g1); + track.finish(); track.close(); const frames: number[] = []; @@ -667,6 +674,7 @@ test("Consumer delivers everything ahead of a decode error", async () => { malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.fromMicros(200_000) }); malformed.finish(); track.writeGroup(malformed); + track.finish(); track.close(); const items: string[] = []; @@ -695,6 +703,7 @@ test("Consumer keeps the first of several decode errors", async () => { group.finish(); track.writeGroup(group); } + track.finish(); track.close(); await settle(); @@ -766,6 +775,7 @@ test("Consumer keeps frames decoded before a group reset (truncated GoP)", async g0.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [4 as Micro]); + track.finish(); track.close(); const items: string[] = []; @@ -806,6 +816,7 @@ test("Consumer skips groups via PTS-span when over the max delay", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 1, [100_000 as Micro]); writeGroupWithLegacyFrames(track, 2, [200_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 300); @@ -846,8 +857,10 @@ test("Consumer measures arrivals as they land", async () => { expect(replacement.spread.peek()).toBe(consumer.spread.peek()); replacement.close(); } finally { + track.finish(); track.close(); consumer.close(); + next.finish(); next.close(); installed.close(); } @@ -862,6 +875,7 @@ test("Consumer delivers groups in sequence order regardless of arrival order", a writeGroupWithLegacyFrames(track, 2, [60_000 as Micro]); writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 1, [30_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -888,6 +902,7 @@ test("Consumer delivers a group that arrives below the cursor", async () => { // three are handed over, in sequence order, since delivery has not passed any of them yet. writeGroupWithLegacyFrames(track, 3, [0 as Micro]); writeGroupWithLegacyFrames(track, 6, [200_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -905,6 +920,7 @@ test("Consumer next() yields no gap before the first frame", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); writeGroupWithLegacyFrames(track, 1, [66_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -938,6 +954,7 @@ test("Consumer reads nothing after close, even the frame a gap was returned ahea consumer.close(); expect(await consumer.next()).toBeUndefined(); + track.finish(); track.close(); }); @@ -959,6 +976,7 @@ test("Consumer keeps a trailing duration marker internal", async () => { }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); await settle(); @@ -989,6 +1007,7 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); expect(await consumer.next()).toEqual({ kind: "end", group: 0, end: 20_000 as Micro }); @@ -1104,6 +1123,7 @@ test("Consumer throws the first error in delivery order", async () => { malformed.finish(); track.writeGroup(malformed); writeGroupWithLegacyFrames(track, 2, [0 as Micro, 100_000 as Micro], wallClock); + track.finish(); track.close(); expect((await nextFrame(consumer))?.frame?.timestamp).toBe(10_000_000 as Micro); @@ -1152,6 +1172,7 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn // Read only once the skip has dropped it, before the newer group's frame is delivered. await rewound; newest.finish(); + track.finish(); track.close(); const read = async () => { @@ -1325,6 +1346,7 @@ test("Consumer buffered signal updates as frames arrive", async () => { expect(ranges[0].start).toBe(0 as Milli); expect((ranges[0].end as number) >= 66).toBeTruthy(); + track.finish(); track.close(); consumer.close(); }); @@ -1341,6 +1363,7 @@ test("Consumer recovers from gap in group sequence numbers", async () => { writeGroupWithLegacyFrames(track, 3, [120_000 as Micro, 140_000 as Micro]); writeGroupWithLegacyFrames(track, 4, [160_000 as Micro, 180_000 as Micro]); writeGroupWithLegacyFrames(track, 5, [200_000 as Micro, 220_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -1370,6 +1393,7 @@ test("Consumer handles empty decode result without deadlock", async () => { group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // valid decode group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 300); @@ -1394,6 +1418,7 @@ test("Consumer preserves empty media from formats without endpoint markers", asy group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); expect(frameOf(await nextFrame(consumer)).payload).toHaveLength(0); @@ -1420,6 +1445,7 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1443,6 +1469,7 @@ test("Consumer with CmafFormat times an untimed track from tfdt", async () => { group.writeFrame({ payload: fragment("untimedDelta") }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1491,6 +1518,7 @@ test("Consumer duration-skips a stalled group once it is covered", async () => { track.writeGroup(g0); track.writeGroup(g1); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1533,6 +1561,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => await new Promise((resolve) => setTimeout(resolve, 20)); g0.writeFrame({ payload: new Uint8Array([20]), timestamp: Time.Timestamp.now() }); g0.finish(); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1927,10 +1956,12 @@ test("a live video frame is delivered before its duration marker arrives", async timestamp: Time.Timestamp.now(), }); group.finish(); + track.finish(); track.close(); // The marker is the frame's duration, not an item. expect(await consumer.next()).toBeUndefined(); consumer.close(); + track.finish(); track.close(); }); @@ -1979,6 +2010,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre expect(error).not.toHaveBeenCalled(); } finally { consumer.close(); + track.finish(); track.close(); error.mockRestore(); } @@ -1996,7 +2028,11 @@ for (const end of [ const consumer = new Consumer(replay(track), { format: new LegacyFormat("data") }); try { const pending = consumer.next(); - track.close(end ?? undefined); + if (end) track.abort(end); + else { + track.finish(); + track.close(); + } if (end) await expect(pending).rejects.toBe(end); else expect(await pending).toBeUndefined(); } finally { @@ -2017,10 +2053,12 @@ test("a group reset does not hide a later container decode failure", async () => // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.now() }); malformed.finish(); + track.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("buffer too short"); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -2042,7 +2080,11 @@ for (const end of [ writeGroupWithLegacyFrames(track, 2, [1_000_000 as Micro]); await settle(); const pending = consumer.next(); - track.close(end ?? undefined); + if (end) track.abort(end); + else { + track.finish(); + track.close(); + } const resumed = await Promise.race([pending, settle(100).then(() => "stalled" as const)]); expect(resumed).not.toBe("stalled"); if (resumed === "stalled") return; @@ -2052,6 +2094,7 @@ for (const end of [ else expect(await consumer.next()).toBeUndefined(); } finally { consumer.close(); + track.finish(); track.close(); } }); diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index 5916111b02e..dd2bc91c7cf 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -184,6 +184,10 @@ export class Producer { if (!err) this.#close(); if (err) this.#group?.abort(err); else this.#group?.finish(); - this.#track.close(err); + if (err) this.#track.abort(err); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index f25254a82e0..ed50e870ddb 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -194,7 +194,11 @@ export class Producer { // Any further update fails on the closed track, but it has to fail as a track error rather // than as a delta with nowhere to put it. this.#encoder.reset(); - this.#track.close(abort); + if (abort) this.#track.abort(abort); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/stream/codec.test.ts b/js/json/src/stream/codec.test.ts index 51af525139e..55a4f646426 100644 --- a/js/json/src/stream/codec.test.ts +++ b/js/json/src/stream/codec.test.ts @@ -158,6 +158,7 @@ test("a rejected record leaves the producer able to retry", () => { const producer = new Producer({ track, compression: "deflate" }); // Closing the track makes every write fail, standing in for any post-appendGroup rejection. + track.finish(); track.close(); expect(() => producer.append({ value: { n: 1 } })).toThrow(); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index 5e5c7612ce1..a3733df11cb 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -89,13 +89,14 @@ export class Producer { // sitting in the group with a generic error instead of the failure that ended the log. this.#group?.abort(abort); this.#group = undefined; - this.#track.close(abort); + this.#track.abort(abort); } /** Finish the track, closing the group. */ finish(): void { this.#group?.finish(); this.#group = undefined; + this.#track.finish(); this.#track.close(); } } diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 8e2d208e7a5..48a825e7ce4 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -124,7 +124,7 @@ test("a failed track preserves its error", async () => { const consumer = new Consumer({ track: track.subscribe() }); const next = consumer.next(); const failure = new Error("source failed"); - track.close(failure); + track.abort(failure); await expect(next).rejects.toBe(failure); }); diff --git a/js/json/src/window/producer.ts b/js/json/src/window/producer.ts index de269416096..929030c11e5 100644 --- a/js/json/src/window/producer.ts +++ b/js/json/src/window/producer.ts @@ -171,6 +171,10 @@ export class Producer { else this.#group?.finish(); this.#group = undefined; - this.#track.close(abort); + if (abort) this.#track.abort(abort); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index 78187201e1f..82a7f8e528c 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -104,6 +104,7 @@ test("concurrent consumer reads are rejected", async () => { const first = consumer.next(); expect(() => consumer.next()).toThrow("multiple calls to next not supported"); + track.finish(); track.close(); expect(await first).toBeUndefined(); }); @@ -410,6 +411,7 @@ test("consumer resumes at a checkpoint after losing a group", async () => { group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); group.finish(); + track.finish(); track.close(); expect((await consumer.next())?.value).toEqual({ push: { index: 1, value: { n: 1 } } }); diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index e500038b24d..8d0a8f3c420 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -101,6 +101,7 @@ for (const trackCount of [8, 32, 128]) { `${trackCount},${observerCount},${(((performance.now() - start) * 1000) / demandUpdates).toFixed(1)}`, ); for (const dispose of disposes) dispose(); + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); broadcast.close(); } diff --git a/js/net/bench/compression.ts b/js/net/bench/compression.ts index 42d1b882b97..bbd8ed0a1d3 100644 --- a/js/net/bench/compression.ts +++ b/js/net/bench/compression.ts @@ -44,6 +44,7 @@ for (const publishers of [1, 4, 16]) { read += performance.now() - started; inflated = groups.reduce((bytes, group) => bytes + hooks.groupCacheBytes(group), 0) - stored; if (inflated !== publishers * frames * payload.length) throw new Error("inflated bytes scale with readers"); + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); } console.log([publishers, readers, "publish", ((publish * 1000) / rounds).toFixed(1), stored].join(",")); @@ -74,6 +75,7 @@ async function datagrams(count: number, queued: number): Promise<[number, number const deliver = ((performance.now() - started) * 1000) / (count * queued); Bun.gc(true); const retained = process.memoryUsage().heapUsed - before; + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); slow.length = 0; return [deliver, retained]; diff --git a/js/net/bench/drift.ts b/js/net/bench/drift.ts index efc820f3300..a37dfdcce29 100644 --- a/js/net/bench/drift.ts +++ b/js/net/bench/drift.ts @@ -54,6 +54,7 @@ export async function run(): Promise { `${retained},${publishers},${readers},${((elapsed * 1e6) / (iterations * publishers * readers)).toFixed(0)}`, ); } finally { + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); } } diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index aa8accfa877..1a9dcdec5ec 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -120,6 +120,7 @@ async function measure(protocol: string, groups: number, viewers: number) { clearInterval(timer); publisher.close(); for (const stream of streams) stream.close(); + track.finish(); track.close(); broadcast.close(); origin.close(); diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index 8f59a911f59..2f09592a101 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -33,6 +33,7 @@ try { group.finish(); track.writeGroup(group); } + track.finish(); track.close(); return { origin, broadcast }; }); diff --git a/js/net/bench/subscribe-tracks.ts b/js/net/bench/subscribe-tracks.ts index 6a7e3e2680c..10c64a9a810 100644 --- a/js/net/bench/subscribe-tracks.ts +++ b/js/net/bench/subscribe-tracks.ts @@ -55,7 +55,7 @@ for (const broadcasts of [1, 16, 128]) { }), ); if (sample) samples.push((performance.now() - start) * 1000); - track.close(new Error("benchmark finished")); + track.abort(new Error("benchmark finished")); } samples.sort((a, b) => a - b); console.log(`${broadcasts},${subscribers},${samples[10].toFixed(2)}`); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 356e1e28ca3..63736cd8542 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -111,6 +111,7 @@ async function measure( if (group) throw new Error("track did not end at its declared boundary"); } const end = (now() - endStart) * 1000; + producer.finish(); producer.close(); // Ending the track releases the newest group too; idle cleanup reclaims the cache. clock += CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8 + 1; diff --git a/js/net/examples/publish.ts b/js/net/examples/publish.ts index e9c38f6d262..2b05eb0b8e3 100644 --- a/js/net/examples/publish.ts +++ b/js/net/examples/publish.ts @@ -32,9 +32,10 @@ async function publishTrack(track: Moq.Track.Producer) { } // Mark the group as complete - group.close(); + group.finish(); // Mark the track as complete (optional) + track.finish(); track.close(); } diff --git a/js/net/src/bandwidth.test.ts b/js/net/src/bandwidth.test.ts index 0b36595661b..e669bbae921 100644 --- a/js/net/src/bandwidth.test.ts +++ b/js/net/src/bandwidth.test.ts @@ -186,6 +186,7 @@ test("a closed track is pruned", () => { const first = track(VIDEO); first.subscribe(); const firstShare = allocator.reserve(first.demand(), 4_000_000); + first.finish(); first.close(); const second = track(VIDEO); diff --git a/js/net/src/bandwidth.ts b/js/net/src/bandwidth.ts index 4285b0a1217..4c03c690a5d 100644 --- a/js/net/src/bandwidth.ts +++ b/js/net/src/bandwidth.ts @@ -93,7 +93,7 @@ interface Registry { function wantsOf(entries: readonly Entry[]): Want[] { const wants: Want[] = []; for (const entry of entries) { - if (entry.demand.closed.peek() !== undefined) continue; + if (hooks.demandReleased(entry.demand).peek() !== undefined) continue; if (entry.demand.wanted.peek() === undefined) continue; wants.push({ id: entry.id, priority: entry.priority, max: entry.max }); } @@ -164,7 +164,7 @@ export class Allocator implements Handle { const entries = effect.get(this.#entries); for (const entry of entries) { effect.get(entry.demand.wanted); - effect.get(entry.demand.closed); + effect.get(hooks.demandReleased(entry.demand)); } this.#publish(); }); @@ -239,7 +239,7 @@ export class Allocator implements Handle { #prune(): void { const entries = this.#entries.peek(); - const live = entries.filter((entry) => entry.demand.closed.peek() === undefined); + const live = entries.filter((entry) => hooks.demandReleased(entry.demand).peek() === undefined); if (live.length !== entries.length) this.#entries.set(live); } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 103ed74d353..09073066205 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -51,6 +51,7 @@ test("consumer dedupes repeat subscriptions onto one upstream request", async () expect((await pendingRequest(consumer))?.name).toBe("audio"); // Once the shared track closes, a later subscribe re-opens it. + producer.finish(); producer.close(); consumer.track("video").subscribe(); expect((await pendingRequest(consumer))?.name).toBe("video"); @@ -104,6 +105,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); first.appendGroup().writeFrame(textFrame("a", Timestamp.fromMillis(1000))); + first.finish(); first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); @@ -116,6 +118,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { const track = next.createTrack("media", { timescale: Timescale.MILLI }); track.appendGroup().writeFrame(textFrame("c", Timestamp.fromMillis(500))); + second.finish(); second.close(); broadcast.close(); next.close(); @@ -127,12 +130,14 @@ test("an empty replacement ends past its predecessor's groups", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); first.appendGroup().finish(); + first.finish(); first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); const subscriber = second.subscribe(); expect(() => second.finishAt({ group: 0, frame: 0 })).toThrow(ProtocolViolation); expect(() => second.finishAt({ group: 0, frame: 1 })).toThrow(ProtocolViolation); + second.finish(); second.close(); expect(await subscriber.finished()).toEqual({ group: 1, frame: 0 }); broadcast.close(); @@ -146,11 +151,13 @@ test("a removed producer writes nothing once its replacement claims the name", a const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); const subscriber = second.subscribe(); + second.finish(); second.close(); expect(() => first.appendGroup()).toThrow("replaced"); expect(() => first.writeGroup(new GroupProducer(1))).toThrow("replaced"); expect(() => first.appendDatagram(Timestamp.fromMillis(0), new Uint8Array())).toThrow("replaced"); expect(await subscriber.finished()).toEqual({ group: 1, frame: 0 }); + first.finish(); first.close(); broadcast.close(); }); @@ -164,8 +171,9 @@ test("a removed producer's abort ignores its replacement's groups", async () => const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); for (let i = 0; i < 3; i++) second.appendGroup().finish(); const abort = new Error("cut off"); - first.close(abort); + first.abort(abort); expect(first.closed.peek()).toBe(abort); + second.finish(); second.close(); broadcast.close(); }); @@ -186,6 +194,7 @@ test("a replacement producer refuses to append a sequence the name already wrote firstProducer.writeGroup(new GroupProducer(8)); firstProducer.insertDatagram(12, Timestamp.fromMillis(0), new Uint8Array()); firstSubscriber.close(); + firstProducer.finish(); firstProducer.close(); const secondSubscriber = broadcast.track("media").subscribe(); @@ -209,6 +218,7 @@ test("a replacement producer refuses to append a sequence the name already wrote expect(nextRequest.accept({ timescale: Timescale.MILLI }).appendGroup().sequence).toBe(0); secondSubscriber.close(); + secondProducer.finish(); secondProducer.close(); nextSubscriber.close(); broadcast.close(); @@ -276,12 +286,14 @@ test("created and requested tracks share a name's sequences", async () => { expect(first.appendGroup().sequence).toBe(0); first.writeGroup(new GroupProducer(8)); first.insertDatagram(12, Timestamp.fromMillis(0), new Uint8Array()); + first.finish(); first.close(); // A replacement refuses to append past nothing, since that would reuse every explicit write. const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); expect(() => second.appendGroup()).toThrow("duplicate sequence"); second.writeGroup(new GroupProducer(13)); + second.finish(); second.close(); // An on-demand producer checks the same namespace, and so does a later created track. @@ -294,10 +306,12 @@ test("created and requested tracks share a name's sequences", async () => { expect(() => requested.appendDatagram(Timestamp.fromMillis(0), new Uint8Array())).toThrow("duplicate sequence"); requested.insertDatagram(14, Timestamp.fromMillis(0), new Uint8Array()); subscriber.close(); + requested.finish(); requested.close(); const later = broadcast.createTrack("media", { timescale: Timescale.MILLI }); expect(() => later.appendGroup()).toThrow("duplicate sequence"); + later.finish(); later.close(); // Only a new broadcast starts over. @@ -326,8 +340,13 @@ test("a written name refuses to reuse its sequences after many other names", () const broadcast = new BroadcastProducer(); const written = broadcast.createTrack("written"); written.appendGroup().finish(); + written.finish(); written.close(); - for (let i = 0; i < 1000; i++) broadcast.createTrack(`unused-${i}`).close(); + for (let i = 0; i < 1000; i++) { + const unused = broadcast.createTrack(`unused-${i}`); + unused.finish(); + unused.close(); + } expect(() => broadcast.createTrack("written").appendGroup()).toThrow("duplicate sequence"); broadcast.close(); @@ -358,6 +377,7 @@ test("publishing-side subscriptions coalesce onto one request", async () => { await broadcast.demand().unused(); expect(producer.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); broadcast.close(); }); @@ -476,6 +496,7 @@ test("a held info lookup ending after removeTrack leaves demand alone", async () await Promise.resolve(); expect(demand.used.peek()).toBe(false); + track.finish(); track.close(); broadcast.close(); }); @@ -494,6 +515,7 @@ test("removeTrack leaves a track that only a request serves", async () => { expect(producer.closed.peek()).toBeUndefined(); subscriber.close(); + producer.finish(); producer.close(); broadcast.close(); }); @@ -511,6 +533,7 @@ test("closing a broadcast rejects a dequeued request", async () => { expect(() => producer.appendGroup()).toThrow("track is closed"); await expect(subscriber.info()).rejects.toThrow("track closed before info was known"); subscriber.close(); + producer.finish(); producer.close(); }); @@ -737,6 +760,7 @@ test("a finished track is still served from its cache", async () => { const broadcast = new BroadcastProducer(); const track = broadcast.createTrack("track1", { timescale: Timescale.MILLI }); track.writeFrame(textFrame("last")); + track.finish(); track.close(); // Like Rust, finishing keeps the track: a late subscriber reads the cache, then the clean end. @@ -753,7 +777,7 @@ test("a finished track is still served from its cache", async () => { test("an aborted track is gone, so a subscribe with no handler answers NotFound", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("track1").close(new Error("boom")); + broadcast.createTrack("track1").abort(new Error("boom")); // Nothing serves requests on demand, so this must fail rather than wait forever. const subscriber = broadcast.consume().track("track1").subscribe(); @@ -765,7 +789,7 @@ test("an aborted track is gone, so a subscribe with no handler answers NotFound" test("an aborted track falls through to a request when a handler is serving", async () => { const broadcast = new BroadcastProducer(); const pulled = wireOf(broadcast).requested(); - broadcast.createTrack("track1").close(new Error("boom")); + broadcast.createTrack("track1").abort(new Error("boom")); broadcast.track("track1").subscribe(); expect((await pulled)?.name).toBe("track1"); @@ -885,6 +909,7 @@ test("broadcast demand watches static and pending tracks rather than broadcast h await demand.unused(); expect(demand.used.peek()).toBe(false); request.reject(); + video.finish(); video.close(); broadcast.close(); expect(await demand.closed).toBeNull(); @@ -902,6 +927,7 @@ test("broadcast closure clears active demand and subscriptions", async () => { expect(demand.used.peek()).toBe(false); await demand.unused(); subscriber.close(); + track.finish(); track.close(); }); @@ -942,6 +968,7 @@ test("removeTrack stops counting the removed track's demand at once", async () = expect(demand.used.peek()).toBe(false); subscriber.close(); + video.finish(); video.close(); broadcast.close(); }); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 65d71072aab..9a26745509f 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -81,12 +81,12 @@ function watchDemand(state: BroadcastState, producer: track.Producer, lookups?: if (state.demands.has(producer)) return; const demand = producer.demand(); // A closed track is never demand, and its close already fired, so nothing would dispose a watcher. - if (demand.closed.peek() !== undefined) return; + if (hooks.demandReleased(demand).peek() !== undefined) return; let active = false; const update = () => { const used = state.closed.peek() === undefined && - demand.closed.peek() === undefined && + hooks.demandReleased(demand).peek() === undefined && ((lookups?.pending ?? 0) > 0 || demand.wanted.peek() !== undefined); if (active === used) return; state.active += used ? 1 : -1; @@ -95,8 +95,8 @@ function watchDemand(state: BroadcastState, producer: track.Producer, lookups?: }; const disposeUsed = demand.wanted.subscribe(update); if (lookups) lookups.changed = update; - const disposeClosed = demand.closed.subscribe(() => { - if (demand.closed.peek() === undefined) return; + const disposeClosed = hooks.demandReleased(demand).subscribe(() => { + if (hooks.demandReleased(demand).peek() === undefined) return; cleanup(); }); const cleanup = () => { @@ -161,13 +161,13 @@ function closeState(state: BroadcastState) { function lookup(state: BroadcastState, name: string): track.Producer | undefined { const inserted = state.tracks.get(name); if (inserted) { - if (!(inserted.closed.peek() instanceof Error)) return inserted; + if (!(hooks.trackReleased(inserted).peek() instanceof Error)) return inserted; state.tracks.delete(name); } const requested = state.requests.get(name); if (requested) { - if (requested.closed.peek() === undefined) return requested; + if (hooks.trackReleased(requested).peek() === undefined) return requested; state.requests.delete(name); } @@ -193,7 +193,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce if (!state.served) { // Answer through the track rather than throwing, so a peer's subscribe is refused with // the code instead of an unhandled error. - producer.close(new NotFound(`track ${name}`)); + producer.abort(new NotFound(`track ${name}`)); return { producer, requested: false }; } @@ -201,7 +201,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce state.lookups.set(producer, lookups); watchDemand(state, producer, lookups); state.requests.set(name, producer); - void producer.closed.then(() => { + void hooks.trackReleased(producer).then(() => { if (state.requests.get(name) === producer) state.requests.delete(name); }); @@ -231,7 +231,10 @@ async function resolveTrackInfo(state: BroadcastState, name: string, hold?: Abor let answered = false; let ended = false; const release = () => { - if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) producer.close(); + if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) { + producer.finish(); + producer.close(); + } }; info.then( () => { @@ -315,7 +318,7 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info state.listing.listOwn(producer.name, producer, info); // A finished track keeps serving its cache, so only an abort evicts it. - void producer.closed.then((closed) => { + void hooks.trackReleased(producer).then((closed) => { if (!(closed instanceof Error)) return; if (state.tracks.get(producer.name) === producer) state.tracks.delete(producer.name); state.listing.unlistOwn(producer.name, producer); @@ -449,7 +452,7 @@ export class Producer { // One logical track per name: an open track or a live request already serves it. // A request a handler already pulled stays duplicate, as in Rust. const live = [this.#state.tracks.get(name), this.#state.requests.get(name)]; - if (live.some((existing) => existing && existing.closed.peek() === undefined)) { + if (live.some((existing) => existing && hooks.trackReleased(existing).peek() === undefined)) { throw new Error(`duplicate track: ${name}`); } diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 3ec036869ee..f6783b1ccee 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -687,6 +687,7 @@ for (const [name, teardown] of Object.entries(teardowns)) { const last = producer.appendGroup(); last.writeFrame(textFrame("last")); last.finish(); + producer.finish(); producer.close(); teardown.end(handle, win); await clock.advance(Nano.fromMilli(teardown.linger)); @@ -779,6 +780,7 @@ for (const [name, drainEnd] of Object.entries(drainEnds)) { remote.close(); reload.close(); for (const dial of dials) (await dial.server).abort(); + producer.finish(); producer.close(); broadcast.close(); origin.close(); @@ -844,6 +846,7 @@ test("a retired GOAWAY predecessor still closes by its handover cap", async () = remote.close(); reload.close(); for (const dial of dials) (await dial.server).abort(); + producer.finish(); producer.close(); broadcast.close(); origin.close(); @@ -904,6 +907,7 @@ test("closing the loop keeps the tokens until the session drains", async () => { const last = producer.appendGroup(); last.writeFrame(textFrame("last")); last.finish(); + producer.finish(); producer.close(); reload.close(); diff --git a/js/net/src/flate/group.test.ts b/js/net/src/flate/group.test.ts index 58da8efaf4c..517e47c5cca 100644 --- a/js/net/src/flate/group.test.ts +++ b/js/net/src/flate/group.test.ts @@ -35,6 +35,7 @@ test("synchronous writers store slices; plain readers share one charged inflated expect(inflating).toHaveBeenCalledTimes(2); } finally { inflating.mockRestore(); + track.finish(); track.close(); } }); @@ -80,6 +81,7 @@ test("empty frames preserve the window and prewritten groups bind without duplic group.finish(); for (const expected of [payload, new Uint8Array(), payload]) expect((await reader.readFrame())?.payload).toEqual(expected); + track.finish(); track.close(); }); @@ -92,6 +94,7 @@ test("a cut compressed group preserves its committed frames before returning its group.abort(cut); expect((await reader.readFrame())?.payload).toEqual(payload); await expect(reader.readFrame()).rejects.toThrow(cut); + track.finish(); track.close(); }); @@ -105,6 +108,7 @@ test("a shared plain read cannot change a later synchronous write's admission", expect(() => group.writeFrame({ payload: new Uint8Array() })).not.toThrow(); group.finish(); expect((await reader.readFrame())?.payload.length).toBe(0); + track.finish(); track.close(); }); @@ -122,6 +126,7 @@ test("datagrams buffered before accept share one stored form, each reader its ow expect(stored(first)?.inflated).toBe(payload.length); expect(first.payload).toEqual(payload); expect(second.payload).toEqual(payload); + track.finish(); track.close(); }); @@ -134,6 +139,7 @@ test("a malformed compressed datagram is refused before reaching readers", () => /declared size/, ); expect(hooks.tryRecvDatagram(reader).kind).toBe("idle"); + track.finish(); track.close(); }); @@ -149,6 +155,7 @@ test("refresh measures stored slices without inflating a compressed group", () = } finally { inflating.mockRestore(); group.finish(); + track.finish(); track.close(); } }); diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 56979bfd8d4..95f37d45f99 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -100,6 +100,17 @@ test("a closed group rejects a further write without discarding committed frames expect((await consumer.readFrame())?.payload[0]).toBe(0); }); +test("an abort after finish keeps the clean outcome", async () => { + const { producer, consumer } = pair(0); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + producer.finish(); + producer.abort(new Error("boom")); + + expect(producer.closed.peek()).toBeNull(); + expect((await consumer.readFrame())?.payload[0]).toBe(1); + expect(await consumer.readFrame()).toBeUndefined(); +}); + test("a read that starts later skips frames below it instead of returning them", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index c1236f09bb4..365b7b47752 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -539,6 +539,7 @@ test("a SUBSCRIBE withdrawn while resuming releases its demand", async () => { const track = await first; while (track.subscription.peek() !== undefined) await track.subscription.changed(); // Gone, so the resume asks for the track again, and nothing answers. + track.finish(); track.close(); const second = wireOf(fx.broadcast).requested(); const parameters = new Parameters(); @@ -657,6 +658,7 @@ test("RENDEZVOUS_TIMEOUT has a zero cap with TIMEOUT for a requested wait", asyn expect(await wire.u53()).toBe(RequestError.id); expect((await RequestError.decode(wire, version)).errorCode).toBe(millis && !live ? 0x02 : 0x10); } finally { + track?.finish(); track?.close(); broadcast?.close(); origin.close(); @@ -692,6 +694,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces } } finally { request.cancel(); + track?.finish(); track?.close(); broadcast?.close(); claim?.close(); @@ -898,6 +901,7 @@ test("a group stream whose FIN never completes is reset when the group expires", // Its successor presents no further, so only the wall clock since it arrived convicts // group 0, after the track has ended. write(1); + track.finish(); track.close(); await clock.advance(Nano.fromMilli(Milli(budget - 1))); expect(reset).toBe(false); @@ -980,6 +984,7 @@ test("a group stream whose FIN never completes is reset after the cache evicts i const later = Milli(10_000); await clock.advance(Nano.fromMilli(later)); write(2); + track.finish(); track.close(); await clock.advance(Nano.fromMilli(Milli(budget - later - 1))); @@ -2646,7 +2651,11 @@ test("subscription completion sends PUBLISH_DONE on every supported draft", asyn expect(await client.reader.u53()).toBe(SubscribeOk.id); await SubscribeOk.decode(client.reader, version); - track.close(abort); + if (abort) track.abort(abort); + else { + track.finish(); + track.close(); + } expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, version); @@ -3499,6 +3508,7 @@ test.each([false, true])("draft-20: exact terminal 5/5 survives group FIN=%s", a } track.finishAt({ group: 5, frame: 5 }); group.finish(); + track.finish(); track.close(); if (finished) { const marker = await fx.uni.read(); @@ -3575,6 +3585,7 @@ test("draft-20: an inline terminal reset after its FIN falls back to a standalon track.finishAt({ group: 5, frame: 5 }); group.finish(); + track.finish(); track.close(); const inline = await reader.decode((c) => Frame.decode(c, header.flags, undefined)); @@ -3625,6 +3636,7 @@ test("draft-20: a clean close past a bounded filter's end still sends PUBLISH_DO // Beyond the end, so it is never served, and it must not hold the subscription open. writeGroup(track, 1); // group 1 + track.finish(); track.close(); expect(await client.reader.u53()).toBe(PublishDone.id); @@ -3839,6 +3851,7 @@ test("draft-20: PUBLISH_DONE waits for a queued group and counts every stream", try { writeGroup(track, 1); + track.finish(); track.close(); // Nothing ends the subscription while the group waits for its slot. @@ -4008,6 +4021,7 @@ test("requester FIN keeps a draft-19 subscription serving; STOP_SENDING cancels await serving; } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4037,6 +4051,7 @@ for (const version of [Version.DRAFT_17, Version.DRAFT_18] as const) { expect(track.subscription.peek()).toBeUndefined(); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4070,6 +4085,7 @@ test("REQUEST_UPDATE applies priority and preserves it when omitted", async () = expect(track.subscription.peek()?.priority).toBe(117); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4095,6 +4111,7 @@ test("REQUEST_UPDATE accepts delivery timeouts without changing the subscription expect(track.subscription.peek()).toEqual(before); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4129,6 +4146,7 @@ test("TRACK_PROPERTY_FILTER on a subscription update closes with PROTOCOL_VIOLAT expect(Number((failure as ProtocolViolation).code)).toBe(0x03); } finally { client.close(); + track.finish(); track.close(); fx.close(); await serving; @@ -4158,6 +4176,7 @@ test("unsupported REQUEST_UPDATE is refused and ends with UPDATE_FAILED", async expect(done.statusCode).toBe(0x08); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4221,6 +4240,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async } } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4248,6 +4268,7 @@ test("a paused SUBSCRIBE ends once its grant is revoked", async () => { expect((await PublishDone.decode(client.reader, V20)).statusCode).toBe(PublishDoneStatus.UNAUTHORIZED); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4295,6 +4316,7 @@ test("a pause resets a group stream parked on flow control", async () => { expect(reset).toBe(true); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4458,6 +4480,7 @@ test("subscriber max age and REQUEST_UPDATE preserve omitted preferences", async expect(track.subscription.peek()?.maxDelay).toBe(Milli(900)); } finally { client.close(); + track.finish(); track.close(); fx.close(); } diff --git a/js/net/src/ietf/subscribe_tracks.test.ts b/js/net/src/ietf/subscribe_tracks.test.ts index a2e3140f685..2e868207fc2 100644 --- a/js/net/src/ietf/subscribe_tracks.test.ts +++ b/js/net/src/ietf/subscribe_tracks.test.ts @@ -197,6 +197,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(); + if (finished) track.finish(); if (finished) track.close(); broadcast.removeTrack("video"); expect(await stream.reader.u53()).toBe(PublishDone.id); @@ -218,6 +219,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(false); + track.finish(); track.close(); broadcast.removeTrack("video"); await clock.advance(Nano.zero); @@ -253,6 +255,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(false); + track.finish(); track.close(); broadcast.removeTrack("video"); await clock.advance(Nano.zero); @@ -287,6 +290,7 @@ for (const version of drafts) { published.set(publish.trackName, stream); } await clock.advance(Nano.zero); + video.finish(); video.close(); if (remove) broadcast.removeTrack("video"); broadcast.close(); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index bf458da1aff..b359681defb 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -846,7 +846,7 @@ export class Subscriber { maxAge = result.maxAge; break; } - if (demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) continue; + if (hooks.demandReleased(demand).peek() === undefined && demand.wanted.peek() !== undefined) continue; throw abandoned; } console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`); @@ -931,7 +931,7 @@ export class Subscriber { const unanswered = new Signal(0); const done = race([ this.#runPublishDone(stream, subscription, broadcast, unanswered).then(() => publisherEnded), - producer.closed.then(() => localEnded), + hooks.trackReleased(producer).then(() => localEnded), revoked, ]); const updates = this.#runUpdates(stream, requestId, producer, priority, maxAge, unanswered, done); @@ -943,7 +943,11 @@ export class Subscriber { const demand = producer.demand(); for (;;) { const reason = await race([done, unwanted(demand).then(() => idle)]); - if (reason === idle && demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) + if ( + reason === idle && + hooks.demandReleased(demand).peek() === undefined && + demand.wanted.peek() !== undefined + ) continue; terminal = reason; break; @@ -955,8 +959,9 @@ export class Subscriber { // straight out of its loop for the same reason. if (terminal === revokedEnded) { console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); - producer.close(refused); + producer.abort(refused); } else { + producer.finish(); producer.close(); } @@ -975,7 +980,7 @@ export class Subscriber { stream.abort(err); const e = await sessionCause(this.#quic, err); if (this.#localClose) hooks.releaseTrack(producer); - else producer.close(e); + else producer.abort(e); console.warn( `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, ); @@ -1033,7 +1038,7 @@ export class Subscriber { const { tail, track } = subscription; const complete = () => count > 0n && BigInt(tail.streams) >= count; - await tail.settle(complete, track.closed); + await tail.settle(complete, hooks.trackReleased(track)); } /** @@ -1103,7 +1108,7 @@ export class Subscriber { } catch (err) { // Nothing reached the wire, but the track can no longer follow its subscribers. console.warn(`subscribe update failed: id=${requestId} error=${reason(error(err))}`); - producer.close(error(err)); + producer.abort(error(err)); // Drafts 14 and 15 require the next request ID, so one spent and never sent would // stall every later request: as in Rust, that ends the session. if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { @@ -1572,7 +1577,7 @@ export class Subscriber { // cache. const frame = stream.tryDecode(decode) ?? - (await race([stream.decodeMaybe(decode), producer?.closed ?? track.closed])); + (await race([stream.decodeMaybe(decode), producer?.closed ?? hooks.trackReleased(track)])); if (!producer && (frame === null || frame instanceof Error)) { stream.stop(frame ?? new StreamError(StreamCode.Cancel)); return; @@ -1648,14 +1653,14 @@ export class Subscriber { if (e instanceof EndOfTrackProperties) { // The group before the marker is complete; only the track's end can't be carried. producer?.finish(); - track.close(e); + track.abort(e); } else if ( e instanceof ProtocolViolation || (e instanceof StreamError && e.code === StreamCode.MalformedTrack) ) { // The publisher broke the track's end or its content, which no later group can repair. producer?.abort(e); - track.close(e); + track.abort(e); } else { // A stream that fails before its first object still names a group, which the // reader sees fail rather than silently go missing. diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index f72d19eb684..52a163525ec 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1051,7 +1051,9 @@ test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at % for (;;) { const request = await wireOf(broadcast).requested(); if (!request) return; - request.accept({ timescale: Timescale.MILLI }).close(); + const accepted = request.accept({ timescale: Timescale.MILLI }); + accepted.finish(); + accepted.close(); } })(); } @@ -2872,7 +2874,7 @@ test("integration: lite draft-05 ends a track with the publisher's reset", async expect(await group?.readString()).toBe("head"); const reset = StreamCode(70); - for (const producer of served) producer.close(new StreamError(reset)); + for (const producer of served) producer.abort(new StreamError(reset)); const closed = await withTimeout( Promise.resolve(track.closed), Nano.fromMilli(Milli(2000)), diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index f9c7c5f09d2..14504ac7fd2 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -400,6 +400,13 @@ export const hooks: { * continues past it. Mirrors the Rust `close` on a `received()` copy; assigned by `track.ts`. */ releaseTrack: (producer: Producer) => void; + /** + * Settles once the producer is released (closed or aborted), unlike its public `closed`, + * which also settles on a reached end while the producer keeps serving. Assigned by `track.ts`. + */ + trackReleased: (producer: Producer) => GetPromise; + /** {@link trackReleased} for a demand handle. Assigned by `track.ts`. */ + demandReleased: (demand: Demand) => GetPromise; /** The producer's largest reported location and its subsequent changes. */ producerLargest: (producer: Producer) => Getter; @@ -566,6 +573,12 @@ export const hooks: { releaseTrack: () => { throw new Error("track.ts not loaded"); }, + trackReleased: () => { + throw new Error("track.ts not loaded"); + }, + demandReleased: () => { + throw new Error("track.ts not loaded"); + }, producerLargest: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index 7f47d064fe5..47475f4c87f 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -76,13 +76,15 @@ test("a track leaving the table is removed", async () => { const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "+b", "synced"]); broadcast.removeTrack("a"); - b.close(new Error("aborted")); + b.abort(new Error("aborted")); expect(await tracks.drain()).toEqual(["-a", "-b"]); }); test("a finished track is listed while the table holds it", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("a").close(); + const a = broadcast.createTrack("a"); + a.finish(); + a.close(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); // It finished before it was removed. @@ -108,7 +110,9 @@ test("a lister finishes or removes a track", async () => { test("a recreated track leaves as it ended before it is added again", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("a").close(); + const a = broadcast.createTrack("a"); + a.finish(); + a.close(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); // The finished track still holds the name; its replacement takes it over. @@ -205,10 +209,12 @@ test("a clean end follows the removals before it", async () => { const live = broadcast.createTrack("live"); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "+b", "+live", "synced"]); + a.finish(); a.close(); broadcast.removeTrack("a"); broadcast.removeTrack("b"); broadcast.createTrack("unseen"); + live.finish(); live.close(); broadcast.close(); expect(await tracks.drain()).toEqual(["~a", "-b", "~live", "end"]); @@ -221,6 +227,7 @@ test("a clean end follows the removals before it", async () => { const own = both.createTrack("a"); const served = list(both); expect(await served.drain()).toEqual(["+a", "synced"]); + own.finish(); own.close(); both.close(); expect(await served.drain()).toEqual(["~a", "end"]); @@ -230,6 +237,7 @@ test("a clean end follows the removals before it", async () => { const gone = closing.createTrack("gone"); const reader = list(closing); expect(await reader.drain()).toEqual(["+gone", "synced"]); + gone.finish(); gone.close(); closing.removeTrack("gone"); closing.close(); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index 2ca070e1ba7..45a8d2e5342 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -14,6 +14,7 @@ */ import { type GetPromise, type Getter, Signal } from "@moq/signals"; import { StreamCode, StreamError } from "./error.ts"; +import { hooks } from "./internal.ts"; import type * as track from "./track.ts"; /** @@ -36,7 +37,7 @@ export type TracksEvent = /** Whether `own` closed cleanly, so its track leaves the listing as finished. */ function finished(own: track.Producer): boolean { - return own.closed.peek() === null; + return hooks.trackReleased(own).peek() === null; } /** One listed track and who lists it. */ diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 26ef77d27ec..b665548b359 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -245,6 +245,7 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { const last = producer.appendGroup(); last.writeFrame(textFrame("last")); last.finish(); + producer.finish(); producer.close(); const closing = server.close(); @@ -300,6 +301,7 @@ async function servedRawSubscription() { // Finish the track only once it is served: a track closed before anyone subscribed is gone. expect("start" in (await decodeSubscribeResponse(subscriber.reader, version))).toBe(true); + producer.finish(); producer.close(); // SUBSCRIBE_END, then the publisher's FIN. await subscriber.reader.readAll(); @@ -467,6 +469,7 @@ test("close waits for a request served while withdrawals are in flight", async ( startGroup: 0, }).encode(subscriber.writer, version); expect("start" in (await decodeSubscribeResponse(subscriber.reader, version))).toBe(true); + producer.finish(); producer.close(); await subscriber.reader.readAll(); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index f76c909fbbf..3231eae906d 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -217,6 +217,7 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF // arrives late opens no stream at all, so there is nothing firmer to wait on. await new Promise((resolve) => setTimeout(resolve, 5)); } + track.finish(); track.close(); try { @@ -819,6 +820,7 @@ test("lite draft-06: a cut group is dropped early, once", async () => { sub.serve(2); expect(await sub.servedSequence()).toBe(2); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -858,6 +860,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { expect(await sub.servedSequence()).toBe(5); await replayUpdate({ priority: 0, startGroup: 2 }).encode(sub.client.writer, version); await flush(); + sub.track.finish(); sub.track.close(); const codes: number[] = []; for (;;) { @@ -928,6 +931,7 @@ test("lite draft-06: the end sweep does not drop a sent datagram", async () => { expect(await sub.servedSequence()).toBe(0); sub.track.insertDatagram(1, Timestamp.fromMillis(1), new Uint8Array([1])); await flush(); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -954,6 +958,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { sub.serve(1); sub.serve(2); expect(await sub.servedSequence()).toBe(2); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -1011,6 +1016,7 @@ test("lite draft-06: an update lowering the start is swept at the end", async () expect(await sub.servedSequence()).toBe(5); await replayUpdate({ priority: 0, startGroup: 2 }).encode(sub.client.writer, Version.DRAFT_06); await flush(); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([[2, 4]]); } finally { @@ -1033,6 +1039,7 @@ test("lite draft-06: a raised floor is not dropped early", async () => { sub.serve(sequence); expect(await sub.servedSequence()).toBe(sequence); } + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -1137,6 +1144,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { hooks.startTrack(sub.track, { requested: 2 }); sub.serve(6); await flush(); + sub.track.finish(); sub.track.close(); const response = await decodeSubscribeResponse(sub.client.reader, version); if (!("start" in response)) throw new Error("expected SUBSCRIBE_START"); @@ -1634,6 +1642,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy second.writeFrame(textFrame("hello")); second.finish(); track.writeGroup(second); + track.finish(); track.close(); const resp = await pending; @@ -1676,6 +1685,7 @@ async function heldOpenEnd() { group.writeFrame(textFrame("hello")); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1758,6 +1768,7 @@ test("lite draft-07: a group whose close fails is dropped", async () => { group.writeFrame(textFrame("hello")); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1823,6 +1834,7 @@ test("blocked DROP responses stop opening later groups", async () => { await new Promise((resolve) => setImmediate(resolve)); expect(opens).toHaveBeenCalledTimes(8); release(); + track.finish(); track.close(); let dropped = 0; for (;;) { @@ -1889,6 +1901,7 @@ async function serve( // the order written rather than whatever the cache hands over in one batch. await new Promise((resolve) => setTimeout(resolve, 5)); } + track.finish(); track.close(); const reader = pair.client.incomingUnidirectionalStreams.getReader(); @@ -2187,6 +2200,7 @@ test("lite draft-05: a group waiting for a stream slot is dropped when the subsc test("lite draft-05: a group waiting for a stream slot survives the track finishing", async () => { const { client, track, freeSlot, outcome, close } = await saturatedGroup(); + track.finish(); track.close(); // SUBSCRIBE_END goes out while the group is still waiting for its slot. @@ -2863,6 +2877,7 @@ for (const version of [Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07]) { client.close(); server.close(); publisher.close(); + track.finish(); track.close(); broadcast.close(); origin.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 9f7c1a02beb..1911a5a7df0 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -837,7 +837,7 @@ export class Subscriber { state.revoked = refused; console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); state.cancel.abort(refused); - state.producer?.close(refused); + state.producer?.abort(refused); state.stream?.abort(refused); }); try { @@ -886,7 +886,7 @@ export class Subscriber { // its TRACK stream so a peer that never answers can't hold one per attempt. const retire = () => { state.cancel.abort(valid?.reason); - state.producer?.close(error(valid?.reason)); + state.producer?.abort(error(valid?.reason)); state.stream?.abort(error(valid?.reason)); }; valid?.addEventListener("abort", retire); @@ -952,7 +952,7 @@ export class Subscriber { // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. // Updates stop quietly at the FIN or a failed write, either of which can land before the // responses ahead of it are decoded, so only a refused update is terminal on its own. - const terminal: PromiseLike[] = [closed, producer.closed]; + const terminal: PromiseLike[] = [closed, hooks.trackReleased(producer)]; if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates.then(() => closed)); const done = race(terminal); @@ -963,11 +963,16 @@ export class Subscriber { const demand = producer.demand(); for (;;) { const reason = await race([done, unwanted(demand).then(() => idle)]); - if (reason === idle && demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) + if ( + reason === idle && + hooks.demandReleased(demand).peek() === undefined && + demand.wanted.peek() !== undefined + ) continue; break; } + producer.finish(); producer.close(); // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. @@ -976,7 +981,7 @@ export class Subscriber { console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { const e = await sessionCause(this.#quic, err); - producer.close(e); + producer.abort(e); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); } finally { @@ -1395,7 +1400,7 @@ export class Subscriber { entry.held?.close(); entry.held = undefined; if (!resp) { - if (entry.end === undefined && entry.track.closed.peek() === undefined) + if (entry.end === undefined && hooks.trackReleased(entry.track).peek() === undefined) throw new ProtocolViolation("subscribe stream ended without SUBSCRIBE_END"); return; } @@ -1411,7 +1416,7 @@ export class Subscriber { if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); entry.end = resp.end.group; // A local close can win the race with the response; there is nothing left to end. - if (entry.track.closed.peek() !== undefined) continue; + if (hooks.trackReleased(entry.track).peek() !== undefined) continue; try { // Lower groups may still be on the wire behind a higher one, so readers hold // at a hole below the end until the tail settles. @@ -1457,7 +1462,7 @@ export class Subscriber { return tail.covers(start, end); }; - return tail.settle(complete, track.closed); + return tail.settle(complete, hooks.trackReleased(track)); } /** @@ -1479,7 +1484,7 @@ export class Subscriber { stream: Stream, ): Promise { const track = entry.track; - const stopped: Promise = race([track.closed, stream.reader.closed]).then(() => null); + const stopped: Promise = race([hooks.trackReleased(track), stream.reader.closed]).then(() => null); let lastSent: track.Subscription = { priority: msg.priority, order: msg.order, @@ -1605,7 +1610,7 @@ export class Subscriber { const violation = new ProtocolViolation( `group ${group.sequence} is at or past the declared end ${entry.end}`, ); - track.close(violation); + track.abort(violation); throw violation; } track.writeGroup(producer); @@ -1614,13 +1619,13 @@ export class Subscriber { // TRACK_INFO (or implicit defaults) resolves it on the subscribe stream. let scale = timescale.peek(); while (scale === undefined) { - if (track.closed.peek() !== undefined) { + if (hooks.trackReleased(track).peek() !== undefined) { // Subscription ended before the scale resolved; nothing to decode. producer.finish(); stream.stop(StreamCode.Cancel); return; } - await Signal.race(timescale, track.closed); + await Signal.race(timescale, hooks.trackReleased(track)); scale = timescale.peek(); } @@ -1799,7 +1804,7 @@ export class Subscriber { this.#closed.abort(cut); for (const { track } of this.#subscribes.values()) { - if (err) track.close(err); + if (err) track.abort(err); else hooks.releaseTrack(track); } diff --git a/js/net/src/resume.test-util.ts b/js/net/src/resume.test-util.ts index fd3a1a15fd1..41d2911028e 100644 --- a/js/net/src/resume.test-util.ts +++ b/js/net/src/resume.test-util.ts @@ -15,6 +15,7 @@ export function readsFrom(active: BroadcastConsumer | undefined, source: Broadca return sub.tryRecvGroup() !== undefined; } finally { sub.close(); + track.finish(); track.close(); source.removeTrack(name); } diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 5dac619b351..8ca55fa468e 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -364,7 +364,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on } } catch (err) { if (stopped) return; - output.close(err instanceof Error ? err : new Error(String(err))); + output.abort(err instanceof Error ? err : new Error(String(err))); broadcast.close(); return err; } @@ -376,7 +376,11 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on stopped = true; input.close(); release.resolve(); - output.close(err); + if (err) output.abort(err); + else { + output.finish(); + output.close(); + } broadcast.close(); }, }; @@ -568,7 +572,7 @@ test("a replacement claiming different track properties is refused once the old expect(sub.closed.peek()).toBeUndefined(); // The old copy failing with its route is what ends the track. - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(() => sub.tryRecvGroup()).toThrow("changed its properties"); @@ -587,7 +591,7 @@ test("waiting for the end of a track whose replacement was refused ends with the finished.catch(() => {}); // The old copy failing with its route leaves only the refusal. - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await expect(finished).rejects.toThrow("changed its properties"); sub.close(); @@ -795,7 +799,7 @@ test("a route that goes for good ends the track as its last copy does", async () ta.write(0); expect(drain(sub)).toEqual([0]); - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); disposeA(); await settle(); expect(request.active.peek()).toBeUndefined(); @@ -901,7 +905,7 @@ test("a replacement spliced before the first copy answers is still checked again await settle(); expect((await sub.info()).priority).toBe(1); - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(() => sub.tryRecvGroup()).toThrow("changed its properties"); @@ -1175,6 +1179,7 @@ test("draining groups to their end leaves buffered datagrams readable", async () const sub = front.consumer.track("seq").subscribe(); ta.write(0); ta.producer.appendDatagram(undefined, enc.encode("d")); + ta.producer.finish(); ta.producer.close(); expect(drain(sub)).toEqual([0]); expect(await sub.recvGroup()).toBeUndefined(); @@ -1461,7 +1466,7 @@ test.each([true, false])( expect(dec.decode((await group.readFrame())?.payload)).toBe("1.0"); // The old route goes away, and the reader reads on and sees its copy end. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); const later = next(); await settle(); failing.abort(new StreamError(StreamCode.SessionClosed)); @@ -1745,7 +1750,7 @@ test("the largest follows the serving copy across a swap without moving back, an expect(hooks.peekLargest(sub).largest).toEqual({ group: 3, frame: 0 }); // The route fails: its copy's stream ends, the logical track's does not. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); let settled = false; const next = sub.largest().finally(() => { settled = true; @@ -1764,6 +1769,7 @@ test("the largest follows the serving copy across a swap without moving back, an // No route replaces this one: the stream ends with its copy. front.conclude(); const last = sub.largest(); + tb.producer.finish(); tb.producer.close(); expect(await last).toBeUndefined(); @@ -1966,7 +1972,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival front.serve(b.consume()); await settle(); // Its route fails once replaced: closing cleanly would declare the track ends at group 1. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); front.serve(c.consume()); await settle(); @@ -2009,6 +2015,7 @@ test("a clean end waits for a group the replaced copy still carries", async () = // The new route has only the group already handed out, and ends there. tb.write(2); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2131,6 +2138,7 @@ test("the serving copy closing cleanly settles closed and leaves its datagrams r const sub = front.consumer.track("seq").subscribe(); ta.write(0); ta.producer.appendDatagram(undefined, enc.encode("d")); + ta.producer.finish(); ta.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); @@ -2317,6 +2325,7 @@ test.each(["lands", "expired"])( // The new route has only the group already handed out, and ends there. Nothing reads. tb.write(2); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2385,6 +2394,7 @@ test("a clean end keeps the serving copy while a group read from a replaced one // The new route has all of group 1 and ends there. tb.write(1, 2); + tb.producer.finish(); tb.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); @@ -2419,6 +2429,7 @@ test("a retained copy declaring another end after a clean end fails the reads", // The new route ends at 3, while the old one still owes group 1. tb.write(2); tb.producer.finishAt({ group: 3, frame: 0 }); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2458,6 +2469,7 @@ test("a clean end keeps the serving copy while a group handed out before the swa front.serve(b.consume()); await settle(); tb.write(0, 2); + tb.producer.finish(); tb.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 7a7c6d0cbf8..912c9b037c1 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -249,7 +249,7 @@ function subscribe(wire: BroadcastWire, name: string, subscription: track.Subscr return wire.subscribe(name, subscription); } catch (err) { const failed = new track.Producer(name); - failed.close(err instanceof Error ? err : new Error(String(err))); + failed.abort(err instanceof Error ? err : new Error(String(err))); return failed.subscribe(subscription); } } diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index c83f05f1372..1e0cd097928 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -69,6 +69,7 @@ test.each([Lite.ALPN_05, Lite.ALPN_06])( expect(await reader.finished()).toEqual({ group: 3, frame: 0 }); video.writeFrame(textFrame("2")); + video.finish(); video.close(); expect(await readAll(reader)).toEqual(["2"]); expect(await reader.closed).toBeNull(); @@ -94,6 +95,7 @@ test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_20])( video.writeFrame(textFrame("1")); video.writeFrame(textFrame("2")); const ended = performance.now(); + video.finish(); video.close(); expect(await readAll(reader)).toEqual(["1", "2"]); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 24a365b6f46..f7b24c8dccc 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -195,6 +195,7 @@ test("a replacement wants the group past its predecessor", () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("test"); first.writeGroup(new GroupProducer(7)); + first.finish(); first.close(); const second = broadcast.createTrack("test"); @@ -242,6 +243,7 @@ test("the group range bounds datagrams", async () => { for (const sequence of [4, 5, 7, 6]) { producer.insertDatagram(sequence, Timestamp.fromMillis(0), enc.encode("x")); } + producer.finish(); producer.close(); expect((await track.recvDatagram())?.sequence).toBe(5); @@ -256,6 +258,7 @@ for (const timed of [true, false]) { producer.appendDatagram(timestamp, enc.encode("before")); const subscriber = producer.subscribe({ maxDelay: Milli(50) }); const sequence = producer.appendDatagram(timestamp, enc.encode("after")); + producer.finish(); producer.close(); const datagram = await subscriber.recvDatagram(); expect(datagram?.sequence).toBe(sequence); @@ -273,6 +276,7 @@ test("datagram buffers drop oldest at capacity without delaying active subscribe producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("x")); expect((await fast.recvDatagram())?.sequence).toBe(sequence); } + producer.finish(); producer.close(); for (let sequence = count - 64; sequence < count; sequence++) { expect((await slow.recvDatagram())?.sequence).toBe(sequence); @@ -385,6 +389,7 @@ test("insertDatagram stale does not rewind after append", async () => { test("insertDatagram after close is refused", () => { const producer = new TrackProducer("test"); const ts = Timestamp.fromMillis(0); + producer.finish(); producer.close(); expect(() => producer.insertDatagram(0, ts, enc.encode("x"))).toThrow("track is closed"); expect(() => producer.appendDatagram(ts, enc.encode("x"))).toThrow("track is closed"); @@ -392,7 +397,7 @@ test("insertDatagram after close is refused", () => { test("insertDatagram after abort is refused", () => { const producer = new TrackProducer("test"); - producer.close(new Error("cancel")); + producer.abort(new Error("cancel")); expect(() => producer.insertDatagram(0, Timestamp.fromMillis(0), enc.encode("x"))).toThrow("track is closed"); }); @@ -446,6 +451,7 @@ test("subscriber maxAge is refused before defaults or updates take effect", () = // Publisher retention keeps its distinct maxAge field. producer.accept({ maxAge: Milli(500) }); track.close(); + producer.finish(); producer.close(); }); @@ -454,6 +460,7 @@ test("an unknown subscriber order is refused", () => { const producer = new TrackProducer("test"); expect(() => producer.subscribe({ order: "newest" } as unknown as Subscription)).toThrow(TypeError); expect(producer.subscription.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -836,6 +843,7 @@ test("a late lower group within the budget is delivered", async () => { late.finish(); expect((await track.recvGroup())?.sequence).toBe(4); + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); }); @@ -909,6 +917,7 @@ test("a stamped successor expires a group stalled before its first frame", async clock.set(11_000); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1000) }); + producer.finish(); producer.close(); expect((await track.recvGroup())?.sequence).toBe(1); @@ -1115,6 +1124,7 @@ test("a fully read group still reports its expiry", async () => { expect(await raced).toMatchObject({ code: StreamCode.Old }); expect(group.expired.peek()).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1151,6 +1161,7 @@ test("a stalled track keeps only its newest group", () => { clock.set(12_000); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1176,6 +1187,7 @@ test("frozen timestamps still age out by wall clock", () => { clock.set(12_500); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1199,7 +1211,9 @@ test("a group shared by two tracks ages each from its own arrival", () => { // Group 0's successor joined the first track a full budget ago. expect(drain(first.subscribe({ maxDelay: Milli(1000) }))).toEqual([1]); } finally { + first.finish(); first.close(); + second.finish(); second.close(); clock.installed.close(); } @@ -1218,6 +1232,7 @@ test("a startup burst ages out by media time", () => { // behind, past 1.5s. Their successors arrived just now. expect(drain(producer.subscribe({ maxDelay: Milli(1500) }))).toEqual([2, 3, 4]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1254,6 +1269,7 @@ test("a parked read wakes at its wall deadline", async () => { await clock.advance(11_000); expect(await read).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1295,6 +1311,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn expect(stale).toBe(true); expect(await guarded).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1340,6 +1357,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy expect(stale).toBe(true); expect(await guarded).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1366,6 +1384,7 @@ test("a run of empty groups ages out by wall clock", () => { expect(drain(track)).toEqual([RUN + 1]); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([RUN + 1]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1388,6 +1407,7 @@ test("a live track retains its newest closed group through idle expiry", async ( expect(replay.tryRecvGroup()).toBeUndefined(); replay.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1434,6 +1454,7 @@ test("idle expiry reclaims an ended track's held mirror", async () => { if (!group) throw new Error("missing group"); expect(new TextDecoder().decode((await group.readFrame())?.payload)).toBe("first"); + producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); producer.subscribe({ maxDelay: Milli(100) }); @@ -1516,6 +1537,7 @@ test("an abandoned open group ages out with no further write", async () => { await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); expect(await read).toBeInstanceOf(TooFarBehind); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1536,6 +1558,7 @@ test("publisher maxAge does not change the idle cache deadline", async () => { await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); expect(idle.closed.peek()).toBeInstanceOf(TooFarBehind); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1556,6 +1579,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { expect(await group.readString()).toBe("only"); expect(await group.readFrame()).toBeUndefined(); + producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); producer.subscribe({ maxDelay: Milli(100) }); @@ -1614,6 +1638,7 @@ test("latency budget retains a consumed live-edge anchor", async () => { late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(0) }); late.finish(); producer.writeGroup(late); + producer.finish(); producer.close(); expect(track.recvGroup()).resolves.toBeUndefined(); @@ -1661,11 +1686,13 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca // Arrival readers can now offer the unread group below the old floor; ordered ones cannot rewind. if (!ordered) expect((await capped)?.sequence).toBe(0); expect((await (ordered ? capped : read()))?.sequence).toBe(4); + producer.finish(); producer.close(); expect(await read()).toBeUndefined(); } finally { subscriber.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1685,6 +1712,7 @@ test("subscription updates narrow the cursor before its first read", async () => } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1706,6 +1734,7 @@ test("a subscription cap chooses its own live edge", async () => { } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1729,6 +1758,7 @@ test("subscription updates bound each reader's datagrams", async () => { } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1776,6 +1806,7 @@ test("withRange caps frame-level reads like the group cursor", async () => { producer.writeFrame({ payload: enc.encode("zero"), timestamp: Timestamp.fromMillis(0) }); producer.writeFrame({ payload: enc.encode("one"), timestamp: Timestamp.fromMillis(1) }); + producer.finish(); producer.close(); expect((await track.readFrame())?.group).toBe(0); @@ -1824,6 +1855,7 @@ test("recvGroup serves a late arrival after a newer group", async () => { // Staleness is the latency window's job, not arrival order's: the track // still finishes normally afterward. + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); }); @@ -1844,6 +1876,7 @@ test("withRange parks recvGroup beyond the cap and a raised cap re-offers", asyn expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); // A clean close keeps the parked group claimable: the cap may still rise. + producer.finish(); producer.close(); expect(await Promise.race([pending, new Promise((resolve) => setTimeout(() => resolve("parked"), 10))])).toBe( "parked", @@ -1939,6 +1972,7 @@ test("closing the subscriber releases a recvGroup parked after a clean close", a const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); + producer.finish(); producer.close(); track.close(); expect(await pending).toBeUndefined(); @@ -2018,6 +2052,7 @@ test("recvDatagram does not commit the group cursor", async () => { test("nextGroup returns undefined when track closes", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe().ordered(); + producer.finish(); producer.close(); expect(await track.nextGroup()).toBeUndefined(); }); @@ -2042,6 +2077,7 @@ test("a closed track still delivers a group parked above the cap once the cap is // Group 2 parks above the cap; a clean close must not resolve it as finished. const parked = track.nextGroup(); + producer.finish(); producer.close(); const timeout = new Promise((resolve) => setTimeout(() => resolve("pending"), 10)); expect(await Promise.race([parked, timeout])).toBe("pending"); @@ -2066,6 +2102,7 @@ test("end reports the head after the highest produced sequence, datagrams includ producer.writeGroup(new GroupProducer(0)); producer.appendDatagram(Timestamp.fromMillis(1), enc.encode("x")); + producer.finish(); producer.close(); expect(track.end()).toEqual({ group: 2, frame: 0 }); }); @@ -2074,12 +2111,13 @@ test("end reports the head after the highest produced sequence, datagrams includ test("end is group 0 for an empty track and undefined after an abort", async () => { const empty = new TrackProducer("test"); const emptyTrack = empty.subscribe(); + empty.finish(); empty.close(); expect(emptyTrack.end()).toEqual({ group: 0, frame: 0 }); const aborted = new TrackProducer("test"); const abortedTrack = aborted.subscribe(); - aborted.close(new Error("boom")); + aborted.abort(new Error("boom")); expect(abortedTrack.end()).toBeUndefined(); }); @@ -2098,6 +2136,7 @@ test("readFrame does not livelock when a sole group finishes before the next arr const g1 = producer.appendGroup(); g1.writeFrame(textFrame("hello")); g1.finish(); + producer.finish(); producer.close(); }, 10); @@ -2144,6 +2183,7 @@ test("largest names the newest frame written", async () => { // Reading a group does not move the edge: it is what the track produced, not what is left. await subscriber.recvGroup(); const ended = subscriber.largest(); + producer.finish(); producer.close(); expect(await ended).toBeUndefined(); }); @@ -2165,6 +2205,7 @@ test("largest ends at the declared end while the producer stays open", async () group.finish(); expect(await ended).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -2192,6 +2233,7 @@ test("a late group below the largest changes nothing", async () => { late.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); expect(await subscriber.largest()).toBeUndefined(); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2223,6 +2265,7 @@ test("a recreated largest group is reported before the end", async () => { expect(await next).toEqual({ group: 0, frame: 1 }); again.finish(); expect(await subscriber.largest()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -2232,6 +2275,7 @@ test("a datagram raises the largest", async () => { producer.appendGroup().finish(); expect(producer.appendDatagram(Timestamp.now(), enc.encode("d"))).toBe(1); expect(await subscriber.largest()).toEqual({ group: 1, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2275,6 +2319,7 @@ test("largest reports only within the bounds", async () => { expect(await isPending(next)).toBe(true); framedGroup(producer, 8, 0); expect(await next).toEqual({ group: 8, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2305,6 +2350,7 @@ test("largest follows the bounds as they move", async () => { expect(await next).toEqual({ group: 6, frame: 0 }); reader.withRange({ start: { included: 3 } }); expect(await isPending(reader.largest())).toBe(true); + producer.finish(); producer.close(); }); @@ -2313,6 +2359,7 @@ test("largest follows the bounds as they move", async () => { test("largest stays ended when the bounds widen", async () => { const producer = new TrackProducer("test").accept({}); framedGroup(producer, 5, 1).finish(); + producer.finish(); producer.close(); const reader = producer.subscribe().withRange({ end: { excluded: 3 } }); @@ -2345,6 +2392,7 @@ test("a live-edge reader starts on an empty largest group", async () => { const after = await reader.recvGroup(); expect(after?.sequence).toBe(2); expect(await after?.readFrame()).toBeDefined(); + producer.finish(); producer.close(); }); @@ -2361,6 +2409,7 @@ test("largest survives a mirror replay of an aborted group", async () => { const late = producer.subscribe(); expect(await late.largest()).toEqual({ group: 0, frame: MAX_GROUP_FRAMES - 1 }); + producer.finish(); producer.close(); }); @@ -2414,6 +2463,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { expect(group?.sequence).toBe(0); await expect(group?.readString()).rejects.toBe(error); track.close(); + producer.finish(); producer.close(); }); @@ -2422,6 +2472,7 @@ test("a lowered range still re-offers after the producer closes", () => { const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); for (const sequence of [0, 1]) producer.writeGroup(new GroupProducer(sequence)); expect(track.tryRecvGroup()?.sequence).toBe(1); + producer.finish(); producer.close(); track.withRange({}); @@ -2440,6 +2491,7 @@ for (const abort of [undefined, new Error("gone")]) { track.withRange({}); expect(hooks.tryRecvGroup(track, false)).toEqual(abort ? { kind: "error", error: abort } : { kind: "done" }); + producer.finish(); producer.close(); }); } @@ -2467,6 +2519,7 @@ test("finishAt refuses an end at or below a produced sequence", () => { expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); expect(() => producer.appendGroup()).toThrow(); expect(() => producer.insertDatagram(6, Timestamp.now(), new Uint8Array(1))).toThrow(); + producer.finish(); producer.close(); }); @@ -2492,6 +2545,7 @@ test("finishAt declares an end ahead of the live edge without ending the track", expect((await track.recvGroup())?.sequence).toBe(7); // A clean close keeps the declared end rather than the live edge. + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); expect(track.end()).toEqual({ group: 8, frame: 0 }); @@ -2530,10 +2584,11 @@ for (const ordered of [false, true]) { expect(track.closed.peek()).toBeUndefined(); const aborted = new Error("cut off"); - producer.close(aborted); + producer.abort(aborted); await expect(receive()).rejects.toThrow("cut off"); await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow("cut off"); } finally { + producer.finish(); producer.close(); track.close(); } @@ -2561,6 +2616,7 @@ for (const channel of ["group", "datagram"]) { expect(await track.recvDatagram()).toBeUndefined(); expect(track.closed.peek()).toBeUndefined(); } finally { + producer.finish(); producer.close(); track.close(); } @@ -2570,7 +2626,7 @@ for (const channel of ["group", "datagram"]) { test("finished rejects when the track aborts or closes without an end", async () => { const aborted = new TrackProducer("test"); const pending = aborted.subscribe().finished(); - aborted.close(new Error("boom")); + aborted.abort(new Error("boom")); await expect(pending).rejects.toThrow("boom"); const producer = new TrackProducer("test"); @@ -2582,6 +2638,7 @@ test("finished rejects when the track aborts or closes without an end", async () const clean = new TrackProducer("test"); const reader = clean.subscribe(); clean.appendGroup().finish(); + clean.finish(); clean.close(); expect(await reader.finished()).toEqual({ group: 1, frame: 0 }); }); @@ -2608,6 +2665,7 @@ test("an omitted publisher limit still ages idle groups out", async () => { expect(after.tryRecvGroup()).toBeUndefined(); after.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2629,6 +2687,7 @@ test("a live track does not pin an aborted newest group", () => { expect(late.tryRecvGroup()).toBeUndefined(); late.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2645,6 +2704,7 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn // Idle well past the window, but protected while the track is live. clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); + producer.finish(); producer.close(); expect(await (await subscriber.recvGroup())?.readString()).toBe("snapshot"); expect(await subscriber.recvGroup()).toBeUndefined(); @@ -2666,6 +2726,7 @@ test("a late subscriber to a closed unlimited track is released once the cache a const source = producer.appendGroup(); source.writeFrame(textFrame("last")); source.finish(); + producer.finish(); producer.close(); // Counts the map entries subscribing leaves behind, rather than the heap: any entry @@ -2708,7 +2769,7 @@ test("an abort keeps finished groups for a slow reader, then reports it", async } producer.appendGroup().writeFrame(textFrame("open")); const boom = new Error("boom"); - producer.close(boom); + producer.abort(boom); for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { for (let i = 0; i < 2; i++) { @@ -2731,7 +2792,7 @@ test("an abort after the declared end settles ends clean", async () => { group.finish(); } producer.finishAt({ group: 2, frame: 0 }); - producer.close(new Error("boom")); + producer.abort(new Error("boom")); for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { expect((await next())?.sequence).toBe(0); @@ -2744,10 +2805,51 @@ test("an abort after the declared end reached by a datagram ends clean", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("d")); producer.finishAt({ group: 1, frame: 0 }); - producer.close(new Error("boom")); + producer.abort(new Error("boom")); expect(producer.closed.peek()).toBeNull(); }); +test("finish is not terminal: closed settles once the groups below the end finish", async () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const subscriber = producer.subscribe(); + const group = producer.appendGroup(); + group.writeFrame(textFrame("late")); + producer.finish(); + expect(producer.closed.peek()).toBeUndefined(); + + // The group below the end is still writable after finish. + group.writeFrame(textFrame("later")); + group.finish(); + await settle(); + expect(producer.closed.peek()).toBeNull(); + // Settled, not released: the producer keeps serving its cache. + expect(hooks.trackReleased(producer).peek()).toBeUndefined(); + + const read = await subscriber.recvGroup(); + expect(read?.sequence).toBe(0); + expect(await subscriber.recvGroup()).toBeUndefined(); + producer.close(); + expect(hooks.trackReleased(producer).peek()).toBeNull(); +}); + +test("closed reports an abort before the end settles", () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + producer.appendGroup().writeFrame(textFrame("open")); + producer.finish(); + const abort = new Error("boom"); + producer.abort(abort); + expect(producer.closed.peek()).toBe(abort); +}); + +test("close without finish aborts with a cancel", () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + producer.writeFrame(textFrame("only")); + producer.close(); + const closed = producer.closed.peek(); + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(StreamCode.Cancel); +}); + test("an ended track's buffered groups age out for a stale subscriber", async () => { for (const abort of [undefined, new Error("boom")]) { const clock = manualTime(10_000); @@ -2757,13 +2859,18 @@ test("an ended track's buffered groups age out for a stale subscriber", async () const group = producer.appendGroup(); group.writeFrame(textFrame("x")); group.finish(); - producer.close(abort); + if (abort) producer.abort(abort); + else { + producer.finish(); + producer.close(); + } // Nothing writes after close; the scheduled cleanup can lag by one scan slice. await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); if (abort) expect(() => stale.tryRecvGroup()).toThrow(abort); else expect(stale.tryRecvGroup()).toBeUndefined(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2777,7 +2884,7 @@ test("an abort leaves a group already taken readable, then reports the abort", a producer.appendGroup().writeFrame(textFrame("held")); const held = [await arrival.recvGroup(), await ordered.nextGroup()]; const boom = new Error("boom"); - producer.close(boom); + producer.abort(boom); for (const group of held) { expect(group?.sequence).toBe(0); @@ -2876,6 +2983,7 @@ test("unknown track metadata keeps presentation-time expiry until accepted", () producer.accept({ timescale: Timescale.MILLI }); expect(drain(producer.subscribe({ maxDelay: Milli(500) }))).toEqual([2, 3]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2905,6 +3013,7 @@ test("an untimed subscriber starts at the oldest fresh group", () => { producer.writeFrame({ payload: enc.encode("5") }); expect(drain(live)).toEqual([5]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2974,6 +3083,7 @@ test("explicit and producer-cut drops survive outside the group cache", async () expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 4, end: 5, error: cut }); reader.close(); + producer.finish(); producer.close(); }); @@ -2996,6 +3106,7 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () for (const sequence of holes.keys()) expect(hooks.givenUp(reader, sequence).error).toBeUndefined(); reader.close(); + producer.finish(); producer.close(); }); @@ -3022,6 +3133,7 @@ test("an upstream drop ends a group still arriving with its error", async () => expect(await whole?.readString()).toBe("4"); reader.close(); + producer.finish(); producer.close(); }); @@ -3035,6 +3147,7 @@ test("a drop below the window is reported once the window lowers to it", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 2, end: 3, error: DROPPED }); reader.close(); + producer.finish(); producer.close(); }); @@ -3053,6 +3166,7 @@ test("a re-created cut group is served, not dropped", async () => { expect(recv.kind === "group" ? recv.group.sequence : recv).toBe(4); reader.close(); + producer.finish(); producer.close(); }); @@ -3075,6 +3189,7 @@ test("a wide drop is held and reported as one run", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); reader.close(); + producer.finish(); producer.close(); }); @@ -3090,6 +3205,7 @@ test("adjacent drops with the same code merge into one run", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); reader.close(); + producer.finish(); producer.close(); }); @@ -3102,6 +3218,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => producer.writeGroup(group); for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); group.finish(); + producer.finish(); producer.close(); await clock.advance(10_000 + CACHE_WINDOW_MS + 1); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 4 }); @@ -3111,6 +3228,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => expect(reader.end()).toEqual({ group: 5, frame: 5 }); reader.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -3130,6 +3248,7 @@ test("a wire end repeats or refines but never moves", () => { ]) { expect(() => hooks.endTrack(producer, moved)).toThrow(ProtocolViolation); } + producer.finish(); producer.close(); }); @@ -3143,6 +3262,7 @@ test("a mid-group end refuses to cut existing frames", () => { expect(() => producer.finishAt({ group: 5, frame: 5 })).toThrow(ProtocolViolation); expect(producer.subscribe().end()).toBeUndefined(); producer.finishAt({ group: 5, frame: 6 }); + producer.finish(); producer.close(); }); @@ -3164,6 +3284,7 @@ test.each([true, false])("a mid-group end caps later writes (declared first: %s) for (let i = 0; i < 3; i++) overfull.writeFrame({ payload: new Uint8Array([i]) }); group.abort(new Error("replaced")); expect(() => producer.writeGroup(overfull)).toThrow(ProtocolViolation); + producer.finish(); producer.close(); }); @@ -3270,6 +3391,7 @@ test("IETF provenance survives normalization on timed and untimed tracks", () => stampGroup(producer, 1, 500); producer.writeFrame({ payload: enc.encode("arrival"), timestamp: Timestamp.fromMillis(250) }); } finally { + producer.finish(); producer.close(); } }); @@ -3361,6 +3483,7 @@ test("an evicted group still bounds, and a late group below it is unverified", a const track = producer.subscribe({ maxDelay: Milli(60_000) }); expect(() => stampGroup(producer, 1, 4000)).toThrow(TimestampMismatch); stampGroup(producer, 1, 500); + producer.finish(); producer.close(); const timestamps = new Map(); @@ -3427,6 +3550,7 @@ test("a pending tail holds readers at a hole until it fills", async () => { expect((await next)?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); expect(reader.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3437,6 +3561,7 @@ test("a pending tail ends readers at the boundary once the track closes", async const next = reader.recvGroup(); expect(await settled(next)).toBe(false); + producer.finish(); producer.close(); expect(await next).toBeUndefined(); expect(await reader.closed).toBeNull(); @@ -3451,6 +3576,7 @@ test("without a declared start, the first arrival stands in for it", async () => producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3476,6 +3602,7 @@ test("a source start follows the current requested floor after an update", async expect(hooks.sourceStart(reader)).toBe(9); } finally { reader.close(); + producer.finish(); producer.close(); } }); @@ -3497,6 +3624,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" expect((await lower.recvGroup())?.sequence).toBe(3); const next = lower.recvGroup(); expect(await settled(next)).toBe(false); + producer.finish(); producer.close(); expect(await next).toBeUndefined(); }); @@ -3522,6 +3650,7 @@ test("an end after the highest group holds the reader at the hole", async () => expect((await next)?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3534,7 +3663,7 @@ test("an abort errors a pending tail short of a group", async () => { const next = reader.recvGroup(); expect(await settled(next)).toBe(false); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); await expect(next).rejects.toThrow("dropped"); }); @@ -3542,7 +3671,7 @@ test("an abort after a full tail ends cleanly", async () => { const { producer, reader, write } = pendingTail(2); write(1); write(0); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); expect((await reader.recvGroup())?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); @@ -3568,7 +3697,7 @@ for (const [waited, clean] of [ expect(await settled(next)).toBe(false); clock.set(waited); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); if (clean) expect(await next).toBeUndefined(); else await expect(next).rejects.toThrow("dropped"); } finally { @@ -3592,6 +3721,7 @@ test("an evicted group still fills a pending tail", async () => { expect(producer.subscribe({ maxDelay: Milli(60_000) }).tryRecvGroup()?.sequence).toBe(2); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); } finally { clock.installed.close(); @@ -3604,6 +3734,7 @@ test("a datagram fills its sequence in a pending tail", async () => { write(1); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3618,10 +3749,11 @@ for (const abort of [false, true]) { expect(await settled(next)).toBe(false); producer.dropGroups(0, 2, DROPPED); - if (abort) producer.close(new Error("dropped")); + if (abort) producer.abort(new Error("dropped")); expect(await next).toBeUndefined(); if (abort) expect(await reader.closed).toBeNull(); else expect(reader.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); } diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 57301876f14..7413e5a2011 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -386,7 +386,11 @@ export class Request { /** Reject the request, closing the track optionally with an error. */ reject(err?: Error): void { this.#pending.delete(this); - this.#producer.close(err); + if (err) this.#producer.abort(err); + else { + this.#producer.finish(); + this.#producer.close(); + } } } @@ -426,7 +430,7 @@ export class Staged { return this.producer.accept(this.#info); } catch (err) { // Refused, so its subscribers see the error instead of waiting on an open request. - this.producer.close(err instanceof Error ? err : new Error(String(err))); + this.producer.abort(err instanceof Error ? err : new Error(String(err))); throw err; } } @@ -435,7 +439,7 @@ export class Staged { [Symbol.dispose](): void { if (this.#done) return; this.#done = true; - this.producer.close(new Error("staged track dropped")); + this.producer.abort(new Error("staged track dropped")); } } @@ -569,6 +573,9 @@ class TrackState { // end does not end readers yet. Mirrors the Rust `awaits_tail`. owed = new Signal(false); closed = new Once(); + // The producer's outcome, settled by its close or by a reached and settled end, whichever + // is first. Only the producer's own state uses it. + outcome = new Once(); update: Signal; /** Resolved once the producer commits the immutable properties. */ info = new Signal(undefined); @@ -695,6 +702,7 @@ export class Demand { static { makeDemand = (name, wanted, state) => new Demand(name, wanted, state); hooks.demandPriority = (demand) => demand.#state.info.peek()?.priority ?? DEFAULT_PRIORITY; + hooks.demandReleased = (demand) => demand.#state.closed; } /** @@ -709,9 +717,12 @@ export class Demand { return this.#wanted; } - /** Settles once the track closes: `null` on a clean close, or the abort {@link Error}. */ + /** + * Settles once the track's outcome is final: `null` once its declared end is reached and + * settled, or on a clean release, else the abort {@link Error}. Never because readers left. + */ get closed(): GetPromise { - return this.#state.closed; + return this.#state.outcome; } } @@ -816,7 +827,8 @@ export class Producer { hooks.producerLargest = (producer) => producer.#state.largest; hooks.joinProducer = (producer) => producer.#join(); hooks.endTrack = (producer, end) => producer.#endPending(end); - hooks.releaseTrack = (producer) => producer.#close(undefined, false); + hooks.releaseTrack = (producer) => producer.#close(undefined); + hooks.trackReleased = (producer) => producer.#state.closed; hooks.startTrack = (producer, served) => { producer.#served = served; producer.#owe(); @@ -954,11 +966,13 @@ export class Producer { } /** - * Settles once the track closes: `null` on a clean close, or the abort {@link Error}. + * Settles once the track's outcome is final: `null` once its declared end is reached and + * settled (every group below it finished) or on a clean release, else the abort + * {@link Error}. The producer stays open after a settled end, serving its cache. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. */ get closed(): GetPromise { - return this.#state.closed; + return this.#state.outcome; } /** @@ -987,7 +1001,7 @@ export class Producer { } } } catch (err) { - this.close(err instanceof Error ? err : new Error(String(err))); + this.abort(err instanceof Error ? err : new Error(String(err))); throw err; } this.#state.info.set(resolved); @@ -1651,6 +1665,12 @@ export class Producer { const end = this.#state.end.peek(); const owed = end !== undefined && this.#owes(finalSequence(end)); for (const state of [this.#state, ...this.#sinks]) state.owed.set(owed); + this.#settle(); + } + + // Settle the outcome cleanly once the declared end is reached and settled. + #settle(): void { + if (this.#state.outcome.peek() === undefined && this.#settled()) this.#state.outcome.set(null); } // Whether the pending tail misses a sequence from where the feed starts up to `final`: one @@ -1720,6 +1740,7 @@ export class Producer { // the end raised it already, so neither a late group below it nor a recreated copy of it // moves it after this. #endLargest(): void { + this.#settle(); if (this.#state.largestEnded.peek() || this.#largestOpen) return; const end = this.#state.end.peek(); if (end === undefined || this.#liveEdge() < finalSequence(end)) return; @@ -1727,35 +1748,55 @@ export class Producer { } /** - * Close the track and every subscriber, mirroring the abort to their groups. Idempotent. + * Declare the track's end after the highest sequence written to its name, including by an + * earlier producer this one replaces. A no-op once an end is declared or the track closed, + * so a cleanup path can finish whatever the track's state. * - * A clean close keeps the end {@link finishAt} declared, or declares one past the highest - * sequence produced; an abort ends without one. Subscribers still draining get the - * finished groups first, then the end or the abort. An abort after the declared end - * settled (reached, with every group below it finished) is a clean close. The groups - * left behind still age out on the idle cache window, so a stale subscriber can't pin - * them. + * Not terminal: groups below the end may still be written. Readers end once the live edge + * reaches it. */ - close(abort?: Error) { - this.#close(abort, true); + finish(): void { + if (this.#state.end.peek() !== undefined || this.#state.closed.peek() !== undefined) return; + this.#finishAt({ group: this.#liveEdge(), frame: 0 }); } - // `declare` is false for a received copy whose session closed locally: a dead route, not - // finished content, so a clean close there leaves the end undeclared. - #close(abort: Error | undefined, declare: boolean) { + /** + * Abort the track and every subscriber, mirroring the error to their open groups. Terminal + * and idempotent. + * + * Subscribers still draining get the finished groups first, then the error. An abort after + * the declared end settled (reached, with every group below it finished) only releases the + * track: readers end cleanly. The groups left behind still age out on the idle cache + * window, so a stale subscriber can't pin them. + */ + abort(error: Error): void { + this.#close(error); + } + + /** + * Release this producer, like dropping the last Rust producer. Idempotent. + * + * With an end declared ({@link finish} or {@link finishAt}), the track ends there and its + * subscribers drain to it. Without one, the track aborts: nothing finished it. + */ + close(): void { + if (this.#state.end.peek() !== undefined) this.#close(undefined); + else this.#close(new StreamError(StreamCode.Cancel, { message: `track ${this.name} closed without finish` })); + } + + // `abort` is undefined for a clean close, which keeps whatever end was declared. A received + // copy whose session closed locally closes cleanly without one: a dead route, not finished + // content. + #close(abort: Error | undefined) { if (this.#state.closed.peek() !== undefined) return; // A gap past the grace was lost before the abort rather than cut off by it. if (abort && this.#tailPending) this.#arrivals.expire(now(), this.#grace()); if (abort && this.#settled()) abort = undefined; - // A replacement continues its name's timeline: ending below an earlier producer's - // groups would contradict the ones subscribers already received. - if (declare && abort === undefined && this.#state.end.peek() === undefined) { - this.#declareEnd({ group: this.#liveEdge(), frame: 0 }); - } // Nobody will finish these, so a subscriber that has not taken one yet never sees it. // Not evicted: a reader already holding one keeps its frames and sees the abort. const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; closeTrackState(this.#state, abort); + if (this.#state.outcome.peek() === undefined) this.#state.outcome.set(abort ?? null); this.#sequence.holders--; if (this.#sequence.owner === this) this.#sequence.owner = undefined; for (const { group } of this.#cache) { diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 9170bec2045..d984bb53ab5 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -89,10 +89,11 @@ async function republish(input: BroadcastConsumer): Promise { out.finish(); } // Closing the broadcast does not end a track inserted into it. + video.finish(); video.close(); } catch (err) { // An upstream abort goes downstream as an abort, never as a clean end. - video.close(err instanceof Error ? err : new Error(String(err))); + video.abort(err instanceof Error ? err : new Error(String(err))); } finally { copy.close(); } diff --git a/js/net/src/util/demand.ts b/js/net/src/util/demand.ts index 7f0789573a8..4f9a2384604 100644 --- a/js/net/src/util/demand.ts +++ b/js/net/src/util/demand.ts @@ -1,10 +1,11 @@ import { Signal } from "@moq/signals"; +import { hooks } from "../internal.ts"; import type { Demand } from "../track.ts"; /** Resolve once no subscriber wants the track, or it closed. */ export async function unwanted(demand: Demand): Promise { - while (demand.wanted.peek() !== undefined && demand.closed.peek() === undefined) { - await Signal.race(demand.wanted, demand.closed); + while (demand.wanted.peek() !== undefined && hooks.demandReleased(demand).peek() === undefined) { + await Signal.race(demand.wanted, hooks.demandReleased(demand)); } } @@ -13,8 +14,8 @@ export async function unwanted(demand: Demand): Promise { * attaches no subscriber, so demand that never arrived is not abandonment. */ export async function abandoned(demand: Demand): Promise { - while (demand.wanted.peek() === undefined && demand.closed.peek() === undefined) { - await Signal.race(demand.wanted, demand.closed); + while (demand.wanted.peek() === undefined && hooks.demandReleased(demand).peek() === undefined) { + await Signal.race(demand.wanted, hooks.demandReleased(demand)); } for (;;) { await unwanted(demand); @@ -24,5 +25,5 @@ export async function abandoned(demand: Demand): Promise { /** Whether no subscriber wants the track now, or it closed. */ export function isAbandoned(demand: Demand): boolean { - return demand.closed.peek() !== undefined || demand.wanted.peek() === undefined; + return hooks.demandReleased(demand).peek() !== undefined || demand.wanted.peek() === undefined; } diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index f7ce74a3b3c..e2fa6760c7c 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -611,6 +611,7 @@ test("a failed encoder stays failed until the capture source changes", async () } finally { encoder.close(); capture.close(); + track.finish(); track.close(); await settle(); error.mockRestore(); diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 2e040d5b01b..41de623f6e6 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -283,7 +283,10 @@ async function setup(order = new Order(), codec?: Codec, groupDuration?: Milli) const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const feed = new Feed(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 0342be1de9a..5683694769a 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -423,7 +423,7 @@ export class Encoder { // A dead encoder can't serve anyone, so the current subscriber and every later one get // the real error rather than a track that stays silent. - if (fatal) track?.close(fatal); + if (fatal) track?.abort(fatal); effect.set(this.#out.active, enabled && !!format && !!track && !fatal, false); }); @@ -745,7 +745,7 @@ export class Encoder { const cause = error instanceof Error ? error : new Error(String(error)); this.#failure.peek().set(cause); this.#out.error.set(cause); - this.#live?.track.close(cause); + this.#live?.track.abort(cause); } /** Stop and release everything this holds. */ diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index f9d90dad24d..01909482aea 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -167,6 +167,7 @@ test("subscriber demand hands the producer to the rendition and clears it when t // Closing the producer (encoder error / teardown) clears the signal, with no lingering per-subscription // effect watching it. + track?.finish(); track?.close(); await settle(); expect(rendition.track.peek()).toBeUndefined(); @@ -339,7 +340,7 @@ test("replacing a closed rendition track keeps the broadcast and publishes a fre // The encoder wrote, then failed. track.appendGroup().finish(); - track.close(new Error("encoder failed")); + track.abort(new Error("encoder failed")); video.close(); await settle(); @@ -435,7 +436,7 @@ test("registering a sibling after an encoder failure keeps the network broadcast await settle(); const track = video.track.peek(); if (!track) throw new Error("expected demanded video track"); - track.close(new Error("encoder failed")); + track.abort(new Error("encoder failed")); await settle(); broadcast.audio("audio/new").config.set(audioConfig); await settle(); @@ -600,7 +601,7 @@ test("a replacement track name is never reused, even one never served", async () const fail = async (name: string) => { const sub = net.track(name).subscribe(); await settle(); - video.track.peek()?.close(new Error("encoder failed")); + video.track.peek()?.abort(new Error("encoder failed")); video.close(); await settle(); sub.close(); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 8e5562f058f..85d7c80facc 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -366,6 +366,7 @@ export class Broadcast { const kinds = new Map(); this.#kinds = kinds; effect.cleanup(() => { + for (const { track } of tracks.values()) track.finish(); for (const { track } of tracks.values()) track.close(); if (this.#kinds === kinds) this.#kinds = new Map(); }); @@ -397,7 +398,10 @@ export class Broadcast { maxAge: Milli(Number.MAX_SAFE_INTEGER), priority: Catalog.PRIORITY.catalog, }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); this.catalog.serve(track, effect, { compression }); } } @@ -417,11 +421,12 @@ export class Broadcast { let entry = tracks.get(rendition.name); const cause = entry && errors.get(entry.owner)?.peek(); - if (entry && cause) entry.track.close(cause); + if (entry && cause) entry.track.abort(cause); const name = names[rendition.name] ?? rendition.name; if (entry && entry.track.name !== name) { // The catalog moved this rendition to a new name for new media. End the old track // cleanly on its last identity, so its subscribers follow the catalog. + entry.track.finish(); entry.track.close(); entry = undefined; } else if (entry && entry.owner !== rendition && entry.track.closed.peek() !== undefined) { @@ -448,7 +453,7 @@ export class Broadcast { if (error) { inner.run((failure) => { const cause = failure.get(error); - if (cause) served.close(cause); + if (cause) served.abort(cause); }); } inner.run((demand) => { diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index 0e46e581472..a2dd356f559 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -458,6 +458,7 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi } finally { encoder.close(); capture.close(); + track.finish(); track.close(); clock.mockRestore(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); @@ -556,6 +557,7 @@ test("an encoder torn down while a frame is in flight closes that frame", async } finally { encoder.close(); capture.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); diff --git a/js/publish/src/video/encoder-stall.test.ts b/js/publish/src/video/encoder-stall.test.ts index b0902272fbd..8441cfb25f5 100644 --- a/js/publish/src/video/encoder-stall.test.ts +++ b/js/publish/src/video/encoder-stall.test.ts @@ -109,7 +109,14 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l const order = new Order(); // An on-time audio rendition shares the broadcast, writing each capture as it happens. const audio = new Estimator(); - const rendition = { config: new Signal(undefined), track: new Signal(track), close: () => track.close() }; + const rendition = { + config: new Signal(undefined), + track: new Signal(track), + close: () => { + track.finish(); + track.close(); + }, + }; const encoder = new Encoder("video", { broadcast: { video: () => rendition, order } as never, capture: { @@ -164,6 +171,7 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); await clock.advance(Nano.zero); installed.close(); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index c5a663372b2..92b261a3941 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -116,7 +116,10 @@ test("encoding tracks encoder config in its child effect", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const broadcast = { video: () => rendition, order: new Order() }; const capture = { @@ -154,7 +157,10 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op const rendition = { config: new Signal(undefined), track: live, - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal(undefined) }, @@ -181,11 +187,13 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op expect(track.closed.peek()).toBeUndefined(); discontinuity.mockClear(); + track.finish(); track.close(); encoder.close(); expect(discontinuity).not.toHaveBeenCalled(); } finally { encoder.close(); + track.finish(); track.close(); discontinuity.mockRestore(); } @@ -203,7 +211,10 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { @@ -285,7 +296,10 @@ test("every published config fits a probe for its own codec", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { @@ -615,7 +629,10 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const source = new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }); const capture = { @@ -671,6 +688,7 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -778,7 +796,10 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, @@ -951,7 +972,10 @@ async function recordKeyframes( const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, @@ -975,6 +999,7 @@ async function recordKeyframes( } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -1219,7 +1244,10 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, @@ -1270,6 +1298,7 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -1287,7 +1316,10 @@ test("the catalog bitrate and frame rate only rise", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 6828af26c58..804fc462aae 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -561,7 +561,7 @@ export class Encoder { const cause = error instanceof Error ? error : new Error(String(error)); this.#failure.peek().set(cause); this.#out.error.set(cause); - track.close(cause); + track.abort(cause); } // Publishes the catalog for the probe: its codec string, the coded-size ceiling, and the bitrate and diff --git a/js/room/src/chat.test.ts b/js/room/src/chat.test.ts index 4b342004491..ceeece0e18a 100644 --- a/js/room/src/chat.test.ts +++ b/js/room/src/chat.test.ts @@ -45,7 +45,7 @@ test("chat rejects non-string window records and propagates track failures", asy timestamp: Time.Timestamp.now(), }); await expect(subscriber.recv()).rejects.toThrow("chat record must be a string"); - track.close(new Error("chat aborted")); + track.abort(new Error("chat aborted")); await expect(subscriber.recv()).rejects.toThrow("chat aborted"); } finally { subscriber.close(); diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index 1c7067a0854..f4d6e3a4a17 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -159,7 +159,10 @@ function serveSnapshot( maxAge: Milli(86_400_000), priority: PRIORITY, }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const producer = new Json.Snapshot.Producer({ track }); effect.cleanup(() => producer.finish()); diff --git a/js/watch/src/audio/decoder.test.ts b/js/watch/src/audio/decoder.test.ts index 4abb76e2486..80599b43212 100644 --- a/js/watch/src/audio/decoder.test.ts +++ b/js/watch/src/audio/decoder.test.ts @@ -940,6 +940,7 @@ async function livePlayback(burst: number) { }, // Rename the rendition, publishing the rest of the timeline on the new track. rename(name: string, patch: Record = {}) { + track.finish(); track.close(); track = producer.createTrack(name, { timescale: Moq.Time.Timescale.MILLI }); sequence = 0; diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index adc680ac30b..5ee4387f408 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -112,6 +112,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< subscriber.update({ maxDelay: budget(current) }); } } finally { + track.finish(); track.close(); consumer.close(); await reader; diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 58eda7488ba..d164695f3a1 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -287,6 +287,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato delivery.resolve(); abort.abort(); consumer.close(); + track.finish(); track.close(); await decode; signals.close(); diff --git a/js/watch/src/media.test.ts b/js/watch/src/media.test.ts index 9dd5b22c67b..7a5605f325c 100644 --- a/js/watch/src/media.test.ts +++ b/js/watch/src/media.test.ts @@ -53,7 +53,7 @@ for (const end of [ const consumer = new Container.Consumer(track.subscribe(), { format: new Container.Legacy.Format("data") }); try { const pending = nextMedia(consumer); - track.close(end); + track.abort(end); if (end instanceof Moq.Error.Stream) expect(await pending).toBeUndefined(); else await expect(pending).rejects.toBe(end); } finally { diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index dd4c12354a4..75393e8eceb 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -962,6 +962,7 @@ it("a renamed video track keeps the delay, so audio neither skips nor goes silen const before = { inserted, played, buffered: ring.length }; silent = 0; + (playback.track as Moq.Track.Producer).finish(); (playback.track as Moq.Track.Producer).close(); playback.switchRendition("video/1"); video.replace(playback.producer.createTrack("video/1", { timescale: Moq.Time.Timescale.MILLI })); diff --git a/test/audio-quality/clients/js/src/capture.test.ts b/test/audio-quality/clients/js/src/capture.test.ts index 9e3a0d1053e..d33382742ee 100644 --- a/test/audio-quality/clients/js/src/capture.test.ts +++ b/test/audio-quality/clients/js/src/capture.test.ts @@ -32,7 +32,7 @@ test("arrival capture stamps a later group before a stalled earlier group", asyn timestamp: Time.Timestamp.fromMicros(20_000), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(20_000)), }); - late.close(); + late.finish(); await read(late.consume()); expect(captured).toEqual([[10, 20, 1, 20]]); now = 50; @@ -40,15 +40,15 @@ test("arrival capture stamps a later group before a stalled earlier group", asyn timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(0)), }); - early.close(); + early.finish(); await waiting; expect(captured).toEqual([ [10, 20, 1, 20], [50, 0, 0, 20], ]); } finally { - early.close(); - late.close(); + early.finish(); + late.finish(); clock.mockRestore(); } }); @@ -59,7 +59,7 @@ test("arrival capture excludes duration markers", async () => { timestamp: Time.Timestamp.fromMicros(100_000), payload: Legacy.encodeFrame(new Uint8Array(), Micro(100_000)), }); - group.close(); + group.finish(); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config })) captured.push(arrival); @@ -187,7 +187,7 @@ test("closing the observer waits for in-flight groups and keeps their decode fai }); test("a reset group truncates its arrivals without failing capture", async () => { - const error = await captureGroupEnd((group) => group.close(new NetError.TooFarBehind())); + const error = await captureGroupEnd((group) => group.abort(new NetError.TooFarBehind())); expect(error).toBeUndefined(); }); @@ -203,7 +203,7 @@ test("arrival durations come from Opus packets despite missing or differently si payload: Legacy.encodeFrame(new Uint8Array([toc, 0]), Micro(timestamp)), }); } - group.close(); + group.finish(); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config })) { captured.push(arrival); @@ -221,7 +221,7 @@ test("arrival capture uses AAC's codec duration when the container leaves it imp timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(0)), }); - group.close(); + group.finish(); const aac = Catalog.AudioConfigSchema.parse({ ...config, codec: "mp4a.40.2", sampleRate: 44100 }); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config: aac })) { @@ -236,7 +236,7 @@ test("arrival capture refuses a packet with no usable duration", async () => { timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([0x7b]), Micro(0)), }); - group.close(); + group.finish(); const reader = arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config }); await expect(reader.next()).rejects.toThrow("no encoded frame duration for opus"); }); diff --git a/test/interop/clients/js-native/control.ts b/test/interop/clients/js-native/control.ts index 8493f3d4c4e..c6db7e9610e 100644 --- a/test/interop/clients/js-native/control.ts +++ b/test/interop/clients/js-native/control.ts @@ -99,12 +99,12 @@ if (mode === "status") { assert.equal((await commands.next()).value, "probe", "missing queued-request barrier"); const group = first.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const demand = second.demand(); while (demand.wanted.peek() === undefined) await demand.wanted.changed(); const next = second.appendGroup(); next.writeFrame({ payload: new Uint8Array([2]) }); - next.close(); + next.finish(); assert.equal((await commands.next()).value, "done"); console.error("request credit resumed"); } finally { diff --git a/test/interop/clients/js-native/server.ts b/test/interop/clients/js-native/server.ts index e44b127ac69..a29ca196458 100644 --- a/test/interop/clients/js-native/server.ts +++ b/test/interop/clients/js-native/server.ts @@ -31,7 +31,7 @@ const marker = origin.createBroadcast(Moq.Path.from("replacement")); const ready = marker.createTrack("ready"); const markerGroup = ready.appendGroup(); markerGroup.writeFrame({ payload: new Uint8Array([42]) }); -markerGroup.close(); +markerGroup.finish(); marker.announce(); type Socket = { @@ -173,7 +173,7 @@ if (mode === "credit") { try { const probe = firstTrack.appendGroup(); probe.writeFrame({ payload: new Uint8Array([1]) }); - probe.close(); + probe.finish(); const data = await first.recvGroup(); assert(data); assert.deepEqual((await data.readFrame())?.payload, new Uint8Array([1])); @@ -182,7 +182,7 @@ if (mode === "credit") { await queued; const next = secondTrack.appendGroup(); next.writeFrame({ payload: new Uint8Array([2]) }); - next.close(); + next.finish(); const resumed = await second.recvGroup(); assert(resumed); assert.deepEqual((await resumed.readFrame())?.payload, new Uint8Array([2])); @@ -215,7 +215,7 @@ if (mode === "credit") { while (demand.wanted.peek() === undefined) await demand.wanted.changed(); const group = video.appendGroup(); group.writeFrame({ payload: new Uint8Array(32) }); - group.close(); + group.finish(); assert.equal((await commands.next()).value, "first group"); await initial.control.writer.u53(GoAway.id); await new GoAway({ @@ -227,7 +227,7 @@ if (mode === "credit") { initial.connection.abort(); const next = video.appendGroup(); next.writeFrame({ payload: new Uint8Array(32).fill(1) }); - next.close(); + next.finish(); assert.equal((await commands.next()).value, "done"); console.error("js goaway redirect resumed"); } finally { diff --git a/test/interop/clients/js-native/tail.ts b/test/interop/clients/js-native/tail.ts index a3cdc4154ec..23f6ebb9aa5 100644 --- a/test/interop/clients/js-native/tail.ts +++ b/test/interop/clients/js-native/tail.ts @@ -62,14 +62,15 @@ try { for (let sequence = 0; sequence < GROUPS; sequence++) { const group = track.appendGroup(); if (drop && sequence === 1) { - group.close(new Error("cut")); + group.abort(new Error("cut")); continue; } for (const size of compress ? [BYTES / 2, 0, BYTES / 2] : [BYTES]) { group.writeFrame({ payload: new Uint8Array(size).fill(sequence) }); } - group.close(); + group.finish(); } + track.finish(); track.close(); } } diff --git a/test/interop/clients/js/open-gop/main.ts b/test/interop/clients/js/open-gop/main.ts index d851fc3dd8c..87d02089059 100644 --- a/test/interop/clients/js/open-gop/main.ts +++ b/test/interop/clients/js/open-gop/main.ts @@ -64,7 +64,7 @@ window.runRecovery = async (packets) => { let group: Moq.Group.Producer | undefined; for (const packet of packets) { if (group?.sequence !== packet.group) { - group?.close(); + group?.finish(); group = new Moq.Group.Producer(packet.group); track.writeGroup(group); } @@ -75,7 +75,8 @@ window.runRecovery = async (packets) => { timestamp: Moq.Time.Timestamp.fromMicros(timestamp), }); } - group?.close(); + group?.finish(); + track.finish(); track.close(); const media = producer.consume(); const config = Catalog.VideoConfigSchema.parse({ diff --git a/test/interop/clients/js/src/finish.ts b/test/interop/clients/js/src/finish.ts index 57c4b99b751..2f5344dc542 100644 --- a/test/interop/clients/js/src/finish.ts +++ b/test/interop/clients/js/src/finish.ts @@ -142,7 +142,8 @@ export function finish( const healthTrack = health.createTrack("live"); const group = healthTrack.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("session-alive") }); - group.close(); + group.finish(); + healthTrack.finish(); healthTrack.close(); health.announce(); state.ended = true; diff --git a/test/p2p/src/main.ts b/test/p2p/src/main.ts index 84dea2b2e91..bea74b9be3d 100644 --- a/test/p2p/src/main.ts +++ b/test/p2p/src/main.ts @@ -110,6 +110,7 @@ window.checkTransport = async () => { client?.abort(); server?.abort(); transport.close(); + track.finish(); track.close(); broadcast.close(); publishing.close(); diff --git a/test/receive-credit/sender.ts b/test/receive-credit/sender.ts index b2fb5d7b3cb..05e304230b9 100644 --- a/test/receive-credit/sender.ts +++ b/test/receive-credit/sender.ts @@ -60,19 +60,21 @@ try { } else if (command === "high") { const group = high.appendGroup(); group.writeFrame({ payload: new Uint8Array(window / 2).fill(2) }); - group.close(); + group.finish(); } else if (command === "after") { const group = low.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("after") }); - group.close(); + group.finish(); } else { throw new Error(`unknown command: ${command}`); } } } finally { // End the live tracks, or the close waits out its deadline for them. - partial?.close(); + partial?.finish(); + low.finish(); low.close(); + high.finish(); high.close(); await session.close(); origin.close(); diff --git a/test/video/src/captions.ts b/test/video/src/captions.ts index a28f6a03349..727ca911525 100644 --- a/test/video/src/captions.ts +++ b/test/video/src/captions.ts @@ -22,7 +22,7 @@ export function captions() { const track = producer.createTrack(name); const group = track.appendGroup(); group.writeFrame({ payload: Legacy.encodeFrame(new TextEncoder().encode(text), Micro(1_000_000)) }); - group.close(); + group.finish(); } producer.announce(); const broadcast = new Broadcast({ origin, name: Path.from("captions"), catalogFormat: "manual", catalog }); diff --git a/test/video/src/poster.ts b/test/video/src/poster.ts index 18a50b0bd11..893d70e919b 100644 --- a/test/video/src/poster.ts +++ b/test/video/src/poster.ts @@ -122,8 +122,8 @@ async function poster(audio: boolean, buffered: boolean) { }, close() { player.close(); - group.close(); - audioGroup?.close(); + group.finish(); + audioGroup?.finish(); producer.close(); origin.close(); canvas.remove(); diff --git a/test/wasm/baseline/publisher.ts b/test/wasm/baseline/publisher.ts index 2a0ade8def1..85701f07bd7 100644 --- a/test/wasm/baseline/publisher.ts +++ b/test/wasm/baseline/publisher.ts @@ -18,6 +18,7 @@ async function prepare(config: Workload): Promise { websocket: { enabled: false }, }); cleanup.push(() => { + track.finish(); track.close(); broadcast.close(); connection.abort(); @@ -31,7 +32,7 @@ async function prepare(config: Workload): Promise { const group = new Moq.Group.Producer(config.sequence); for (const [frame, bytes] of fixtures.entries()) group.writeFrame({ payload: bytes, timestamp: Moq.Time.Timestamp.fromMillis(frame) }); - group.close(); + group.finish(); track.writeGroup(group); // Keep the track and broadcast served until every reader completes. No timing arm or tail-close race. })(), diff --git a/test/wasm/src/main.ts b/test/wasm/src/main.ts index 2412828ded7..55ae14368dc 100644 --- a/test/wasm/src/main.ts +++ b/test/wasm/src/main.ts @@ -172,22 +172,25 @@ async function withPublisher( for (const frame of FIXTURE) { group.writeFrame({ payload: frame, timestamp: Moq.Time.Timestamp.now() }); } - group.close(); + group.finish(); } + track.finish(); track.close(); })(); const rejecting = (async () => { while (missingUsed.peek() === undefined && missing.closed.peek() === undefined) { await Promise.race([missingUsed.changed(), missing.closed]); } - if (missingUsed.peek() !== undefined) missing.close(new Error("no such track: missing")); + if (missingUsed.peek() !== undefined) missing.abort(new Error("no such track: missing")); })(); try { return await run(advance); } finally { stopped = true; + track.finish(); track.close(); + missing.finish(); missing.close(); broadcast.close(); // Teardown, not a graceful end: the case is over and the harness is not waiting From 66661b719171e71d6ec482551a77bfbe79ff3e7e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:39:27 -0700 Subject: [PATCH 23/58] feat(net)!: JS Lister.closed, unused rejects on close, readers drop end() Lister.ended is renamed closed. Group and broadcast Demand.unused now reject once the handle closes, as Rust's do, instead of reading a closed handle as unused. Subscriber.end() and Ordered.end() are removed: readers await finished(); publishers read the snapshot through a private hook. Co-Authored-By: Claude Opus 5.5 --- js/net/src/broadcast.test.ts | 3 ++- js/net/src/broadcast.ts | 6 ++++-- js/net/src/group.test.ts | 14 ++++++++++++++ js/net/src/group.ts | 7 +++++-- js/net/src/ietf/publisher.ts | 4 ++-- js/net/src/ietf/subscriber.ts | 4 ++-- js/net/src/ietf/tail.test.ts | 9 +++++---- js/net/src/integration.test.ts | 6 +++++- js/net/src/internal.ts | 8 ++++++++ js/net/src/listing.test.ts | 2 +- js/net/src/listing.ts | 4 ++-- js/net/src/lite/publisher.ts | 4 ++-- js/net/src/lite/subscriber.ts | 23 ++++++++++++++++------- js/net/src/lite/tail.test.ts | 10 +++++----- js/net/src/resume.test.ts | 10 +++++----- js/net/src/resume.ts | 2 +- js/net/src/tail.test.ts | 4 ++-- js/net/src/track.test.ts | 20 ++++++++++---------- js/net/src/track.ts | 18 ++---------------- 19 files changed, 93 insertions(+), 65 deletions(-) diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 09073066205..210d398fb08 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -925,7 +925,8 @@ test("broadcast closure clears active demand and subscriptions", async () => { expect(demand.used.peek()).toBe(true); broadcast.close(); expect(demand.used.peek()).toBe(false); - await demand.unused(); + // Like Rust, a closed broadcast rejects rather than reporting it unused. + await expect(demand.unused()).rejects.toThrow("broadcast is closed"); subscriber.close(); track.finish(); track.close(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 9a26745509f..30e28d94858 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -346,9 +346,11 @@ export class Demand { return this.#state.used; } - /** Wait until every track becomes unused or the broadcast closes. */ + /** Wait until every track becomes unused. Throws once the broadcast closes. */ async unused(): Promise { - while (this.#state.used.peek() && this.#state.closed.peek() === undefined) { + for (;;) { + if (this.#state.closed.peek() !== undefined) throw new Error("broadcast is closed"); + if (!this.#state.used.peek()) return; await Signal.race(this.#state.used, this.#state.closed); } } diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 95f37d45f99..020236d849e 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -29,6 +29,20 @@ test("used reflects mirror demand and unused resolves when the last reader leave expect(producer.demand().used.peek()).toBe(false); }); +test("unused rejects once the group closes, even while read", async () => { + const producer = new Producer(0); + const reader = producer.mirror(); + const pending = producer.demand().unused(); + const abort = new Error("boom"); + producer.abort(abort); + await expect(pending).rejects.toBe(abort); + + const finished = new Producer(1); + finished.finish(); + await expect(finished.demand().unused()).rejects.toThrow("group is closed"); + reader.close(); +}); + function pair(sequence: number) { const producer = new Producer(sequence); return { producer, consumer: producer.consume() }; diff --git a/js/net/src/group.ts b/js/net/src/group.ts index d9e34e2c251..7dd9f330b19 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -138,9 +138,12 @@ export class Demand { return this.#used; } - /** Wait until all mirror readers leave or the group closes. */ + /** Wait until all mirror readers leave. Throws once the group closes: its abort error, or a clean-close error. */ async unused(): Promise { - while (this.#used.peek() && this.#state.closed.peek() === undefined) { + for (;;) { + const closed = this.#state.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("group is closed"); + if (!this.#used.peek()) return; await Signal.race(this.#used, this.#state.closed); } } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 882537e4bff..baa0070f5a3 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -998,7 +998,7 @@ export class Publisher { // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what // tells the subscriber where the track ended. - const final = track.end(); + const final = hooks.subscriberEnd(track); if (ended && !publishError && final !== undefined && !streams.terminalSent) { await this.#runEndOfTrack({ requestId: msg.requestId, @@ -1184,7 +1184,7 @@ export class Publisher { // The track ends right after this group's last object, so the marker rides this // stream rather than a separate one. - const end = track.end(); + const end = hooks.subscriberEnd(track); const marked = end?.group === group.sequence && end.frame === next; if (marked) { await new Frame({ endOfTrack: true }).encode( diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index b359681defb..763c1d329c7 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -745,8 +745,8 @@ export class Subscriber { try { for (;;) { while (!lister.used.peek()) { - if (lister.ended.peek() !== undefined) return; - await race([Signal.race(lister.used, lister.ended)]); + if (lister.closed.peek() !== undefined) return; + await race([Signal.race(lister.used, lister.closed)]); } lister.abort(new Error("track listing requires moq-lite-07 or newer")); } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index 23a16bf55f8..7e4203bb3d1 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; +import { hooks } from "../internal.ts"; import { GRACE } from "../ledger.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; @@ -182,7 +183,7 @@ test("END_OF_TRACK after a group's last object ends the track after that group", await done(TRACK_ENDED, 1n); expect(await reader.recvGroup()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 4, frame: 1 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 4, frame: 1 }); }); test("END_OF_TRACK at object 0 ends the track before its group, which never exists", async () => { @@ -198,7 +199,7 @@ test("END_OF_TRACK at object 0 ends the track before its group, which never exis await done(TRACK_ENDED, 2n); expect((await reader.recvGroup())?.sequence).toBe(0); expect(await reader.recvGroup()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); }); test("a standalone END_OF_TRACK at object 5 preserves its terminal group", async () => { @@ -207,7 +208,7 @@ test("a standalone END_OF_TRACK at object 5 preserves its terminal group", async end.write(new Uint8Array([5, 0, 0x4])); end.finish(); await end.handled; - expect(reader.end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 5, frame: 5 }); await done(TRACK_ENDED, 1n); expect(await reader.recvGroup()).toBeUndefined(); }); @@ -227,7 +228,7 @@ test("a compact stream starting at object 5 is subgroup 5, refused without a gro huge.write(new Uint8Array([...new Array(9).fill(0xff), 1, 42])); huge.finish(); await huge.handled; - expect(reader.end()).toBeUndefined(); + expect(hooks.subscriberEnd(reader)).toBeUndefined(); const group = groupStream(subscriber, 6, { ...FLAGS, hasSubgroupObject: true }); group.write(object("6.0")); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 52a163525ec..9047f886e47 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -2820,7 +2820,11 @@ async function runSessionDeath( client.abort(); server.abort(); origin.close(); - return [closed, await withTimeout(groupEnd, Nano.fromMilli(Milli(2000)), "the group never ended"), track.end()]; + return [ + closed, + await withTimeout(groupEnd, Nano.fromMilli(Milli(2000)), "the group never ended"), + hooks.subscriberEnd(track), + ]; } for (const [name, protocol, version] of [ diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 14504ac7fd2..dcc47126632 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -411,6 +411,11 @@ export const hooks: { producerLargest: (producer: Producer) => Getter; /** Re-evaluate a live-edge join at this subscriber's copy. */ + /** + * The track's exclusive end as this subscriber's copy knows it now, for a publisher + * forwarding it. Readers await `finished()` instead. Assigned by `track.ts`. + */ + subscriberEnd: (subscriber: Subscriber) => Position | undefined; joinSubscriber: (subscriber: Subscriber) => void; /** Re-evaluate a live-edge join at a producer. */ joinProducer: (producer: Producer) => void; @@ -585,6 +590,9 @@ export const hooks: { joinProducer: () => { throw new Error("track.ts not loaded"); }, + subscriberEnd: () => { + throw new Error("track.ts not loaded"); + }, joinSubscriber: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index 47475f4c87f..f1bc8291c7a 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -404,5 +404,5 @@ test("a lister sees the broadcast end", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); broadcast.close(); - expect(lister.ended.peek()).toBeNull(); + expect(lister.closed.peek()).toBeNull(); }); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index 45a8d2e5342..9f56711dbc6 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -429,8 +429,8 @@ export class Lister { return this.#listing.used; } - /** Settles once the broadcast ended. */ - get ended(): GetPromise { + /** Settles once the broadcast ended, as every listing of it does. */ + get closed(): GetPromise { return this.#listing.closed; } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 089effdb8b6..96eefef6105 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1177,7 +1177,7 @@ export class Publisher { // Lite cannot name a frame, so a mid-group end rounds up past its group, whose own // stream FIN says where it stops. const boundary = () => { - const end = track.end(); + const end = hooks.subscriberEnd(track); return end ? finalSequence(end) : (hooks.latestSequence(track) ?? -1) + 1; }; @@ -1320,7 +1320,7 @@ export class Publisher { } } // A declared end goes out while lower groups are still being produced. - if (!endSent && track.end() !== undefined) { + if (!endSent && hooks.subscriberEnd(track) !== undefined) { if (!(await sendEnd())) return false; continue; } diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 1911a5a7df0..a4a2494718d 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -666,9 +666,9 @@ export class Subscriber { async #runListing(broadcast: Path.Valid, consumed: Consumed, lister: Lister) { try { for (;;) { - if (lister.ended.peek() !== undefined || this.#closed.signal.aborted) return; + if (lister.closed.peek() !== undefined || this.#closed.signal.aborted) return; if (!lister.used.peek()) { - await untilAborted(race([Signal.race(lister.used, lister.ended)]), this.#closed.signal).catch( + await untilAborted(race([Signal.race(lister.used, lister.closed)]), this.#closed.signal).catch( () => {}, ); continue; @@ -703,8 +703,8 @@ export class Subscriber { ?.ready(broadcast, "subscribe", { signal: setup, until: async () => { - while (lister.used.peek() && lister.ended.peek() === undefined) { - await Signal.race(lister.used, lister.ended); + while (lister.used.peek() && lister.closed.peek() === undefined) { + await Signal.race(lister.used, lister.closed); } }, }) @@ -1349,7 +1349,10 @@ export class Subscriber { let unused = group .demand() .unused() - .then((): typeof idle => idle); + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ); // A decode consumes its frame whenever it lands, so one outstanding across a re-arm is // kept and awaited again rather than abandoned with its frame. let pending: Promise | undefined; @@ -1365,7 +1368,10 @@ export class Subscriber { unused = group .demand() .unused() - .then((): typeof idle => idle); + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ); continue; } // Abandoned mid-group: the truncated group must never end clean. @@ -1838,7 +1844,10 @@ async function untilAbandoned(group: netGroup.Producer, step: Promise): Pr group .demand() .unused() - .then((): typeof idle => idle), + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ), ]), ); if (value !== idle) return value as T; diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index 88e2f7bed09..f3fff8cf33b 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -139,7 +139,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); expect(await reader.recvGroup()).toBeUndefined(); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); }); test("a group read across the subscribe stream's FIN is delivered whole", async () => { @@ -221,7 +221,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver await settle(); expect(ended).toBe(false); expect(reader.closed.peek()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); // The grace gives up on group 0, and the settled subscription ends the reader. now = GRACE; @@ -309,7 +309,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 0, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 0, frame: 0 }); }); // lite-05 specified an inclusive end, so there the group costs only its own stream. @@ -436,7 +436,7 @@ test("lite-07 settles a dropped range without grace", async () => { expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 3, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 3, frame: 0 }); }); for (const started of [false, true]) { @@ -474,7 +474,7 @@ test("lite-07 FIN waits for the skipped and reset final range to settle", async finished = true; }); // Wait for SUBSCRIBE_END to be decoded without advancing any timers. - while (reader.end()?.group !== 3) await Promise.resolve(); + while (hooks.subscriberEnd(reader)?.group !== 3) await Promise.resolve(); expect(finished).toBe(false); last.reset(); await last.handled; diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 8ca55fa468e..a5e41f7ff9b 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -2167,14 +2167,14 @@ test("an end a replaced copy declared holds across a swap", async () => { // b serves before its subscription learns the end. front.serve(b.consume()); await settle(); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); expect(await sub.finished()).toEqual({ group: 2, frame: 0 }); // A copy declaring another end claims different content. tb.producer.finishAt({ group: 3, frame: 0 }); await settle(); expect(sub.closed.peek()).toBeInstanceOf(Error); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); // Refused, the reader holds no copy. expect(ta.producer.demand().wanted.peek()).toBeUndefined(); expect(tb.producer.demand().wanted.peek()).toBeUndefined(); @@ -2199,7 +2199,7 @@ test("an end a replaced copy declared holds once the copy is let go in the same // A datagram read lets go of the replaced copy before its end is reported. ta.producer.finishAt({ group: 2, frame: 0 }); expect(hooks.tryRecvDatagram(sub).kind).toBe("idle"); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); tb.producer.finishAt({ group: 3, frame: 0 }); await settle(); @@ -2231,7 +2231,7 @@ test("a replaced copy whose session closed locally binds no end", async () => { tb.write(1); await settle(); expect(drain(sub)).toEqual([1]); - expect(sub.end()).toBeUndefined(); + expect(hooks.subscriberEnd(sub)).toBeUndefined(); expect(sub.closed.peek()).toBeUndefined(); sub.close(); @@ -2440,7 +2440,7 @@ test("a retained copy declaring another end after a clean end fails the reads", // The copy still holding group 1 claims another end: its content is refused. ta.producer.finishAt({ group: 5, frame: 0 }); await settle(); - expect(sub.end()).toEqual({ group: 3, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 3, frame: 0 }); await expect(sub.recvGroup()).rejects.toThrow("changed its properties"); } finally { sub.close(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 912c9b037c1..5fbac752882 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -890,7 +890,7 @@ class Reader implements ResumeSource { // refuses an end below what it received. #learnEnd(): track.Position | undefined { for (const copy of this.#copies) { - const end = copy.sub.end(); + const end = hooks.subscriberEnd(copy.sub); if (end) this.#declare(end); } const declared = this.#declared; diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index 1e0cd097928..4a78dee9a44 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -73,7 +73,7 @@ test.each([Lite.ALPN_05, Lite.ALPN_06])( video.close(); expect(await readAll(reader)).toEqual(["2"]); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 3, frame: 0 }); + expect(await reader.finished()).toEqual({ group: 3, frame: 0 }); } finally { close(); } @@ -100,7 +100,7 @@ test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_20])( expect(await readAll(reader)).toEqual(["1", "2"]); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 4, frame: 0 }); + expect(await reader.finished()).toEqual({ group: 4, frame: 0 }); // Every counted stream arrived, so nothing waited out the grace. expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); } finally { diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index f7b24c8dccc..79ebaa8ad40 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -2098,13 +2098,13 @@ test("a closed track still delivers a group parked above the cap once the cap is test("end reports the head after the highest produced sequence, datagrams included", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe(); - expect(track.end()).toBeUndefined(); + expect(hooks.subscriberEnd(track)).toBeUndefined(); producer.writeGroup(new GroupProducer(0)); producer.appendDatagram(Timestamp.fromMillis(1), enc.encode("x")); producer.finish(); producer.close(); - expect(track.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 2, frame: 0 }); }); // 0 is the only encoding for "nothing produced"; an abort declares no boundary at all. @@ -2113,12 +2113,12 @@ test("end is group 0 for an empty track and undefined after an abort", async () const emptyTrack = empty.subscribe(); empty.finish(); empty.close(); - expect(emptyTrack.end()).toEqual({ group: 0, frame: 0 }); + expect(hooks.subscriberEnd(emptyTrack)).toEqual({ group: 0, frame: 0 }); const aborted = new TrackProducer("test"); const abortedTrack = aborted.subscribe(); aborted.abort(new Error("boom")); - expect(abortedTrack.end()).toBeUndefined(); + expect(hooks.subscriberEnd(abortedTrack)).toBeUndefined(); }); test("readFrame does not livelock when a sole group finishes before the next arrives", async () => { @@ -2531,7 +2531,7 @@ test("finishAt declares an end ahead of the live edge without ending the track", expect(first?.sequence).toBe(5); producer.finishAt({ group: 8, frame: 0 }); - expect(track.end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 8, frame: 0 }); expect(await track.finished()).toEqual({ group: 8, frame: 0 }); // Not terminal: groups below the end still arrive, including a straggler. @@ -2548,10 +2548,10 @@ test("finishAt declares an end ahead of the live edge without ending the track", producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); - expect(track.end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 8, frame: 0 }); // A late subscriber sees the same end. - expect(producer.subscribe().end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(producer.subscribe())).toEqual({ group: 8, frame: 0 }); }); for (const ordered of [false, true]) { @@ -3225,7 +3225,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => // The cap excludes the only group, so nothing is within it. const reader = producer.subscribe().withRange({ end: { excluded: 5 } }); expect(await reader.largest()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 5, frame: 5 }); reader.close(); } finally { producer.finish(); @@ -3240,7 +3240,7 @@ test("a wire end repeats or refines but never moves", () => { hooks.endTrack(producer, { group: 6, frame: 0 }); hooks.endTrack(producer, { group: 5, frame: 5 }); hooks.endTrack(producer, { group: 5, frame: 5 }); - expect(producer.subscribe().end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(producer.subscribe())).toEqual({ group: 5, frame: 5 }); for (const moved of [ { group: 5, frame: 4 }, { group: 6, frame: 0 }, @@ -3260,7 +3260,7 @@ test("a mid-group end refuses to cut existing frames", () => { producer.writeGroup(group); for (let i = 0; i < 6; i++) group.writeFrame({ payload: new Uint8Array([i]) }); expect(() => producer.finishAt({ group: 5, frame: 5 })).toThrow(ProtocolViolation); - expect(producer.subscribe().end()).toBeUndefined(); + expect(hooks.subscriberEnd(producer.subscribe())).toBeUndefined(); producer.finishAt({ group: 5, frame: 6 }); producer.finish(); producer.close(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 7413e5a2011..47635c526d7 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2144,6 +2144,8 @@ export class Subscriber { static { makeSubscriber = (name, state) => new Subscriber(name, state); + hooks.subscriberEnd = (subscriber) => + subscriber.#resume ? subscriber.#resume.end() : subscriber.#state.end.peek(); hooks.joinSubscriber = (subscriber) => { if (subscriber.#resume) subscriber.#resume.join(); else if (subscriber.#state.producer) hooks.joinProducer(subscriber.#state.producer); @@ -2317,17 +2319,6 @@ export class Subscriber { return this.#state.update; } - /** - * The track's exclusive end: the one {@link Producer.finishAt} declared, which can be - * ahead of the live edge, or the head of the sequence after the highest produced once the - * producer closes cleanly (`{ group: 0, frame: 0 }` for a track that produced none). - * Groups and datagrams share the sequence namespace, so it can sit past the last group. - * Undefined until declared, and after an abort that declared none. Mirrors the Rust `end`. - */ - end(): Position | undefined { - return this.#resume ? this.#resume.end() : this.#state.end.peek(); - } - /** * Resolve with the track's exclusive end once it is known, mirroring the Rust `finished`. * @@ -2959,11 +2950,6 @@ export class Ordered { return this.#subscriber.largest(); } - /** The track's exclusive end; see {@link Subscriber.end}. */ - end(): Position | undefined { - return this.#subscriber.end(); - } - /** Resolve with the track's exclusive end once known; see {@link Subscriber.finished}. */ finished(): Promise { return this.#subscriber.finished(); From 7349c26a2b29df1ec89656a3c91510c9c99e3abe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:43:07 -0700 Subject: [PATCH 24/58] feat(net)!: JS group Consumer.close releases only its handle A consume() handle shares the producer's state, so its close() ended the group for every reader. close() now takes no error and releases only this handle: a shared handle ends its own reads, a mirror closes its own copy. Wire and track fan-out fail a reader's copy through a private hook. Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/group.ts | 5 +++-- js/net/src/group.test.ts | 17 +++++++++++++++ js/net/src/group.ts | 42 +++++++++++++++++++++++++++--------- js/net/src/ietf/publisher.ts | 4 ++-- js/net/src/internal.ts | 8 +++++++ js/net/src/lite/publisher.ts | 12 +++++------ js/net/src/resume.ts | 2 +- js/net/src/track.ts | 10 ++++----- 8 files changed, 74 insertions(+), 26 deletions(-) diff --git a/js/e2ee/src/group.ts b/js/e2ee/src/group.ts index 395dd11fbb7..b723f34be0a 100644 --- a/js/e2ee/src/group.ts +++ b/js/e2ee/src/group.ts @@ -171,7 +171,8 @@ export class Consumer { return { timestamp: frame.timestamp, plaintext }; } catch (err) { if (err instanceof Failure && (err.code === "authentication" || err.code === "identity")) { - this.#inner.close(this.#reader.terminal.fail()); + this.#reader.terminal.fail(); + this.#inner.close(); } throw err; } @@ -181,7 +182,7 @@ export class Consumer { #check(): void { const failed = this.#reader.terminal.failed; if (!failed) return; - this.#inner.close(failed); + this.#inner.close(); throw failed; } diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 020236d849e..2de98500243 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -48,6 +48,23 @@ function pair(sequence: number) { return { producer, consumer: producer.consume() }; } +test("closing a consumer releases only that handle", async () => { + const { producer, consumer } = pair(0); + const other = producer.consume(); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + + consumer.close(); + expect(await consumer.closed).toBeNull(); + expect(producer.closed.peek()).toBeUndefined(); + + // The group stays writable, and another reader sees every frame and the end. + producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); + producer.finish(); + expect((await other.readFrame())?.payload[0]).toBe(1); + expect((await other.readFrame())?.payload[0]).toBe(2); + expect(await other.readFrame()).toBeUndefined(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 7dd9f330b19..3649b1ebf7a 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -270,7 +270,7 @@ export class Producer { /** A read handle for this group. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer(this.#state, false); } /** @@ -297,7 +297,7 @@ export class Producer { const closed = this.#state.closed.peek(); if (closed !== undefined) { dst.closed.set(closed); - return makeConsumer(dst); + return makeConsumer(dst, true); } this.#mirrors ??= new Set(); @@ -313,7 +313,7 @@ export class Producer { dispose(); }); - return makeConsumer(dst); + return makeConsumer(dst, true); } /** Watch the mirror readers attached to this group. */ @@ -424,7 +424,7 @@ export class Producer { } } -let makeConsumer: (state: GroupState) => Consumer; +let makeConsumer: (state: GroupState, owned: boolean) => Consumer; // A replacement copy asked for on behalf of one consumer, settled or still out. interface Attempt { @@ -626,9 +626,13 @@ export class Consumer { #expired?: Expired; // Closed by its reader, which ends it whatever is left unread. #released = false; + // Whether this consumer alone reads `#state` (a mirror), so releasing it closes that state. + // A `consume()` handle shares the producer's state and only lets go of its own view. + #owned: boolean; - private constructor(state: GroupState) { + private constructor(state: GroupState, owned: boolean) { this.#state = state; + this.#owned = owned; this.sequence = state.sequence; } @@ -676,7 +680,8 @@ export class Consumer { } static { - makeConsumer = (state) => new Consumer(state); + makeConsumer = (state, owned) => new Consumer(state, owned); + hooks.releaseGroupReader = (group, abort) => group.#release(abort); hooks.groupTimestamp = (group) => (group.#state.unverified ? undefined : group.#state.timestamp); hooks.groupLatest = (group) => (group.#state.unverified ? undefined : group.#state.latest); hooks.unverifyGroup = (group) => { @@ -833,6 +838,7 @@ export class Consumer { // The copy left behind stops teeing into the state this consumer read. if (this.#state.closed.peek() === undefined) this.#state.closed.set(null); this.#state = next; + this.#owned = replacement.group.#owned; this.#expiry = replacement.expiry; this.#skip = index; this.#closed?.refresh(); @@ -933,7 +939,7 @@ export class Consumer { this.#closed?.refresh(); return; } - if (this.#expire()) return; + if (this.#released || this.#expire()) return; if (this.#recover?.wants(undefined)) { await this.#resume(undefined); continue; @@ -986,6 +992,8 @@ export class Consumer { return; } + // A released shared handle ends here; the group itself stays open for others. + if (this.#released) return; // Nothing buffered and the group is still open: this wait is the stall the // drift budget bounds, and the only place it applies. if (this.#expire()) continue; @@ -1016,8 +1024,17 @@ export class Consumer { return frame ? frame.payload[0] === 1 : undefined; } - /** Closes the group, optionally with an error to abort readers. Idempotent. */ - close(abort?: Error) { + /** + * Release this handle, like dropping a Rust consumer: it reads what is buffered and then + * ends. Never changes the group for other readers. Idempotent. + */ + close(): void { + this.#release(undefined); + } + + // Release this reader, failing it with `abort` if given. A mirror closes its own state, + // which drops its demand; a shared handle ends only its own view. + #release(abort: Error | undefined): void { // A copy failure recovery was continuing past ends with the abort instead. if (abort && this.#recover && this.#state.closed.peek() instanceof Error) { this.#terminal ??= abort; @@ -1028,7 +1045,12 @@ export class Consumer { this.#recover = undefined; // Closing is terminal for this reader, even with frames still unread. this.#released = true; - if (this.#state.closed.peek() === undefined) this.#state.closed.set(abort ?? null); + if (this.#owned) { + if (this.#state.closed.peek() === undefined) this.#state.closed.set(abort ?? null); + } else if (this.#state.closed.peek() === undefined) { + if (abort) this.#terminal ??= abort; + if (this.#verdict.peek() === undefined) this.#verdict.set(this.#terminal ?? null); + } this.#closed?.refresh(); } } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index baa0070f5a3..778f11c95cd 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1105,7 +1105,7 @@ export class Publisher { opened.resolve(); } if (!stream) { - group.close(failure ?? new Error("no stream slot")); + hooks.releaseGroupReader(group, failure ?? new Error("no stream slot")); return; } streams.opened += 1; @@ -1220,7 +1220,7 @@ export class Publisher { } } catch (err: unknown) { // A failed open has no stream to reset, but still ends only this group. - group.close(error(err)); + hooks.releaseGroupReader(group, error(err)); } finally { group.close(); } diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index dcc47126632..cce7a228322 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -531,6 +531,11 @@ export const hooks: { /** Keep a mirror of a group accepted unverified below its track's eviction watermark out of media-time expiry. */ unverifyGroup: (group: GroupConsumer) => void; /** Return a group's first timestamp, retained even after its first frame is read. */ + /** + * Release a group reader, failing it with `abort` if given: a mirror closes its own copy, + * a shared handle ends only itself. Assigned by `group.ts`. + */ + releaseGroupReader: (group: GroupConsumer, abort?: Error) => void; groupTimestamp: (group: GroupConsumer) => Timestamp | undefined; groupLatest: (group: GroupConsumer) => Timestamp | undefined; /** Return when a group joined its track at this hop, on the installed clock. */ @@ -716,6 +721,9 @@ export const hooks: { arriveGroup: () => { throw new Error("group.ts not loaded"); }, + releaseGroupReader: () => { + throw new Error("group.ts not loaded"); + }, groupTimestamp: () => { throw new Error("group.ts not loaded"); }, diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 96eefef6105..6036e2f1cff 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1001,7 +1001,7 @@ export class Publisher { let fetched: group.Consumer | undefined; const watch = this.#gate.watch(msg.broadcast, () => { console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`); - fetched?.close(revoked); + if (fetched) hooks.releaseGroupReader(fetched, revoked); stream.abort(revoked); }); try { @@ -1075,7 +1075,7 @@ export class Publisher { serving(group); // Revoked while the group resolved: the watch already reset the stream. if (watch.revoked()) { - group.close(unauthorized(msg.broadcast)); + hooks.releaseGroupReader(group, unauthorized(msg.broadcast)); return; } await this.#runFetchGroup(group, stream.writer, { @@ -1093,7 +1093,7 @@ export class Publisher { console.warn( `fetch error: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group} error=${reason(e)}`, ); - group?.close(e); + if (group) hooks.releaseGroupReader(group, e); stream.abort(e); } } finally { @@ -1733,13 +1733,13 @@ export class Publisher { } if (!stream) { const failed = failure ?? new Error("no stream slot"); - group.close(failed); + hooks.releaseGroupReader(group, failed); return failed; } return stream; } catch (err: unknown) { const failed = error(err); - group.close(failed); + hooks.releaseGroupReader(group, failed); return failed; } } @@ -1818,7 +1818,7 @@ export class Publisher { } catch (err: unknown) { const e = error(err); stream.reset(e); - group.close(e); + hooks.releaseGroupReader(group, e); return e; } finally { priority.remove(stream); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 5fbac752882..938e1463965 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -1042,7 +1042,7 @@ class Reader implements ResumeSource { for (const dispose of this.#dispose) dispose(); this.#dispose = []; // A recovery in flight settles with the group rather than adopting a replacement. - for (const group of this.#open) group.close(abort); + for (const group of this.#open) hooks.releaseGroupReader(group, abort); this.#open.clear(); for (const copy of this.#copies) this.#drop(copy, abort); this.#copies = []; diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 47635c526d7..28955de944a 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1116,11 +1116,11 @@ export class Producer { for (const entry of this.#cache) { const mirror = entry.mirrors.get(sink); if (mirror) { - mirror.close(abort); + hooks.releaseGroupReader(mirror, abort); entry.mirrors.delete(sink); } } - for (const group of sink.groups.peek()) group.close(abort); + for (const group of sink.groups.peek()) hooks.releaseGroupReader(group, abort); dispose(); }); } @@ -1808,7 +1808,7 @@ export class Producer { this.#uncache(entry); } for (const sink of this.#sinks) { - for (const group of sink.groups.peek()) group.close(abort); + for (const group of sink.groups.peek()) hooks.releaseGroupReader(group, abort); closeTrackState(sink, abort); } this.#sinks.clear(); @@ -2455,10 +2455,10 @@ export class Subscriber { closeTrackState(this.#state, abort); this.#resume?.close(abort); this.#state.groups.mutate((groups) => { - for (const group of groups) group.close(abort); + for (const group of groups) hooks.releaseGroupReader(group, abort); groups.length = 0; }); - this.#frameGroup?.close(abort); + if (this.#frameGroup) hooks.releaseGroupReader(this.#frameGroup, abort); this.#frameGroup = undefined; this.#state.timeline.length = 0; this.#wall.timer?.abort(); From 8553cadbf1030c8a04897829a5e7a310c8b85b17 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:45:40 -0700 Subject: [PATCH 25/58] docs(net): migration notes and JS docs for the lifecycle vocabulary Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 4 ++-- doc/lib/js/publish.md | 5 ++++- js/net/MIGRATE.md | 9 +++++++++ rs/moq-net/MIGRATE.md | 7 +++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index bb0294939f4..781ff514574 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -29,13 +29,13 @@ const broadcast = origin.createBroadcast(Moq.Path.from("chat.room")); const track = broadcast.createTrack("messages", { timescale: Moq.Time.Timescale.MILLI }); const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("hello"), timestamp: Moq.Time.Timestamp.now() }); -group.close(); +group.finish(); broadcast.announce(); ``` - **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A broadcast is invisible, locally and remotely, until `announce()`. - **Answer claimed paths with `using`.** `dynamic(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned producer live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. -- **End content by closing tracks.** A broadcast has no finished state. When the input ends, close each track cleanly; the broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. +- **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. - **Requests can pin an epoch.** `consumer.request(path, { epoch })` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs). The resolved broadcast's `epoch` names the route it came through, pinned or not. - **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 70a92171d94..c9b0756f0c7 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -95,7 +95,10 @@ signals.run((effect) => { timescale: Moq.Time.Timescale.MILLI, maxAge: Milli(86_400_000), }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const meta = new Json.Snapshot.Producer({ track }); meta.update({ value: current, at: Moq.Time.Timestamp.now() }); diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 2bdc084a66e..60eb5eae5d9 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased +### One lifecycle vocabulary ([#6750](https://github.com/moq-dev/moq/pull/6750)) + +`Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. +`Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. +`Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. +`Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. +The wire layer's `Lister.ended` is `closed`. +`Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. + ### Published names refuse navigation segments Broadcast creation and route publication now refuse literal `.` and `..` segments, including segments in the producer's root. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 4c6429b1d5e..0b1d6a0d9f1 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### One lifecycle vocabulary ([#6750](https://github.com/moq-dev/moq/pull/6750)) + +`closed()` on `group::Producer`, `group::Demand`, `track::Producer`, and `track::Demand` now waits for the outcome and returns `Result<()>`: `Ok(())` once the end settles (a finished group; a track whose declared end was reached with every group below it finished), or the abort error. Match on the result where you awaited `()` or treated `Error::Dropped` as a clean end; `poll_closed` returns `Poll>`. +`group::Producer::abort` after `finish` only releases the cache: readers keep the clean end. +`track::Producer::close()` is gone; call `abort(err)`. A session closing locally aborts its copies with `Error::Stream(StreamError::Session(SessionError::Cancel))`, which resume and origin treat as a dead route rather than an end. +`track::Producer::end()` is `finished()`. + ### `stats::Refusal` is `stats::RefusalKind`, and `time::now` is gone (#6510) Rename `moq_net::stats::Refusal` to `moq_net::stats::RefusalKind`, so it no longer shares a name with `moq_auth::Refusal`; its variants and `Refusals` are unchanged. From 59da867078de56e4e0663b6b739cbecea92bb07d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 03:09:10 -0700 Subject: [PATCH 26/58] refactor(ffi)!: MoqAllocator and allocator() in the bindings and C ABI Mirror moq_net::bandwidth::Allocator and Session::allocator() across moq-ffi, every hand-written wrapper, OBS, and moq-c (moq_session_allocator, moq_allocator_reserve, moq_allocator_close). Python and Swift move the nested Bandwidth.Consumer to a top-level BandwidthConsumer, matching Kotlin, Dart, and C++. Co-Authored-By: Claude Opus 5.5 --- cpp/obs/src/moq-output.cpp | 8 +- cpp/obs/src/moq-output.h | 2 +- dart/moq/MIGRATE.md | 4 + dart/moq/lib/src/aliases.dart | 4 +- dart/moq/lib/src/client.dart | 4 +- dart/moq/test/moq_test.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 198 +++++++++--------- go/wrapper/MIGRATE.md | 4 + go/wrapper/README.md | 2 +- go/wrapper/audio/audio.go | 6 +- go/wrapper/bandwidth_test.go | 2 +- go/wrapper/bridge.go | 2 +- go/wrapper/internal/bridge/handles.go | 4 +- go/wrapper/session.go | 18 +- go/wrapper/video/video.go | 6 +- kt/moq/MIGRATE.md | 4 + .../kotlin/dev/moq/Aliases.kt | 4 +- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 4 +- py/moq-rs/MIGRATE.md | 4 + py/moq-rs/README.md | 2 +- py/moq-rs/moq/__init__.py | 5 +- py/moq-rs/moq/audio.py | 4 +- py/moq-rs/moq/session.py | 49 ++--- py/moq-rs/moq/video.py | 4 +- rs/moq-c/MIGRATE.md | 4 + rs/moq-c/README.md | 6 +- rs/moq-c/src/audio.rs | 10 +- rs/moq-c/src/bandwidth.rs | 18 +- rs/moq-c/src/session.rs | 2 +- rs/moq-c/src/test.rs | 20 +- rs/moq-c/src/video.rs | 8 +- rs/moq-ffi/MIGRATE.md | 4 + rs/moq-ffi/src/audio.rs | 10 +- rs/moq-ffi/src/bandwidth.rs | 28 +-- rs/moq-ffi/src/session.rs | 14 +- rs/moq-ffi/src/test.rs | 14 +- rs/moq-ffi/src/video.rs | 12 +- swift/MIGRATE.md | 4 + swift/Sources/Moq/Audio.swift | 2 +- swift/Sources/Moq/Bandwidth.swift | 38 ++-- swift/Sources/Moq/Client.swift | 8 +- swift/Sources/Moq/Video.swift | 2 +- 42 files changed, 289 insertions(+), 261 deletions(-) diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index a0f081fc656..b5cc82622f8 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -253,7 +253,7 @@ void MoQOutput::OnConnect(const std::shared_ptr ¤t, moq::expected current->session = *result; { std::lock_guard lock(media_mutex); - bandwidth = current->session->bandwidth(); + allocator = current->session->allocator(); } { std::lock_guard lock(mutex); @@ -392,7 +392,7 @@ void MoQOutput::Reset() track.producer->finish(); } audio_tracks.clear(); - bandwidth.reset(); + allocator.reset(); // Close the broadcast so the origin retracts it immediately; Start() // creates a fresh one on restart. @@ -686,7 +686,7 @@ void MoQOutput::VideoData(struct encoder_packet *packet) // NOTE: Caller must hold media_mutex. void MoQOutput::Reserve(Track &track) { - if (track.reservation || !bandwidth || track.bitrate_bps == 0) + if (track.reservation || !allocator || track.bitrate_bps == 0) return; auto demand = track.producer->demand(); if (!demand) { @@ -694,7 +694,7 @@ void MoQOutput::Reserve(Track &track) track.bitrate_bps = 0; return; } - track.reservation = bandwidth->reserve(*demand, track.bitrate_bps); + track.reservation = allocator->reserve(*demand, track.bitrate_bps); } // NOTE: Caller must hold media_mutex. diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index 7e43b3b980f..49b3c274c24 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -194,7 +194,7 @@ class MoQOutput { std::map audio_tracks; // The session's allocator once it connects. It survives reconnects: grants // read empty while disconnected and resume on the next connection. - std::shared_ptr bandwidth; + std::shared_ptr allocator; // `closed()` of retired sessions still draining, which the destructor waits // out. Guarded by signal_mutex. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 473edcec354..c5354607c6a 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -8,6 +8,10 @@ How to update your code for each breaking change, newest first. `AuthToken` is `Credential`; `Auth.add` returns it. +### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`Bandwidth` is `Allocator`, and `bandwidth()` is `allocator()`. The producers' `bandwidth` option keeps its name. + ### Connection state `Moq.connect` returns a connecting native session. Replace status, connect-count, and failure polls with `session.state()` and `session.stateChanged(after: state.revision)`. `ConnectionState` carries `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index e716beccb81..dc09ec76747 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -139,12 +139,12 @@ typedef FetchGroupOptions = MoqFetchGroupOptions; typedef TrackInfo = MoqTrackInfo; /// Divides one connection's send estimate among the tracks sharing it. -typedef Bandwidth = MoqBandwidth; +typedef Allocator = MoqAllocator; /// An owned bandwidth estimate; release it to stop receive PROBE demand. typedef BandwidthConsumer = MoqBandwidthConsumer; -/// One track's standing claim on a [Bandwidth]. +/// One track's standing claim on an [Allocator]. typedef Reservation = MoqReservation; /// A snapshot of transport connection statistics. diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 6b10a5d5386..7ad433be5b9 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -235,10 +235,10 @@ final class Moq { /// The session's bandwidth allocator. /// - /// Every call returns a handle to the same registry. [Bandwidth.reserve] + /// Every call returns a handle to the same registry. [Allocator.reserve] /// a share for an app-owned encoder; dropping the [Reservation] hands /// the room back. - Bandwidth bandwidth() => session.bandwidth(); + Allocator allocator() => session.allocator(); /// The session's authorization: the grant its peer gave it, and more tokens to present. /// diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 709e28701fb..3a1e4e4faea 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -224,7 +224,7 @@ void main() { ), ).timeout(timeout); final serverSession = await accepted; - expect(client.bandwidth(), isA()); + expect(client.allocator(), isA()); final announcement = client.announced().updates().firstWhere( (event) => event is AnnounceEventStart, diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 302f8a47fb7..897aa2a401e 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -473,7 +471,7 @@ class MoqAudioProducerOptions { final MoqAudioEncoderInput input; final MoqAudioEncoderOutput output; final String? track; - final MoqBandwidth? bandwidth; + final MoqAllocator? bandwidth; MoqAudioProducerOptions({ required this.input, required this.output, @@ -504,7 +502,7 @@ class FfiConverterMoqAudioProducerOptions { ); final track = track_lifted.value; new_offset += track_lifted.bytesRead; - final bandwidth_lifted = FfiConverterOptionalMoqBandwidth.read( + final bandwidth_lifted = FfiConverterOptionalMoqAllocator.read( Uint8List.view(buf.buffer, new_offset), ); final bandwidth = bandwidth_lifted.value; @@ -525,7 +523,7 @@ class FfiConverterMoqAudioProducerOptions { FfiConverterMoqAudioEncoderInput.allocationSize(value.input) + FfiConverterMoqAudioEncoderOutput.allocationSize(value.output) + FfiConverterOptionalString.allocationSize(value.track) + - FfiConverterOptionalMoqBandwidth.allocationSize(value.bandwidth) + + FfiConverterOptionalMoqAllocator.allocationSize(value.bandwidth) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -546,7 +544,7 @@ class FfiConverterMoqAudioProducerOptions { value.track, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalMoqBandwidth.write( + new_offset += FfiConverterOptionalMoqAllocator.write( value.bandwidth, Uint8List.view(buf.buffer, new_offset), ); @@ -557,7 +555,7 @@ class FfiConverterMoqAudioProducerOptions { return FfiConverterMoqAudioEncoderInput.allocationSize(value.input) + FfiConverterMoqAudioEncoderOutput.allocationSize(value.output) + FfiConverterOptionalString.allocationSize(value.track) + - FfiConverterOptionalMoqBandwidth.allocationSize(value.bandwidth) + + FfiConverterOptionalMoqAllocator.allocationSize(value.bandwidth) + 0; } } @@ -4677,7 +4675,7 @@ class MoqVideoProducerOptions { final MoqVideoEncoderInput input; final MoqVideoEncoderOutput output; final String? track; - final MoqBandwidth? bandwidth; + final MoqAllocator? bandwidth; MoqVideoProducerOptions({ required this.input, required this.output, @@ -4708,7 +4706,7 @@ class FfiConverterMoqVideoProducerOptions { ); final track = track_lifted.value; new_offset += track_lifted.bytesRead; - final bandwidth_lifted = FfiConverterOptionalMoqBandwidth.read( + final bandwidth_lifted = FfiConverterOptionalMoqAllocator.read( Uint8List.view(buf.buffer, new_offset), ); final bandwidth = bandwidth_lifted.value; @@ -4729,7 +4727,7 @@ class FfiConverterMoqVideoProducerOptions { FfiConverterMoqVideoEncoderInput.allocationSize(value.input) + FfiConverterMoqVideoEncoderOutput.allocationSize(value.output) + FfiConverterOptionalString.allocationSize(value.track) + - FfiConverterOptionalMoqBandwidth.allocationSize(value.bandwidth) + + FfiConverterOptionalMoqAllocator.allocationSize(value.bandwidth) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -4750,7 +4748,7 @@ class FfiConverterMoqVideoProducerOptions { value.track, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalMoqBandwidth.write( + new_offset += FfiConverterOptionalMoqAllocator.write( value.bandwidth, Uint8List.view(buf.buffer, new_offset), ); @@ -4761,7 +4759,7 @@ class FfiConverterMoqVideoProducerOptions { return FfiConverterMoqVideoEncoderInput.allocationSize(value.input) + FfiConverterMoqVideoEncoderOutput.allocationSize(value.output) + FfiConverterOptionalString.allocationSize(value.track) + - FfiConverterOptionalMoqBandwidth.allocationSize(value.bandwidth) + + FfiConverterOptionalMoqAllocator.allocationSize(value.bandwidth) + 0; } } @@ -8721,29 +8719,29 @@ class FfiConverterMoqCredential { } } -abstract class MoqBandwidthInterface { +abstract class MoqAllocatorInterface { MoqReservation reserve({required MoqTrackDemand demand, required int maxBps}); } -final _MoqBandwidthFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqbandwidth(ptr, status)); +final _MoqAllocatorFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqallocator(ptr, status)); }); -class MoqBandwidth implements MoqBandwidthInterface { +class MoqAllocator implements MoqAllocatorInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqBandwidth._(this._ptr) { - _MoqBandwidthFinalizer.attach(this, _ptr, detach: this); + MoqAllocator._(this._ptr) { + _MoqAllocatorFinalizer.attach(this, _ptr, detach: this); } - factory MoqBandwidth.lift(Pointer ptr) { - return MoqBandwidth._(ptr); + factory MoqAllocator.lift(Pointer ptr) { + return MoqAllocator._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqbandwidth(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqallocator(_ptr, status), ); } @@ -8752,8 +8750,8 @@ class MoqBandwidth implements MoqBandwidthInterface { return; } _isDisposed = true; - _MoqBandwidthFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqbandwidth(_ptr, status)); + _MoqAllocatorFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqallocator(_ptr, status)); } MoqReservation reserve({ @@ -8761,7 +8759,7 @@ class MoqBandwidth implements MoqBandwidthInterface { required int maxBps, }) { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqbandwidth_reserve( + (status) => uniffi_moq_ffi_fn_method_moqallocator_reserve( uniffiClonePointer(), FfiConverterMoqTrackDemand.lower(demand), FfiConverterUInt64.lower(maxBps), @@ -8773,26 +8771,26 @@ class MoqBandwidth implements MoqBandwidthInterface { } } -class FfiConverterMoqBandwidth { - static MoqBandwidth lift(Pointer ptr) { - return MoqBandwidth.lift(ptr); +class FfiConverterMoqAllocator { + static MoqAllocator lift(Pointer ptr) { + return MoqAllocator.lift(ptr); } - static Pointer lower(MoqBandwidth value) { + static Pointer lower(MoqAllocator value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqBandwidth value) { + static int allocationSize(MoqAllocator value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqBandwidth.lift(pointer), 8); + return LiftRetVal(MoqAllocator.lift(pointer), 8); } - static int write(MoqBandwidth value, Uint8List buf) { + static int write(MoqAllocator value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -13312,8 +13310,8 @@ class FfiConverterMoqClient { } abstract class MoqSessionInterface { + MoqAllocator allocator(); MoqAuth auth(); - MoqBandwidth bandwidth(); void cancel({required int code}); UniffiRustFuture closed(); MoqOriginConsumer consume(); @@ -13356,24 +13354,24 @@ class MoqSession implements MoqSessionInterface { rustCall((status) => uniffi_moq_ffi_fn_free_moqsession(_ptr, status)); } - MoqAuth auth() { + MoqAllocator allocator() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqsession_auth( + (status) => uniffi_moq_ffi_fn_method_moqsession_allocator( uniffiClonePointer(), status, ), - FfiConverterMoqAuth.lift, + FfiConverterMoqAllocator.lift, null, ); } - MoqBandwidth bandwidth() { + MoqAuth auth() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqsession_bandwidth( + (status) => uniffi_moq_ffi_fn_method_moqsession_auth( uniffiClonePointer(), status, ), - FfiConverterMoqBandwidth.lift, + FfiConverterMoqAuth.lift, null, ); } @@ -14370,6 +14368,53 @@ class FfiConverterOptionalDouble64 { } } +class FfiConverterOptionalMoqAllocator { + static MoqAllocator? lift(RustBuffer buf) { + return FfiConverterOptionalMoqAllocator.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqAllocator.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqAllocator? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqAllocator.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqAllocator? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List( + FfiConverterOptionalMoqAllocator.allocationSize(value), + ); + FfiConverterOptionalMoqAllocator.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqAllocator? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqAllocator.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqAnchor { static MoqAnchor? lift(RustBuffer buf) { return FfiConverterOptionalMoqAnchor.read(buf.asUint8List()).value; @@ -14509,53 +14554,6 @@ class FfiConverterOptionalMoqAudioFrame { } } -class FfiConverterOptionalMoqBandwidth { - static MoqBandwidth? lift(RustBuffer buf) { - return FfiConverterOptionalMoqBandwidth.read(buf.asUint8List()).value; - } - - static LiftRetVal read(Uint8List buf) { - if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { - return LiftRetVal(null, 1); - } - final result = FfiConverterMoqBandwidth.read( - Uint8List.view(buf.buffer, buf.offsetInBytes + 1), - ); - return LiftRetVal(result.value, result.bytesRead + 1); - } - - static int allocationSize([MoqBandwidth? value]) { - if (value == null) { - return 1; - } - return FfiConverterMoqBandwidth.allocationSize(value) + 1; - } - - static RustBuffer lower(MoqBandwidth? value) { - if (value == null) { - return toRustBuffer(Uint8List.fromList([0])); - } - final buf = Uint8List( - FfiConverterOptionalMoqBandwidth.allocationSize(value), - ); - FfiConverterOptionalMoqBandwidth.write(value, buf); - return toRustBuffer(buf); - } - - static int write(MoqBandwidth? value, Uint8List buf) { - if (value == null) { - buf[0] = 0; - return 1; - } - buf[0] = 1; - return FfiConverterMoqBandwidth.write( - value, - Uint8List.view(buf.buffer, buf.offsetInBytes + 1), - ) + - 1; - } -} - class FfiConverterOptionalMoqBandwidthConsumer { static MoqBandwidthConsumer? lift(RustBuffer buf) { return FfiConverterOptionalMoqBandwidthConsumer.read( @@ -16670,7 +16668,7 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqcredential_grant( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqbandwidth( +external Pointer uniffi_moq_ffi_fn_clone_moqallocator( Pointer handle, Pointer uniffiStatus, ); @@ -16678,7 +16676,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqbandwidth( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqbandwidth( +external void uniffi_moq_ffi_fn_free_moqallocator( Pointer handle, Pointer uniffiStatus, ); @@ -16691,7 +16689,7 @@ external void uniffi_moq_ffi_fn_free_moqbandwidth( Pointer, ) >(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqbandwidth_reserve( +external Pointer uniffi_moq_ffi_fn_method_moqallocator_reserve( Pointer ptr, Pointer demand, int max_bps, @@ -18699,7 +18697,7 @@ external void uniffi_moq_ffi_fn_free_moqsession( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_auth( +external Pointer uniffi_moq_ffi_fn_method_moqsession_allocator( Pointer ptr, Pointer uniffiStatus, ); @@ -18707,7 +18705,7 @@ external Pointer uniffi_moq_ffi_fn_method_moqsession_auth( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_bandwidth( +external Pointer uniffi_moq_ffi_fn_method_moqsession_auth( Pointer ptr, Pointer uniffiStatus, ); @@ -19462,7 +19460,7 @@ external int uniffi_moq_ffi_checksum_method_moqcredential_closed(); external int uniffi_moq_ffi_checksum_method_moqcredential_grant(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbandwidth_reserve(); +external int uniffi_moq_ffi_checksum_method_moqallocator_reserve(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbandwidthconsumer_cancel(); @@ -19901,10 +19899,10 @@ external int uniffi_moq_ffi_checksum_method_moqclient_cancel(); external int uniffi_moq_ffi_checksum_method_moqclient_connect(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqsession_auth(); +external int uniffi_moq_ffi_checksum_method_moqsession_allocator(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqsession_bandwidth(); +external int uniffi_moq_ffi_checksum_method_moqsession_auth(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_cancel(); @@ -20146,7 +20144,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqaudioproducer_reanchor() != 34412) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqaudioproducer_reservation() != 43848) { + if (uniffi_moq_ffi_checksum_method_moqaudioproducer_reservation() != 50185) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqaudioproducer_write() != 48796) { @@ -20170,7 +20168,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqcredential_grant() != 23328) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbandwidth_reserve() != 27807) { + if (uniffi_moq_ffi_checksum_method_moqallocator_reserve() != 34785) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbandwidthconsumer_cancel() != 58304) { @@ -20631,10 +20629,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 5016) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 1680) { + if (uniffi_moq_ffi_checksum_method_moqsession_allocator() != 12118) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqsession_bandwidth() != 8006) { + if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 1680) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqsession_cancel() != 39476) { @@ -20710,10 +20708,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqvideoproducer_finish() != 40208) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqvideoproducer_reservation() != 64430) { + if (uniffi_moq_ffi_checksum_method_moqvideoproducer_reservation() != 25410) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqvideoproducer_set_bitrate() != 28203) { + if (uniffi_moq_ffi_checksum_method_moqvideoproducer_set_bitrate() != 19081) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqvideoproducer_write() != 6141) { diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 8f79fae97a7..dd28f2785d0 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -8,6 +8,10 @@ How to update your code for each breaking change, newest first. `AuthToken` is `Credential`; `Auth.Add` returns it. +### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`Bandwidth` is `Allocator`, and `Session.Bandwidth()` is `Session.Allocator()`. The `Bandwidth` field of `ProducerOptions` keeps its name and takes a `*moq.Allocator`. + ### Connection state `Dial` returns a connecting native client. Replace `Session.Status`, `Connects`, and `Failure` with `State()` and `StateChanged(ctx, state.Revision)`. `ConnectionState` carries `Phase`, `Connects`, `Failure`, and `Version`; `ConnectionPhase` replaces `ConnectionStatus`. Cancelling a state wait only cancels that wait. Cancel the session itself to stop its dial. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index ced19add59c..e4a288fdbb9 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -71,7 +71,7 @@ pinning a generated self-signed certificate. `Client.Session().Stats()` returns a connection stats snapshot. Fields are nil when the transport backend does not report that metric yet. -An encoder you manage can claim a share of `Client.Session().Bandwidth()` with +An encoder you manage can claim a share of `Client.Session().Allocator()` with `Reserve(demand, maxBps)`, using the track's `Demand()`. Keep one reservation and change its ceiling with `Update(maxBps)`. Call `Update(0)` before abandoning or replacing it to return its share and automatic probe ceiling immediately, even while the track diff --git a/go/wrapper/audio/audio.go b/go/wrapper/audio/audio.go index c363805c685..1baeac920f0 100644 --- a/go/wrapper/audio/audio.go +++ b/go/wrapper/audio/audio.go @@ -93,7 +93,7 @@ type ProducerOptions struct { // Track names the published track; nil derives a name from the codec. Track *string // Bandwidth reserves the track against a session allocator when non-nil. - Bandwidth *moq.Bandwidth + Bandwidth *moq.Allocator } // NewProducer opens an audio track on the catalog's broadcast and registers its rendition. @@ -102,9 +102,9 @@ func NewProducer(catalog *media.CatalogProducer, options ProducerOptions) (*Prod if err != nil { return nil, err } - var bw **ffi.MoqBandwidth + var bw **ffi.MoqAllocator if options.Bandwidth != nil { - raw := bridge.Bandwidth(options.Bandwidth) + raw := bridge.Allocator(options.Bandwidth) bw = &raw } inner, err := ffi.NewMoqAudioProducer(bridge.CatalogProducer(catalog), ffi.MoqAudioProducerOptions{Input: options.Input, Output: out, Track: options.Track, Bandwidth: bw}) diff --git a/go/wrapper/bandwidth_test.go b/go/wrapper/bandwidth_test.go index ef0407bcad6..f5fada85225 100644 --- a/go/wrapper/bandwidth_test.go +++ b/go/wrapper/bandwidth_test.go @@ -27,7 +27,7 @@ func TestReservationUpdateReleasesSubscribedTrack(t *testing.T) { // Dial returns while connecting; the grants need the live session's estimate. waitConnects(t, ctx, client.Session(), 1) // Grant assertions use a live QUIC estimate, but need no traffic or probing. - bandwidth := client.Session().Bandwidth() + bandwidth := client.Session().Allocator() broadcast, err := moq.NewBroadcastProducer() if err != nil { t.Fatal(err) diff --git a/go/wrapper/bridge.go b/go/wrapper/bridge.go index 99ca4693d1d..116ac0f1392 100644 --- a/go/wrapper/bridge.go +++ b/go/wrapper/bridge.go @@ -18,7 +18,7 @@ func init() { bridge.TrackConsumer = func(v any) *ffi.MoqTrackConsumer { return v.(*TrackConsumer).inner } bridge.GroupConsumer = func(v any) *ffi.MoqGroupConsumer { return v.(*GroupConsumer).inner } bridge.TrackDemand = func(inner *ffi.MoqTrackDemand) any { return &TrackDemand{inner: inner} } - bridge.Bandwidth = func(v any) *ffi.MoqBandwidth { return v.(*Bandwidth).inner } + bridge.Allocator = func(v any) *ffi.MoqAllocator { return v.(*Allocator).inner } bridge.Reservation = func(inner *ffi.MoqReservation) any { return &Reservation{inner: inner} } bridge.VideoDecodedFrame = func(inner *ffi.MoqVideoDecodedFrame) any { return &VideoDecodedFrame{inner: inner} } } diff --git a/go/wrapper/internal/bridge/handles.go b/go/wrapper/internal/bridge/handles.go index b05c641574a..310331fde96 100644 --- a/go/wrapper/internal/bridge/handles.go +++ b/go/wrapper/internal/bridge/handles.go @@ -22,8 +22,8 @@ var ( GroupConsumer func(any) *ffi.MoqGroupConsumer // TrackDemand wraps a handle as a *moq.TrackDemand. TrackDemand func(*ffi.MoqTrackDemand) any - // Bandwidth returns the handle behind a *moq.Bandwidth. - Bandwidth func(any) *ffi.MoqBandwidth + // Allocator returns the handle behind a *moq.Allocator. + Allocator func(any) *ffi.MoqAllocator // Reservation wraps a native reservation as *moq.Reservation. Reservation func(*ffi.MoqReservation) any // CatalogProducer returns the handle behind a *media.CatalogProducer. diff --git a/go/wrapper/session.go b/go/wrapper/session.go index 84868b3b679..069ba4fd741 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -62,12 +62,12 @@ func (s *Session) Stats() ConnectionStats { return connectionStatsFromFFI(s.inner.Stats()) } -// Bandwidth is the session's bandwidth allocator. Every call returns a handle +// Allocator is the session's bandwidth allocator. Every call returns a handle // to the same registry, so reservations made through one are visible to the // others. A client handle survives reconnects: the grant is nil while // disconnected and resumes on the next connection. -func (s *Session) Bandwidth() *Bandwidth { - return &Bandwidth{inner: s.inner.Bandwidth()} +func (s *Session) Allocator() *Allocator { + return &Allocator{inner: s.inner.Allocator()} } // Auth is the session's authorization: the grant its peer gave it, and more @@ -135,9 +135,9 @@ func (t *Credential) Closed(ctx context.Context) error { // Close withdraws the token, removing its grant from the union. func (t *Credential) Close() { t.inner.Cancel() } -// Bandwidth divides one connection's send estimate among the tracks sharing it. -type Bandwidth struct { - inner *ffi.MoqBandwidth +// Allocator divides one connection's send estimate among the tracks sharing it. +type Allocator struct { + inner *ffi.MoqAllocator } // Reserve claims up to maxBps for the track demand watches; every track @@ -145,11 +145,11 @@ type Bandwidth struct { // reserve the most the track can ever send. Reuse Reservation.Update to change // the ceiling, or call Update(0) before abandoning or replacing the reservation // to hand the room back without waiting for garbage collection. -func (b *Bandwidth) Reserve(demand *TrackDemand, maxBps uint64) *Reservation { - return &Reservation{inner: b.inner.Reserve(demand.inner, maxBps)} +func (a *Allocator) Reserve(demand *TrackDemand, maxBps uint64) *Reservation { + return &Reservation{inner: a.inner.Reserve(demand.inner, maxBps)} } -// Reservation is one track's standing claim on a Bandwidth. +// Reservation is one track's standing claim on an Allocator. // // Grant is a snapshot: nil means no estimate or no demand, so hold the current // rate, and 0 is a real zero grant. diff --git a/go/wrapper/video/video.go b/go/wrapper/video/video.go index e368d4be04b..1e5fe87da7b 100644 --- a/go/wrapper/video/video.go +++ b/go/wrapper/video/video.go @@ -82,14 +82,14 @@ type ProducerOptions struct { // Track names the published track; nil derives a name from the codec. Track *string // Bandwidth reserves the track against a session allocator when non-nil. - Bandwidth *moq.Bandwidth + Bandwidth *moq.Allocator } // NewProducer opens a video track on the catalog's broadcast and registers its rendition. func NewProducer(catalog *media.CatalogProducer, options ProducerOptions) (*Producer, error) { - var bw **ffi.MoqBandwidth + var bw **ffi.MoqAllocator if options.Bandwidth != nil { - raw := bridge.Bandwidth(options.Bandwidth) + raw := bridge.Allocator(options.Bandwidth) bw = &raw } inner, err := ffi.NewMoqVideoProducer(bridge.CatalogProducer(catalog), ffi.MoqVideoProducerOptions{Input: options.Input, Output: options.Output, Track: options.Track, Bandwidth: bw}) diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 055a168cddd..edc288c9439 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -8,6 +8,10 @@ How to update your code for each breaking change, newest first. `AuthToken` is `Credential`; `Auth.add` returns it. +### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`Bandwidth` is `Allocator`, and `bandwidth()` is `allocator()`. The producers' `bandwidth` option keeps its name. + ### Connection state `Moq.connect` returns a connecting native session. Replace `status()`, `connects()`, and failure polls with `session.state()` and `session.stateChanged(state.revision)`. `ConnectionState` carries `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index a755b05cb2e..d26c31bc320 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -148,10 +148,10 @@ typealias VideoEncoderOutput = uniffi.moq.MoqVideoEncoderOutput /** Which encoder implementation to use: automatic, hardware, software, or one named backend. */ typealias VideoEncoderKind = uniffi.moq.MoqVideoEncoderKind /** Divides one connection's send estimate among the tracks sharing it. */ -typealias Bandwidth = uniffi.moq.MoqBandwidth +typealias Allocator = uniffi.moq.MoqAllocator /** An owned bandwidth estimate; release it to stop receive PROBE demand. */ typealias BandwidthConsumer = uniffi.moq.MoqBandwidthConsumer -/** One track's standing claim on a [Bandwidth]. */ +/** One track's standing claim on an [Allocator]. */ typealias Reservation = uniffi.moq.MoqReservation /** A snapshot of transport connection statistics. */ typealias ConnectionStats = uniffi.moq.MoqConnectionStats diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 4fd1f624deb..2334c05b00f 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -54,11 +54,11 @@ class Moq internal constructor( /** * The session's bandwidth allocator. * - * Every call returns a handle to the same registry. [Bandwidth.reserve] a + * Every call returns a handle to the same registry. [Allocator.reserve] a * share for an app-owned encoder, or pass the handle to an audio or video * producer. */ - fun bandwidth(): Bandwidth = session.bandwidth() + fun allocator(): Allocator = session.allocator() /** * The session's authorization: the grant its peer gave it, and more tokens to present. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index ebd350e94eb..b3443676874 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -8,6 +8,10 @@ How to update your code for each breaking change, newest first. `moq.AuthToken` is `moq.Credential`; `Auth.add` returns it. +### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`moq.Bandwidth` is `moq.Allocator`, and `Session.bandwidth()` is `allocator()`. `Bandwidth.Consumer`, returned by `recv_bandwidth()`, is the top-level `moq.BandwidthConsumer`. The producers' `bandwidth=` argument keeps its name. + ### Connection state Client entry returns a connecting native session. Replace `Session.status()`, `connects()`, and both `failure()` polls with `state()` and `await state_changed(state.revision)`. The `ConnectionState` fields include `phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. Wait for `CONNECTED` when work requires an established transport, or await `closed()` for the terminal result. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index c2480e80e85..dd473ae3199 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -158,7 +158,7 @@ client = moq.Client( - `.publish() → OriginProducer`, `.consume() → OriginConsumer`. The wired origin sides. - `.stats() → ConnectionStats`. Snapshot RTT, bandwidth estimates, and byte/packet counters. - `.state()` and `await .state_changed(after)`. Read one lifecycle snapshot and wait with its revision; first-dial failures and reconnects share the same session. - - `.bandwidth() → Bandwidth`. Divide the send estimate between encoders and app-owned tracks. + - `.allocator() → Allocator`. Divide the send estimate between encoders and app-owned tracks. - `.auth() → Auth`. On `moq-lite-07-wip` and on moq-transport draft-17+ with the MoQ Auth extension, `.grant()` reads the peer's `Grant` and `await .add(token) → Credential` presents a fresh token without reconnecting. ### Publishing diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 3f7e04a5b71..af30ce6cdb9 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -41,7 +41,7 @@ TrackRequest, ) from .server import Request, Server, Transport -from .session import Auth, Bandwidth, Credential, Reservation, Session +from .session import Allocator, Auth, BandwidthConsumer, Credential, Reservation, Session from .subscribe import ( BroadcastConsumer, GroupConsumer, @@ -81,6 +81,7 @@ ) __all__ = [ + "Allocator", "Anchor", "Announce", "AnnounceConsumer", @@ -96,7 +97,7 @@ "AudioSampleFormat", "Auth", "Backoff", - "Bandwidth", + "BandwidthConsumer", "BroadcastConsumer", "BroadcastDynamic", "BroadcastPrepared", diff --git a/py/moq-rs/moq/audio.py b/py/moq-rs/moq/audio.py index 1d01a0925d8..ac3ad996662 100644 --- a/py/moq-rs/moq/audio.py +++ b/py/moq-rs/moq/audio.py @@ -21,7 +21,7 @@ from ._stream import _Stream from .media import Audio, AudioInit, CatalogProducer from .publish import TrackDemand -from .session import Bandwidth, Reservation +from .session import Allocator, Reservation from .subscribe import BroadcastConsumer from .types import AudioCodec as Codec from .types import AudioEncoderInput as EncoderInput @@ -112,7 +112,7 @@ def __init__( output: EncoderOutput, *, track: str | None = None, - bandwidth: Bandwidth | None = None, + bandwidth: Allocator | None = None, ) -> None: """Open an audio track on the catalog's broadcast and register its rendition.""" self._inner = MoqAudioProducer( diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 7f2df1edcae..496933c0fe8 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -2,7 +2,7 @@ from __future__ import annotations -from moq_ffi import MoqAuth, MoqBandwidth, MoqBandwidthConsumer, MoqCredential, MoqReservation, MoqSession +from moq_ffi import MoqAllocator, MoqAuth, MoqBandwidthConsumer, MoqCredential, MoqReservation, MoqSession from .origin import OriginConsumer, OriginProducer from .publish import TrackDemand @@ -99,12 +99,12 @@ def auth(self) -> Auth: connection.""" return Auth(self._inner.auth()) - def recv_bandwidth(self) -> Bandwidth.Consumer | None: + def recv_bandwidth(self) -> BandwidthConsumer | None: """Hold an owned receive-estimate consumer; releasing the last one stops PROBE.""" inner = self._inner.recv_bandwidth() - return None if inner is None else Bandwidth.Consumer(inner) + return None if inner is None else BandwidthConsumer(inner) - def bandwidth(self) -> Bandwidth: + def allocator(self) -> Allocator: """The session's bandwidth allocator. Every call returns a handle to the same registry, so reservations made @@ -112,7 +112,7 @@ def bandwidth(self) -> Bandwidth: reconnects: the grant is ``None`` while disconnected and resumes on the next connection. """ - return Bandwidth(self._inner.bandwidth()) + return Allocator(self._inner.allocator()) class Auth: @@ -172,31 +172,32 @@ def cancel(self) -> None: self._inner.cancel() -class Bandwidth: - """Divides one connection's send estimate among the tracks sharing it. +class BandwidthConsumer: + """An owned bandwidth estimate, in bits per second, returned by :meth:`Session.recv_bandwidth`.""" - Minted by :meth:`Session.bandwidth`. Clones share one reservation registry. - """ + def __init__(self, inner: MoqBandwidthConsumer) -> None: + self._inner = inner + + def rate(self) -> int | None: + """The current estimate, or None while unavailable.""" + return self._inner.rate() - class Consumer: - """An owned bandwidth estimate, in bits per second.""" + async def rate_changed(self) -> int | None: + """Wait for the estimate to change and return the new rate, raising when its producer closes.""" + return await self._inner.rate_changed() - def __init__(self, inner: MoqBandwidthConsumer) -> None: - self._inner = inner + def cancel(self) -> None: + """Release this consumer's demand, including a pending wait.""" + self._inner.cancel() - def rate(self) -> int | None: - """The current estimate, or None while unavailable.""" - return self._inner.rate() - async def rate_changed(self) -> int | None: - """Wait for the estimate to change and return the new rate, raising when its producer closes.""" - return await self._inner.rate_changed() +class Allocator: + """Divides one connection's send estimate among the tracks sharing it. - def cancel(self) -> None: - """Release this consumer's demand, including a pending wait.""" - self._inner.cancel() + Minted by :meth:`Session.allocator`. Clones share one reservation registry. + """ - def __init__(self, inner: MoqBandwidth) -> None: + def __init__(self, inner: MoqAllocator) -> None: self._inner = inner def reserve(self, demand: TrackDemand, max_bps: int) -> Reservation: @@ -210,7 +211,7 @@ def reserve(self, demand: TrackDemand, max_bps: int) -> Reservation: class Reservation: - """One track's standing claim on a :class:`Bandwidth`. + """One track's standing claim on an :class:`Allocator`. :meth:`grant` is a snapshot: ``None`` means no estimate or no demand, so hold the current rate, and ``0`` is a real zero grant. diff --git a/py/moq-rs/moq/video.py b/py/moq-rs/moq/video.py index e3fdb63b800..06aee2b6f04 100644 --- a/py/moq-rs/moq/video.py +++ b/py/moq-rs/moq/video.py @@ -20,7 +20,7 @@ from ._stream import _Stream from .media import CatalogProducer, Dimensions, Video from .publish import TrackDemand -from .session import Bandwidth, Reservation +from .session import Allocator, Reservation from .subscribe import BroadcastConsumer from .types import VideoCodec as Codec from .types import VideoDecodedFrame @@ -85,7 +85,7 @@ def __init__( output: EncoderOutput, *, track: str | None = None, - bandwidth: Bandwidth | None = None, + bandwidth: Allocator | None = None, ) -> None: """Open a video track on the catalog's broadcast and register its rendition.""" self._inner = MoqVideoProducer( diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 27fed02ed2a..cef68c477aa 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Session allocator ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`moq_session_bandwidth` is `moq_session_allocator`, `moq_bandwidth_reserve` is `moq_allocator_reserve`, and `moq_bandwidth_close` is `moq_allocator_close`, matching `moq_net::bandwidth::Allocator`. Signatures are unchanged. The receive estimate keeps its names (`moq_session_recv_bandwidth`, `moq_bandwidth_consumer_*`). + ### Sessions report a lifecycle snapshot `moq_session_connect` and `moq_session_request_accept` take a `moq_state_callback` instead of a `moq_status_callback`: `void on_state(void *user_data, const moq_connection_state *state)`. diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index a9d616eb56a..1d2d00d6618 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -33,9 +33,9 @@ int32_t moq_log_level(const char *level, uintptr_t level_len); int32_t moq_session_connect(const char *url, uintptr_t url_len, const moq_client_config *config, uint32_t origin_publish, uint32_t origin_consume, moq_state_callback on_state, void *user_data); moq_client_config moq_client_defaults(void); int32_t moq_session_close(uint32_t session); -int32_t moq_session_bandwidth(uint32_t session); -int32_t moq_bandwidth_reserve(uint32_t bandwidth, uint32_t track, uint64_t max_bps); -int32_t moq_bandwidth_close(uint32_t bandwidth); +int32_t moq_session_allocator(uint32_t session); +int32_t moq_allocator_reserve(uint32_t allocator, uint32_t track, uint64_t max_bps); +int32_t moq_allocator_close(uint32_t allocator); int32_t moq_reservation_grant(uint32_t reservation, uint64_t *bps, bool *present); int32_t moq_reservation_update(uint32_t reservation, uint64_t max_bps); int32_t moq_reservation_close(uint32_t reservation); diff --git a/rs/moq-c/src/audio.rs b/rs/moq-c/src/audio.rs index d8cb10bc28f..eb1f9508795 100644 --- a/rs/moq-c/src/audio.rs +++ b/rs/moq-c/src/audio.rs @@ -316,7 +316,7 @@ impl Audio { /// - `name` must point to `name_len` bytes of UTF-8. /// - `input` / `output` must point to fully populated structs. /// - `output->codec` must point to `output->codec_len` bytes of UTF-8. -/// - `bandwidth` is a handle from [`crate::moq_session_bandwidth`], or 0 to leave the +/// - `allocator` is a handle from [`crate::moq_session_allocator`], or 0 to leave the /// configured bitrate unclaimed. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_encode_audio( @@ -325,7 +325,7 @@ pub unsafe extern "C" fn moq_encode_audio( name_len: usize, input: *const moq_audio_encoder_input, output: *const moq_audio_encoder_output, - bandwidth: u32, + allocator: u32, ) -> i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; @@ -357,13 +357,13 @@ pub unsafe extern "C" fn moq_encode_audio( options.config.frame_duration = Duration::from_micros(micros.into()); } - let bandwidth = ffi::parse_id_optional(bandwidth)?; + let allocator = ffi::parse_id_optional(allocator)?; let mut state = State::lock(); - if let Some(id) = bandwidth { + if let Some(id) = allocator { options.bandwidth = state.bandwidth.allocator(id)?; } let State { publish, audio, .. } = &mut *state; - audio.publish(publish.target(broadcast)?, encoder_input, options, bandwidth.is_some()) + audio.publish(publish.target(broadcast)?, encoder_input, options, allocator.is_some()) }) } diff --git a/rs/moq-c/src/bandwidth.rs b/rs/moq-c/src/bandwidth.rs index 2ee23a081a0..d833b191b09 100644 --- a/rs/moq-c/src/bandwidth.rs +++ b/rs/moq-c/src/bandwidth.rs @@ -51,22 +51,22 @@ impl Bandwidth { /// /// Returns a non-zero handle, or a negative error if the session is unknown. #[unsafe(no_mangle)] -pub extern "C" fn moq_session_bandwidth(session: u32) -> i32 { +pub extern "C" fn moq_session_allocator(session: u32) -> i32 { ffi::enter(move || { let session = ffi::parse_id(session)?; let mut state = State::lock(); - let allocator = state.session.bandwidth(session)?; + let allocator = state.session.allocator(session)?; state.bandwidth.insert(allocator) }) } -/// Release a bandwidth handle. Reservations taken against it stay until they +/// Release an allocator handle. Reservations taken against it stay until they /// themselves are closed (or the session's allocator is gone). #[unsafe(no_mangle)] -pub extern "C" fn moq_bandwidth_close(bandwidth: u32) -> i32 { +pub extern "C" fn moq_allocator_close(allocator: u32) -> i32 { ffi::enter(move || { - let bandwidth = ffi::parse_id(bandwidth)?; - State::lock().bandwidth.close(bandwidth) + let allocator = ffi::parse_id(allocator)?; + State::lock().bandwidth.close(allocator) }) } @@ -76,13 +76,13 @@ pub extern "C" fn moq_bandwidth_close(bandwidth: u32) -> i32 { /// ever send. Drop the reservation with [`moq_reservation_close`] to hand the /// room back. #[unsafe(no_mangle)] -pub extern "C" fn moq_bandwidth_reserve(bandwidth: u32, track: u32, max_bps: u64) -> i32 { +pub extern "C" fn moq_allocator_reserve(allocator: u32, track: u32, max_bps: u64) -> i32 { ffi::enter(move || { - let bandwidth = ffi::parse_id(bandwidth)?; + let allocator = ffi::parse_id(allocator)?; let track = ffi::parse_id(track)?; let mut state = State::lock(); let demand = state.publish.track_demand(track)?; - state.bandwidth.reserve(bandwidth, &demand, max_bps) + state.bandwidth.reserve(allocator, &demand, max_bps) }) } diff --git a/rs/moq-c/src/session.rs b/rs/moq-c/src/session.rs index 878aa176a03..7841b8552e7 100644 --- a/rs/moq-c/src/session.rs +++ b/rs/moq-c/src/session.rs @@ -287,7 +287,7 @@ impl Session { /// which the session probes for. /// /// Errors with [`Error::Offline`] for an accepted session still in SETUP. - pub fn bandwidth(&self, id: Id) -> Result { + pub fn allocator(&self, id: Id) -> Result { match self.link(id)? { Link::Dialed { connection, .. } => Ok(connection.allocator()), Link::Accepted(accepted) => Ok(accepted.as_ref().ok_or(Error::Offline)?.session.allocator()), diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 731d4bc3920..21043a1d4db 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5540,8 +5540,8 @@ fn bandwidth_reservations_split_the_estimate() { let (first_sub, first_cb) = subscribe_named_track(consume, b"a"); let (second_sub, second_cb) = subscribe_named_track(consume, b"b"); - let first = id(moq_bandwidth_reserve(bandwidth, first_track, 4_000_000)); - let second = id(moq_bandwidth_reserve(bandwidth, second_track, 2_000_000)); + let first = id(moq_allocator_reserve(bandwidth, first_track, 4_000_000)); + let second = id(moq_allocator_reserve(bandwidth, second_track, 2_000_000)); estimate .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); @@ -5558,7 +5558,7 @@ fn bandwidth_reservations_split_the_estimate() { assert_eq!(grant(second), Some(2_000_000)); assert_eq!(moq_reservation_close(second), 0); - assert_eq!(moq_bandwidth_close(bandwidth), 0); + assert_eq!(moq_allocator_close(bandwidth), 0); assert_eq!(moq_consume_track_cancel(first_sub), 0); assert_eq!(moq_consume_track_cancel(second_sub), 0); let _ = first_cb.recv_terminal(); @@ -5592,16 +5592,16 @@ fn bandwidth_handles_share_the_registry() { let (first_sub, first_cb) = subscribe_named_track(consume, b"a"); let (second_sub, second_cb) = subscribe_named_track(consume, b"b"); - let reserved = id(moq_bandwidth_reserve(first, first_track, 4_000_000)); + let reserved = id(moq_allocator_reserve(first, first_track, 4_000_000)); assert_eq!(wait_grant(reserved), 3_000_000); - let other = id(moq_bandwidth_reserve(second, second_track, 4_000_000)); + let other = id(moq_allocator_reserve(second, second_track, 4_000_000)); assert_eq!(wait_grant(other), 1_500_000); assert_eq!(grant(reserved), Some(1_500_000)); assert_eq!(moq_reservation_close(reserved), 0); assert_eq!(moq_reservation_close(other), 0); - assert_eq!(moq_bandwidth_close(first), 0); - assert_eq!(moq_bandwidth_close(second), 0); + assert_eq!(moq_allocator_close(first), 0); + assert_eq!(moq_allocator_close(second), 0); assert_eq!(moq_consume_track_cancel(first_sub), 0); assert_eq!(moq_consume_track_cancel(second_sub), 0); let _ = first_cb.recv_terminal(); @@ -5658,7 +5658,7 @@ fn encode_video_bitrate_caps_the_reservation() { assert_eq!(moq_consume_track_cancel(sub), 0); let _ = cb.recv_terminal(); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_bandwidth_close(bandwidth), 0); + assert_eq!(moq_allocator_close(bandwidth), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); @@ -5756,8 +5756,8 @@ fn server_accepts_a_session() { let mut stats: moq_connection_stats = unsafe { std::mem::zeroed() }; assert_eq!(unsafe { moq_session_stats(session, &mut stats) }, 0); - let bandwidth = id(moq_session_bandwidth(session)); - assert_eq!(moq_bandwidth_close(bandwidth), 0); + let bandwidth = id(moq_session_allocator(session)); + assert_eq!(moq_allocator_close(bandwidth), 0); // The broadcast the server publishes reaches the client. A client only asks the // server about what it watches, so wait on the path rather than request it once. diff --git a/rs/moq-c/src/video.rs b/rs/moq-c/src/video.rs index 32508928e39..b3844f810ee 100644 --- a/rs/moq-c/src/video.rs +++ b/rs/moq-c/src/video.rs @@ -723,14 +723,14 @@ unsafe fn encoder_kind(output: &moq_video_encoder_output) -> Resultencoder` must point to `output->encoder_len` bytes of UTF-8 when /// `output->kind` is `MOQ_VIDEO_ENCODER_KIND_NAMED`. -/// - `bandwidth` is a handle from [`crate::moq_session_bandwidth`], or 0 to hold the +/// - `allocator` is a handle from [`crate::moq_session_allocator`], or 0 to hold the /// configured bitrate regardless of congestion. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_encode_video( broadcast: u32, input: *const moq_video_encoder_input, output: *const moq_video_encoder_output, - bandwidth: u32, + allocator: u32, ) -> i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; @@ -759,9 +759,9 @@ pub unsafe extern "C" fn moq_encode_video( let rendition = block_on(config.probe())?; let encoder = block_on(moq_video::encode::Sink::open(&config))?; - let bandwidth = ffi::parse_id_optional(bandwidth)?; + let allocator = ffi::parse_id_optional(allocator)?; let mut state = State::lock(); - let allocator = bandwidth.map(|id| state.bandwidth.allocator(id)).transpose()?; + let allocator = allocator.map(|id| state.bandwidth.allocator(id)).transpose()?; let State { publish, video, .. } = &mut *state; let id = video.publish(publish.target(broadcast)?, format, &config, rendition.clone(), encoder)?; if let Some(allocator) = allocator.as_ref() { diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 5455df0a1a4..c818610938a 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -9,6 +9,10 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it `MoqAuthToken` is `MoqCredential`, the core API's name for one presented credential. +### `MoqAllocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`MoqBandwidth` is `MoqAllocator`, and `MoqSession::bandwidth()` is `allocator()`, matching `moq_net::bandwidth::Allocator`. The `bandwidth` option field on audio and video producers keeps its name. + ### Connection state `MoqClient::connect` returns a connecting session without waiting for its first dial to succeed, natively and in the browser. Read `MoqSession::state()` and pass its `revision` to `state_changed(after)` to observe first-dial failures and reconnects. `MoqConnectionState` carries `phase`, `connects`, `failure`, and `version` together. These replace `status()`, `connects()`, and both `failure()` polls. Cancel the returned session to stop dialing; cancelling or releasing the client does not close it. diff --git a/rs/moq-ffi/src/audio.rs b/rs/moq-ffi/src/audio.rs index 112039bf169..cd91bea0c3d 100644 --- a/rs/moq-ffi/src/audio.rs +++ b/rs/moq-ffi/src/audio.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use crate::bandwidth::{MoqBandwidth, MoqReservation}; +use crate::bandwidth::{MoqAllocator, MoqReservation}; use crate::consumer::MoqBroadcastConsumer; use crate::demand::MoqTrackDemand; use crate::error::MoqError; @@ -139,7 +139,7 @@ pub struct MoqAudioProducerOptions { pub track: Option, /// Session bandwidth allocator; absent reserves no share. #[uniffi(default = None)] - pub bandwidth: Option>, + pub bandwidth: Option>, } impl MoqAudioEncoderOutput { @@ -344,7 +344,7 @@ pub struct MoqAudioProducer { inner: std::sync::Mutex>, reservation: std::sync::Mutex>>, /// Held so the reservation's registry outlives extra bandwidth handles. - _bandwidth: Option>, + _bandwidth: Option>, finished: crate::producer::Finished, } @@ -386,7 +386,7 @@ impl MoqAudioProducer { } /// This encoder's bandwidth reservation, if it was published against a - /// [`MoqBandwidth`]. Dropping the handle does not release the claim; the + /// [`MoqAllocator`]. Dropping the handle does not release the claim; the /// producer holds it until [`finish`](Self::finish). pub fn reservation(&self) -> Option> { self.reservation.lock().unwrap().clone() @@ -455,7 +455,7 @@ impl MoqAudioProducer { moq_audio::encode::Config::from_input(moq_audio::encode::Codec::Aac, &rated).frame_duration; } if let Some(bandwidth) = &bandwidth { - options.bandwidth = bandwidth.allocator().clone(); + options.bandwidth = bandwidth.inner().clone(); } let (producer, finished) = broadcast.with_state(|state| { diff --git a/rs/moq-ffi/src/bandwidth.rs b/rs/moq-ffi/src/bandwidth.rs index 5d55722cc46..1d5a25fcbb6 100644 --- a/rs/moq-ffi/src/bandwidth.rs +++ b/rs/moq-ffi/src/bandwidth.rs @@ -8,21 +8,21 @@ use crate::error::MoqError; /// Divides one connection's send estimate among the tracks sharing it. /// -/// Minted by [`MoqSession::bandwidth`](crate::session::MoqSession::bandwidth). +/// Minted by [`MoqSession::allocator`](crate::session::MoqSession::allocator). /// Clones share one reservation registry, so two handles from the same session /// see each other's claims. #[derive(uniffi::Object)] -pub struct MoqBandwidth { +pub struct MoqAllocator { allocator: moq_net::bandwidth::Allocator, } -impl MoqBandwidth { +impl MoqAllocator { pub(crate) fn new(allocator: moq_net::bandwidth::Allocator) -> Self { Self { allocator } } #[cfg(all(feature = "audio", not(target_arch = "wasm32")))] - pub(crate) fn allocator(&self) -> &moq_net::bandwidth::Allocator { + pub(crate) fn inner(&self) -> &moq_net::bandwidth::Allocator { &self.allocator } @@ -35,7 +35,7 @@ impl MoqBandwidth { } #[uniffi::export] -impl MoqBandwidth { +impl MoqAllocator { /// Reserve up to `max_bps` for the track `demand` watches, returning the reservation. /// /// Every track producer hands out its `demand()`, so raw, media, and JSON @@ -49,7 +49,7 @@ impl MoqBandwidth { } } -/// One track's standing claim on a [`MoqBandwidth`]. +/// One track's standing claim on a [`MoqAllocator`]. /// /// [`grant`](Self::grant) is [`moq_net::bandwidth::Reservation::peek`]: `None` /// means no estimate or no demand, so hold the current rate, and `Some(0)` is a @@ -94,8 +94,8 @@ mod tests { use crate::producer::{MoqBroadcastProducer, MoqTrackProducer}; - fn bandwidth(estimate: &moq_net::bandwidth::Producer) -> Arc { - Arc::new(MoqBandwidth::new(moq_net::bandwidth::Allocator::new( + fn allocator(estimate: &moq_net::bandwidth::Producer) -> Arc { + Arc::new(MoqAllocator::new(moq_net::bandwidth::Allocator::new( estimate.consume(), ))) } @@ -108,7 +108,7 @@ mod tests { #[test] fn concurrent_reservations_split_the_estimate() { let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = bandwidth(&estimate); + let bandwidth = allocator(&estimate); let broadcast = MoqBroadcastProducer::new().unwrap(); let first = named_track(&broadcast, "a"); @@ -139,8 +139,8 @@ mod tests { .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); let allocator = moq_net::bandwidth::Allocator::new(estimate.consume()); - let first = Arc::new(MoqBandwidth::new(allocator.clone())); - let second = Arc::new(MoqBandwidth::new(allocator)); + let first = Arc::new(MoqAllocator::new(allocator.clone())); + let second = Arc::new(MoqAllocator::new(allocator)); let broadcast = MoqBroadcastProducer::new().unwrap(); let track = named_track(&broadcast, "a"); @@ -161,7 +161,7 @@ mod tests { estimate .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); - let bandwidth = bandwidth(&estimate); + let bandwidth = allocator(&estimate); let broadcast = MoqBroadcastProducer::new().unwrap(); let first = named_track(&broadcast, "a"); @@ -182,7 +182,7 @@ mod tests { #[test] fn a_reservation_reports_none_while_disconnected() { let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = bandwidth(&estimate); + let bandwidth = allocator(&estimate); let broadcast = MoqBroadcastProducer::new().unwrap(); let track = named_track(&broadcast, "a"); @@ -209,7 +209,7 @@ mod tests { use std::sync::atomic::Ordering; let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = bandwidth(&estimate); + let bandwidth = allocator(&estimate); let broadcast = MoqBroadcastProducer::new().unwrap(); let track = named_track(&broadcast, "a"); let reserved = bandwidth.reserve(&track.demand().unwrap(), 4_000_000); diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 8b7e974f476..90277a6be84 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use url::Url; use crate::auth::MoqAuth; -use crate::bandwidth::MoqBandwidth; +use crate::bandwidth::MoqAllocator; use crate::error::MoqError; use crate::ffi::Task; use crate::origin::{MoqOriginConsumer, MoqOriginProducer}; @@ -944,9 +944,9 @@ pub struct MoqSession { closed: Task, publisher: Arc, consumer: Arc, - /// One allocator for the session. Every [`bandwidth`](Self::bandwidth) handle + /// One allocator for the session. Every [`allocator`](Self::allocator) handle /// clones it, so they share one reservation registry. - bandwidth: Arc, + allocator: Arc, } impl MoqSession { @@ -990,14 +990,14 @@ impl MoqSession { // is where the remote's broadcasts land (read via its consumer view). let publisher = Arc::new(MoqOriginProducer::from_inner(publish)); let consumer = Arc::new(MoqOriginConsumer::from_inner(subscribe)); - let bandwidth = Arc::new(MoqBandwidth::new(Self::mint_allocator(&inner))); + let allocator = Arc::new(MoqAllocator::new(Self::mint_allocator(&inner))); Self { inner: inner.clone(), shutdown: Default::default(), closed: Task::new(inner.clone()), publisher, consumer, - bandwidth, + allocator, } } @@ -1190,8 +1190,8 @@ impl MoqSession { /// reconnects: the grant is `None` while disconnected and resumes on the /// next connection. An accepted session with no congestion estimate mints /// an unlimited allocator, which reports `None` for every reservation. - pub fn bandwidth(&self) -> Arc { - self.bandwidth.clone() + pub fn allocator(&self) -> Arc { + self.allocator.clone() } /// The session's authorization: the grant its peer gave it, and more tokens to present. diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index e1e4f8c598a..549938cf838 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -498,7 +498,7 @@ async fn media_track_reserves_through_its_demand() { estimate .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); - let bandwidth = crate::bandwidth::MoqBandwidth::new(moq_net::bandwidth::Allocator::new(estimate.consume())); + let bandwidth = crate::bandwidth::MoqAllocator::new(moq_net::bandwidth::Allocator::new(estimate.consume())); let broadcast = MoqBroadcastProducer::new().unwrap(); let media = MoqMediaTrackProducer::audio( @@ -6568,11 +6568,11 @@ async fn cancelled_state_wait_does_not_swallow_the_next_transition() { #[cfg(feature = "video")] #[tokio::test] async fn video_encoder_follows_a_shrinking_grant() { - use crate::bandwidth::MoqBandwidth; + use crate::bandwidth::MoqAllocator; use crate::video::*; let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = std::sync::Arc::new(MoqBandwidth::new(moq_net::bandwidth::Allocator::new( + let bandwidth = std::sync::Arc::new(MoqAllocator::new(moq_net::bandwidth::Allocator::new( estimate.consume(), ))); @@ -6661,10 +6661,10 @@ fn software_camera(bitrate: u64) -> (crate::video::MoqVideoEncoderInput, crate:: #[cfg(feature = "video")] #[tokio::test] async fn dropping_a_video_producer_stops_the_rate_follower() { - use crate::bandwidth::MoqBandwidth; + use crate::bandwidth::MoqAllocator; let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = std::sync::Arc::new(MoqBandwidth::new(moq_net::bandwidth::Allocator::new( + let bandwidth = std::sync::Arc::new(MoqAllocator::new(moq_net::bandwidth::Allocator::new( estimate.consume(), ))); let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -6690,10 +6690,10 @@ async fn dropping_a_video_producer_stops_the_rate_follower() { #[cfg(feature = "video")] #[tokio::test] async fn set_bitrate_caps_a_later_bandwidth_grant() { - use crate::bandwidth::MoqBandwidth; + use crate::bandwidth::MoqAllocator; let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = std::sync::Arc::new(MoqBandwidth::new(moq_net::bandwidth::Allocator::new( + let bandwidth = std::sync::Arc::new(MoqAllocator::new(moq_net::bandwidth::Allocator::new( estimate.consume(), ))); let broadcast = MoqBroadcastProducer::new().unwrap(); diff --git a/rs/moq-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index 01d95c4572f..5c0c78ffc55 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use tokio::sync::oneshot; -use crate::bandwidth::{MoqBandwidth, MoqReservation}; +use crate::bandwidth::{MoqAllocator, MoqReservation}; use crate::consumer::MoqBroadcastConsumer; use crate::demand::MoqTrackDemand; use crate::error::MoqError; @@ -151,7 +151,7 @@ pub struct MoqVideoProducerOptions { pub track: Option, /// Session bandwidth allocator; absent keeps the configured bitrate. #[uniffi(default = None)] - pub bandwidth: Option>, + pub bandwidth: Option>, } /// How the encoded stream is divided into independently decodable groups. @@ -432,7 +432,7 @@ pub struct MoqVideoProducer { /// Last bitrate applied by the follow loop or [`set_bitrate`](Self::set_bitrate). applied: Arc, /// Held so the reservation's registry outlives extra bandwidth handles. - _bandwidth: Option>, + _bandwidth: Option>, finished: crate::producer::Finished, } @@ -511,7 +511,7 @@ impl MoqVideoProducer { /// [`bitrate`](MoqVideoEncoderOutput::bitrate) to the highest you will ask for /// and adapt downwards from there. /// - /// When this producer was published against a [`MoqBandwidth`], the reservation + /// When this producer was published against a [`MoqAllocator`], the reservation /// and follower ceiling move with it, so a later grant cannot retune above this /// value. /// @@ -537,7 +537,7 @@ impl MoqVideoProducer { } /// This encoder's bandwidth reservation, if it was published against a - /// [`MoqBandwidth`]. Dropping the handle does not release the claim; the + /// [`MoqAllocator`]. Dropping the handle does not release the claim; the /// producer holds it until [`finish`](Self::finish). pub fn reservation(&self) -> Option> { self.reservation.lock().unwrap().clone() @@ -1178,7 +1178,7 @@ mod finish_tests { #[tokio::test] async fn finish_ends_the_track_after_a_rate_follower_panic() { let estimate = moq_net::bandwidth::Producer::new(); - let bandwidth = Arc::new(MoqBandwidth::new(moq_net::bandwidth::Allocator::new( + let bandwidth = Arc::new(MoqAllocator::new(moq_net::bandwidth::Allocator::new( estimate.consume(), ))); let broadcast = crate::producer::MoqBroadcastProducer::new().unwrap(); diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 2632de0b841..e57005d98d4 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -8,6 +8,10 @@ How to update your code for each breaking change, newest first. `AuthToken` is `Credential`; `Auth.add(token:)` returns it. +### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) + +`Bandwidth` is `Allocator`, and `Session.bandwidth()` is `allocator()`. `Bandwidth.Consumer`, returned by `recvBandwidth()`, is the top-level `BandwidthConsumer`. The producers' `bandwidth:` parameter keeps its name. + ### Connection state `Client.connect(to:)` returns a connecting native session. Replace `status()`, `connects()`, and both `failure()` polls with `state()` and `stateChanged(after: state.revision)`. Use `ConnectionState.phase`, `connects`, `failure`, and `version`; `ConnectionPhase` replaces `ConnectionStatus`. diff --git a/swift/Sources/Moq/Audio.swift b/swift/Sources/Moq/Audio.swift index 719aabdf1ff..20917a3d5c9 100644 --- a/swift/Sources/Moq/Audio.swift +++ b/swift/Sources/Moq/Audio.swift @@ -30,7 +30,7 @@ public enum Audio { input: EncoderInput, output: EncoderOutput, track: String? = nil, - bandwidth: Bandwidth? = nil + bandwidth: Allocator? = nil ) throws { ffi = try MoqAudioProducer( catalog: catalog.ffi, diff --git a/swift/Sources/Moq/Bandwidth.swift b/swift/Sources/Moq/Bandwidth.swift index 1bb855b52c5..01cec96e3a7 100644 --- a/swift/Sources/Moq/Bandwidth.swift +++ b/swift/Sources/Moq/Bandwidth.swift @@ -1,29 +1,29 @@ import MoqFFI -/// Divides one connection's send estimate among the tracks sharing it. -/// -/// Minted by `Session.bandwidth`. Handles from the same session share one -/// reservation registry. -public final class Bandwidth: Sendable { - /// An owned bandwidth estimate, in bits per second. - public final class Consumer: Sendable { - let ffi: MoqBandwidthConsumer +/// An owned bandwidth estimate, in bits per second, returned by `Session.recvBandwidth()`. +public final class BandwidthConsumer: Sendable { + let ffi: MoqBandwidthConsumer - init(_ ffi: MoqBandwidthConsumer) { self.ffi = ffi } + init(_ ffi: MoqBandwidthConsumer) { self.ffi = ffi } - /// The current estimate, or nil while unavailable. - public func rate() -> UInt64? { ffi.rate() } + /// The current estimate, or nil while unavailable. + public func rate() -> UInt64? { ffi.rate() } - /// Wait for the estimate to change and return the new rate, throwing when its producer closes. - public func rateChanged() async throws -> UInt64? { try await ffi.rateChanged() } + /// Wait for the estimate to change and return the new rate, throwing when its producer closes. + public func rateChanged() async throws -> UInt64? { try await ffi.rateChanged() } - /// Release this consumer's demand, including a pending wait. - public func cancel() { ffi.cancel() } - } + /// Release this consumer's demand, including a pending wait. + public func cancel() { ffi.cancel() } +} - let ffi: MoqBandwidth +/// Divides one connection's send estimate among the tracks sharing it. +/// +/// Minted by `Session.allocator`. Handles from the same session share one +/// reservation registry. +public final class Allocator: Sendable { + let ffi: MoqAllocator - init(_ ffi: MoqBandwidth) { + init(_ ffi: MoqAllocator) { self.ffi = ffi } @@ -36,7 +36,7 @@ public final class Bandwidth: Sendable { } } -/// One track's standing claim on a `Bandwidth`. +/// One track's standing claim on an `Allocator`. /// /// `grant()` is a snapshot: `nil` means no estimate or no demand, so hold the /// current rate, and `0` is a real zero grant. diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index c4e602ecb85..4a242965799 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -147,8 +147,8 @@ public final class Session: Sendable { } /// Hold receive-estimate demand until the returned consumer is released. - public func recvBandwidth() -> Bandwidth.Consumer? { - ffi.recvBandwidth().map(Bandwidth.Consumer.init) + public func recvBandwidth() -> BandwidthConsumer? { + ffi.recvBandwidth().map(BandwidthConsumer.init) } /// The session's bandwidth allocator. @@ -157,7 +157,7 @@ public final class Session: Sendable { /// through one are visible to the others. A client handle survives /// reconnects: the grant is `nil` while disconnected and resumes on the /// next connection. - public func bandwidth() -> Bandwidth { - Bandwidth(ffi.bandwidth()) + public func allocator() -> Allocator { + Allocator(ffi.allocator()) } } diff --git a/swift/Sources/Moq/Video.swift b/swift/Sources/Moq/Video.swift index 26f1df4c9e1..975cada99ac 100644 --- a/swift/Sources/Moq/Video.swift +++ b/swift/Sources/Moq/Video.swift @@ -36,7 +36,7 @@ public enum Video { input: EncoderInput, output: EncoderOutput, track: String? = nil, - bandwidth: Bandwidth? = nil + bandwidth: Allocator? = nil ) throws { ffi = try MoqVideoProducer( catalog: catalog.ffi, From 1d1e2360736dd262da21bcf382bb27e9b71b29fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 03:15:21 -0700 Subject: [PATCH 27/58] fix(net): finish the remaining callers and narrow the quest moq-audio's capture logs a finished track's Ok outcome, the drain and compat clients finish their groups, rustfmt, and the quest now covers only the JS consumers whose close() still ends shared state. Co-Authored-By: Claude Opus 5.5 --- quest/m1/api-audit/lifecycle.md | 79 +++++++++++------------------- rs/moq-audio/src/encode/capture.rs | 17 ++++--- rs/moq-net/src/ietf/subscriber.rs | 15 ++++-- rs/moq-net/src/lite/subscriber.rs | 9 +++- rs/moq-net/src/model/group.rs | 5 +- rs/moq-net/src/model/origin.rs | 5 +- rs/moq-net/src/model/track.rs | 9 +++- test/drain/drain.ts | 2 +- test/interop/compat/client.ts | 2 +- 9 files changed, 72 insertions(+), 71 deletions(-) diff --git a/quest/m1/api-audit/lifecycle.md b/quest/m1/api-audit/lifecycle.md index 60e1acd57e2..a158d0b9390 100644 --- a/quest/m1/api-audit/lifecycle.md +++ b/quest/m1/api-audit/lifecycle.md @@ -1,58 +1,35 @@ -# [L] One lifecycle vocabulary +# [M] One lifecycle vocabulary ## Goal Every model handle in moq-net and `@moq/net` (broadcast, track, group, their -demand, `Finish`, listings) uses each lifecycle word with one meaning, and -Rust and JS agree on when each observer fires. +demand, listings) uses each lifecycle word with one meaning, and Rust and JS +agree on when each observer fires. ## Plan -Decided 2026-10-23 (API audit follow-up, maintainer), from a matrix of every -handle's writer verbs and observers: - -- **`finish()` / `finish_at(end)`** declare a clean end. Not terminal: lower - writes stay possible. JS gains `Track.Producer.finish()`. -- **`abort(err)`** ends with an error and is terminal. An abort after the - outcome settled (a finished group, a track whose end was reached) only - releases cache: readers keep the clean outcome. Tracks behave this way - today; groups change to match and record no `cut` unless the abort is - eviction policy. -- **`closed()`** on producers, `Finish`, and demand handles resolves once the - outcome is final: a settled finish, an abort, or the last writer dropping. - Rust returns `Result<(), Error>` (`Ok` for clean), JS `Error | null`. It - never fires because readers left; `unused` reports that. Today Rust - `closed()` ignores finish at every layer and reads `Dropped` after a clean - close. -- **`close()`** is JS-only and releases that handle, like a Rust drop. It - never changes state other handles share. Today a JS group, announce, or - broadcast consumer's `close()` ends it for every reader, and the last - broadcast consumer's close ends the producer; check what the JS wire layer - relies on before changing it. `broadcast::Producer::close` keeps its name: - a broadcast has no abort, and it means "drop every clone now". - -Changes: - -- Rust `track::Producer::close()`, crate-private after - [session-only items stay private](/quest/m1/api-audit/model-private.md), - folds into `abort` with the clean-close error - ([moq-dev/moq#6482](https://github.com/moq-dev/moq/pull/6482) makes it - `Stream(Session(Cancel))`). Readers that drained a closed copy to a clean - end now get that error: update `resume.rs`'s clean-end mapping and the - sessions that call it; failover is unchanged since `route_failed` already - accepts it. -- Rust `group::Producer::closed`/`poll_closed` fire on finish; sessions that - need abort-only get a private poll. -- JS `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split - into `finish()` and `abort(err)`. -- JS `Finish.closed` also fires when a newer epoch supersedes it, as Rust does. -- JS `Lister.ended` becomes `closed`. JS `Demand.unused` rejects on close as - Rust's does. -- Rust `track::Producer::end()` becomes `finished()` (the declared end, a - snapshot). JS `Subscriber.end()` and `Ordered.end()` go: readers await - `finished()`. - -Behavior changes get tests on mock time in both languages. - -Public API: moq-net and `@moq/net` breaking (observer semantics, JS producer -verbs, renames). Wire: none. +Decided 2026-10-23 (API audit follow-up, maintainer): `finish()` declares a +clean end, `abort(err)` is terminal, `closed()` resolves on the final outcome, +and JS `close()` releases only that handle, like a Rust drop. +[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed the +producer verbs, `closed()` semantics in both languages, group consumer +`close()`, `Lister.closed`, `Demand.unused`, and the reader `end()` removal. + +What remains is JS consumers whose `close()` still ends state other handles +share. The JS wire layer and origin rely on it today: + +- `Announce.Consumer.close(abort?)` closes the shared announcement queue, which + is how a reader cancels the stream. Pick: count consumers and give the + producer an `unused()` the session watches (recommended), or keep one reader + per queue and document `close()` as cancel. +- The last `Broadcast.Consumer.close()` closes the broadcast, including a + producer's. The origin relies on it: superseding a published broadcast + closes its producer once no consumer holds it. Pick: the producer stays open + and publishers watch `demand().unused()`, as in Rust (recommended), or keep + it for wire-owned broadcasts only and close superseded producers explicitly. + +`@moq/e2ee` wraps the old verbs (`Group.Producer.close(abort?)`); mirror the +net split there. + +Public API: `@moq/net` breaking (consumer `close()` semantics), plus +`@moq/e2ee`. Wire: none. diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 8a7c29091bc..d6ca13f8218 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -553,8 +553,8 @@ impl Driver { tokio::select! { biased; changed = desired_changed(&self.desired, revision) => changed.is_some(), - err = track.closed() => { - log_track_ended(err); + closed = track.closed() => { + log_track_ended(closed); false } } @@ -811,7 +811,7 @@ async fn until_wanted(demand: &mut moq_net::track::Demand, wanted: bool) -> bool Ok(next) if next.is_some() == wanted => return true, Ok(_) => {} Err(err) => { - log_track_ended(err); + log_track_ended(Err(err)); return false; } } @@ -1193,9 +1193,14 @@ impl Supervisor { } } -/// An application dropping or closing its producer ends publishing normally; -/// a specific abort (such as a transport reset) stays visible in diagnostics. -fn log_track_ended(err: moq_net::Error) { +/// A finished track, or an application dropping or closing its producer, ends +/// publishing normally; a specific abort (such as a transport reset) stays +/// visible in diagnostics. +fn log_track_ended(outcome: Result<(), moq_net::Error>) { + let Err(err) = outcome else { + tracing::debug!("audio track finished; stopping capture"); + return; + }; if matches!(err, moq_net::Error::Dropped | moq_net::Error::Closed) { tracing::debug!("audio track no longer announced; stopping capture"); } else { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 815595cccfe..34963730765 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -14631,7 +14631,10 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_fill(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); + assert!(matches!( + h.track.closed().now_or_never(), + Some(Err(Error::MalformedTrack)) + )); } /// A subgroup object without a Timestamp on a timed track makes the whole track @@ -14660,7 +14663,10 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_group(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); + assert!(matches!( + h.track.closed().now_or_never(), + Some(Err(Error::MalformedTrack)) + )); } /// END_OF_TRACK after a group's last object ends the track right after that group. @@ -16518,7 +16524,10 @@ mod joining_fetch_tests { } if !complete { assert!( - matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Err(Error::Cancel))), + matches!( + observed.poll_closed(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Cancel)) + ), "partial group must abort" ); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index f710438e36c..2881e926013 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -565,7 +565,9 @@ impl SubscriptionCleanup { for (_, entry) in self.0.lock().drain() { let _ = match entry.tail.read().is_ended() { true => entry.producer.finish(), - false => entry.producer.abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), + false => entry + .producer + .abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), }; } } @@ -2986,7 +2988,10 @@ mod tests { } _ => { assert!(matches!(res, Err(Error::ProtocolViolation)), "{version:?}: {res:?}"); - assert!(matches!(track.closed().now_or_never(), Some(Err(Error::ProtocolViolation)))); + assert!(matches!( + track.closed().now_or_never(), + Some(Err(Error::ProtocolViolation)) + )); } } } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 6559583386d..24e484d800a 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -4645,7 +4645,10 @@ mod test { producer.finish().unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); - assert!(handle.poll_aborted(&waiter).is_pending(), "a finish alone frees nothing"); + assert!( + handle.poll_aborted(&waiter).is_pending(), + "a finish alone frees nothing" + ); assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_some()); producer.abort(Error::Cancel).unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a195c6c49bd..cbd198d9bb6 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -3880,10 +3880,7 @@ async fn serve_front(task: FrontTask) -> Vec { let Some(io) = tracks.get_mut(&name) else { continue }; io.copy = None; io.held = None; - io.closed = matches!( - result, - Err(Error::Stream(StreamError::Session(SessionError::Cancel))) - ); + io.closed = matches!(result, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))); Event::TrackEnded { track: name, source, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index a3b1c9952ff..a9ed6b7d8b2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -13074,7 +13074,9 @@ mod test { group .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"b")) .unwrap(); - producer.abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))).unwrap(); + producer + .abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))) + .unwrap(); assert!(matches!( consumer.poll_complete(&kio::Waiter::noop()), Poll::Ready(Err(_)) @@ -13098,7 +13100,10 @@ mod test { producer.append_group().unwrap().finish().unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); - assert!(producer.poll_aborted(&waiter).is_pending(), "lower groups may still arrive"); + assert!( + producer.poll_aborted(&waiter).is_pending(), + "lower groups may still arrive" + ); producer.clone().abort(Error::Cancel).unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); diff --git a/test/drain/drain.ts b/test/drain/drain.ts index 834381e909a..a59cb248256 100644 --- a/test/drain/drain.ts +++ b/test/drain/drain.ts @@ -126,7 +126,7 @@ const ticker = setInterval(() => { payload: new TextEncoder().encode(String(group.sequence)), timestamp: Moq.Time.Timestamp.now(), }); - group.close(); + group.finish(); lastPublished = group.sequence; }, GROUP_INTERVAL_MS); diff --git a/test/interop/compat/client.ts b/test/interop/compat/client.ts index eddb6343fd9..df6fb32942a 100644 --- a/test/interop/compat/client.ts +++ b/test/interop/compat/client.ts @@ -311,7 +311,7 @@ if (command === "sign") { payload: new TextEncoder().encode("compat-fetch"), timestamp: Moq.Time.Timestamp.fromMicros(1 as TimestampMicros), }); - group.close(); + group.finish(); await connection.closed; } else if (command === "subscribe") { // The flags of the interop subscriber this stands in for. From f4d691dddaccdf863b9344e7a4ebadc4cc075ae0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 08:32:52 -0700 Subject: [PATCH 28/58] refactor(net)!: a reservation's snapshot is rate() everywhere Rename bandwidth::Reservation::peek() to rate(), matching JS's Reservation.rate, and carry it through moq-ffi (MoqReservation::rate), the C ABI (moq_reservation_rate), and the Python, Swift, Kotlin, Dart, Go, and OBS wrappers, with a MIGRATE note in each. Co-Authored-By: Claude Opus 5.5 --- cpp/obs/src/moq-output.cpp | 2 +- dart/moq/MIGRATE.md | 2 +- dart/moq_ffi/lib/src/moq.dart | 12 +++++----- go/wrapper/MIGRATE.md | 2 +- go/wrapper/bandwidth_test.go | 22 ++++++++--------- go/wrapper/session.go | 10 ++++---- kt/moq/MIGRATE.md | 2 +- py/moq-rs/MIGRATE.md | 2 +- py/moq-rs/moq/session.py | 8 +++---- rs/moq-c/MIGRATE.md | 2 +- rs/moq-c/README.md | 2 +- rs/moq-c/src/bandwidth.rs | 6 ++--- rs/moq-c/src/test.rs | 32 ++++++++++++------------- rs/moq-ffi/MIGRATE.md | 2 +- rs/moq-ffi/src/bandwidth.rs | 30 +++++++++++------------ rs/moq-ffi/src/test.rs | 16 ++++++------- rs/moq-mux/src/container/producer.rs | 6 ++--- rs/moq-net/MIGRATE.md | 1 + rs/moq-net/src/model/bandwidth.rs | 36 ++++++++++++++-------------- swift/MIGRATE.md | 2 +- swift/Sources/Moq/Bandwidth.swift | 8 +++---- 21 files changed, 103 insertions(+), 102 deletions(-) diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index b5cc82622f8..f2f707b8c05 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -665,7 +665,7 @@ void MoQOutput::VideoData(struct encoder_packet *packet) // Read before the frame is written, so a retune lands on the encoder's next // frame. obs_encoder_update only flags an active encoder for its own thread. if (track.rate && track.reservation) { - if (auto next = track.rate->Update(track.reservation->grant(), MoQRate::Clock::now())) + if (auto next = track.rate->Update(track.reservation->rate(), MoQRate::Clock::now())) SetBitrate(encoder, *next); } diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index c5354607c6a..85763148b52 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -10,7 +10,7 @@ How to update your code for each breaking change, newest first. ### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`Bandwidth` is `Allocator`, and `bandwidth()` is `allocator()`. The producers' `bandwidth` option keeps its name. +`Bandwidth` is `Allocator`, `bandwidth()` is `allocator()`, and `Reservation.grant()` is `rate()`. The producers' `bandwidth` option keeps its name. ### Connection state diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 897aa2a401e..34a6c2a04cc 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8900,7 +8900,7 @@ class FfiConverterMoqBandwidthConsumer { } abstract class MoqReservationInterface { - int? grant(); + int? rate(); void update({required int maxBps}); } @@ -8935,9 +8935,9 @@ class MoqReservation implements MoqReservationInterface { rustCall((status) => uniffi_moq_ffi_fn_free_moqreservation(_ptr, status)); } - int? grant() { + int? rate() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqreservation_grant( + (status) => uniffi_moq_ffi_fn_method_moqreservation_rate( uniffiClonePointer(), status, ), @@ -16751,7 +16751,7 @@ external void uniffi_moq_ffi_fn_free_moqreservation( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqreservation_grant( +external RustBuffer uniffi_moq_ffi_fn_method_moqreservation_rate( Pointer ptr, Pointer uniffiStatus, ); @@ -19472,7 +19472,7 @@ external int uniffi_moq_ffi_checksum_method_moqbandwidthconsumer_rate(); external int uniffi_moq_ffi_checksum_method_moqbandwidthconsumer_rate_changed(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqreservation_grant(); +external int uniffi_moq_ffi_checksum_method_moqreservation_rate(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqreservation_update(); @@ -20181,7 +20181,7 @@ void _checkApiChecksums() { 38021) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqreservation_grant() != 59401) { + if (uniffi_moq_ffi_checksum_method_moqreservation_rate() != 45979) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqreservation_update() != 9626) { diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index dd28f2785d0..fdc699cbbc6 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -10,7 +10,7 @@ How to update your code for each breaking change, newest first. ### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`Bandwidth` is `Allocator`, and `Session.Bandwidth()` is `Session.Allocator()`. The `Bandwidth` field of `ProducerOptions` keeps its name and takes a `*moq.Allocator`. +`Bandwidth` is `Allocator`, `Session.Bandwidth()` is `Session.Allocator()`, and `Reservation.Grant()` is `Reservation.Rate()`. The `Bandwidth` field of `ProducerOptions` keeps its name and takes a `*moq.Allocator`. ### Connection state diff --git a/go/wrapper/bandwidth_test.go b/go/wrapper/bandwidth_test.go index f5fada85225..5783bed2e4c 100644 --- a/go/wrapper/bandwidth_test.go +++ b/go/wrapper/bandwidth_test.go @@ -24,7 +24,7 @@ func TestReservationUpdateReleasesSubscribedTrack(t *testing.T) { t.Fatal(err) } defer client.Close() - // Dial returns while connecting; the grants need the live session's estimate. + // Dial returns while connecting; the rates need the live session's estimate. waitConnects(t, ctx, client.Session(), 1) // Grant assertions use a live QUIC estimate, but need no traffic or probing. bandwidth := client.Session().Allocator() @@ -53,14 +53,14 @@ func TestReservationUpdateReleasesSubscribedTrack(t *testing.T) { } return demand, bandwidth.Reserve(demand, ceiling) } - grant := func(reservation *moq.Reservation, want uint64) { + rate := func(reservation *moq.Reservation, want uint64) { t.Helper() - got := reservation.Grant() + got := reservation.Rate() if got == nil { - t.Fatalf("grant unavailable, want %d", want) + t.Fatalf("rate unavailable, want %d", want) } if *got != want { - t.Fatalf("grant = %d, want %d", *got, want) + t.Fatalf("rate = %d, want %d", *got, want) } } @@ -69,18 +69,18 @@ func TestReservationUpdateReleasesSubscribedTrack(t *testing.T) { replacement := bandwidth.Reserve(demand, 1) // Even a retained predecessor must relinquish the allocation explicitly. // Priority makes this independent of the live estimate's exact value. - grant(sibling, 0) + rate(sibling, 0) retired.Update(0) - grant(retired, 0) - grant(replacement, 1) - grant(sibling, 1) + rate(retired, 0) + rate(replacement, 1) + rate(sibling, 1) // Zero is reversible and repeated release is harmless. retired.Update(math.MaxUint64) - grant(sibling, 0) + rate(sibling, 0) retired.Update(0) retired.Update(0) - grant(sibling, 1) + rate(sibling, 1) if demand.Wanted() == nil { t.Fatal("release ended the track subscription") } diff --git a/go/wrapper/session.go b/go/wrapper/session.go index 069ba4fd741..fd589c07f23 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -151,17 +151,17 @@ func (a *Allocator) Reserve(demand *TrackDemand, maxBps uint64) *Reservation { // Reservation is one track's standing claim on an Allocator. // -// Grant is a snapshot: nil means no estimate or no demand, so hold the current -// rate, and 0 is a real zero grant. +// Rate is a snapshot: nil means no estimate or no demand, so hold the current +// rate, and 0 is a real zero rate. // Update(0) relinquishes the allocation while the track stays subscribed; the // handle remains usable and a later Update can claim bandwidth again. type Reservation struct { inner *ffi.MoqReservation } -// Grant returns this reservation's slice right now, in bits per second. -func (r *Reservation) Grant() *uint64 { - return r.inner.Grant() +// Rate returns this reservation's slice right now, in bits per second. +func (r *Reservation) Rate() *uint64 { + return r.inner.Rate() } // Update changes the ceiling, keeping the same claim. Zero immediately gives diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index edc288c9439..f8cf24729d2 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -10,7 +10,7 @@ How to update your code for each breaking change, newest first. ### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`Bandwidth` is `Allocator`, and `bandwidth()` is `allocator()`. The producers' `bandwidth` option keeps its name. +`Bandwidth` is `Allocator`, `bandwidth()` is `allocator()`, and `Reservation.grant()` is `rate()`. The producers' `bandwidth` option keeps its name. ### Connection state diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index b3443676874..0ebbf24c0e3 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -10,7 +10,7 @@ How to update your code for each breaking change, newest first. ### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`moq.Bandwidth` is `moq.Allocator`, and `Session.bandwidth()` is `allocator()`. `Bandwidth.Consumer`, returned by `recv_bandwidth()`, is the top-level `moq.BandwidthConsumer`. The producers' `bandwidth=` argument keeps its name. +`moq.Bandwidth` is `moq.Allocator`, `Session.bandwidth()` is `allocator()`, and `Reservation.grant()` is `rate()`. `Bandwidth.Consumer`, returned by `recv_bandwidth()`, is the top-level `moq.BandwidthConsumer`. The producers' `bandwidth=` argument keeps its name. ### Connection state diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 496933c0fe8..17803fff23c 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -213,16 +213,16 @@ def reserve(self, demand: TrackDemand, max_bps: int) -> Reservation: class Reservation: """One track's standing claim on an :class:`Allocator`. - :meth:`grant` is a snapshot: ``None`` means no estimate or no demand, so - hold the current rate, and ``0`` is a real zero grant. + :meth:`rate` is a snapshot: ``None`` means no estimate or no demand, so + hold the current rate, and ``0`` is a real zero rate. """ def __init__(self, inner: MoqReservation) -> None: self._inner = inner - def grant(self) -> int | None: + def rate(self) -> int | None: """This reservation's slice right now, in bits per second.""" - return self._inner.grant() + return self._inner.rate() def update(self, max_bps: int) -> None: """Change the ceiling, keeping the same claim.""" diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index cef68c477aa..4a62a031a57 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -6,7 +6,7 @@ How to update your code for each breaking change, newest first. ### Session allocator ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`moq_session_bandwidth` is `moq_session_allocator`, `moq_bandwidth_reserve` is `moq_allocator_reserve`, and `moq_bandwidth_close` is `moq_allocator_close`, matching `moq_net::bandwidth::Allocator`. Signatures are unchanged. The receive estimate keeps its names (`moq_session_recv_bandwidth`, `moq_bandwidth_consumer_*`). +`moq_session_bandwidth` is `moq_session_allocator`, `moq_bandwidth_reserve` is `moq_allocator_reserve`, and `moq_bandwidth_close` is `moq_allocator_close`, matching `moq_net::bandwidth::Allocator`. `moq_reservation_grant` is `moq_reservation_rate`, matching `moq_net::bandwidth::Reservation::rate()`. Signatures are unchanged. The receive estimate keeps its names (`moq_session_recv_bandwidth`, `moq_bandwidth_consumer_*`). ### Sessions report a lifecycle snapshot diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index 1d2d00d6618..3c0e691e569 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -36,7 +36,7 @@ int32_t moq_session_close(uint32_t session); int32_t moq_session_allocator(uint32_t session); int32_t moq_allocator_reserve(uint32_t allocator, uint32_t track, uint64_t max_bps); int32_t moq_allocator_close(uint32_t allocator); -int32_t moq_reservation_grant(uint32_t reservation, uint64_t *bps, bool *present); +int32_t moq_reservation_rate(uint32_t reservation, uint64_t *bps, bool *present); int32_t moq_reservation_update(uint32_t reservation, uint64_t max_bps); int32_t moq_reservation_close(uint32_t reservation); diff --git a/rs/moq-c/src/bandwidth.rs b/rs/moq-c/src/bandwidth.rs index d833b191b09..66b0b124b54 100644 --- a/rs/moq-c/src/bandwidth.rs +++ b/rs/moq-c/src/bandwidth.rs @@ -89,17 +89,17 @@ pub extern "C" fn moq_allocator_reserve(allocator: u32, track: u32, max_bps: u64 /// This reservation's slice right now, in bits per second. /// /// `present` is false when there is no estimate or no demand: hold the current -/// rate. `present` true and `bps` 0 is a real zero grant. +/// rate. `present` true and `bps` 0 is a real zero rate. /// /// # Safety /// - `bps` and `present` must be valid pointers. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_reservation_grant(reservation: u32, bps: *mut u64, present: *mut bool) -> i32 { +pub unsafe extern "C" fn moq_reservation_rate(reservation: u32, bps: *mut u64, present: *mut bool) -> i32 { ffi::enter(move || { let reservation = ffi::parse_id(reservation)?; let bps = unsafe { bps.as_mut() }.ok_or(Error::InvalidPointer)?; let present = unsafe { present.as_mut() }.ok_or(Error::InvalidPointer)?; - match State::lock().bandwidth.reservation(reservation)?.peek() { + match State::lock().bandwidth.reservation(reservation)?.rate() { Some(rate) => { *bps = rate.as_bps(); *present = true; diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 21043a1d4db..d94c26a6d04 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5486,22 +5486,22 @@ fn test_allocator() -> (u32, moq_net::bandwidth::Producer) { (bandwidth, estimate) } -fn grant(reservation: u32) -> Option { +fn rate(reservation: u32) -> Option { let mut bps = 0; let mut present = false; - assert_eq!(unsafe { moq_reservation_grant(reservation, &mut bps, &mut present) }, 0); + assert_eq!(unsafe { moq_reservation_rate(reservation, &mut bps, &mut present) }, 0); present.then_some(bps) } -fn wait_grant(reservation: u32) -> u64 { +fn wait_rate(reservation: u32) -> u64 { let deadline = std::time::Instant::now() + TIMEOUT; loop { - if let Some(bps) = grant(reservation) { + if let Some(bps) = rate(reservation) { return bps; } assert!( std::time::Instant::now() < deadline, - "timed out waiting for a reservation grant" + "timed out waiting for a reservation rate" ); std::thread::sleep(Duration::from_millis(10)); } @@ -5546,16 +5546,16 @@ fn bandwidth_reservations_split_the_estimate() { .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); - wait_grant(first); - wait_grant(second); - let a = grant(first).expect("first grant"); - let b = grant(second).expect("second grant"); + wait_rate(first); + wait_rate(second); + let a = rate(first).expect("first rate"); + let b = rate(second).expect("second rate"); assert!(a + b <= 3_000_000, "{a} + {b} oversubscribed"); assert_eq!(a, 1_500_000); assert_eq!(b, 1_500_000); assert_eq!(moq_reservation_close(first), 0); - assert_eq!(grant(second), Some(2_000_000)); + assert_eq!(rate(second), Some(2_000_000)); assert_eq!(moq_reservation_close(second), 0); assert_eq!(moq_allocator_close(bandwidth), 0); @@ -5593,10 +5593,10 @@ fn bandwidth_handles_share_the_registry() { let (second_sub, second_cb) = subscribe_named_track(consume, b"b"); let reserved = id(moq_allocator_reserve(first, first_track, 4_000_000)); - assert_eq!(wait_grant(reserved), 3_000_000); + assert_eq!(wait_rate(reserved), 3_000_000); let other = id(moq_allocator_reserve(second, second_track, 4_000_000)); - assert_eq!(wait_grant(other), 1_500_000); - assert_eq!(grant(reserved), Some(1_500_000)); + assert_eq!(wait_rate(other), 1_500_000); + assert_eq!(rate(reserved), Some(1_500_000)); assert_eq!(moq_reservation_close(reserved), 0); assert_eq!(moq_reservation_close(other), 0); @@ -5644,15 +5644,15 @@ fn encode_video_bitrate_caps_the_reservation() { estimate .set(Some(moq_net::bandwidth::Rate::from_bps(4_000_000))) .unwrap(); - assert_eq!(wait_grant(reservation), 4_000_000); + assert_eq!(wait_rate(reservation), 4_000_000); assert_eq!(moq_encode_video_bitrate(producer, 1_000_000), 0); - assert_eq!(grant(reservation), Some(1_000_000)); + assert_eq!(rate(reservation), Some(1_000_000)); estimate .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); - assert_eq!(grant(reservation), Some(1_000_000)); + assert_eq!(rate(reservation), Some(1_000_000)); assert_eq!(moq_reservation_close(reservation), 0); assert_eq!(moq_consume_track_cancel(sub), 0); diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index c818610938a..aa94cedf512 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -11,7 +11,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ### `MoqAllocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`MoqBandwidth` is `MoqAllocator`, and `MoqSession::bandwidth()` is `allocator()`, matching `moq_net::bandwidth::Allocator`. The `bandwidth` option field on audio and video producers keeps its name. +`MoqBandwidth` is `MoqAllocator`, and `MoqSession::bandwidth()` is `allocator()`, matching `moq_net::bandwidth::Allocator`. `MoqReservation::grant()` is `rate()`, matching `moq_net::bandwidth::Reservation::rate()`. The `bandwidth` option field on audio and video producers keeps its name. ### Connection state diff --git a/rs/moq-ffi/src/bandwidth.rs b/rs/moq-ffi/src/bandwidth.rs index 1d5a25fcbb6..8a78446195b 100644 --- a/rs/moq-ffi/src/bandwidth.rs +++ b/rs/moq-ffi/src/bandwidth.rs @@ -51,9 +51,9 @@ impl MoqAllocator { /// One track's standing claim on a [`MoqAllocator`]. /// -/// [`grant`](Self::grant) is [`moq_net::bandwidth::Reservation::peek`]: `None` +/// [`rate`](Self::rate) is [`moq_net::bandwidth::Reservation::rate`]: `None` /// means no estimate or no demand, so hold the current rate, and `Some(0)` is a -/// real zero grant. +/// real zero rate. #[derive(uniffi::Object)] pub struct MoqReservation { inner: moq_net::bandwidth::Reservation, @@ -75,10 +75,10 @@ impl MoqReservation { /// This reservation's slice right now, in bits per second. /// /// `None` means no estimate or no demand: hold the current rate. `Some(0)` - /// is a real zero grant. - pub fn grant(&self) -> Option { + /// is a real zero rate. + pub fn rate(&self) -> Option { let _guard = crate::ffi::enter(); - self.inner.peek().map(moq_net::bandwidth::Rate::as_bps) + self.inner.rate().map(moq_net::bandwidth::Rate::as_bps) } /// Change the ceiling, keeping the same claim. @@ -123,8 +123,8 @@ mod tests { .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); - let a = first.grant().unwrap(); - let b = second.grant().unwrap(); + let a = first.rate().unwrap(); + let b = second.rate().unwrap(); assert!(a + b <= 3_000_000, "{a} + {b} oversubscribed"); assert_eq!(a, 1_500_000); assert_eq!(b, 1_500_000); @@ -146,13 +146,13 @@ mod tests { let track = named_track(&broadcast, "a"); let _sub = track.consume(None).unwrap(); let reserved = first.reserve(&track.demand().unwrap(), 4_000_000); - assert_eq!(reserved.grant(), Some(3_000_000)); + assert_eq!(reserved.rate(), Some(3_000_000)); let other = named_track(&broadcast, "b"); let _other_sub = other.consume(None).unwrap(); let other = second.reserve(&other.demand().unwrap(), 4_000_000); - assert_eq!(reserved.grant(), Some(1_500_000)); - assert_eq!(other.grant(), Some(1_500_000)); + assert_eq!(reserved.rate(), Some(1_500_000)); + assert_eq!(other.rate(), Some(1_500_000)); } #[test] @@ -171,10 +171,10 @@ mod tests { let second = named_track(&broadcast, "b"); let _second_sub = second.consume(None).unwrap(); let second = bandwidth.reserve(&second.demand().unwrap(), 2_000_000); - assert_eq!(second.grant(), Some(1_500_000)); + assert_eq!(second.rate(), Some(1_500_000)); drop(first); - assert_eq!(second.grant(), Some(2_000_000)); + assert_eq!(second.rate(), Some(2_000_000)); } /// A missing estimate (disconnected) reports `None`; a later value is a grant @@ -192,15 +192,15 @@ mod tests { estimate .set(Some(moq_net::bandwidth::Rate::from_bps(2_000_000))) .unwrap(); - assert_eq!(reserved.grant(), Some(2_000_000)); + assert_eq!(reserved.rate(), Some(2_000_000)); estimate.set(None).unwrap(); - assert_eq!(reserved.grant(), None); + assert_eq!(reserved.rate(), None); estimate .set(Some(moq_net::bandwidth::Rate::from_bps(1_000_000))) .unwrap(); - assert_eq!(reserved.grant(), Some(1_000_000)); + assert_eq!(reserved.rate(), Some(1_000_000)); } #[cfg(all(feature = "video", not(target_arch = "wasm32")))] diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 549938cf838..cfa8af5eae7 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -510,10 +510,10 @@ async fn media_track_reserves_through_its_demand() { let demand = media.demand().unwrap(); let reservation = bandwidth.reserve(&demand, 4_000_000); // No subscriber yet: no demand, so hold the current rate. - assert_eq!(reservation.grant(), None); + assert_eq!(reservation.rate(), None); let _sub = subscribe(&broadcast, &demand.name()).await; - assert_eq!(reservation.grant(), Some(3_000_000)); + assert_eq!(reservation.rate(), Some(3_000_000)); } #[tokio::test] @@ -6602,7 +6602,7 @@ async fn video_encoder_follows_a_shrinking_grant() { .unwrap(); let reservation = video.reservation().expect("published against an allocator"); - assert_eq!(reservation.grant(), None, "no demand yet"); + assert_eq!(reservation.rate(), None, "no demand yet"); let consumer = broadcast.consume().unwrap(); let _sub = consumer.subscribe_track("camera".into(), None).await.unwrap(); @@ -6610,12 +6610,12 @@ async fn video_encoder_follows_a_shrinking_grant() { estimate .set(Some(moq_net::bandwidth::Rate::from_bps(4_000_000))) .unwrap(); - assert_eq!(reservation.grant(), Some(4_000_000)); + assert_eq!(reservation.rate(), Some(4_000_000)); estimate .set(Some(moq_net::bandwidth::Rate::from_bps(1_000_000))) .unwrap(); - assert_eq!(reservation.grant(), Some(1_000_000)); + assert_eq!(reservation.rate(), Some(1_000_000)); let deadline = moq_clock::Instant::now() + TIMEOUT; loop { @@ -6715,16 +6715,16 @@ async fn set_bitrate_caps_a_later_bandwidth_grant() { estimate .set(Some(moq_net::bandwidth::Rate::from_bps(4_000_000))) .unwrap(); - assert_eq!(reservation.grant(), Some(4_000_000)); + assert_eq!(reservation.rate(), Some(4_000_000)); video.set_bitrate(1_000_000).unwrap(); - assert_eq!(reservation.grant(), Some(1_000_000)); + assert_eq!(reservation.rate(), Some(1_000_000)); assert_eq!(video.applied_bitrate(), 1_000_000); estimate .set(Some(moq_net::bandwidth::Rate::from_bps(3_000_000))) .unwrap(); - assert_eq!(reservation.grant(), Some(1_000_000)); + assert_eq!(reservation.rate(), Some(1_000_000)); assert_eq!(video.applied_bitrate(), 1_000_000); video.finish().unwrap(); diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index c8e9648db1b..c5e7e98a521 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -992,7 +992,7 @@ mod tests { let _encoder_sub = encoder_track.subscribe(None); let encoder = allocator.reserve(&encoder_track.demand(), moq_net::bandwidth::Rate::from_bps(8_000_000)); assert_eq!( - encoder.peek(), + encoder.rate(), Some(moq_net::bandwidth::Rate::from_bps(6_000_000)), "alone, the encoder takes the whole estimate" ); @@ -1005,14 +1005,14 @@ mod tests { producer.write(sized_frame(0, true, 100_000)).unwrap(); producer.write(sized_frame(500_000, false, 100_000)).unwrap(); assert_eq!( - encoder.peek(), + encoder.rate(), Some(moq_net::bandwidth::Rate::from_bps(6_000_000)), "nothing claimed before the first window" ); producer.cut(Some(Timestamp::from_micros(1_000_000).unwrap())).unwrap(); assert_eq!( - encoder.peek(), + encoder.rate(), Some(moq_net::bandwidth::Rate::from_bps(4_400_000)), "the encoder's grant drops by the passthrough peak (1.6 Mbps)" ); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 5fe6840bb0f..97cfd0159d3 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -15,6 +15,7 @@ Rename these, which kept their behavior: | `auth::Auth` | `auth::Credential` | | `track::Consumer::query()`, `track::Querying` | `track::Consumer::info()`, `track::Resolving` | | `bandwidth::Reservation::consumer()` | `bandwidth::Reservation::consume()` | +| `bandwidth::Reservation::peek()` | `bandwidth::Reservation::rate()` | | `stats::Control` | `stats::Message` | | `stats::Bytes` | `stats::Totals` | | `transport::Stats`, `transport::poll::Session::stats()` | `transport::Metrics`, `transport::poll::Session::metrics()` | diff --git a/rs/moq-net/src/model/bandwidth.rs b/rs/moq-net/src/model/bandwidth.rs index 5a83d94ca67..81f2cbec202 100644 --- a/rs/moq-net/src/model/bandwidth.rs +++ b/rs/moq-net/src/model/bandwidth.rs @@ -376,7 +376,7 @@ impl Reservation { /// /// Stateless, unlike [`Consumer::poll_changed`], which carries a cursor over what it last /// reported and so needs a handle of its own. - pub fn peek(&self) -> Option { + pub fn rate(&self) -> Option { self.share.grant() } @@ -877,7 +877,7 @@ mod tests { let second_share = allocator.reserve(&second.demand(), bps(4_000_000)); assert_eq!(first_share.changed().await.unwrap(), Some(bps(1_000_000))); - assert_eq!(second_share.peek(), Some(bps(1_000_000))); + assert_eq!(second_share.rate(), Some(bps(1_000_000))); } /// An unwatched track releases its reservation, since `publish_capture` stops @@ -895,15 +895,15 @@ mod tests { let (_idle_broadcast, idle) = track(VIDEO); let idle_share = allocator.reserve(&idle.demand(), bps(4_000_000)); - assert_eq!(watched_share.peek(), Some(bps(2_000_000))); + assert_eq!(watched_share.rate(), Some(bps(2_000_000))); // Not zero: an idle share reports "no opinion" so a sender that is mid-shutdown // holds its rate instead of retuning to the floor on the way out. - assert_eq!(idle_share.peek(), None); + assert_eq!(idle_share.rate(), None); // It joins, and the split happens. let _idle_sub = idle.subscribe(None); - assert_eq!(watched_share.peek(), Some(bps(1_000_000))); - assert_eq!(idle_share.peek(), Some(bps(1_000_000))); + assert_eq!(watched_share.rate(), Some(bps(1_000_000))); + assert_eq!(idle_share.rate(), Some(bps(1_000_000))); } /// Demand transitions have to wake a parked share, not just change what a later @@ -1080,7 +1080,7 @@ mod tests { let (_second_broadcast, second) = track(VIDEO); let _second_sub = second.subscribe(None); let second_reserved = allocator.reserve(&second.demand(), bps(4_000_000)); - assert_eq!(second_reserved.peek(), Some(bps(1_000_000))); + assert_eq!(second_reserved.rate(), Some(bps(1_000_000))); // A read handle is not the claim: the reservation outliving it is what keeps the // room, and the reservation going away is what returns it. @@ -1089,7 +1089,7 @@ mod tests { drop(first_reserved); assert_eq!(allocator.registry.read().entries.len(), 1); - assert_eq!(second_reserved.peek(), Some(bps(2_000_000))); + assert_eq!(second_reserved.rate(), Some(bps(2_000_000))); // The orphaned reader is woken and told, rather than being left parked on a slice // that will never move again. It reports the same "hold your rate" as an unwatched @@ -1116,19 +1116,19 @@ mod tests { let large_reserved = allocator.reserve(&large.demand(), bps(8_000_000)); // Max-min fair: the small claim is satisfied in full, the rest goes to the other. - assert_eq!(small_reserved.peek(), Some(bps(1_000_000))); - assert_eq!(large_reserved.peek(), Some(bps(5_000_000))); + assert_eq!(small_reserved.rate(), Some(bps(1_000_000))); + assert_eq!(large_reserved.rate(), Some(bps(5_000_000))); // It reopens at a mode that can use much more, and the split follows without a // second entry appearing. small_reserved.update(bps(4_000_000)); assert_eq!(allocator.registry.read().entries.len(), 2); - assert_eq!(small_reserved.peek(), Some(bps(3_000_000))); - assert_eq!(large_reserved.peek(), Some(bps(3_000_000))); + assert_eq!(small_reserved.rate(), Some(bps(3_000_000))); + assert_eq!(large_reserved.rate(), Some(bps(3_000_000))); // And back down, which has to release the difference rather than hold it. small_reserved.update(bps(1_000_000)); - assert_eq!(large_reserved.peek(), Some(bps(5_000_000))); + assert_eq!(large_reserved.rate(), Some(bps(5_000_000))); } /// Collected-language callers can relinquish a ceiling before their retained @@ -1148,20 +1148,20 @@ mod tests { let (_sibling_broadcast, sibling) = track(VIDEO); let _sibling_sub = sibling.subscribe(None); let sibling = allocator.reserve(&sibling.demand(), bps(4_000_000)); - assert_eq!(sibling.peek(), Some(bps(1_000_000))); + assert_eq!(sibling.rate(), Some(bps(1_000_000))); assert_eq!(target.poll_changed(&waiter), Poll::Ready(bps(12_000_000))); retired.update(Rate::ZERO); - assert_eq!(retired.peek(), Some(Rate::ZERO)); - assert_eq!(replacement.peek(), Some(bps(1_500_000))); - assert_eq!(sibling.peek(), Some(bps(1_500_000))); + assert_eq!(retired.rate(), Some(Rate::ZERO)); + assert_eq!(replacement.rate(), Some(bps(1_500_000))); + assert_eq!(sibling.rate(), Some(bps(1_500_000))); assert_eq!(target.poll_changed(&waiter), Poll::Ready(bps(8_000_000))); retired.update(Rate::ZERO); assert!(target.poll_changed(&waiter).is_pending()); // The registration remains usable, so an encoder may resume in place. retired.update(bps(4_000_000)); - assert_eq!(sibling.peek(), Some(bps(1_000_000))); + assert_eq!(sibling.rate(), Some(bps(1_000_000))); assert_eq!(target.poll_changed(&waiter), Poll::Ready(bps(12_000_000))); assert!(encoder.demand().is_used()); } diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index e57005d98d4..7ce029a5cd4 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -10,7 +10,7 @@ How to update your code for each breaking change, newest first. ### `Allocator` ([#6751](https://github.com/moq-dev/moq/pull/6751)) -`Bandwidth` is `Allocator`, and `Session.bandwidth()` is `allocator()`. `Bandwidth.Consumer`, returned by `recvBandwidth()`, is the top-level `BandwidthConsumer`. The producers' `bandwidth:` parameter keeps its name. +`Bandwidth` is `Allocator`, `Session.bandwidth()` is `allocator()`, and `Reservation.grant()` is `rate()`. `Bandwidth.Consumer`, returned by `recvBandwidth()`, is the top-level `BandwidthConsumer`. The producers' `bandwidth:` parameter keeps its name. ### Connection state diff --git a/swift/Sources/Moq/Bandwidth.swift b/swift/Sources/Moq/Bandwidth.swift index 01cec96e3a7..f776f23506e 100644 --- a/swift/Sources/Moq/Bandwidth.swift +++ b/swift/Sources/Moq/Bandwidth.swift @@ -38,8 +38,8 @@ public final class Allocator: Sendable { /// One track's standing claim on an `Allocator`. /// -/// `grant()` is a snapshot: `nil` means no estimate or no demand, so hold the -/// current rate, and `0` is a real zero grant. +/// `rate()` is a snapshot: `nil` means no estimate or no demand, so hold the +/// current rate, and `0` is a real zero rate. public final class Reservation: Sendable { let ffi: MoqReservation @@ -48,8 +48,8 @@ public final class Reservation: Sendable { } /// This reservation's slice right now, in bits per second. - public func grant() -> UInt64? { - ffi.grant() + public func rate() -> UInt64? { + ffi.rate() } /// Change the ceiling, keeping the same claim. From 657d2a9526d08663de3782bcd3a02bea668ef3b9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 08:47:38 -0700 Subject: [PATCH 29/58] chore(net): regenerate the @moq/net API reports Co-Authored-By: Claude Opus 5.5 --- js/net/api/index.api.md | 40 ++++++++++++++++++++-------------------- js/net/api/zod.api.md | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index dcb352605a4..09245f7a3bf 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -78,29 +78,23 @@ declare namespace Auth { Handle, Unsupported, Grant, - Auth_2 as Auth + Credential_2 as Credential } } -interface Auth_2 { - close(): void; - readonly closed: PromiseLike; - readonly grant: Getter; -} - -declare namespace Auth_3 { +declare namespace Auth_2 { export { } } -declare namespace Auth_4 { +declare namespace Auth_3 { export { Duplicate, Handle, Unsupported, Grant, - Auth_2 as Auth + Credential_2 as Credential } } @@ -168,9 +162,9 @@ function connect(input: ConnectProps): Promise; export class Connection { constructor(props?: ConnectionProps); static readonly accept: typeof accept; + readonly allocator: Getter; announced(scope?: Path_2.Pattern, options?: Announce_3.Options): Announce_3.Consumer; - readonly auth: Getter; - readonly bandwidth: Getter; + readonly auth: Getter; static readonly certificateHash: typeof certificateHash; close(abort?: Error): void; get closed(): GetPromise; @@ -222,7 +216,7 @@ interface ConnectionProps { } interface ConnectProps { - auth?: Auth_4.Handle; + auth?: Auth_3.Handle; consume?: Producer_5; extensions?: Extensions.Extensions; implementation?: string; @@ -302,6 +296,12 @@ const Cost: { zero: bigint; }; +interface Credential_2 { + close(): void; + readonly closed: PromiseLike; + readonly grant: Getter; +} + class Cursor { constructor(buffer: Uint8Array, version: StreamVersion); bool(): boolean; @@ -412,7 +412,7 @@ declare namespace Error_2 { interface Established { abort(): void; announced(scope?: Path_2.Pattern, options?: Announce_3.Options): Announce_3.Consumer; - readonly auth: Auth_4.Handle; + readonly auth: Auth_3.Handle; close(): Promise; closed: Promise; readonly peerImplementation: Promise; @@ -499,7 +499,7 @@ class GroupTooLarge extends Stream { class Handle { constructor(); abort(err?: Error): void; - add(token: string | Uint8Array): Promise; + add(token: string | Uint8Array): Promise; get grant(): Getter; } @@ -515,7 +515,7 @@ const HopSchema: z.core.$ZodBranded, "Hop", "out">; declare namespace Ietf { export { - Auth_3 as Auth, + Auth_2 as Auth, Cluster, Epoch_3 as Epoch, Setup, @@ -742,6 +742,7 @@ class Producer { appendGroup(): Producer_2; close(abort?: Error): void; get closed(): GetPromise; + createGroup(sequence: number): Producer_2; demand(): Demand; dropGroups(start: number, end: number, error: Error): void; finishAt(end: Position_2): void; @@ -753,7 +754,6 @@ class Producer { subscribe(options?: Subscription): Subscriber; get subscription(): Getter; writeFrame(frame: Frame): void; - writeGroup(group: Producer_2): void; } class Producer_2 { @@ -894,15 +894,15 @@ interface RequestOptions { class Reservation implements Disposable { [Symbol.dispose](): void; close(): void; - get grant(): Getter; peek(): number | undefined; + get rate(): Getter; update(max: number): void; } function resolve(base: Valid, rel: Relative): Valid; interface Route { - auth?: Auth_2; + auth?: Credential_2; cost: Cost; epoch?: Epoch_2.Valid; hops: Hop[]; @@ -914,7 +914,7 @@ const Route: { epoch?: Epoch_2.Valid; hops?: readonly Hop[]; cost?: Cost; - auth?: Auth_2; + auth?: Credential_2; }): Route; }; diff --git a/js/net/api/zod.api.md b/js/net/api/zod.api.md index 953b970143b..d3196029845 100644 --- a/js/net/api/zod.api.md +++ b/js/net/api/zod.api.md @@ -148,6 +148,7 @@ class Producer { appendGroup(): Producer_2; close(abort?: Error): void; get closed(): GetPromise; + createGroup(sequence: number): Producer_2; demand(): Demand; dropGroups(start: number, end: number, error: Error): void; finishAt(end: Position_2): void; @@ -159,7 +160,6 @@ class Producer { subscribe(options?: Subscription): Subscriber; get subscription(): Getter; writeFrame(frame: Frame): void; - writeGroup(group: Producer_2): void; } class Producer_2 { From 46bd7e465e304d10c41abe35b5ef46e2fa0e0868 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 08:47:52 -0700 Subject: [PATCH 30/58] feat(net)!: consumer close releases only its handle for announcements and broadcasts Announce.Consumer.close() no longer closes the shared queue: consumers are counted and sessions watch the producer's unused(). The last Broadcast.Consumer.close() no longer closes a produced broadcast; the origin closes a superseded or released broadcast once its demand is unused, and a session's producer-less broadcast still retires once no handle holds it, as Rust sessions do with close_unheld. Broadcast.Producer.demand() is public. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 2 ++ js/net/api/index.api.md | 23 ++++++++---- js/net/api/zod.api.md | 11 +++--- js/net/src/announced.test.ts | 27 ++++++++++++++ js/net/src/announced.ts | 35 +++++++++++++++--- js/net/src/broadcast.test.ts | 21 ++++++++++- js/net/src/broadcast.ts | 29 ++++++++------- js/net/src/connection/forward.test.ts | 8 ++++- js/net/src/connection/pool.ts | 6 +++- js/net/src/connection/reload.ts | 6 +++- js/net/src/ietf/publisher.test.ts | 6 ++-- js/net/src/ietf/subscriber.ts | 8 ++++- js/net/src/lite/subscriber.ts | 8 ++++- js/net/src/mock.ts | 12 +++++++ js/net/src/origin-demand.test.ts | 7 ++-- js/net/src/origin.test.ts | 32 +++++++++++------ js/net/src/origin.ts | 52 +++++++++++++++++++-------- quest/m1/api-audit/README.md | 2 +- quest/m1/api-audit/lifecycle.md | 34 ++++++------------ 19 files changed, 238 insertions(+), 91 deletions(-) diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 60eb5eae5d9..1dd9bfb7353 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -9,6 +9,8 @@ How to update your code for each breaking change, newest first. `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. `Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. `Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. +`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches `unused()`, which resolves once the last consumer closes, instead of its `closed`. +The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. A publisher that closed its broadcast once readers left watches `demand().unused()` instead. A broadcast created on an origin and then superseded by a new `createBroadcast` at its path closes once nobody subscribes to it, rather than once no consumer handle holds it. `Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. The wire layer's `Lister.ended` is `closed`. `Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index dcb352605a4..f61daaf7b3e 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -124,6 +124,7 @@ declare namespace Broadcast { export { Tracks, TracksEvent, + Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -133,6 +134,7 @@ declare namespace Broadcast_2 { export { Tracks, TracksEvent, + Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -243,14 +245,14 @@ class ConnectTimeout extends Error { class Consumer { [Symbol.asyncIterator](): AsyncGenerator; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; next(): Promise; get synced(): Getter; } class Consumer_2 { - close(abort?: Error): void; + close(): void; get closed(): GetPromise; get done(): boolean; get frameCount(): number; @@ -345,6 +347,12 @@ class Demand_2 { get used(): Getter; } +class Demand_3 { + get closed(): GetPromise; + unused(): Promise; + get used(): Getter; +} + class Duplicate extends Error { constructor(message?: string); } @@ -608,7 +616,6 @@ type Order = "ascending" | "descending"; class Ordered { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; @@ -737,13 +744,15 @@ interface Probe { class Producer { constructor(name: string); + abort(error: Error): void; accept(info?: Partial): this; appendDatagram(timestamp: Timestamp | undefined, payload: Uint8Array): number; appendGroup(): Producer_2; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; demand(): Demand; dropGroups(start: number, end: number, error: Error): void; + finish(): void; finishAt(end: Position_2): void; info(): Promise; insertDatagram(sequence: number, timestamp: Timestamp | undefined, payload: Uint8Array): void; @@ -758,10 +767,11 @@ class Producer { class Producer_2 { constructor(sequence: number); - close(abort?: Error): void; + abort(error: Error): void; get closed(): GetPromise; consume(): Consumer_2; demand(): Demand_2; + finish(): void; get isClosed(): boolean; readonly sequence: number; writeFrame(frame: Frame): void; @@ -779,6 +789,7 @@ class Producer_3 { get closed(): GetPromise; consume(): Consumer_4; createTrack(name: string, info?: Partial): track.Producer; + demand(): Demand_3; removeTrack(name: string): void; get route(): Route | undefined; track(name: string): track.Consumer; @@ -790,6 +801,7 @@ class Producer_4 { close(abort?: Error): void; get closed(): GetPromise; consume(): Consumer; + unused(): Promise; } class Producer_5 implements Table { @@ -1073,7 +1085,6 @@ function stripPrefix(prefix: Valid, path: Valid): Valid | null; class Subscriber { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; diff --git a/js/net/api/zod.api.md b/js/net/api/zod.api.md index 953b970143b..f88e7587111 100644 --- a/js/net/api/zod.api.md +++ b/js/net/api/zod.api.md @@ -18,7 +18,7 @@ type Bound = { }; class Consumer { - close(abort?: Error): void; + close(): void; get closed(): GetPromise; get done(): boolean; get frameCount(): number; @@ -116,7 +116,6 @@ type Order = "ascending" | "descending"; class Ordered { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; @@ -143,13 +142,15 @@ interface Position_2 { class Producer { constructor(name: string); + abort(error: Error): void; accept(info?: Partial): this; appendDatagram(timestamp: Timestamp | undefined, payload: Uint8Array): number; appendGroup(): Producer_2; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; demand(): Demand; dropGroups(start: number, end: number, error: Error): void; + finish(): void; finishAt(end: Position_2): void; info(): Promise; insertDatagram(sequence: number, timestamp: Timestamp | undefined, payload: Uint8Array): void; @@ -164,10 +165,11 @@ class Producer { class Producer_2 { constructor(sequence: number); - close(abort?: Error): void; + abort(error: Error): void; get closed(): GetPromise; consume(): Consumer; demand(): Demand_2; + finish(): void; get isClosed(): boolean; readonly sequence: number; writeFrame(frame: Frame): void; @@ -198,7 +200,6 @@ class Staged { class Subscriber { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 70569c6f12d..dcd482fbffa 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -65,6 +65,33 @@ test("closing resolves next with undefined", async () => { expect(await consumer.next()).toBeUndefined(); }); +test("closing a consumer releases only that handle", async () => { + const producer = new Announce.Producer(); + const first = producer.consume(); + const second = producer.consume(); + const unused = producer.unused(); + + first.close(); + first.close(); + expect(await first.next()).toBeUndefined(); + expect(producer.closed.peek()).toBeUndefined(); + + // The other reader keeps the stream: an event still reaches it. + const route = Route.default; + producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); + expect((await second.next())?.prefix).toBe(p("a")); + + // The last one leaving is what the producer watches for, and a reader parked on next() ends. + const parked = second.next(); + second.close(); + expect(await parked).toBeUndefined(); + await unused; + expect(producer.closed.peek()).toBeUndefined(); + + producer.close(); + await expect(producer.unused()).rejects.toThrow("closed"); +}); + test("aborting rejects next", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 35ba251bf62..1bf6a973c4c 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -68,6 +68,8 @@ class AnnounceState { // A reader holds the last event it took before syncing until it asks for the next // one, so whatever it does with that event lands before synced flips. reading = false; + // Open consumer handles. The stream is unused once the last one closes. + consumers = new Signal(0); } // Synced once nothing is owed and the reader has come back for more. @@ -109,6 +111,20 @@ export class Producer { return makeConsumer(this.#state); } + /** + * Resolves once no consumer holds the stream: the last one {@link Consumer.close}d. Rejects + * once the stream closes. + */ + async unused(): Promise { + const state = this.#state; + for (;;) { + const closed = state.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("announcements are closed"); + if (state.consumers.peek() === 0) return; + await Signal.race(state.consumers, state.closed); + } + } + /** Writes an event to the queue. */ append(event: Event) { if (this.#state.closed.peek() !== undefined) throw new Error("announcements are closed"); @@ -162,9 +178,12 @@ let makeConsumer: (state: AnnounceState) => Consumer; */ export class Consumer { #state: AnnounceState; + // Guards against a double close() on this handle over-decrementing the consumer count. + #released = false; private constructor(state: AnnounceState) { this.#state = state; + state.consumers.update((count) => count + 1); } /** Settles once the stream closes; see {@link Producer.closed}. */ @@ -198,12 +217,13 @@ export class Consumer { } } - /** Returns the next event, or undefined once the stream closes. */ + /** Returns the next event, or undefined once the stream or this handle closes. */ async next(): Promise { const state = this.#state; state.reading = false; settle(state); for (;;) { + if (this.#released) return undefined; const announce = state.queue.peek().shift(); if (announce) { if (state.owed) state.owed -= 1; @@ -215,12 +235,17 @@ export class Consumer { if (closed instanceof Error) throw closed; if (closed !== undefined) return undefined; - await Signal.race(this.#state.queue, this.#state.closed); + await Signal.race(state.queue, state.closed, state.consumers); } } - /** Closes the reader. Idempotent. */ - close(abort?: Error) { - closeState(this.#state, abort); + /** + * Release this handle, like dropping a Rust handle: other consumers keep reading, and the + * producer sees the stream {@link Producer.unused} once the last one closes. Idempotent. + */ + close() { + if (this.#released) return; + this.#released = true; + this.#state.consumers.update((count) => count - 1); } } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index a7517bf9e51..c8e245a68b5 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -112,7 +112,7 @@ test("a replacement producer starts over", async () => { const group = new GroupProducer(8); first.writeGroup(group); group.writeFrame(textFrame("a", Timestamp.fromMillis(1000))); - group.close(); + group.finish(); firstSubscriber.close(); first.finish(); first.close(); @@ -427,6 +427,25 @@ test("a consumer clone shares the broadcast until every handle closes", () => { expect(clone.closed.peek()).toBeDefined(); }); +test("closing every consumer leaves a produced broadcast to its producer", async () => { + const producer = new BroadcastProducer(); + const track = producer.createTrack("video"); + const consumer = producer.consume(); + const subscriber = consumer.track("video").subscribe(); + consumer.close(); + expect(producer.closed.peek()).toBeUndefined(); + + // The producer watches demand instead: the last subscriber leaving is what it sees. + const unused = producer.demand().unused(); + subscriber.close(); + await unused; + expect(producer.closed.peek()).toBeUndefined(); + + track.close(); + producer.close(); + expect(producer.closed.peek()).toBeNull(); +}); + test("double-closing a consumer handle does not prematurely close the broadcast", () => { const consumer = new TestConsumer(); const clone = consumer.clone(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index a41c8ba1314..da79b742a77 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -62,9 +62,11 @@ class BroadcastState { // publisher that has started pulling them. The counterpart of a live Rust `broadcast::Dynamic`; // without one, a track nobody publishes is `NotFound` rather than a request nobody will answer. served = false; - // Live consumer handles sharing this state (see {@link Consumer.clone}). The broadcast - // closes once the last one closes, so a shared consumer can be handed to several callers. + // Live consumer handles sharing this state (see {@link Consumer.clone}). consumers = 0; + // A wire layer's broadcast, which has no producer: it ends once no consumer holds it, as a + // Rust session retires a source nothing holds. A produced broadcast ends only on its close. + unheld = false; used = new Signal(false); active = 0; // The demand watcher of each open track, disposed when it closes or leaves the broadcast. @@ -326,9 +328,9 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info let makeDemand: (state: BroadcastState) => Demand; /** - * A watch-only view of demand for any track in a broadcast. + * A watch-only view of demand for any track in a broadcast, mirroring the Rust `broadcast::Demand`. * - * @internal Tests only; nothing outside the package consumes it. + * @public */ export class Demand { #state: BroadcastState; @@ -411,9 +413,8 @@ export class Producer { } /** - * Watch demand for the broadcast's tracks. - * - * @internal Tests only; nothing outside the package consumes it. + * Watch demand for the broadcast's tracks: a publisher that should stop once nobody reads + * waits on its {@link Demand.unused}, since closing every consumer leaves the broadcast open. */ demand(): Demand { return this.#demand; @@ -569,6 +570,7 @@ export class Consumer { // A standalone consumer is a wire layer's, which serves every track on demand. this.#state = new BroadcastState(); this.#state.served = true; + this.#state.unheld = true; } this.#state.consumers++; registerWire(this, { @@ -623,8 +625,9 @@ export class Consumer { * Settles with `null` once the broadcast closes; a broadcast end carries no cause. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. * - * Shared by every {@link clone}: it settles once the last handle closes. The subscribing - * wire layer peeks it to evict a closed entry from its per-path consume cache. + * Shared by every {@link clone}. A produced broadcast settles once its producer closes; a + * broadcast consumed from a session, once the session ends it or the last handle closes. + * The subscribing wire layer peeks it to evict a closed entry from its per-path consume cache. */ get closed(): GetPromise { return this.#state.closed; @@ -634,7 +637,7 @@ export class Consumer { * Return another handle to the same broadcast, reference-counted with this one. * * Both handles read the same tracks, carry the same {@link path}, and share one {@link closed} - * state; the broadcast closes only once *every* handle has {@link close}d. Used by the connection's per-path + * state. Used by the connection's per-path * consume cache to share one subscription across callers. Subclasses that resolve info over * the wire override this to preserve their type (see the wire layer's consumed broadcast). */ @@ -679,13 +682,13 @@ export class Consumer { } /** - * Release this handle. The broadcast is closed once this was the last live handle; - * while other {@link clone}s remain open it stays live. + * Release this handle, like dropping a Rust handle. A produced broadcast stays open until + * its producer closes; one consumed from a session ends once no {@link clone} holds it. */ close(): void { if (this.#closed) return; this.#closed = true; - if (--this.#state.consumers > 0) return; + if (--this.#state.consumers > 0 || !this.#state.unheld) return; closeState(this.#state); } } diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 9a737265a5e..e177d54929e 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -64,7 +64,13 @@ class FakeSession { const stream = new Announce.Producer(); this.requests.push({ scope: scope.text, hidden: options?.hidden, stream }); if (this.syncedAtOnce) stream.synced(); - return stream.consume(); + const consumer = stream.consume(); + // The last reader leaving cancels the request, as a session's does. + void stream.unused().then( + () => stream.close(), + () => {}, + ); + return consumer; } /** The scopes of the announce requests still open. */ diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index e6f09fe9b01..4b1913fc401 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -354,7 +354,11 @@ export class Connection { pumpAnnounced(effect, origin.announced(scope, options), producer); }); - void consumer.closed.then(stop); + void producer.unused().then( + () => producer.close(), + () => {}, + ); + void producer.closed.then(stop); return consumer; } diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 849f663417f..a1b2bdf9a1c 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -719,7 +719,11 @@ export class Reload { }); this.#signals.cleanup(() => pump.close()); - void consumer.closed.then(() => pump.close()); + void producer.unused().then( + () => producer.close(), + () => {}, + ); + void producer.closed.then(() => pump.close()); return consumer; } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 365b7b47752..277b37c07bf 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -8,7 +8,7 @@ import { error, ProtocolViolation, SessionCode, SessionError, StreamCode, unauth import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "../group.ts"; import { type Hop, HopSchema } from "../hop.ts"; import { hooks } from "../internal.ts"; -import { createMockTransportPair } from "../mock.ts"; +import { createMockTransportPair, SessionBroadcast } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Reader, Stream, Writer } from "../stream.ts"; @@ -435,7 +435,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => await resolving; expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. - const answer = new BroadcastProducer().consume(); + const answer = new SessionBroadcast(); hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); @@ -485,7 +485,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as await resolving; expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. - const answer = new BroadcastProducer().consume(); + const answer = new SessionBroadcast(); if (pending) hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 763c1d329c7..9b2ee1af076 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -355,13 +355,19 @@ export class Subscriber { }); } this.#announcedConsumers.set(announced, filter); + const consumer = announced.consume(); + // The last reader leaving ends the stream, which cancels the request. + void announced.unused().then( + () => announced.close(), + () => {}, + ); void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => { this.#announcedConsumers.delete(announced); announced.close(); }); - return announced.consume(); + return consumer; } /** diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index a4a2494718d..a1b1aa8a263 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -258,8 +258,14 @@ export class Subscriber { announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { const announced = new announce.Producer(); // The wire speaks announce interest by prefix, and echoes suffixes beneath it. + const consumer = announced.consume(); + // The last reader leaving ends the stream, which cancels the request. + void announced.unused().then( + () => announced.close(), + () => {}, + ); void this.#runAnnounced(announced, scopeHead(scope), scope, options?.hidden ?? false); - return announced.consume(); + return consumer; } async #runAnnounced( diff --git a/js/net/src/mock.ts b/js/net/src/mock.ts index 826842e81cf..ba6e74ef78b 100644 --- a/js/net/src/mock.ts +++ b/js/net/src/mock.ts @@ -4,6 +4,7 @@ * Creates paired client/server transports connected via TransformStreams. */ +import { Consumer as BroadcastConsumer } from "./broadcast.ts"; import type { TransportStats } from "./connection/stats.ts"; import type { Frame } from "./group.ts"; import type { SendStream } from "./stream.ts"; @@ -14,6 +15,17 @@ export function textFrame(text: string, timestamp = Timestamp.now()): Frame { return { payload: new TextEncoder().encode(text), timestamp }; } +/** + * A broadcast as a session hands it out: no producer, so it ends once no handle holds it, + * which is how a test sees the origin let go of an answer. + */ +export class SessionBroadcast extends BroadcastConsumer { + // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public + constructor() { + super(); + } +} + // High watermark to prevent writes from blocking on backpressure. // Real WebTransport has kernel buffers; we simulate this with a large queue. const WRITABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index 89d2f5e207c..0b5fb78344d 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -12,7 +12,7 @@ import { Publisher as LitePublisher } from "./lite/publisher.ts"; import { Subscribe as LiteSubscribe } from "./lite/subscribe.ts"; import { Track } from "./lite/track.ts"; import { Version as LiteVersion } from "./lite/version.ts"; -import { createMockTransportPair } from "./mock.ts"; +import { createMockTransportPair, SessionBroadcast } from "./mock.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; import { readsFrom } from "./resume.test-util"; @@ -170,9 +170,8 @@ test.each([true, false])( local.close(); } await settle(); - const late = new Broadcast(); - const front = late.consume(); - hooks.acceptRequest(old, front); + const late = new SessionBroadcast(); + hooks.acceptRequest(old, late); expect(late.closed.peek()).toBeNull(); const next = origin.request(path); const fresh = (await requests.next()).value; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 7f157c7520d..ca385798563 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -7,6 +7,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; import { hooks, spreadHash } from "./internal.ts"; +import { SessionBroadcast } from "./mock.ts"; import type { Consumer, Table } from "./origin.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; @@ -239,25 +240,32 @@ test("a republish closes the superseded broadcast", async () => { origin.close(); }); -test("a consumer clone keeps a superseded broadcast alive", async () => { +test("a subscriber keeps a superseded broadcast alive", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); const first = publish(origin, path); + const track = first.createTrack("video"); const mine = await routed(consumer, path); - expect(mine).toBeDefined(); + const subscriber = mine?.track("video").subscribe(); + expect(subscriber).toBeDefined(); + // Demand lands a microtask after the subscribe. + await settle(); publish(origin, path); await settle(); - // The application's clone holds the old broadcast open even though it is unpublished. + // The subscriber holds the old broadcast open even though it is unpublished. expect(first.closed.peek()).toBeUndefined(); - mine?.close(); + // A handle alone is no demand: the last subscriber leaving closes it. + subscriber?.close(); await settle(); - expect(first.closed.peek()).not.toBeUndefined(); + expect(first.closed.peek()).toBeNull(); + mine?.close(); + track.close(); origin.close(); }); @@ -481,10 +489,10 @@ test("a request resolves once a front answers, and survives its withdrawal", asy expect(request.active.peek()).toBeUndefined(); // A session answers (simulated): the slot's front resolves the request. - const upstream = new BroadcastProducer(); + const upstream = new SessionBroadcast(); const slot = wireOf(origin).requests.peek()?.get(path); expect(slot).toBeDefined(); - expect(wireOf(origin).answer(path, upstream.consume())).toBeDefined(); + expect(wireOf(origin).answer(path, upstream)).toBeDefined(); expect(request.active.peek()).toBeDefined(); // A second request for the same path shares the answer, each through a handle of its @@ -1971,16 +1979,21 @@ test("a broadcast a derived handle released cannot announce again", async () => const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const path = Path.from("room"); const broadcast = publish(scoped, path); + const track = broadcast.createTrack("video"); const held = await routed(consumer, path); + const subscriber = held?.track("video").subscribe(); + await settle(); scoped.close(); - // The application's clone keeps the broadcast open, but the released handle no longer routes it. + // A subscriber keeps the broadcast open, but the released handle no longer routes it. expect(broadcast.closed.peek()).toBeUndefined(); expect(() => broadcast.announce()).toThrow("broadcast is closed"); expect((await announcedView(origin)).peek().has(path)).toBe(false); + subscriber?.close(); held?.close(); + track.close(); origin.close(); }); @@ -2516,7 +2529,7 @@ test("an unread resolved request retains its broadcast until its last handle clo const dynamic = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); const requests = dynamic.requested(); const held = origin.consume().request(path); - const old = new BroadcastProducer(); + const old = new SessionBroadcast(); hooks.acceptRequest((await requests.next()).value, old); // Change epochs before the active getter or its subscription can clone the answer. dynamic.updateRoute({ epoch: NEWER }); @@ -2527,7 +2540,6 @@ test("an unread resolved request retains its broadcast until its last handle clo expect(held.active.peek()).toBeUndefined(); await requests.return?.(); dynamic.close(); - old.close(); origin.close(); }); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 2c54c2cb456..a8e0736028e 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -480,6 +480,25 @@ function publishPath(path: Path.Valid): void { } } +// A broadcast created through the origin: its producer, and the handle the origin routes. +interface Created { + producer: broadcast.Producer; + front: broadcast.Consumer; +} + +// Let go of a created broadcast the origin no longer routes. Only a producer ends a broadcast, as +// in Rust, so it closes once nobody subscribes to it rather than under a reader still draining it. +function retire({ producer, front }: Created): void { + front.close(); + const demand = producer.demand(); + if (!demand.used.peek()) producer.close(); + else + void demand.unused().then( + () => producer.close(), + () => {}, + ); +} + /** Reactive backing state shared by origin producers and consumers. */ class OriginState { // Both tables decouple the application producing into the origin from the @@ -493,7 +512,7 @@ class OriginState { // newest first. Local and routes stay separate so a session can never announce a // received entry back to a peer, which is what makes an origin shared by both // directions echo-free. - created: Map | undefined = new Map(); + created: Map | undefined = new Map(); local = new VersionedSignal | undefined>(new Map()); advertisedLocal = new VersionedSignal | undefined>(new Map()); routes = new VersionedSignal | undefined>(new Map()); @@ -1188,8 +1207,8 @@ export class Producer implements Table { * withdraws it from everyone again. * * Close the producer to drop it. Creating a path again supersedes the previous - * broadcast: the origin drops its handle on the old one, which closes it unless the - * application still holds a consumer clone. Announce with a {@link Route.epoch} + * broadcast: the origin retracts the old one and closes it once nobody subscribes to it. + * Announce with a {@link Route.epoch} * (`Epoch.mint()` per run) so a restart replaces the old broadcast rather than resuming * into it; at the same epoch, a local broadcast competes on cost and wins ties. */ @@ -1213,20 +1232,21 @@ export class Producer implements Table { : undefined, }); + const entry = { producer, front }; const previous = created.get(path); - created.set(path, front); + created.set(path, entry); if (previous) { - this.#retractExact(path, previous); - previous.close(); + this.#retractExact(path, previous.front); + retire(previous); } // Drop it when the broadcast closes, unless a recreate already replaced it: a stale // broadcast closing must not unpublish the live one. Clearing `created` also stops a - // later announce, since a consumer clone can keep the broadcast open past this close. + // later announce, since a subscriber can keep the broadcast open past this release. const release = this.#hold(() => { this.#retractExact(path, front); - if (this.#state.created?.get(path) === front) this.#state.created.delete(path); - front.close(); + if (this.#state.created?.get(path) === entry) this.#state.created.delete(path); + retire(entry); }); void front.closed.then(release); @@ -1235,7 +1255,7 @@ export class Producer implements Table { #advertiseExact(path: Path.Valid, front: broadcast.Consumer, route: Route): void { if (!this.#state.local.peek()) throw new Error("origin is closed"); - if (this.#state.created?.get(path) !== front) throw new Error("broadcast is closed"); + if (this.#state.created?.get(path)?.front !== front) throw new Error("broadcast is closed"); // Both maps move together, so every reader sees the broadcast and its route at once. this.#state.local.mutate((broadcasts) => { broadcasts?.set(path, front); @@ -1487,9 +1507,7 @@ export class Producer implements Table { if (this.#state.closed.peek() !== undefined) return; this.#state.closed.set(abort ?? null); for (const watch of this.#state.watchers.values()) watch.version.update((n) => n + 1); - for (const front of this.#state.created?.values() ?? []) { - front.close(); - } + for (const created of this.#state.created?.values() ?? []) retire(created); this.#state.created = undefined; this.#state.local.update(() => undefined); this.#state.advertisedLocal.update(() => undefined); @@ -1967,7 +1985,13 @@ export class Consumer { const patterns = this.#scope.patterns(scope); void producer.closed.then(this.#state.watch([...patterns].map(scopeHead))); void this.#runAnnounced(producer, patterns, options?.hidden ?? false); - return producer.consume(); + const consumer = producer.consume(); + // The last reader leaving ends the stream and its watch. + void producer.unused().then( + () => producer.close(), + () => {}, + ); + return consumer; } /** The routes visible to one announcement watcher. */ diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index f382640a3e4..8cd39700aca 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -71,7 +71,7 @@ Rejected in planning: - [Session-only items stay private](/quest/m1/api-audit/model-private.md) - model items only the built-in sessions use are private - [A request's source enforces its validity](/quest/m1/api-audit/answer.md) - a stale source refuses new track requests by itself, with no public additions - [moq-transport stays private](/quest/m1/api-audit/ietf-private.md) - no public moq-net item exists only for moq-transport, and a client names its request target once as a URL -- [One lifecycle vocabulary](/quest/m1/api-audit/lifecycle.md) - finish, abort, closed, and close mean one thing on every handle, and Rust and JS observers fire alike +- [One lifecycle vocabulary in @moq/e2ee](/quest/m1/api-audit/lifecycle.md) - @moq/e2ee ends tracks and groups with finish and abort, as @moq/net does - [moq-net names say one thing](/quest/m1/api-audit/net-names.md) - demand says `wanted`, colliding stats and transport names are renamed, and Rust and JS info lookups mirror - [Request handlers are named requests](/quest/m1/api-audit/requests.md) - `dynamic()` and `Dynamic` become `requests()` and `Requests` at every layer diff --git a/quest/m1/api-audit/lifecycle.md b/quest/m1/api-audit/lifecycle.md index a158d0b9390..4aa41d96977 100644 --- a/quest/m1/api-audit/lifecycle.md +++ b/quest/m1/api-audit/lifecycle.md @@ -1,35 +1,21 @@ -# [M] One lifecycle vocabulary +# [S] One lifecycle vocabulary in @moq/e2ee ## Goal -Every model handle in moq-net and `@moq/net` (broadcast, track, group, their -demand, listings) uses each lifecycle word with one meaning, and Rust and JS -agree on when each observer fires. +`@moq/e2ee` uses the lifecycle words the way `@moq/net` does, so a publisher +wrapping its tracks and groups in encryption ends them with the same calls. ## Plan Decided 2026-10-23 (API audit follow-up, maintainer): `finish()` declares a clean end, `abort(err)` is terminal, `closed()` resolves on the final outcome, and JS `close()` releases only that handle, like a Rust drop. -[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed the -producer verbs, `closed()` semantics in both languages, group consumer -`close()`, `Lister.closed`, `Demand.unused`, and the reader `end()` removal. +[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed this in +moq-net and `@moq/net`, including consumer `close()` releasing only its handle +for groups, announcements, and broadcasts. -What remains is JS consumers whose `close()` still ends state other handles -share. The JS wire layer and origin rely on it today: +What remains: `@moq/e2ee` still wraps the old verbs +(`Group.Producer.close(abort?)`, `Track.Producer.close(abort?)`). Split them +into `finish()` and `abort(err)` as in `@moq/net`. -- `Announce.Consumer.close(abort?)` closes the shared announcement queue, which - is how a reader cancels the stream. Pick: count consumers and give the - producer an `unused()` the session watches (recommended), or keep one reader - per queue and document `close()` as cancel. -- The last `Broadcast.Consumer.close()` closes the broadcast, including a - producer's. The origin relies on it: superseding a published broadcast - closes its producer once no consumer holds it. Pick: the producer stays open - and publishers watch `demand().unused()`, as in Rust (recommended), or keep - it for wire-owned broadcasts only and close superseded producers explicitly. - -`@moq/e2ee` wraps the old verbs (`Group.Producer.close(abort?)`); mirror the -net split there. - -Public API: `@moq/net` breaking (consumer `close()` semantics), plus -`@moq/e2ee`. Wire: none. +Public API: `@moq/e2ee` breaking. Wire: none. From 08854f91b0d87c52ac59c44928906a69b7243ea6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 08:58:54 -0700 Subject: [PATCH 31/58] test(e2ee): observe a consumer's release on a session broadcast Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/e2ee.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index 22c7df742f7..a186320abfa 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -246,15 +246,32 @@ test("a broadcast speaks semantic names and puts physical names on the wire", as net.close(); }); +// A broadcast as a session hands it out: no producer, so it ends once no handle holds it. +class SessionBroadcast extends Broadcast.Consumer { + // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public + constructor() { + super(); + } +} + test("closing a protected consumer releases its broadcast handle", () => { const generation = credential().produce(); - const net = new Broadcast.Producer(); - const consumer = new Protected.Consumer(net.consume(), generation.credential.consume(generation.epoch)); + const net = new SessionBroadcast(); + const consumer = new Protected.Consumer(net, generation.credential.consume(generation.epoch)); expect(consumer.closed.peek()).toBeUndefined(); consumer.close(); consumer.close(); expect(consumer.closed.peek()).toBeNull(); +}); + +test("closing a protected consumer leaves a produced broadcast to its producer", () => { + const generation = credential().produce(); + const net = new Broadcast.Producer(); + const consumer = new Protected.Consumer(net.consume(), generation.credential.consume(generation.epoch)); + consumer.close(); + expect(net.closed.peek()).toBeUndefined(); net.close(); + expect(consumer.closed.peek()).toBeNull(); }); test("closing a protected producer ends its broadcast", () => { From b3fb2d2ef3cd99252b71cf20fec7dcd5c226ebd5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 09:35:10 -0700 Subject: [PATCH 32/58] chore: refresh the @moq/net API report for requests() Co-Authored-By: Claude Opus 5.5 --- js/net/api/index.api.md | 22 +++++++++++----------- rs/moq-net/src/model/mod.rs | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index dcb352605a4..b9cdd1a1da4 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -349,13 +349,6 @@ class Duplicate extends Error { constructor(message?: string); } -class Dynamic { - close(): void; - readonly prefix: Path_2.Valid; - requested(): AsyncIterableIterator; - get route(): Route; -} - function empty(): Valid; function encode(path: Valid): Valid; @@ -639,7 +632,7 @@ declare namespace Origin_2 { Producer_5 as Producer, Requesting, Consumer_5 as Consumer, - Dynamic, + Requests, Request_3 as Request, Prepared } @@ -656,7 +649,7 @@ declare namespace Origin_3 { Producer_5 as Producer, Requesting, Consumer_5 as Consumer, - Dynamic, + Requests, Request_3 as Request, Prepared } @@ -800,8 +793,8 @@ class Producer_5 implements Table { consume(): Consumer_5; createBroadcast(path: Path_2.Valid): Broadcast_2.Producer; get discovery(): Getter; - dynamic(prefix: Path_2.Valid): Dynamic; request(path: Path_2.Valid, options?: RequestOptions): Requesting; + requests(prefix: Path_2.Valid): Requests; scope(root: Path_2.Valid, patterns: Path_2.Patterns): Producer_5; } @@ -891,6 +884,13 @@ interface RequestOptions { epoch?: Epoch_2.Valid; } +class Requests { + close(): void; + readonly prefix: Path_2.Valid; + requested(): AsyncIterableIterator; + get route(): Route; +} + class Reservation implements Disposable { [Symbol.dispose](): void; close(): void; @@ -1099,8 +1099,8 @@ interface Table { readonly closed: GetPromise; createBroadcast(path: Path_2.Valid): Broadcast_2.Producer; readonly discovery: Getter; - dynamic(prefix: Path_2.Valid): Dynamic; request(path: Path_2.Valid, options?: RequestOptions): Requesting; + requests(prefix: Path_2.Valid): Requests; } declare namespace Time { diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 87abe4cc2e7..56e87852501 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -49,8 +49,8 @@ pub use timed::Timed; pub mod origin { pub use super::follow::AnnouncedBroadcast; pub use super::origin_impl::{ - Config, Consumer, Cost, Driver, Requests, HopLimit, Link, Preference, Prepared, Producer, Publication, Request, - Requesting, Route, Routing, Source, + Config, Consumer, Cost, Driver, HopLimit, Link, Preference, Prepared, Producer, Publication, Request, + Requesting, Requests, Route, Routing, Source, }; } From b91d74b78126da405315f88e03a5c5da5e303722 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 09:57:18 -0700 Subject: [PATCH 33/58] fix(net): settle a track's outcome after its groups, and end a released reader's parked read Rust track closed() resolved Ok as soon as the declared end was reached, while a group below it was still open and could abort. It now waits for those groups to finish, as JS does. JS Group.Consumer.close() on a shared handle now wakes its own parked readFrame()/readable(), and done/isClosed reflect the release. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.test.ts | 19 +++++++++++++++ js/net/src/group.ts | 13 ++++++---- rs/moq-net/src/model/track.rs | 45 +++++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 2de98500243..009f464b443 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -65,6 +65,25 @@ test("closing a consumer releases only that handle", async () => { expect(await other.readFrame()).toBeUndefined(); }); +test("closing a consumer ends its own parked reads", async () => { + const { producer, consumer } = pair(0); + const other = producer.consume(); + const reading = consumer.readFrame(); + const waiting = consumer.readable(); + consumer.close(); + expect(await reading).toBeUndefined(); + await waiting; + expect(consumer.isClosed).toBe(true); + expect(consumer.done).toBe(true); + + // The other reader is still parked on the open group, and reads the next frame. + const next = other.readFrame(); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + expect((await next)?.payload[0]).toBe(1); + expect(other.isClosed).toBe(false); + producer.finish(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 3649b1ebf7a..7cd6dc85db2 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -756,7 +756,8 @@ export class Consumer { // The next change a parked read waits for. #changed(): GetPromise { - const changed: Getter[] = [this.#state.frames, this.#state.closed]; + // The verdict too: releasing a shared handle settles only it, and must end a parked read. + const changed: Getter[] = [this.#state.frames, this.#state.closed, this.#verdict]; if (this.#expiry) changed.push(...this.#expiry.changed); if (this.#recover) changed.push(this.#recover.changed); return Signal.race(...changed); @@ -878,14 +879,16 @@ export class Consumer { return { sequence, frame, inflated: slice?.inflated }; } - /** True once no further frames can be read: the group has closed and every buffered frame is read. */ + /** True once no further frames can be read: this handle or the group has closed, and every buffered frame is read. */ get done(): boolean { - return this.#terminal !== undefined || (this.#bufferedFrames().length === 0 && this.#settled()); + return ( + this.#terminal !== undefined || (this.#bufferedFrames().length === 0 && (this.#released || this.#settled())) + ); } - /** True once the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */ + /** True once this handle or the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */ get isClosed(): boolean { - return this.#terminal !== undefined || this.#settled(); + return this.#terminal !== undefined || this.#released || this.#settled(); } // Whether the copy being read ended, for good: a failure recovery may continue is not an end. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index e92e6909c54..f08af848bc7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2501,10 +2501,23 @@ impl TrackState { .map(|()| producer.read().abort.clone().unwrap_or(Error::Dropped)) } - /// The track's outcome once final: `Ok` once its declared end is reached, which a later - /// abort keeps, or the abort error. + /// The track's outcome once final: `Ok` once its declared end is reached with every + /// cached group below it finished, which a later abort keeps, or the abort error. fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { if self.is_complete() { + // An abort after the end settled keeps the clean end; until then a group below it + // may still abort, which would cut the track off, so wait for each to finish. One + // that aborts leaves the outcome to the track's own abort or drop. + if self.abort.is_none() + && let Some(fin) = self.final_sequence() + { + for (_, slot) in self.lookup.range(..fin) { + if !slot.group.is_finished() { + let _ = slot.group.poll_closed(waiter); + return Poll::Pending; + } + } + } Poll::Ready(Ok(())) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) @@ -13000,6 +13013,34 @@ mod test { assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); } + /// A group still open below a reached end keeps the outcome pending: it finishing settles + /// the end and wakes the wait, while an abort before then cuts the track off. + #[test] + #[cfg(feature = "async")] + fn closed_waits_for_open_groups_below_the_end() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut producer = track_producer("test", Info::timed()); + let mut open = producer.append_group().unwrap(); + producer.finish_at(Position::group(1)).unwrap(); + let woken = Arc::new(AtomicBool::new(false)); + let waiter = kio::Waiter::new(futures::task::waker(Arc::new(FlagWake(woken.clone())))); + assert!(producer.poll_closed(&waiter).is_pending(), "group 0 may still abort"); + open.finish().unwrap(); + assert!(woken.load(Ordering::SeqCst), "the group finishing wakes the wait"); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let mut cut = track_producer("cut", Info::timed()); + let _open = cut.append_group().unwrap(); + cut.finish_at(Position::group(1)).unwrap(); + assert!(cut.poll_closed(&kio::Waiter::noop()).is_pending()); + cut.clone().abort(Error::Cancel).unwrap(); + assert!(matches!( + cut.poll_closed(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Cancel)) + )); + } + /// An abort short of the end, or the last producer leaving without one, is the outcome. #[test] #[cfg(feature = "async")] From f8979320a2e0349e21f08c740adeb6d34337cd71 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:18:58 -0700 Subject: [PATCH 34/58] fix(net): count a received end's tail before settling, and list an open finished track as finished A wire end declared behind a hole settled the JS track's outcome before the pending tail was recorded, so closed reported a clean end with groups owed. A broadcast closing now reports a track whose producer finished but stays open as finished, as the Rust listing does. Co-Authored-By: Claude Opus 5.5 --- js/net/src/internal.ts | 5 +++++ js/net/src/listing.test.ts | 11 +++++++++++ js/net/src/listing.ts | 8 ++++++-- js/net/src/track.test.ts | 20 ++++++++++++++++++++ js/net/src/track.ts | 4 +++- 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 7a11ff61e99..af837c4b186 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -356,6 +356,8 @@ export const hooks: { trackReleased: (producer: Producer) => GetPromise; /** {@link trackReleased} for a demand handle. Assigned by `track.ts`. */ demandReleased: (demand: Demand) => GetPromise; + /** Whether the producer declared the track's end, released or not. Assigned by `track.ts`. */ + trackFinished: (producer: Producer) => boolean; /** The producer's largest reported location and its subsequent changes. */ producerLargest: (producer: Producer) => Getter; @@ -536,6 +538,9 @@ export const hooks: { demandReleased: () => { throw new Error("track.ts not loaded"); }, + trackFinished: () => { + throw new Error("track.ts not loaded"); + }, producerLargest: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index f1bc8291c7a..a74c0f92f77 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -94,6 +94,17 @@ test("a finished track is listed while the table holds it", async () => { expect(await tracks.drain()).toEqual(["end"]); }); +test("a track that finished but stays open ends the listing as finished", async () => { + const broadcast = new BroadcastProducer(); + const a = broadcast.createTrack("a"); + broadcast.createTrack("b"); + a.finish(); + const tracks = list(broadcast); + expect(await tracks.drain()).toEqual(["+a", "+b", "synced"]); + broadcast.close(); + expect(await tracks.drain()).toEqual(["~a", "end"]); +}); + test("a lister finishes or removes a track", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index b7c5c6dd128..05f612d0533 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -35,9 +35,13 @@ export type TracksEvent = | { kind: "removed"; name: string } | { kind: "finished"; name: string }; -/** Whether `own` closed cleanly, so its track leaves the listing as finished. */ +/** + * Whether `own` leaves the listing as finished: it closed cleanly, or it declared its end and is + * still open, as the Rust listing counts a producer that finished but still holds its track. + */ function finished(own: track.Producer): boolean { - return hooks.trackReleased(own).peek() === null; + const released = hooks.trackReleased(own).peek(); + return released === null || (released === undefined && hooks.trackFinished(own)); } /** One listed track and who lists it. */ diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index f1e06cc780e..3e05e5e62c0 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -3519,6 +3519,26 @@ async function settled(read: Promise): Promise { return done; } +// The end arrives after a higher group did, with lower ones still owed: it must not settle. +test("a received end behind a hole settles only once the hole fills", () => { + const producer = new TrackProducer("test"); + const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + hooks.startTrack(producer, { start: 0 }); + const late = new GroupProducer(2); + late.finish(); + producer.writeGroup(late); + hooks.endTrack(producer, { group: 3, frame: 0 }); + expect(producer.closed.peek()).toBeUndefined(); + + for (const sequence of [0, 1]) { + const group = new GroupProducer(sequence); + group.finish(); + producer.writeGroup(group); + } + expect(producer.closed.peek()).toBeNull(); + reader.close(); +}); + test("a pending tail holds readers at a hole until it fills", async () => { const { producer, reader, write } = pendingTail(3); write(2); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 3fffc5ac404..07936253d46 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -815,6 +815,7 @@ export class Producer { hooks.endTrack = (producer, end) => producer.#endPending(end); hooks.releaseTrack = (producer) => producer.#close(undefined); hooks.trackReleased = (producer) => producer.#state.closed; + hooks.trackFinished = (producer) => producer.#state.end.peek() !== undefined; hooks.startTrack = (producer, served) => { producer.#served = served; producer.#owe(); @@ -1562,6 +1563,8 @@ export class Producer { // The wire's end: repeating it is a no-op, and a mid-group end may refine the group // boundary it falls just before. Mirrors the Rust `finish_at` on a `received()` copy. #endPending(end: Position): void { + // Before the end lands, so settling it already counts what the tail still owes. + this.#tailPending = true; const declared = this.#state.end.peek(); if (declared === undefined) { this.finishAt(end); @@ -1571,7 +1574,6 @@ export class Producer { } this.#declareEnd(end); } - this.#tailPending = true; this.#owe(); } From 9420bff80b7af8447bfa1760b498e7dfd1fcbd14 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:45:28 -0700 Subject: [PATCH 35/58] feat(net)!: announcements say wanted, and the origin closes what it retires Per the newer maintainer decision: Announce.Producer exposes a wanted getter instead of unused(), matching the wanted()/wanted_changed() plan. Broadcast demand stays internal, and the origin closes a superseded or released producer explicitly rather than retiring it on demand. A released announce consumer ends its own parked next() while others still read. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 4 +-- js/net/src/announced.test.ts | 16 ++++++----- js/net/src/announced.ts | 39 +++++++++++++-------------- js/net/src/broadcast.test.ts | 5 ---- js/net/src/broadcast.ts | 9 ++++--- js/net/src/connection/forward.test.ts | 6 ++--- js/net/src/connection/pool.ts | 6 ++--- js/net/src/connection/reload.ts | 6 ++--- js/net/src/ietf/subscriber.ts | 6 ++--- js/net/src/lite/subscriber.ts | 6 ++--- js/net/src/origin.test.ts | 34 +++++++++-------------- js/net/src/origin.ts | 24 ++++++----------- js/net/src/util/announce.ts | 12 +++++++++ 13 files changed, 77 insertions(+), 96 deletions(-) create mode 100644 js/net/src/util/announce.ts diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 1dd9bfb7353..07e5ef15230 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -9,8 +9,8 @@ How to update your code for each breaking change, newest first. `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. `Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. `Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. -`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches `unused()`, which resolves once the last consumer closes, instead of its `closed`. -The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. A publisher that closed its broadcast once readers left watches `demand().unused()` instead. A broadcast created on an origin and then superseded by a new `createBroadcast` at its path closes once nobody subscribes to it, rather than once no consumer handle holds it. +`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches its new `wanted` getter, which turns false once the last consumer closes, instead of its `closed`. +The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. Close the producer when you are done with it. A broadcast created on an origin closes when a new `createBroadcast` supersedes it at its path, or when the origin or scope that created it closes, even while a consumer handle holds it. Tracks already subscribed keep serving until they end. `Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. The wire layer's `Lister.ended` is `closed`. `Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index dcd482fbffa..62ccb950968 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -67,13 +67,18 @@ test("closing resolves next with undefined", async () => { test("closing a consumer releases only that handle", async () => { const producer = new Announce.Producer(); + expect(producer.wanted.peek()).toBe(false); const first = producer.consume(); const second = producer.consume(); - const unused = producer.unused(); + expect(producer.wanted.peek()).toBe(true); + // A reader parked on next() ends when its own handle closes, while another still wants it. + const early = first.next(); first.close(); first.close(); + expect(await early).toBeUndefined(); expect(await first.next()).toBeUndefined(); + expect(producer.wanted.peek()).toBe(true); expect(producer.closed.peek()).toBeUndefined(); // The other reader keeps the stream: an event still reaches it. @@ -81,15 +86,12 @@ test("closing a consumer releases only that handle", async () => { producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); expect((await second.next())?.prefix).toBe(p("a")); - // The last one leaving is what the producer watches for, and a reader parked on next() ends. - const parked = second.next(); + // The last one leaving is what the producer watches for. + const unwanted = producer.wanted.changed(); second.close(); - expect(await parked).toBeUndefined(); - await unused; + expect(await unwanted).toBe(false); expect(producer.closed.peek()).toBeUndefined(); - producer.close(); - await expect(producer.unused()).rejects.toThrow("closed"); }); test("aborting rejects next", async () => { diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 1bf6a973c4c..4fd53dab3e7 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -68,8 +68,9 @@ class AnnounceState { // A reader holds the last event it took before syncing until it asks for the next // one, so whatever it does with that event lands before synced flips. reading = false; - // Open consumer handles. The stream is unused once the last one closes. - consumers = new Signal(0); + // Open consumer handles; the stream is wanted while any is. + consumers = 0; + wanted = new Signal(false); } // Synced once nothing is owed and the reader has come back for more. @@ -112,17 +113,11 @@ export class Producer { } /** - * Resolves once no consumer holds the stream: the last one {@link Consumer.close}d. Rejects - * once the stream closes. + * Whether any consumer still holds the stream: false before the first {@link consume} and + * once the last one {@link Consumer.close}s, which is how a reader cancels it. */ - async unused(): Promise { - const state = this.#state; - for (;;) { - const closed = state.closed.peek(); - if (closed !== undefined) throw closed ?? new Error("announcements are closed"); - if (state.consumers.peek() === 0) return; - await Signal.race(state.consumers, state.closed); - } + get wanted(): Getter { + return this.#state.wanted; } /** Writes an event to the queue. */ @@ -178,12 +173,13 @@ let makeConsumer: (state: AnnounceState) => Consumer; */ export class Consumer { #state: AnnounceState; - // Guards against a double close() on this handle over-decrementing the consumer count. - #released = false; + // Set once this handle closes, ending its own reads and guarding the consumer count. + #released = new Signal(false); private constructor(state: AnnounceState) { this.#state = state; - state.consumers.update((count) => count + 1); + state.consumers++; + state.wanted.set(true); } /** Settles once the stream closes; see {@link Producer.closed}. */ @@ -223,7 +219,7 @@ export class Consumer { state.reading = false; settle(state); for (;;) { - if (this.#released) return undefined; + if (this.#released.peek()) return undefined; const announce = state.queue.peek().shift(); if (announce) { if (state.owed) state.owed -= 1; @@ -235,17 +231,18 @@ export class Consumer { if (closed instanceof Error) throw closed; if (closed !== undefined) return undefined; - await Signal.race(state.queue, state.closed, state.consumers); + await Signal.race(state.queue, state.closed, this.#released); } } /** * Release this handle, like dropping a Rust handle: other consumers keep reading, and the - * producer sees the stream {@link Producer.unused} once the last one closes. Idempotent. + * producer sees the stream no longer {@link Producer.wanted} once the last one closes. + * Idempotent. */ close() { - if (this.#released) return; - this.#released = true; - this.#state.consumers.update((count) => count - 1); + if (this.#released.peek()) return; + this.#released.set(true); + if (--this.#state.consumers === 0) this.#state.wanted.set(false); } } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index c8e245a68b5..e3f957b7e86 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -433,12 +433,7 @@ test("closing every consumer leaves a produced broadcast to its producer", async const consumer = producer.consume(); const subscriber = consumer.track("video").subscribe(); consumer.close(); - expect(producer.closed.peek()).toBeUndefined(); - - // The producer watches demand instead: the last subscriber leaving is what it sees. - const unused = producer.demand().unused(); subscriber.close(); - await unused; expect(producer.closed.peek()).toBeUndefined(); track.close(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index da79b742a77..e9c2dc52a3e 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -328,9 +328,9 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info let makeDemand: (state: BroadcastState) => Demand; /** - * A watch-only view of demand for any track in a broadcast, mirroring the Rust `broadcast::Demand`. + * A watch-only view of demand for any track in a broadcast. * - * @public + * @internal Tests only; nothing outside the package consumes it. */ export class Demand { #state: BroadcastState; @@ -413,8 +413,9 @@ export class Producer { } /** - * Watch demand for the broadcast's tracks: a publisher that should stop once nobody reads - * waits on its {@link Demand.unused}, since closing every consumer leaves the broadcast open. + * Watch demand for the broadcast's tracks. + * + * @internal Tests only; nothing outside the package consumes it. */ demand(): Demand { return this.#demand; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index e177d54929e..ea1e4e40b18 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -10,6 +10,7 @@ import * as Path from "../path.ts"; import { registerWire, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; +import { closeUnwanted } from "../util/announce.ts"; const LINGER = 2000; @@ -66,10 +67,7 @@ class FakeSession { if (this.syncedAtOnce) stream.synced(); const consumer = stream.consume(); // The last reader leaving cancels the request, as a session's does. - void stream.unused().then( - () => stream.close(), - () => {}, - ); + closeUnwanted(stream); return consumer; } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 4b1913fc401..14279b4ac03 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -31,6 +31,7 @@ import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; +import { closeUnwanted } from "../util/announce.ts"; /** How long an unreferenced shared connection lingers before it actually closes. */ const LINGER_MS = Milli(2000); @@ -354,10 +355,7 @@ export class Connection { pumpAnnounced(effect, origin.announced(scope, options), producer); }); - void producer.unused().then( - () => producer.close(), - () => {}, - ); + closeUnwanted(producer); void producer.closed.then(stop); return consumer; diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index a1b2bdf9a1c..d355f7cd398 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -20,6 +20,7 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; +import { closeUnwanted } from "../util/announce.ts"; /** * Exponential backoff settings for the reconnect loop. @@ -719,10 +720,7 @@ export class Reload { }); this.#signals.cleanup(() => pump.close()); - void producer.unused().then( - () => producer.close(), - () => {}, - ); + closeUnwanted(producer); void producer.closed.then(() => pump.close()); return consumer; diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 9b2ee1af076..1f7a5ddc656 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -81,6 +81,7 @@ import { } from "./subscribe_namespace.ts"; import { MalformedAuthToken } from "./token.ts"; import { Version } from "./version.ts"; +import { closeUnwanted } from "../util/announce.ts"; // Wire ceiling (2^62-1). A draining session stamps it on every live route so any other // candidate outranks it, while the route stays selectable as the last path. Matches Rust @@ -357,10 +358,7 @@ export class Subscriber { this.#announcedConsumers.set(announced, filter); const consumer = announced.consume(); // The last reader leaving ends the stream, which cancels the request. - void announced.unused().then( - () => announced.close(), - () => {}, - ); + closeUnwanted(announced); void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => { this.#announcedConsumers.delete(announced); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index a1b1aa8a263..637acce93a4 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -80,6 +80,7 @@ import { Version, waitsForSubscriberFin, } from "./version.ts"; +import { closeUnwanted } from "../util/announce.ts"; // A peer's buffered TRACKS events decode without reaching a task boundary, and a flood of adds // and removals of one name never reaches the announces cap: yield so it cannot starve the host. @@ -260,10 +261,7 @@ export class Subscriber { // The wire speaks announce interest by prefix, and echoes suffixes beneath it. const consumer = announced.consume(); // The last reader leaving ends the stream, which cancels the request. - void announced.unused().then( - () => announced.close(), - () => {}, - ); + closeUnwanted(announced); void this.#runAnnounced(announced, scopeHead(scope), scope, options?.hidden ?? false); return consumer; } diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index ca385798563..6c8a296b30e 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -7,7 +7,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; import { hooks, spreadHash } from "./internal.ts"; -import { SessionBroadcast } from "./mock.ts"; +import { SessionBroadcast, textFrame } from "./mock.ts"; import type { Consumer, Table } from "./origin.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; @@ -240,31 +240,28 @@ test("a republish closes the superseded broadcast", async () => { origin.close(); }); -test("a subscriber keeps a superseded broadcast alive", async () => { +test("a superseded broadcast closes, and its subscribers keep reading", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); const first = publish(origin, path); - const track = first.createTrack("video"); + const track = first.createTrack("video", { timescale: Timescale.MILLI }); const mine = await routed(consumer, path); const subscriber = mine?.track("video").subscribe(); - expect(subscriber).toBeDefined(); - // Demand lands a microtask after the subscribe. - await settle(); + if (!subscriber) throw new Error("expected a subscriber"); publish(origin, path); - await settle(); - - // The subscriber holds the old broadcast open even though it is unpublished. - expect(first.closed.peek()).toBeUndefined(); - - // A handle alone is no demand: the last subscriber leaving closes it. - subscriber?.close(); - await settle(); + // The origin closes the old producer itself, whoever still holds a handle on it. expect(first.closed.peek()).toBeNull(); + // A track already subscribed is not the broadcast: it keeps serving until it ends. + track.writeFrame(textFrame("late")); + expect((await subscriber.recvGroup())?.sequence).toBe(0); + + subscriber.close(); mine?.close(); + track.finish(); track.close(); origin.close(); }); @@ -1979,21 +1976,16 @@ test("a broadcast a derived handle released cannot announce again", async () => const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const path = Path.from("room"); const broadcast = publish(scoped, path); - const track = broadcast.createTrack("video"); const held = await routed(consumer, path); - const subscriber = held?.track("video").subscribe(); - await settle(); scoped.close(); - // A subscriber keeps the broadcast open, but the released handle no longer routes it. - expect(broadcast.closed.peek()).toBeUndefined(); + // The released handle closes what it created, whoever still holds a handle on it. + expect(broadcast.closed.peek()).toBeNull(); expect(() => broadcast.announce()).toThrow("broadcast is closed"); expect((await announcedView(origin)).peek().has(path)).toBe(false); - subscriber?.close(); held?.close(); - track.close(); origin.close(); }); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index a8e0736028e..792d1f85d85 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -37,6 +37,7 @@ import { sameInstance, wireOf, } from "./wire.ts"; +import { closeUnwanted } from "./util/announce.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; @@ -486,17 +487,11 @@ interface Created { front: broadcast.Consumer; } -// Let go of a created broadcast the origin no longer routes. Only a producer ends a broadcast, as -// in Rust, so it closes once nobody subscribes to it rather than under a reader still draining it. +// End a created broadcast the origin no longer routes. Consumer handles never end a produced +// broadcast, so the origin closes its producer; tracks already subscribed keep serving. function retire({ producer, front }: Created): void { front.close(); - const demand = producer.demand(); - if (!demand.used.peek()) producer.close(); - else - void demand.unused().then( - () => producer.close(), - () => {}, - ); + producer.close(); } /** Reactive backing state shared by origin producers and consumers. */ @@ -1207,8 +1202,8 @@ export class Producer implements Table { * withdraws it from everyone again. * * Close the producer to drop it. Creating a path again supersedes the previous - * broadcast: the origin retracts the old one and closes it once nobody subscribes to it. - * Announce with a {@link Route.epoch} + * broadcast: the origin retracts and closes the old one, and tracks already subscribed + * keep serving until they end. Announce with a {@link Route.epoch} * (`Epoch.mint()` per run) so a restart replaces the old broadcast rather than resuming * into it; at the same epoch, a local broadcast competes on cost and wins ties. */ @@ -1242,7 +1237,7 @@ export class Producer implements Table { // Drop it when the broadcast closes, unless a recreate already replaced it: a stale // broadcast closing must not unpublish the live one. Clearing `created` also stops a - // later announce, since a subscriber can keep the broadcast open past this release. + // later announce. const release = this.#hold(() => { this.#retractExact(path, front); if (this.#state.created?.get(path) === entry) this.#state.created.delete(path); @@ -1987,10 +1982,7 @@ export class Consumer { void this.#runAnnounced(producer, patterns, options?.hidden ?? false); const consumer = producer.consume(); // The last reader leaving ends the stream and its watch. - void producer.unused().then( - () => producer.close(), - () => {}, - ); + closeUnwanted(producer); return consumer; } diff --git a/js/net/src/util/announce.ts b/js/net/src/util/announce.ts new file mode 100644 index 00000000000..71f3760a1b8 --- /dev/null +++ b/js/net/src/util/announce.ts @@ -0,0 +1,12 @@ +import { Signal } from "@moq/signals"; +import type { Producer } from "../announced.ts"; + +/** Close an announcement stream once no consumer wants it: how a reader cancels the request. */ +export function closeUnwanted(producer: Producer): void { + void (async () => { + while (producer.wanted.peek() && producer.closed.peek() === undefined) { + await Signal.race(producer.wanted, producer.closed); + } + producer.close(); + })(); +} From 057e2bb0ee4f5c74d77b22d3d46afd6ac5f00cb4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:48:21 -0700 Subject: [PATCH 36/58] feat(c)!: name the request handlers *_requests The maintainer decided moq-c is not frozen and takes the rename like every binding. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/MIGRATE.md | 7 +++++++ rs/moq-c/README.md | 8 ++++---- rs/moq-c/src/api.rs | 36 ++++++++++++++++++------------------ rs/moq-c/src/test.rs | 34 +++++++++++++++++----------------- 4 files changed, 46 insertions(+), 39 deletions(-) diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 27fed02ed2a..ae6188655b1 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Request handlers are named requests + +The request-handler functions are renamed to match moq-net's `requests()`; their arguments and callbacks are unchanged: +`moq_origin_dynamic` and `moq_origin_dynamic_cancel` are `moq_origin_requests` and `moq_origin_requests_cancel`, +`moq_publish_dynamic`, `moq_publish_track_dynamic`, and `moq_track_request_dynamic` are `moq_publish_requests`, `moq_publish_track_requests`, and `moq_track_request_requests`, +and `moq_publish_dynamic_cancel` is `moq_publish_requests_cancel`. + ### Sessions report a lifecycle snapshot `moq_session_connect` and `moq_session_request_accept` take a `moq_state_callback` instead of a `moq_status_callback`: `void on_state(void *user_data, const moq_connection_state *state)`. diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index a9d616eb56a..65c30cbf47f 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -92,10 +92,10 @@ int32_t moq_publish_demand_cancel(uint32_t watcher); int32_t moq_publish_demand_wanted(uint32_t watcher, uint64_t *group, bool *present); // Publishing: Requests -int32_t moq_publish_dynamic(uint32_t broadcast, moq_status_callback on_request, void *user_data); -int32_t moq_publish_track_dynamic(uint32_t track, moq_status_callback on_group, void *user_data); -int32_t moq_track_request_dynamic(uint32_t request, moq_status_callback on_group, void *user_data); -int32_t moq_publish_dynamic_cancel(uint32_t dynamic); +int32_t moq_publish_requests(uint32_t broadcast, moq_status_callback on_request, void *user_data); +int32_t moq_publish_track_requests(uint32_t track, moq_status_callback on_group, void *user_data); +int32_t moq_track_request_requests(uint32_t request, moq_status_callback on_group, void *user_data); +int32_t moq_publish_requests_cancel(uint32_t dynamic); int32_t moq_track_request_name(uint32_t request, moq_string *dst); int32_t moq_track_request_accept(uint32_t request, const moq_track_info *info); int32_t moq_track_request_video(uint32_t request, const moq_video_init *config); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index cff5ec7c619..82554c0b7bf 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -736,7 +736,7 @@ pub struct moq_section { /// A route advertisement: hops and a static cost. /// -/// Pair with [moq_publish_announce] or [moq_origin_dynamic]. Zeroed (NULL hops, +/// Pair with [moq_publish_announce] or [moq_origin_requests]. Zeroed (NULL hops, /// hops_len 0, cost 0) is the default route. `hops` is borrowed for the duration /// of the call that reads it. /// @@ -1774,7 +1774,7 @@ pub unsafe extern "C" fn moq_origin_create_broadcast(origin: u32, path: *const c /// required: a NULL callback is refused before the route is advertised. It is /// invoked with a positive request handle for each /// pending broadcast, then exactly once more with a terminal code: `0` (stopped -/// cleanly, including after [moq_origin_dynamic_cancel]) or a negative error. +/// cleanly, including after [moq_origin_requests_cancel]) or a negative error. /// After the terminal (`<= 0`) callback, `user_data` is never touched again. /// /// Returns a non-zero handle on success, or a negative code on failure. @@ -1784,7 +1784,7 @@ pub unsafe extern "C" fn moq_origin_create_broadcast(origin: u32, path: *const c /// - `on_request` must be non-NULL; a missing callback is refused before the route is advertised. /// - The caller must keep `user_data` valid until the terminal (`<= 0`) `on_request` callback. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_origin_dynamic( +pub unsafe extern "C" fn moq_origin_requests( origin: u32, prefix: *const c_char, prefix_len: usize, @@ -1802,18 +1802,18 @@ pub unsafe extern "C" fn moq_origin_dynamic( /// Stop serving and retract the route. /// /// Returns immediately: zero on success, or a negative code if already closed. -/// The [moq_origin_dynamic] `on_request` callback still fires once more with a +/// The [moq_origin_requests] `on_request` callback still fires once more with a /// terminal `0` (or a negative error), and that final callback is where /// `user_data` should be released. #[unsafe(no_mangle)] -pub extern "C" fn moq_origin_dynamic_cancel(dynamic: u32) -> i32 { +pub extern "C" fn moq_origin_requests_cancel(dynamic: u32) -> i32 { ffi::enter(move || { let dynamic = ffi::parse_id(dynamic)?; State::lock().origin.dynamic_close(dynamic) }) } -/// The path of a broadcast request delivered to a [moq_origin_dynamic] callback. +/// The path of a broadcast request delivered to a [moq_origin_requests] callback. /// /// The destination borrows the request's storage: copy it out before accept, /// reject, or [moq_broadcast_request_free]. @@ -2075,7 +2075,7 @@ pub extern "C" fn moq_origin_announced_broadcast_cancel(task: u32) -> i32 { /// Resolves against what is announced *now*, where [moq_origin_announced_broadcast] waits /// indefinitely: it returns an announced broadcast at once, and fails when none is reachable, /// including a broadcast created but not announced. It does NOT wait for a later -/// announcement. Serve on-demand paths with [moq_origin_dynamic]. A client session only +/// announcement. Serve on-demand paths with [moq_origin_requests]. A client session only /// asks its peer about paths something watches, through [moq_origin_announced] or /// [moq_origin_announced_broadcast], so a path nothing watches is unreachable on it. /// @@ -2123,7 +2123,7 @@ pub extern "C" fn moq_origin_request_cancel(task: u32) -> i32 { /// Close an origin and clean up its resources. /// /// The origin keeps running while a broadcast published on it or a -/// [moq_origin_dynamic] handler lives; close or cancel those to end it. +/// [moq_origin_requests] handler lives; close or cancel those to end it. /// /// Returns a zero on success, or a negative code on failure. #[unsafe(no_mangle)] @@ -2899,7 +2899,7 @@ pub unsafe extern "C" fn moq_publish_track_demand( /// Without a live handler a subscription to an unknown track name is refused. While one /// is live, `on_request` is invoked with a positive request handle for each pending /// track, then exactly once more with a terminal code: `0` (the broadcast finished, or -/// [moq_publish_dynamic_cancel] was called) or a negative error. After the terminal +/// [moq_publish_requests_cancel] was called) or a negative error. After the terminal /// (`<= 0`) callback, `user_data` is never touched again. Answer each request with /// [moq_track_request_accept], [moq_track_request_video], [moq_track_request_audio], /// or [moq_track_request_abort]; the subscriber waits until you do. @@ -2910,7 +2910,7 @@ pub unsafe extern "C" fn moq_publish_track_demand( /// - `on_request` must be non-NULL. /// - The caller must keep `user_data` valid until the terminal (`<= 0`) `on_request` callback. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_publish_dynamic( +pub unsafe extern "C" fn moq_publish_requests( broadcast: u32, on_request: ffi::moq_status_callback, user_data: *mut c_void, @@ -2927,7 +2927,7 @@ pub unsafe extern "C" fn moq_publish_dynamic( /// Without a live handler a fetch that misses the cache fails as not found. While one is /// live, `on_group` is invoked with a positive group-request handle for each miss, then /// exactly once more with a terminal code: `0` (the track ended, or -/// [moq_publish_dynamic_cancel] was called) or a negative error. After the terminal +/// [moq_publish_requests_cancel] was called) or a negative error. After the terminal /// (`<= 0`) callback, `user_data` is never touched again. Cached groups never reach the /// handler. Answer each request with [moq_group_request_accept] or [moq_group_request_abort]. /// A request whose group nobody reads any more is withdrawn: answering it then fails as not @@ -2939,7 +2939,7 @@ pub unsafe extern "C" fn moq_publish_dynamic( /// - `on_group` must be non-NULL. /// - The caller must keep `user_data` valid until the terminal (`<= 0`) `on_group` callback. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_publish_track_dynamic( +pub unsafe extern "C" fn moq_publish_track_requests( track: u32, on_group: ffi::moq_status_callback, user_data: *mut c_void, @@ -2951,21 +2951,21 @@ pub unsafe extern "C" fn moq_publish_track_dynamic( }) } -/// Stop a request handler from [moq_publish_dynamic], [moq_publish_track_dynamic], or -/// [moq_track_request_dynamic]. Requests not yet delivered are rejected. +/// Stop a request handler from [moq_publish_requests], [moq_publish_track_requests], or +/// [moq_track_request_requests]. Requests not yet delivered are rejected. /// /// Returns immediately: zero on success, or a negative code if already closed. The /// handler's callback still fires once more with a terminal `0`, and that final /// callback is where `user_data` should be released. #[unsafe(no_mangle)] -pub extern "C" fn moq_publish_dynamic_cancel(dynamic: u32) -> i32 { +pub extern "C" fn moq_publish_requests_cancel(dynamic: u32) -> i32 { ffi::enter(move || { let dynamic = ffi::parse_id(dynamic)?; State::lock().publish.dynamic_close(dynamic) }) } -/// The name of a track request delivered to a [moq_publish_dynamic] callback. +/// The name of a track request delivered to a [moq_publish_requests] callback. /// /// The destination borrows the request's storage: copy it out before accepting, /// aborting, or freeing the request. @@ -2987,7 +2987,7 @@ pub unsafe extern "C" fn moq_track_request_name(request: u32, dst: *mut moq_stri /// /// A track requested by a fetch has that group pending from birth. Register the /// handler here, before [moq_track_request_accept], so the request survives the -/// transition; the callback contract is that of [moq_publish_track_dynamic]. +/// transition; the callback contract is that of [moq_publish_track_requests]. /// /// Returns a non-zero handle on success, or a negative code on failure. /// @@ -2995,7 +2995,7 @@ pub unsafe extern "C" fn moq_track_request_name(request: u32, dst: *mut moq_stri /// - `on_group` must be non-NULL. /// - The caller must keep `user_data` valid until the terminal (`<= 0`) `on_group` callback. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_track_request_dynamic( +pub unsafe extern "C" fn moq_track_request_requests( request: u32, on_group: ffi::moq_status_callback, user_data: *mut c_void, diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 884494dc621..41c3cb13ff8 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -2721,7 +2721,7 @@ fn dynamic_serves_a_request_under_a_prefix() { let cb = Callback::new(); let prefix = b"live"; let dynamic = id(unsafe { - moq_origin_dynamic( + moq_origin_requests( origin, prefix.as_ptr() as *const c_char, prefix.len(), @@ -2759,8 +2759,8 @@ fn dynamic_serves_a_request_under_a_prefix() { assert!(req_cb.recv() > 0); req_cb.recv_terminal(); - assert_eq!(moq_origin_dynamic_cancel(dynamic), 0); - assert!(moq_origin_dynamic_cancel(dynamic) < 0, "double-cancel should fail"); + assert_eq!(moq_origin_requests_cancel(dynamic), 0); + assert!(moq_origin_requests_cancel(dynamic) < 0, "double-cancel should fail"); assert_eq!(cb.recv_terminal(), 0); assert_eq!(moq_publish_close(served), 0); assert_eq!(moq_origin_close(origin), 0); @@ -2771,7 +2771,7 @@ fn dynamic_refuses_a_missing_callback() { let origin = id(moq_origin_create()); let prefix = b"live"; let code = unsafe { - moq_origin_dynamic( + moq_origin_requests( origin, prefix.as_ptr() as *const c_char, prefix.len(), @@ -3079,11 +3079,11 @@ fn dynamic_serves_track_requests() { let broadcast = publish_broadcast(origin, b"dynamic-tracks"); assert!( - unsafe { moq_publish_dynamic(broadcast, None, std::ptr::null_mut()) } < 0, + unsafe { moq_publish_requests(broadcast, None, std::ptr::null_mut()) } < 0, "a missing on_request must be refused" ); let request_cb = Callback::new(); - let dynamic = id(unsafe { moq_publish_dynamic(broadcast, Some(channel_callback), request_cb.ptr) }); + let dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) }); // The subscribe stays pending until the request is answered. let consume = consume_local(broadcast); @@ -3163,9 +3163,9 @@ fn dynamic_serves_track_requests() { assert_eq!(moq_track_request_free(request), 0); assert!(freed_cb.recv_terminal() < 0, "a freed request fails its subscriber"); - assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); + assert_eq!(moq_publish_requests_cancel(dynamic), 0); assert_eq!(request_cb.recv_terminal(), 0); - assert!(moq_publish_dynamic_cancel(dynamic) < 0, "double-close should fail"); + assert!(moq_publish_requests_cancel(dynamic) < 0, "double-close should fail"); assert_eq!(moq_consume_track_cancel(consumer), 0); assert_eq!(frame_cb.recv_terminal(), 0); @@ -3182,7 +3182,7 @@ fn dynamic_track_request_publishes_media() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"dynamic-media"); let request_cb = Callback::new(); - let _dynamic = id(unsafe { moq_publish_dynamic(broadcast, Some(channel_callback), request_cb.ptr) }); + let _dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) }); let consume = consume_local(broadcast); let frame_cb = Callback::new(); @@ -3229,7 +3229,7 @@ fn track_request_accepts_after_the_broadcast_closes() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"dynamic-closed"); let request_cb = Callback::new(); - let _dynamic = id(unsafe { moq_publish_dynamic(broadcast, Some(channel_callback), request_cb.ptr) }); + let _dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) }); let consume = consume_local(broadcast); let frame_cb = Callback::new(); @@ -3285,7 +3285,7 @@ fn track_dynamic_serves_a_fetch_miss() { id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), std::ptr::null()) }); let group_cb = Callback::new(); - let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); + let dynamic = id(unsafe { moq_publish_track_requests(track, Some(channel_callback), group_cb.ptr) }); let fetch = fetch_group(broadcast, "data", 5, 11); let request = id(group_cb.recv()); @@ -3319,7 +3319,7 @@ fn track_dynamic_serves_a_fetch_miss() { .expect_err("a rejected fetch fails"); assert!(matches!(err, moq_net::Error::NotFound), "got {err:?}"); - assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); + assert_eq!(moq_publish_requests_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_publish_close(broadcast), 0); @@ -3336,7 +3336,7 @@ fn a_group_request_nobody_reads_is_withdrawn() { let track = id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), std::ptr::null()) }); let group_cb = Callback::new(); - let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); + let dynamic = id(unsafe { moq_publish_track_requests(track, Some(channel_callback), group_cb.ptr) }); let ascending = |start, end| { moq_net::track::Subscription::default() @@ -3373,7 +3373,7 @@ fn a_group_request_nobody_reads_is_withdrawn() { assert_eq!(moq_group_request_free(next), 0); drop(reader); - assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); + assert_eq!(moq_publish_requests_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_publish_close(broadcast), 0); @@ -3385,13 +3385,13 @@ fn track_request_dynamic_survives_accept() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"request-dynamic"); let request_cb = Callback::new(); - let _dynamic = id(unsafe { moq_publish_dynamic(broadcast, Some(channel_callback), request_cb.ptr) }); + let _dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) }); // A fetch of a track the broadcast has not declared requests the track first. let fetch = fetch_group(broadcast, "archive", 9, 0); let request = id(request_cb.recv()); let group_cb = Callback::new(); - let track_dynamic = id(unsafe { moq_track_request_dynamic(request, Some(channel_callback), group_cb.ptr) }); + let track_dynamic = id(unsafe { moq_track_request_requests(request, Some(channel_callback), group_cb.ptr) }); let track = id(unsafe { moq_track_request_accept(request, std::ptr::null()) }); let group_request = id(group_cb.recv()); @@ -3407,7 +3407,7 @@ fn track_request_dynamic_survives_accept() { assert_eq!(moq_publish_group_finish(group), 0); assert_eq!(fetch.recv_timeout(TIMEOUT).unwrap().unwrap(), (0, payload.to_vec())); - assert_eq!(moq_publish_dynamic_cancel(track_dynamic), 0); + assert_eq!(moq_publish_requests_cancel(track_dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_publish_close(broadcast), 0); From 1497ca5c22aa53e9cbce6971b3a3a8e295f2113b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:51:02 -0700 Subject: [PATCH 37/58] chore(net): regenerate the API report and format Co-Authored-By: Claude Opus 5.5 --- js/net/api/index.api.md | 11 +---------- js/net/src/connection/forward.test.ts | 2 +- js/net/src/connection/pool.ts | 2 +- js/net/src/connection/reload.ts | 2 +- js/net/src/ietf/subscriber.ts | 2 +- js/net/src/lite/subscriber.ts | 2 +- js/net/src/origin.ts | 2 +- 7 files changed, 7 insertions(+), 16 deletions(-) diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 54708d6760a..4c6654e3e4c 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -121,7 +121,6 @@ declare namespace Broadcast { export { Tracks, TracksEvent, - Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -131,7 +130,6 @@ declare namespace Broadcast_2 { export { Tracks, TracksEvent, - Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -344,12 +342,6 @@ class Demand_2 { get used(): Getter; } -class Demand_3 { - get closed(): GetPromise; - unused(): Promise; - get used(): Getter; -} - class Duplicate extends Error { constructor(message?: string); } @@ -786,7 +778,6 @@ class Producer_3 { get closed(): GetPromise; consume(): Consumer_4; createTrack(name: string, info?: Partial): track.Producer; - demand(): Demand_3; removeTrack(name: string): void; get route(): Route | undefined; track(name: string): track.Consumer; @@ -798,7 +789,7 @@ class Producer_4 { close(abort?: Error): void; get closed(): GetPromise; consume(): Consumer; - unused(): Promise; + get wanted(): Getter; } class Producer_5 implements Table { diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index ea1e4e40b18..757ab26bf66 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -7,10 +7,10 @@ import * as Epoch from "../epoch.ts"; import { Route } from "../hop.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { registerWire, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; -import { closeUnwanted } from "../util/announce.ts"; const LINGER = 2000; diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 14279b4ac03..cdbcfbb5e5e 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -11,6 +11,7 @@ import type * as Auth from "../auth.ts"; import type { Handle } from "../bandwidth.ts"; import * as Origin from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Follow } from "../util/observed.ts"; import { after } from "../util/timer.ts"; import { type AcceptProps as AcceptPropsType, accept } from "./accept.ts"; @@ -31,7 +32,6 @@ import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; -import { closeUnwanted } from "../util/announce.ts"; /** How long an unreferenced shared connection lingers before it actually closes. */ const LINGER_MS = Milli(2000); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index d355f7cd398..d9a6862e8c5 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -7,6 +7,7 @@ import { Allocator } from "../bandwidth.ts"; import { error, reason, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Follow } from "../util/observed.ts"; import { wireOf } from "../wire.ts"; import { @@ -20,7 +21,6 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; -import { closeUnwanted } from "../util/announce.ts"; /** * Exponential backoff settings for the reconnect loop. diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 0dcdd2c4502..09f0e727b52 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -37,6 +37,7 @@ import { Tail } from "../tail.ts"; import type { Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Deadline, requestOptions } from "../util/deadline.ts"; import { abandoned as demandAbandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; @@ -81,7 +82,6 @@ import { } from "./subscribe_namespace.ts"; import { MalformedAuthToken } from "./token.ts"; import { Version } from "./version.ts"; -import { closeUnwanted } from "../util/announce.ts"; // Wire ceiling (2^62-1). A draining session stamps it on every live route so any other // candidate outranks it, while the route stays selectable as the last path. Matches Rust diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 637acce93a4..20493e4e71a 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -32,6 +32,7 @@ import { Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Deadline, REQUEST_TIMEOUT_MS, requestOptions } from "../util/deadline.ts"; import { abandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; @@ -80,7 +81,6 @@ import { Version, waitsForSubscriberFin, } from "./version.ts"; -import { closeUnwanted } from "../util/announce.ts"; // A peer's buffered TRACKS events decode without reaching a task boundary, and a flood of adds // and removals of one name never reaches the announces cap: yield so it cannot starve the host. diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 130717454be..610f45508f3 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -28,6 +28,7 @@ import { } from "./internal.ts"; import * as Path from "./path.ts"; import { Front } from "./resume.ts"; +import { closeUnwanted } from "./util/announce.ts"; import { type Advertised, type Advertisements, @@ -37,7 +38,6 @@ import { sameInstance, wireOf, } from "./wire.ts"; -import { closeUnwanted } from "./util/announce.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; From 2c9e1a13bf6247a78b1d1f2e7a618d3e679a83a5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:52:42 -0700 Subject: [PATCH 38/58] test(net): abort with the lifecycle verb in a resume test from main Co-Authored-By: Claude Opus 5.5 --- js/net/src/resume.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 48d859f8a0d..f1a9a7b534c 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -1155,8 +1155,8 @@ test("a drop a replaced route held back is no verdict on the route serving next" expect(recvDisposition(sub)).toBe("idle"); front.serve(c.consume()); await settle(); - ta.producer.close(new StreamError(StreamCode.SessionClosed)); - tb.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); + tb.producer.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(recvDisposition(sub)).toBe("idle"); tc.write(1); From a8bbf321c2a64347c34a3ef35e51767787293329 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:54:34 -0700 Subject: [PATCH 39/58] test(js): use requestsRoute in a test main added Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/publisher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index eedf94ccf43..888dace1231 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -666,7 +666,7 @@ async function servedSubscription( broadcast = new BroadcastProducer(); const front = new Front(); front.serve(broadcast.consume()); - const dynamic = origin.dynamicRoute(Path.from("test")); + const dynamic = origin.requestsRoute(Path.from("test")); void (async () => { for await (const request of dynamic.requested()) hooks.acceptRequest(request, front.consumer); })(); From 8d896d6d4c341f9c1bfecf03c856049c48267fc2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 11:02:48 -0700 Subject: [PATCH 40/58] fix(net): a released shared group consumer reads nothing more Its queue is the producer's, so reading after release took frames from the other readers. A track's own read handle still drains what it buffered. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.test.ts | 7 +++++++ js/net/src/group.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 009f464b443..6cf1174335e 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -81,6 +81,13 @@ test("closing a consumer ends its own parked reads", async () => { producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); expect((await next)?.payload[0]).toBe(1); expect(other.isClosed).toBe(false); + + // A later write never reaches the released handle, so it takes nothing from the other reader. + producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); + expect(consumer.done).toBe(true); + expect(consumer.tryReadFrame()).toBeUndefined(); + expect(await consumer.readFrame()).toBeUndefined(); + expect((await other.readFrame())?.payload[0]).toBe(2); producer.finish(); }); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 7cd6dc85db2..84e126ad0d8 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -849,6 +849,8 @@ export class Consumer { // The unread frames. Every look at the buffer goes through here, so readiness, reads and // `done` agree on what is left. #bufferedFrames(): Frame[] { + // A released shared handle reads nothing more: the queue it shares belongs to the other readers. + if (this.#released && !this.#owned) return []; const frames = this.#state.frames.peek(); // A resumed copy repeats the frames an earlier one already delivered. while (this.#state.start < this.#skip && frames.length > 0) { @@ -1028,8 +1030,10 @@ export class Consumer { } /** - * Release this handle, like dropping a Rust consumer: it reads what is buffered and then - * ends. Never changes the group for other readers. Idempotent. + * Release this handle, like dropping a Rust consumer. Never changes the group for other + * readers. A track's read handle drains what it buffered and then ends; one from + * {@link Producer.consume} ends at once, since its queue is shared with the producer's other + * readers. Idempotent. */ close(): void { this.#release(undefined); From 29792cf72f427d7aa357b11e066b0d86531a6f60 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 11:36:29 -0700 Subject: [PATCH 41/58] fix(net): a settled track end is final Latch the settled end before any write that could reopen it (a late group below the end, a datagram, a fresh grace for the tail) as well as before an abort, so `closed()` and readers never see a clean outcome revised. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 60 +++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 0a9049a0457..23419bef488 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -458,8 +458,9 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, - // Whether the declared end still stands after an abort: every group below it was - // produced and finished before the abort landed. See [`Self::is_complete`]. + // Whether the declared end settled before a write that could reopen it or an abort: + // every group below it was produced and finished then, so the clean end stands for + // good. See [`Self::latch_settled`]. settled: bool, // Active subscriptions, in their own [`kio::Lock`] so a read-only `Consumer` @@ -1021,6 +1022,7 @@ impl TrackState { /// Billed before the debt is settled, so its bytes accrue debt, but pushed after, so the /// newcomer is never a victim of its own write. fn push_datagram(&mut self, datagram: super::datagram::Stored) { + self.latch_settled(); let datagram = super::datagram::Buffered::new(datagram, &self.cache); self.charge_debt(); if self.datagrams.len() == MAX_DATAGRAMS { @@ -1852,6 +1854,7 @@ impl TrackState { /// groups reach subscribers, fetched backfill is served by sequence only. A group /// counts as content only once [`Self::reveal`] reports its first frame or finish. fn insert_group(&mut self, group: &group::Handle, visible: bool) { + self.latch_settled(); let sequence = group.sequence; self.next_stamp = self.next_stamp.wrapping_add(1); let stamp = self.next_stamp; @@ -2184,6 +2187,7 @@ impl TrackState { /// or above it, and a pending tail above the last arrival, is waited for a fresh grace: /// the feed may only now be sending it. fn set_start(&mut self, start_sequence: Option, pending: bool) { + self.latch_settled(); self.start_sequence = start_sequence; self.start_pending = pending; if let Some(start) = start_sequence { @@ -2250,13 +2254,15 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - // `sealed` is a declared end the last producer dropped short of. An abort still wins - // unless that end had already settled: a group below it was still open. + if self.settled { + return true; + } + // `sealed` is a declared end the last producer dropped short of. let reached = self.sealed || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && (self.abort.is_none() || self.settled) + reached && self.abort.is_none() } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2343,18 +2349,21 @@ impl TrackState { /// Whether the declared end is reached, the pending tail owes nothing below it, and /// every cached group below it finished, so nothing the end promised is still in flight. - /// - /// Decided as an abort lands, which ends any wait for the tail: a group the tail still - /// owed was cut off by the abort, so readers get the error rather than a clean end. fn is_settled(&self) -> bool { let Some(fin) = self.final_sequence() else { return false; }; let reached = self.sealed || self.reaches(fin); - reached - && (self.abort.is_none() || self.settled) - && !self.owes(fin) - && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) + reached && !self.owes(fin) && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) + } + + /// Record that the end settled before a write that could reopen it: an abort, a late + /// group below the end, or a fresh grace for the tail. Whoever saw the clean end, a + /// reader or `closed()`, keeps it; a group the end still waited on was cut off instead. + fn latch_settled(&mut self) { + if !self.settled && self.abort.is_none() && self.is_settled() { + self.settled = true; + } } /// Where a replacement route should pick this track up: one past the last frame @@ -2508,7 +2517,7 @@ impl TrackState { // An abort after the end settled keeps the clean end; until then a group below it // may still abort, which would cut the track off, so wait for each to finish. One // that aborts leaves the outcome to the track's own abort or drop. - if self.abort.is_none() + if !self.settled && let Some(fin) = self.final_sequence() { for (_, slot) in self.lookup.range(..fin) { @@ -2621,7 +2630,7 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) -> OpenGroups { // past the grace was lost before the abort rather than cut off by it. let now = state.cache.pool().now_wall(); state.fold_arrivals(now); - state.settled = state.is_settled(); + state.latch_settled(); state.abort = Some(err.clone()); // Keep what finished for consumers still draining: they get it, then the abort (or // the clean end, when the end settled). Clearing here would abort finished groups a @@ -13041,6 +13050,29 @@ mod test { )); } + /// A settled end is final: a late group below it and a later abort leave the outcome + /// `closed()` already saw. + #[test] + #[cfg(feature = "async")] + fn a_settled_end_survives_a_late_group_and_an_abort() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", Info::timed()); + let demand = producer.demand(); + producer + .create_group(group::Info { sequence: 5 }) + .unwrap() + .finish() + .unwrap(); + producer.finish_at(Position::group(6)).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let _late = producer.create_group(group::Info { sequence: 4 }).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + } + /// An abort short of the end, or the last producer leaving without one, is the outcome. #[test] #[cfg(feature = "async")] From c70f314fcbc9e73aeb2fd2295020de4b39a061b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 12:23:47 -0700 Subject: [PATCH 42/58] fix(net): a settled end keeps its clean outcome in JS too Settle the JS outcome before a write that could reopen the end (a late group, a datagram, a new start or subscription), and keep the clean end when a later abort lands, as Rust does. Rust readers go back to waiting for a widened tail: the latch fixes the outcome, not what is still served. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 18 ++++++++++++++++++ js/net/src/track.ts | 10 ++++++++-- rs/moq-net/src/model/track.rs | 18 ++++++++++-------- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 3e05e5e62c0..b12cbc8726d 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -2794,6 +2794,24 @@ test("an abort after the declared end reached by a datagram ends clean", () => { expect(producer.closed.peek()).toBeNull(); }); +test("a settled end survives a late group and an abort", async () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const subscriber = producer.subscribe(); + const last = new GroupProducer(5); + producer.writeGroup(last); + last.finish(); + producer.finishAt({ group: 6, frame: 0 }); + await settle(); + expect(producer.closed.peek()).toBeNull(); + + producer.writeGroup(new GroupProducer(4)); + producer.abort(new Error("boom")); + expect(producer.closed.peek()).toBeNull(); + expect((await subscriber.recvGroup())?.sequence).toBe(5); + // The abort cut nothing the end promised, so the subscriber ends cleanly. + expect(await subscriber.recvGroup()).toBeUndefined(); +}); + test("finish is not terminal: closed settles once the groups below the end finish", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index f4dd846a849..57d06dc906c 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -817,6 +817,7 @@ export class Producer { hooks.trackReleased = (producer) => producer.#state.closed; hooks.trackFinished = (producer) => producer.#state.end.peek() !== undefined; hooks.startTrack = (producer, served) => { + producer.#settle(); producer.#served = served; producer.#owe(); for (const state of [producer.#state, ...producer.#sinks]) state.startChanged.update((rev) => rev + 1); @@ -1096,6 +1097,7 @@ export class Producer { // the aggregate. The wire layer observes this signal and emits SUBSCRIBE_UPDATE. #updateSubscription(): void { const combined = combineSubscriptions(this.#sinks); + this.#settle(); const retained = this.#state.info.peek()?.maxAge; if (combined && retained !== undefined) combined.maxDelay = Milli(Math.min(combined.maxDelay ?? Milli.zero, retained)); @@ -1322,6 +1324,7 @@ export class Producer { // frame 0 passes its checked `start` and whether it is `late`; any other checks frame 0 // against this track when it lands. #publish(group: GroupProducer, start?: Timestamp, late = false): void { + this.#settle(); const entry: CachedGroup = { group, mirrors: new Map(), arrived: now() }; this.#undrop(group.sequence); group.closed.then((closed) => { @@ -1476,6 +1479,7 @@ export class Producer { // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache). A // compressed track's rings share only `slice`: each holds its own lazy view of it. #publishDatagram(datagram: Datagram, slice?: Stored): void { + this.#settle(); const { sequence, timestamp } = datagram; this.#advance(sequence, false); this.#arrive(sequence); @@ -1600,7 +1604,9 @@ export class Producer { this.#settle(); } - // Settle the outcome cleanly once the declared end is reached and settled. + // Settle the outcome cleanly once the declared end is reached and settled. Also run before a + // write that could reopen it (a late group below the end, a fresh grace for the tail): that + // write is still served, but the clean outcome stays final. Mirrors the Rust `latch_settled`. #settle(): void { if (this.#state.outcome.peek() === undefined && this.#settled()) this.#state.outcome.set(null); } @@ -1710,7 +1716,7 @@ export class Producer { if (this.#state.closed.peek() !== undefined) return; // A gap past the grace was lost before the abort rather than cut off by it. if (abort && this.#tailPending) this.#arrivals.expire(now(), this.#grace()); - if (abort && this.#settled()) abort = undefined; + if (abort && (this.#state.outcome.peek() === null || this.#settled())) abort = undefined; // Nobody will finish these, so a subscriber that has not taken one yet never sees it. // Not evicted: a reader already holding one keeps its frames and sees the abort. const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 23419bef488..594cf80b202 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2254,15 +2254,13 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - if self.settled { - return true; - } - // `sealed` is a declared end the last producer dropped short of. + // `sealed` is a declared end the last producer dropped short of. An abort still wins + // unless that end had already settled: a group below it was still open. let reached = self.sealed || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && self.abort.is_none() + reached && (self.abort.is_none() || self.settled) } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2358,8 +2356,9 @@ impl TrackState { } /// Record that the end settled before a write that could reopen it: an abort, a late - /// group below the end, or a fresh grace for the tail. Whoever saw the clean end, a - /// reader or `closed()`, keeps it; a group the end still waited on was cut off instead. + /// group below the end, or a fresh grace for the tail. The clean outcome `closed()` saw + /// then stands, and a later abort ends readers cleanly. A late group or a wider + /// subscription is still served; it just cannot turn the outcome into an error. fn latch_settled(&mut self) { if !self.settled && self.abort.is_none() && self.is_settled() { self.settled = true; @@ -2513,11 +2512,14 @@ impl TrackState { /// The track's outcome once final: `Ok` once its declared end is reached with every /// cached group below it finished, which a later abort keeps, or the abort error. fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { + if self.settled { + return Poll::Ready(Ok(())); + } if self.is_complete() { // An abort after the end settled keeps the clean end; until then a group below it // may still abort, which would cut the track off, so wait for each to finish. One // that aborts leaves the outcome to the track's own abort or drop. - if !self.settled + if self.abort.is_none() && let Some(fin) = self.final_sequence() { for (_, slot) in self.lookup.range(..fin) { From 0414bc52c6f195d077249d1765c04e595039dded Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 13:29:34 -0700 Subject: [PATCH 43/58] test(net): use requests() in a test main added Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3e4945b1f3f..752f34702b3 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -17117,7 +17117,7 @@ mod test { #[cfg(feature = "async")] fn a_reader_update_reaches_its_fill() { let mut producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..).with_order(Order::Ascending)); producer.start_at(4).unwrap(); write_group(&producer, 4); From efce7a0257b2595ff26e111faf1dbd470bf4ad85 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 13:36:01 -0700 Subject: [PATCH 44/58] test(c): use the *_requests handlers in tests main added Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 07378b97fdf..e8b23ee52bd 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3390,7 +3390,7 @@ fn a_rejected_group_request_binds_only_its_readers() { let track = id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), std::ptr::null()) }); let group_cb = Callback::new(); - let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); + let dynamic = id(unsafe { moq_publish_track_requests(track, Some(channel_callback), group_cb.ptr) }); // The live feed ran from 10 and ended, so only a fill can serve group 6. { @@ -3443,7 +3443,7 @@ fn a_rejected_group_request_binds_only_its_readers() { assert_eq!(group.sequence, 6); drop(reader); - assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); + assert_eq!(moq_publish_requests_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_publish_close(broadcast), 0); From 3b71e288a9b5790e38d7ef84f57385ee7f2837d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:08:08 -0700 Subject: [PATCH 45/58] fix(net): rename main's new auth::Auth uses to Credential Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/session.rs | 2 +- rs/moq-relay/src/auth/refresh.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index c84eeec208a..283d4db8299 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -1024,7 +1024,7 @@ mod tests { /// Run a client session on a shared handle over `transport` for five simulated seconds, /// presenting one token, and return it after checking the session still runs. - async fn present_on_shared_handle(transport: ScriptedSession) -> crate::auth::Auth { + async fn present_on_shared_handle(transport: ScriptedSession) -> crate::auth::Credential { let handle = crate::auth::Handle::new(); let start = start(Config { runtime: crate::time::sim(), diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index bf4b8e09363..1901d06b380 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -911,7 +911,7 @@ mod tests { moq_net::Session, moq_net::Session, super::super::Lease, - moq_net::auth::Auth, + moq_net::auth::Credential, ) { let (auth, mut admissions) = super::super::Auth::embedded("test"); let (owner, consumer) = lease::Producer::new(grant("a/**", 3)); From 757a1332a18c9971ad39832a5e4f1759b09cd275 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:10:41 -0700 Subject: [PATCH 46/58] fix(net): keep the lifecycle docs and helpers clean in the no-async shape The feature-shape gate from main documents and builds moq-net without async, where track::Producer::closed does not exist and only sessions call poll_aborted. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 9540eeb5a70..4d65c0da53f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -3267,13 +3267,15 @@ impl Producer { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the track's outcome is final; see [`Self::closed`]. + /// Poll until the track's outcome is final: `Ok` once its declared end is reached, or + /// the abort error, or [`Error::Dropped`] once every producer left without either. pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { TrackState::settle(self.state.poll_ref(waiter, |state| state.poll_outcome(waiter))) } /// Poll until the track is aborted, or every producer left; ready with the cause. // Sessions stop serving on an abort, but keep taking lower groups past a finish. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { TrackState::poll_aborted(&self.state, waiter) } From b8bf0fb5bfaf1d2ecf7e5b65d3e2eddae3d4ec7f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:22:38 -0700 Subject: [PATCH 47/58] fix(net): a request whose readers left blocks an overlapping one Main's PREFIX_OVERLAP gate treats a request as on its way out once its producer closes. Closing the last consumer now closes the producer a turn later, through closeUnwanted, so a broader request opened in the same turn went out beside it. It is leaving as soon as nobody wants it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/subscriber.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 940c3d4c3a5..e3abf64e56f 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -355,10 +355,11 @@ export class Subscriber { // The overlapping requests on their way out, such as the narrower ones an exclusive // discovery just closed for this one. Those still open overlap it only where the - // caller holds both on purpose. + // caller holds both on purpose. One whose last reader just closed is leaving even + // before its producer closes. const blockers: Promise[] = []; for (const [other, { prefix: asked, gone }] of this.#announcedConsumers) { - if (gone === undefined || other.closed.peek() === undefined) continue; + if (gone === undefined || (other.wanted.peek() && other.closed.peek() === undefined)) continue; if (Path.hasPrefix(asked, prefix) || Path.hasPrefix(prefix, asked)) blockers.push(gone); } From f7bdfd288d47231495f31d7737420568a294c390 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:44:56 -0700 Subject: [PATCH 48/58] chore(dart): regenerate moq_ffi with the negative-duration patch Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index eca6c7d3802..82c6d3887b3 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -14141,6 +14139,13 @@ class FfiConverterDuration { } static int write(Duration value, Uint8List buf) { + if (value.isNegative) { + throw ArgumentError.value( + value, + "value", + "Duration must not be negative", + ); + } final bytes = buf.buffer.asByteData(buf.offsetInBytes, 12); bytes.setUint64(0, value.inSeconds); final ms = (value.inMicroseconds - (value.inSeconds * 1000000)) * 1000; From beff2e0a9d6df040d090bbe355f281b45bfeba8f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:54:03 -0700 Subject: [PATCH 49/58] fix(net): a track's closed() sees a group that finishes while it scans poll_outcome read is_finished() and then polled the group, so a group finishing between the two returned Ready without parking the waiter, and the scan slept on Pending. One poll decides both. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 4d65c0da53f..be8b2cb56d7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2523,8 +2523,8 @@ impl TrackState { && let Some(fin) = self.final_sequence() { for (_, slot) in self.lookup.range(..fin) { - if !slot.group.is_finished() { - let _ = slot.group.poll_closed(waiter); + // One poll, so a group finishing meanwhile is seen rather than slept on. + if !matches!(slot.group.poll_closed(waiter), Poll::Ready(Ok(()))) { return Poll::Pending; } } From 43a6876c78d74f3326674417ab34af3b0321ded9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 19:06:39 -0700 Subject: [PATCH 50/58] style(net): sort the tokens.ts imports Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/tokens.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/net/src/connection/tokens.ts b/js/net/src/connection/tokens.ts index b4092930d35..66106369e52 100644 --- a/js/net/src/connection/tokens.ts +++ b/js/net/src/connection/tokens.ts @@ -1,4 +1,4 @@ -import { type Credential as Presented, type Handle, Unsupported } from "../auth.ts"; +import { type Handle, type Credential as Presented, Unsupported } from "../auth.ts"; import type { Established } from "./established.ts"; /** What one dial presents, decided from its URL, the offered versions, and the tokens. @internal */ From abc3b975cf3fedefa845d6242c50b7254e53bb43 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 19:15:09 -0700 Subject: [PATCH 51/58] test(net): the SUBSCRIPTION_ENDED tail test reads the missing end through finished() Subscriber.end() is gone in this PR; main's new test used it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/tail.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index 03d779d5da3..27a33406830 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -176,7 +176,8 @@ test("a SUBSCRIPTION_ENDED PUBLISH_DONE drains, then aborts the track", async () expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); expect(await reader.closed).toBeInstanceOf(Error); - expect(reader.end()).toBeUndefined(); + // No end was declared: the track fails rather than finish. + await expect(reader.finished()).rejects.toBeInstanceOf(Error); }); test("an UNAUTHORIZED PUBLISH_DONE reads as the revocation a moq-lite peer sends", async () => { From fd1cc05b06b93b91805302db0e3dfe169be916b5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 16:02:27 -0700 Subject: [PATCH 52/58] fix(net): a released shared group consumer ignores a later abort A read on a closed consume() handle consulted the shared state before its own release, so a producer abort after close() failed the read while the handle's closed stayed null. The release now ends the read first; an owned mirror's release already settles its own state. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.test.ts | 10 ++++++++++ js/net/src/group.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 6cf1174335e..f67d874767a 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -91,6 +91,16 @@ test("closing a consumer ends its own parked reads", async () => { producer.finish(); }); +test("a released consumer ignores a later abort", async () => { + const { producer, consumer } = pair(0); + const reading = consumer.readFrame(); + consumer.close(); + producer.abort(new Error("upstream")); + expect(await reading).toBeUndefined(); + expect(await consumer.readFrame()).toBeUndefined(); + expect(await consumer.closed).toBeNull(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 0752f7db3fd..4e04be14278 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -992,6 +992,8 @@ export class Consumer { return read; } + // A released shared handle ends here, whatever the group does next for its other readers. + if (this.#released && !this.#owned) return; const closed = this.#state.closed.peek(); if (closed instanceof Error) { if (await this.#resume(closed)) continue; @@ -1005,8 +1007,6 @@ export class Consumer { return; } - // A released shared handle ends here; the group itself stays open for others. - if (this.#released) return; // Nothing buffered and the group is still open: this wait is the stall the // drift budget bounds, and the only place it applies. if (this.#expire()) continue; From b4f093a2882ead94d070efdeadea6da293542d71 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 16:13:15 -0700 Subject: [PATCH 53/58] fix(net): release a received copy without declaring an end the publisher never sent Splitting close() mapped the wire copies' clean release to finish() and close(), which declares an end at the received count. A covered lite-07 range, an idle unsubscribe, or an unwanted track-info lookup is a released copy, not finished content, so it releases through the private releaseTrack hook, keeping only an end the publisher declared. Co-Authored-By: Claude Opus 5.5 --- js/net/src/broadcast.ts | 6 ++---- js/net/src/ietf/subscriber.ts | 4 ++-- js/net/src/lite/subscriber.test.ts | 4 +++- js/net/src/lite/subscriber.ts | 9 ++++----- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 4d559320a30..7203542e2bf 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -231,10 +231,8 @@ async function resolveTrackInfo(state: BroadcastState, name: string, hold?: Abor let answered = false; let ended = false; const release = () => { - if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) { - producer.finish(); - producer.close(); - } + if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) + hooks.releaseTrack(producer); }; info.then( () => { diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 194731f2e0f..199a6ce8a10 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1009,8 +1009,8 @@ export class Subscriber { console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); producer.abort(refused); } else { - producer.finish(); - producer.close(); + // Only a received end declares one; a copy nobody wants anymore is released, not finished. + hooks.releaseTrack(producer); } // Closing settled `done`, so an update still being written finishes and nothing starts diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index ecd9f48a22a..5de01e1ee53 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1992,7 +1992,8 @@ test("a publisher FIN without SUBSCRIBE_END still violates an open subscription" }); // A lite-07 publisher FINs a bounded subscription without SUBSCRIBE_END once it covered the range, -// which ends the subscription cleanly rather than violating the protocol. +// which ends the subscription cleanly rather than violating the protocol. Covering a range is +// not ending the track, so no end is declared. test("a lite-07 publisher FIN ends a covered bounded subscription", async () => { const version = Version.DRAFT_07; const { quic, streams, opened, closes } = fakeSession(); @@ -2018,6 +2019,7 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => for (const chunk of chunks) stream.inbound.enqueue(chunk); stream.inbound.close(); expect(await track.closed).toBeNull(); + await expect(track.finished()).rejects.toThrow("track closed before its end was known"); await drainReactions(); expect(closes).toEqual([]); } finally { diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 946e072ccff..65f97dc0738 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -986,12 +986,11 @@ export class Subscriber { } // A publisher that covered the range before reading an update widening it sent - // fewer groups than the track has, so it fails rather than ending clean. + // fewer groups than the track has, so it fails rather than ending clean. Otherwise + // only a received SUBSCRIBE_END declares the track's end: a range covered or a + // subscription nobody wants anymore is a released copy, not finished content. if (short) producer.abort(short); - else { - producer.finish(); - producer.close(); - } + else hooks.releaseTrack(producer); // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); From fc8b671f0ad91b513d82df8e571dc9f8e69c586d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 19:28:26 -0700 Subject: [PATCH 54/58] test(net): read the group without async in abort_after_finish_keeps_the_clean_outcome Main's no-async model gate (#6851) builds tests without the async read_frame. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/group.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index d1c925e1fba..419a7f5d103 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -4799,7 +4799,7 @@ mod test { handle.poll_aborted(&waiter).is_pending(), "a finish alone frees nothing" ); - assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_some()); + assert!(matches!(consumer.poll_read_frame(&waiter), Poll::Ready(Ok(Some(_))))); producer.abort(Error::Cancel).unwrap(); assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Ok(())))); @@ -4807,7 +4807,7 @@ mod test { assert!(matches!(handle.poll_aborted(&waiter), Poll::Ready(Error::Cancel))); assert!(handle.cut_error().is_none()); assert!(!handle.fate().failed()); - assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none()); + assert!(matches!(consumer.poll_read_frame(&waiter), Poll::Ready(Ok(None)))); } /// An abort before the finish is the outcome, on every observer. From 9a856aa8ebcacb0be0e785c0a7af80cca13cc59a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 21:45:50 -0700 Subject: [PATCH 55/58] fix(net): settle a track's outcome once the cache evicts its last open group below the end A prune that evicts the open group holding the end back now re-checks the outcome, as the Rust poll_outcome does once the evicted slot leaves its lookup. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 23 +++++++++++++++++++++++ js/net/src/track.ts | 3 +++ 2 files changed, 26 insertions(+) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 0acfba2133c..a00b70088e4 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -2812,6 +2812,29 @@ test("a settled end survives a late group and an abort", async () => { expect(await subscriber.recvGroup()).toBeUndefined(); }); +// As in Rust, an evicted group no longer holds the end open. +test("closed settles once the cache evicts the last open group below the end", async () => { + const clock = manualTime(10_000); + try { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const open = producer.appendGroup(); + open.writeFrame(textFrame("open")); + const last = producer.appendGroup(); + last.writeFrame(textFrame("last")); + last.finish(); + producer.finish(); + await settle(); + expect(producer.closed.peek()).toBeUndefined(); + + await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); + await settle(); + expect(open.closed.peek()).toBeInstanceOf(TooFarBehind); + expect(producer.closed.peek()).toBeNull(); + } finally { + clock.installed.close(); + } +}); + test("finish is not terminal: closed settles once the groups below the end finish", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 4f0bc2976de..530af000e97 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1260,7 +1260,10 @@ export class Producer { this.#evict(entry); } } + const evicted = retained.length < this.#cache.length; this.#cache = retained; + // An evicted group below the end no longer holds it open, as in Rust. + if (evicted) this.#settle(); // Replace the wakeup with one for the next entry due to age out. Writes settle // retention inline, so this only has to cover the case no write follows. Cheap to From 3f1a00c741330421a82e828d0c64dcb33a35cf79 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 22:15:42 -0700 Subject: [PATCH 56/58] fix(flate): finish the decoder bench's oversized group and track #6900's new bench ends them with the pre-split close(). Co-Authored-By: Claude Opus 5.5 --- js/flate/bench/decoder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/flate/bench/decoder.ts b/js/flate/bench/decoder.ts index 8afbeb09474..6a3d19c075f 100644 --- a/js/flate/bench/decoder.ts +++ b/js/flate/bench/decoder.ts @@ -40,7 +40,8 @@ for (const excess of [64 * 1024, 1024 * 1024, 8 * 1024 * 1024]) { const track = new Track.Producer("overflow-bench").accept({}); const group = track.appendGroup(); group.writeFrame({ payload: slice }); - group.close(); + group.finish(); + track.finish(); track.close(); const samples: number[] = []; for (let sample = 0; sample < 4; sample++) { From fb45912c50a2a6195a837c74fd9d003115e06e26 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 22:20:41 -0700 Subject: [PATCH 57/58] fix(net): latch a track's clean outcome once closed() reports it A received copy's tail grace is judged when polled, so a later subscription with a longer max delay could reopen an end closed() already reported, and an abort within it turned it into an error. poll_outcome now latches the clean outcome it returns, as JS does by settling before a subscription change. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 50 ++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 47ffbced88b..2dd189c59d7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -464,10 +464,11 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, - // Whether the declared end settled before a write that could reopen it or an abort: - // every group below it was produced and finished then, so the clean end stands for - // good. See [`Self::latch_settled`]. - settled: bool, + // Whether the declared end settled before a write that could reopen it or an abort, or + // `closed()` reported it: every group below it was produced and finished then, so the + // clean end stands for good. Atomic so reporting it latches through a read guard. See + // [`Self::latch_settled`]. + settled: AtomicBool, // Active subscriptions, in their own [`kio::Lock`] so a read-only `Consumer` // registers under that lock instead of writing back into the track state. @@ -1080,7 +1081,7 @@ impl TrackState { // Once nothing more is in range: an abort before the end settled cut the track // off. One after it is a clean end (see `is_complete`), as is reaching the end. let end = || match &self.abort { - Some(err) if !self.settled => Err(err.clone()), + Some(err) if !self.settled.load(Ordering::Relaxed) => Err(err.clone()), _ => Ok(None), }; @@ -2276,7 +2277,7 @@ impl TrackState { || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && (self.abort.is_none() || self.settled) + reached && (self.abort.is_none() || self.settled.load(Ordering::Relaxed)) } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2376,8 +2377,8 @@ impl TrackState { /// then stands, and a later abort ends readers cleanly. A late group or a wider /// subscription is still served; it just cannot turn the outcome into an error. fn latch_settled(&mut self) { - if !self.settled && self.abort.is_none() && self.is_settled() { - self.settled = true; + if self.abort.is_none() && self.is_settled() { + self.settled.store(true, Ordering::Relaxed); } } @@ -2527,8 +2528,11 @@ impl TrackState { /// The track's outcome once final: `Ok` once its declared end is reached with every /// cached group below it finished, which a later abort keeps, or the abort error. + /// + /// A clean outcome latches once reported: a received copy's tail grace is judged when + /// polled, and a later subscription asking for a longer one must not reopen it. fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { - if self.settled { + if self.settled.load(Ordering::Relaxed) { return Poll::Ready(Ok(())); } if self.is_complete() { @@ -2545,6 +2549,7 @@ impl TrackState { } } } + self.settled.store(true, Ordering::Relaxed); Poll::Ready(Ok(())) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) @@ -13514,6 +13519,33 @@ mod test { )); } + /// A received end whose tail grace passed is final once `closed()` reports it: a later + /// subscription with a longer grace, and an abort within it, leave the clean outcome. + #[test] + fn a_reported_end_survives_a_longer_grace() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", None); + producer.received(); + let _short = producer.subscribe(None); + producer.start_at(0).unwrap(); + producer.finish_at(Position::group(3)).unwrap(); + for sequence in [0, 2] { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + assert!(producer.poll_closed(&waiter).is_pending(), "held at the hole"); + elapse(&producer, ledger::GRACE); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let _long = producer.subscribe(Subscription::default().with_max_delay(ledger::GRACE * 10)); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + } + /// A settled end is final: a late group below it and a later abort leave the outcome /// `closed()` already saw. #[test] From 03ccc2f0578357da6334de807535ba911b466e1d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 01:37:05 -0700 Subject: [PATCH 58/58] quest: name track::Requests and Resolving in the net-wrap traits quest Co-Authored-By: Claude Opus 5.5 --- quest/m2/net-wrap/traits.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/quest/m2/net-wrap/traits.md b/quest/m2/net-wrap/traits.md index 0b173326a10..4e1a47d236c 100644 --- a/quest/m2/net-wrap/traits.md +++ b/quest/m2/net-wrap/traits.md @@ -14,7 +14,7 @@ with moq-net's types as the default, no `Send` bounds wasm can't meet, and the methods exactly as moq-net has them today. - Every handle a writer or reader can get from moq-net's public API, - including pending operations (`track::Subscribing`, `Querying`, + including pending operations (`track::Subscribing`, `Resolving`, `Fetching`), requests, and adapters, has an associated type, so no trait method returns a concrete moq-net type and a wrapper never leaks its physical handle. Examples of what the rule covers: @@ -29,12 +29,12 @@ the methods exactly as moq-net has them today. - Requests: a broadcast producer's `requests` returns its own `broadcast::Requests`, whose `requested_track` yields its own `track::Request`, so generic code serves undeclared names (as - [E2EE undeclared names](/quest/m2/e2ee/undeclared-names.md) needs). A track - dynamic's `requested_fill` yields its own `track::Fill`, whose + [E2EE undeclared names](/quest/m2/e2ee/undeclared-names.md) needs). A + `track::Requests`'s `requested_fill` yields its own `track::Fill`, whose `accept` returns its own group producer and whose `drop_groups` names the rest dropped. - Pending operations: `track::Consumer::subscribe` resolves to the - wrapper's own subscriber, so E2EE decrypts behind it; `Querying` and + wrapper's own subscriber, so E2EE decrypts behind it; `Resolving` and `Fetching` resolve to the wrapper's logical metadata and groups. - Adapters: a subscriber's `ordered` returns its own `track::Ordered` with the same group, datagram, and control surface. moq-json's snapshot