From 31ecc04cbbabef4f167c077e4f8df55867a0e99f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 21:40:27 -0700 Subject: [PATCH 01/49] quest: claim quest/m1/subscribe-ranges/dynamic Co-Authored-By: Claude Opus 5.5 From 931295836e6dce358b2ab43908da997f1fcb07c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:15:11 -0700 Subject: [PATCH 02/49] wip: fills replace group requests in moq-net --- rs/moq-net/src/ietf/publisher.rs | 14 +- rs/moq-net/src/ietf/subscriber.rs | 62 ++- rs/moq-net/src/lite/publisher.rs | 8 +- rs/moq-net/src/lite/subscriber.rs | 127 +++-- rs/moq-net/src/model/group.rs | 78 +-- rs/moq-net/src/model/ledger.rs | 9 + rs/moq-net/src/model/resume.rs | 2 +- rs/moq-net/src/model/subscription.rs | 42 +- rs/moq-net/src/model/track.rs | 572 ++++++++------------- rs/moq-net/src/model/track/demand.rs | 9 +- rs/moq-net/src/model/track/fill.rs | 717 +++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 386 ++++++++++++++ 12 files changed, 1523 insertions(+), 503 deletions(-) create mode 100644 rs/moq-net/src/model/track/fill.rs create mode 100644 rs/moq-net/src/model/track/miss.rs diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 61f61d9cd60..2e8d64d1082 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -25,16 +25,12 @@ use crate::{ use super::{Message, Version, cluster, error::request, peer, request_update}; -/// Largest millisecond duration every implementation can carry losslessly. -const MAX_SAFE_DELAY_MS: u64 = super::max_age::MAX_MILLIS; - /// Preserve retained groups when the subscriber supplies no age budget. fn serving_subscription(subscriber_priority: u8) -> Subscription { Subscription { priority: super::priority::from_wire(subscriber_priority), - // Demand can cross a Lite hop before the producer's retention bound is - // known, so use the largest duration that remains wire-encodable. - max_delay: Duration::from_millis(MAX_SAFE_DELAY_MS), + // No Max Age is the publisher's retention alone, whatever it turns out to be. + max_delay: None, ..Default::default() } } @@ -1234,7 +1230,7 @@ where // a join, which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(order); if let Some(max_age) = msg.max_age { - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); } // A range that resolves without the live edge has a fixed start, which is no // join: bound the subscription before subscribing so the ratio never judges it. @@ -1736,7 +1732,7 @@ where } if let Some(max_age) = update.max_age { let mut subscription = serve.track.subscription(); - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); serve.track.update(subscription).map_err(failed)?; } if let Some(value) = update.new_group { @@ -12822,7 +12818,7 @@ mod tests { } let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); + assert_eq!(subscription.max_delay, None, "no Max Age is the publisher's retention alone"); let mut subscriber = producer.subscribe(subscription); for sequence in [0, 1] { let group = subscriber diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d590a411019..b7dfaeadaa3 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2756,8 +2756,7 @@ where super::max_age::millis( aggregate .as_ref() - .map(|s| s.max_delay) - .unwrap_or(Duration::from_millis(super::max_age::MAX_MILLIS)), + .map_or(Duration::MAX, |s| s.budget()), ) }); let order = aggregate.map(|s| s.order).unwrap_or_default(); @@ -3067,7 +3066,7 @@ where Idle, Revoked, Done(Result), - Fetch(group::Request), + Fill(track::Fill), Update(Update), } @@ -3119,8 +3118,8 @@ where fetch_done = true; } // An error is the track closing, which the arms below report. - if let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(waiter) { - return Poll::Ready(End::Fetch(request)); + if let Poll::Ready(Ok(requested)) = dynamic.poll_requested_fill(waiter) { + return Poll::Ready(End::Fill(requested)); } let _ = group_fetches.poll(waiter); // A group past the answer's Largest also makes the copy current, so a join @@ -3139,7 +3138,7 @@ where if let Some(subscription) = subscription { priority = subscription.priority; if max_age_supported { - wanted_max_age = Some(super::max_age::millis(subscription.max_delay)); + wanted_max_age = Some(super::max_age::millis(subscription.budget())); } } } @@ -3210,13 +3209,10 @@ where revoked = true; break true; } - End::Fetch(request) => { - let fetch = self.clone().run_group_fetch( - broadcast_path.to_owned(), - track_name.to_owned(), - request, - timescale, - ); + End::Fill(requested) => { + let fetch = self + .clone() + .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), requested, timescale); group_fetches.push(fetch); } End::Idle => { @@ -3326,7 +3322,7 @@ where let mut linger = crate::time::Deadline::new(&self.runtime); let mut subscribed = idle.track.subscription().is_some(); enum Step { - Fetch(group::Request), + Fill(track::Fill), Subscribe, Expired, Revoked, @@ -3340,8 +3336,8 @@ where if gate.poll_denied(waiter).is_ready() { return Poll::Ready(Step::Revoked); } - if let Poll::Ready(Ok(request)) = idle.dynamic.poll_requested_group(waiter) { - return Poll::Ready(Step::Fetch(request)); + if let Poll::Ready(Ok(fill)) = idle.dynamic.poll_requested_fill(waiter) { + return Poll::Ready(Step::Fill(fill)); } let _ = group_fetches.poll(waiter); while let Poll::Ready(Ok(subscription)) = idle.track.poll_subscription_changed(waiter) { @@ -3367,11 +3363,11 @@ where .await; match step { - Step::Fetch(request) => { - let fetch = self.clone().run_group_fetch( + Step::Fill(fill) => { + let fetch = self.clone().serve_fill( broadcast_path.to_owned(), track_name.to_owned(), - request, + fill, idle.timescale, ); group_fetches.push(fetch); @@ -4852,6 +4848,26 @@ where Ok(Fetched::Frame) } + /// Serve a fill a group at a time, newest first for a descending reader, until it is + /// answered or nobody waits on it. + async fn serve_fill(self, broadcast: PathOwned, name: String, fill: track::Fill, timescale: Option) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + { + // Every fetch answers its group, so the same one coming back is a bug; ending the + // fill names it dropped rather than fetching it forever. + if last == Some(sequence) { + debug_assert!(false, "fill group {sequence} left unanswered"); + return; + } + last = Some(sequence); + self.clone() + .run_group_fetch(broadcast.clone(), name.clone(), fill.group(sequence), timescale) + .await; + } + } + /// Fetch a group from the publisher to fill a cache miss, with a standalone (or /// draft-20 filtered) FETCH for this subscription's track. It asks from the frame the reader wants through the end /// of the group, so a publisher that evicted the prefix can still answer. @@ -4863,7 +4879,7 @@ where self, broadcast: PathOwned, name: String, - request: group::Request, + request: track::GroupFill, timescale: Option, ) { let validity = self.validity.start(); @@ -4955,7 +4971,7 @@ where if let Poll::Ready(res) = response { return Poll::Ready(Some(res)); } - request.demand().poll_unused(waiter).map(|_| None) + request.poll_unused(waiter).map(|_| None) }) .await } @@ -5030,9 +5046,9 @@ where let end = (end.group == sequence).then_some(end.object); // Joined fetches still count until they pick the accepted group up from the cache. - let joined = request.result.clone(); + let joined = request.joined(); // The track was accepted before any fetch was asked, so it keeps that info. - let track = request.state.clone(); + let track = request.track(); let mut producer = match request.accept(None) { Ok(producer) => producer, // Already served by a concurrent fetch, or the track closed. diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 63504585370..9d14651c18c 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -92,7 +92,7 @@ struct Shared { } /// Largest millisecond duration every implementation can carry losslessly. -const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; +pub(super) const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// The budget to serve a peer with, given what its wire could tell us. /// @@ -101,10 +101,10 @@ const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// as real time would discard backlog a legacy subscriber never declined, so fall /// back to a window wide enough not to drop and leave enforcement to the receiver, /// exactly as the IETF path does for the same reason. -fn serving_max_delay(version: Version, requested: Duration) -> Duration { +fn serving_max_delay(version: Version, requested: Duration) -> Option { match version.carries_max_delay() { - true => requested, - false => Duration::from_millis(MAX_SAFE_DELAY_MS), + true => Some(requested), + false => Some(Duration::from_millis(MAX_SAFE_DELAY_MS)), } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 2254f3c7014..c606cff201c 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -5800,7 +5800,7 @@ impl TrackServe { let start_moved = active.start != subscription.start(); active.priority = subscription.priority; active.order = subscription.order; - active.max_delay = subscription.max_delay; + active.max_delay = wire_max_delay(subscription.max_delay); // A lowered floor owes groups nobody asked for until now, in the track's // arrivals that held fetches read as well as in the session's tail. let lowered = subscription.start().map(|start| { @@ -5811,7 +5811,7 @@ impl TrackServe { producer.demand_arrivals(groups.clone()); } if let Ok(mut tail) = active.tail.write() { - tail.set_grace(tail::grace(subscription.max_delay)); + tail.set_grace(tail::grace(subscription.budget())); if let Some(groups) = lowered { tail.demand(groups, self.subscriber.runtime.now()); } @@ -5886,7 +5886,7 @@ impl TrackServe { tracing::info!(id, broadcast = %self.subscriber.log_path(&self.path), track = %self.name, "subscribe started"); - let tail = kio::Producer::new(Tail::new(tail::grace(subscription.max_delay))); + let tail = kio::Producer::new(Tail::new(tail::grace(subscription.budget()))); self.subscriber.subscribes.lock().insert( id, TrackEntry { @@ -5977,6 +5977,12 @@ enum Begin { Establish(Establish), } +/// The `Subscriber Max Age` a SUBSCRIBE carries for `max_delay`: `None` is the publisher's +/// max age alone, which the largest value every peer can read leaves to its clamp. +fn wire_max_delay(max_delay: Option) -> std::time::Duration { + max_delay.unwrap_or(std::time::Duration::from_millis(super::MAX_SAFE_DELAY_MS)) +} + /// Buffer the current SUBSCRIBE parameters with an updated end. The caller flushes. fn buffer_update( active: &mut SubStream, @@ -6043,7 +6049,7 @@ impl Establish { broadcast: self.serve.path.as_path(), track: self.serve.name.as_str().into(), priority: self.subscription.priority, - max_delay: self.subscription.max_delay, + max_delay: wire_max_delay(self.subscription.max_delay), start_group: bounds.start_group, end_group: bounds.end_group, start_frame: bounds.start_frame, @@ -6096,7 +6102,7 @@ impl Establish { SubStream { stream, id: self.id, - max_delay: self.subscription.max_delay, + max_delay: wire_max_delay(self.subscription.max_delay), start: self.subscription.start(), priority: self.subscription.priority, order: self.subscription.order, @@ -6455,7 +6461,7 @@ struct ServeLoop { /// Serve on-demand fetches of uncached groups from this session. dynamic: track::Dynamic, sub: Sub, - fetches: kio::Tasks>, + fetches: kio::Tasks>, /// Fetches a pre-lite-05 upstream has no FETCH for, held for the live feed. feed_fetches: FeedFetches, // A dedicated close-watch handle for the session-died arm. @@ -6508,7 +6514,7 @@ struct FeedFetches { } struct HeldFetch { - req: group::Request, + req: track::GroupFill, waiter: kio::Waiter, } @@ -6539,7 +6545,14 @@ impl FeedFetches { } } - fn push(&mut self, req: group::Request, feed: track::Consumer) { + /// Hold every group of `fill` for the live feed. + fn push_fill(&mut self, fill: track::Fill, feed: &track::Consumer) { + for sequence in fill.start().group..fill.end() { + self.push(fill.group(sequence), feed.clone()); + } + } + + fn push(&mut self, req: track::GroupFill, feed: track::Consumer) { let Some(arrivals) = feed.arrivals() else { req.reject(Error::NotFound); return; @@ -6553,7 +6566,7 @@ impl FeedFetches { // The last caller may leave and a replacement attempt arrive before the // queued cancellation gets polled. The old attempt owns no callers now. if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.req.demand().poll_unused(&kio::Waiter::noop()).is_ready()); + debug_assert!(previous.req.poll_unused(&kio::Waiter::noop()).is_ready()); } if self.held.len() >= self.max { req.reject(Error::TooManyRequests); @@ -6625,7 +6638,7 @@ impl FeedFetches { { self.polled += 1; } - if held.req.demand().poll_unused(&held.waiter).is_ready() { + if held.req.poll_unused(&held.waiter).is_ready() { settled.push((held.req, None)); continue; } @@ -6756,16 +6769,17 @@ impl ServeLoop { // (1) Track demand: a fetch, a subscription change, or the origin // handing the track to another route. - // A fetch is cheap and one-shot, so serve it ahead of subscription churn. - match self.dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(req)) => { + // A fill is bounded and serves readers already waiting, so serve it ahead of + // subscription churn. + match self.dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { if self.supports_fetch { self.fetches - .push(FetchServeRun::new(serve.clone(), req, self.timescale)); + .push(FillServeRun::new(serve.clone(), fill, self.timescale)); } else { // No FETCH on the wire: wait for the live feed, which a // subscription arriving alongside may still be opening. - self.feed_fetches.push(req, self.serving.consume()); + self.feed_fetches.push_fill(fill, &self.serving.consume()); } continue; } @@ -6909,10 +6923,14 @@ impl ServeLoop { // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that // still arrives is delivered. - if let Some(requested) = active.requested - && let Ok(mut tail) = active.tail.write() - { - tail.account(requested.group..start.group, serve.subscriber.runtime.now()); + // Recorded as dropped too, so a reader asking for them + // later is told so rather than fetching what the publisher + // already gave up. + if let Some(requested) = active.requested { + if let Ok(mut tail) = active.tail.write() { + tail.account(requested.group..start.group, serve.subscriber.runtime.now()); + } + self.serving.drop_groups(requested.group..start.group); } } // The publisher will never send these groups, so they @@ -6959,7 +6977,7 @@ impl ServeLoop { // subscriber was willing to wait for a late group anyway. if let Ok(mut tail) = active.tail.write() { tail.set_grace(tail::grace( - subscription.map(|sub| sub.max_delay).unwrap_or_default(), + subscription.map(|sub| sub.budget()).unwrap_or_default(), )); tail.expire(serve.subscriber.runtime.now()); } @@ -6989,6 +7007,59 @@ impl ServeLoop { } } +/// Serves one fill a group at a time, newest first for a descending reader, each with its +/// own [`FetchServeRun`], until it is answered or nobody waits on it. +struct FillServeRun { + serve: TrackServe, + fill: track::Fill, + timescale: Option, + run: Option<(u64, FetchServeRun)>, +} + +impl FillServeRun { + fn new(serve: TrackServe, fill: track::Fill, timescale: Option) -> Self { + Self { + serve, + fill, + timescale, + run: None, + } + } +} + +impl kio::Task for FillServeRun { + type Output = (); + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + loop { + let last = match &mut self.run { + Some((sequence, run)) => { + ready!(run.poll(waiter)); + let sequence = *sequence; + self.run = None; + Some(sequence) + } + None => None, + }; + // Withdrawn: nobody waits on what is left, so it records nothing. + if self.fill.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + let Some(sequence) = self.fill.next() else { + return Poll::Ready(()); + }; + // Every run answers its group, so the same one coming back is a bug; ending the + // fill names it dropped rather than fetching it forever. + if last == Some(sequence) { + debug_assert!(false, "fill group {sequence} left unanswered"); + return Poll::Ready(()); + } + let run = FetchServeRun::new(self.serve.clone(), self.fill.group(sequence), self.timescale); + self.run = Some((sequence, run)); + } + } +} + /// Serves one downstream fetch end-to-end on its own bidi stream: send FETCH, /// then fill the group from the bare FRAME messages that follow. The timescale /// comes from this track's TRACK_INFO (already known), and the group sequence @@ -7005,16 +7076,16 @@ struct FetchServeRun { enum FetchRunState { Open { - request: Option, + request: Option, }, Send { - request: Option, + request: Option, stream: Stream, frame_start: u64, }, /// Flushed; waiting for the publisher to answer before accepting. Answer { - request: Option, + request: Option, stream: Stream, frame_start: u64, }, @@ -7023,7 +7094,7 @@ enum FetchRunState { producer: crate::recv::Group, // The accepted request's demand: fetches that joined it but have not yet // picked the group up from the cache. - joined: kio::Producer, + joined: kio::Producer, // Boxed so the other states stay small; one allocation per fetch. ingest: Box, }, @@ -7032,7 +7103,7 @@ enum FetchRunState { impl FetchRunState { /// The downstream request, while it waits on the publisher's answer. - fn request(&self) -> Option<&group::Request> { + fn request(&self) -> Option<&track::GroupFill> { match self { Self::Open { request } | Self::Send { request, .. } | Self::Answer { request, .. } => request.as_ref(), Self::Ingest { .. } | Self::Done => None, @@ -7041,7 +7112,7 @@ impl FetchRunState { } impl FetchServeRun { - fn new(serve: TrackServe, request: group::Request, timescale: Option) -> Self { + fn new(serve: TrackServe, request: track::GroupFill, timescale: Option) -> Self { let session = serve.subscriber.session.clone(); let group = request.sequence(); Self { @@ -7120,7 +7191,7 @@ impl kio::Task for FetchServeRun { // A fetch nobody waits on any more is cancelled upstream, so the publisher stops // serving it (and a relay there releases its own FETCH). Ingest has its own check. if let Some(request) = self.state.request() - && request.demand().poll_unused(waiter).is_ready() + && request.poll_unused(waiter).is_ready() { tracing::debug!(track = %self.serve.name, group = self.group, "fetch abandoned"); if let FetchRunState::Send { stream, .. } | FetchRunState::Answer { stream, .. } = @@ -7252,7 +7323,7 @@ impl kio::Task for FetchServeRun { let group_info = track::Info::default().with_timescale(self.timescale); // The joined fetches pick the group up from the cache only when next // polled, so their demand outlives the request until then. - let joined = request.result.clone(); + let joined = request.joined(); let mut producer = match request.accept(group_info) { Ok(producer) => producer, Err(err) => { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index a6737f39043..7a78aca5ccb 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1709,7 +1709,7 @@ impl Handle { pub(crate) fn demand(&self) -> Demand { Demand { sequence: self.info.sequence, - state: DemandSource::Group(self.state.weak()), + state: self.state.weak(), } } @@ -1726,41 +1726,25 @@ impl Handle { } } -/// A cloneable, watch-only handle to a group's readers or pending fetch callers. +/// A cloneable, watch-only handle to a group's readers. /// -/// An accepted or dropped request ends waits with [`Error::Dropped`]; a rejected request keeps -/// its rejection reason. +/// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps +/// its abort reason. #[derive(Clone)] pub struct Demand { sequence: u64, - state: DemandSource, -} - -#[derive(Clone)] -enum DemandSource { - Group(kio::ProducerWeak), - Fetch(kio::ProducerWeak), + state: kio::ProducerWeak, } impl Demand { - pub(super) fn fetch(sequence: u64, state: kio::ProducerWeak) -> Self { - Self { - sequence, - state: DemandSource::Fetch(state), - } - } - /// The group sequence this handle watches. pub fn sequence(&self) -> u64 { self.sequence } - /// Whether the group or fetch is open and has readers right now. + /// Whether the group is open and has readers right now. pub fn is_used(&self) -> bool { - match &self.state { - DemandSource::Group(state) => !state.is_closed() && state.is_used(), - DemandSource::Fetch(state) => !state.is_closed() && state.is_used(), - } + !self.state.is_closed() && self.state.is_used() } /// Wait until at least one reader needs the group. @@ -1777,38 +1761,23 @@ impl Demand { /// Poll until at least one reader needs the group. pub fn poll_used(&self, waiter: &kio::Waiter) -> Poll> { - match &self.state { - DemandSource::Group(state) => state.poll_used(waiter), - DemandSource::Fetch(state) => state.poll_used(waiter), - } - .map_err(|_| self.abort_reason()) + 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> { - match &self.state { - DemandSource::Group(state) => state.poll_unused(waiter), - DemandSource::Fetch(state) => state.poll_unused(waiter), - } - .map_err(|_| self.abort_reason()) + self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) } - /// Wait until the group or fetch closes, returning its cause. + /// Wait until the group closes, returning its cause. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - match &self.state { - DemandSource::Group(state) => state.closed().await, - DemandSource::Fetch(state) => state.closed().await, - } + self.state.closed().await; self.abort_reason() } fn abort_reason(&self) -> Error { - match &self.state { - DemandSource::Group(state) => state.read().abort.clone(), - DemandSource::Fetch(state) => state.read().rejected.clone(), - } - .unwrap_or(Error::Dropped) + self.state.read().abort.clone().unwrap_or(Error::Dropped) } } @@ -2673,29 +2642,6 @@ impl Fetch { } } -/// A consumer's request for a single past group, handed to a handler via -/// [`track::Dynamic::poll_requested_group`]. -/// -/// The handler fulfills it by calling [`Self::accept`], which inserts the group -/// into the track cache (resolving every [`track::Consumer::fetch_group`] that joined the -/// attempt) and returns a [`Producer`] to fill. A relay typically opens a wire -/// FETCH and waits for the publisher to answer before accepting, so a group the -/// publisher lacks is rejected rather than accepted and then aborted. The request -/// carries its own producer handle, so it works the same whether or not the track -/// has been accepted yet. -pub struct Request { - // pending: /quest/m1/public-model/deferred.md - pub(crate) state: kio::Producer, - pub(super) fetch: kio::Shared, - pub(super) sequence: u64, - pub(super) priority: u8, - pub(super) max_delay: Option, - pub(super) frame_start: u64, - // pending: /quest/m1/public-model/deferred.md - pub(crate) result: kio::Producer, - pub(super) done: bool, -} - #[cfg(test)] mod test { use super::*; diff --git a/rs/moq-net/src/model/ledger.rs b/rs/moq-net/src/model/ledger.rs index 64dad02890e..051f4670875 100644 --- a/rs/moq-net/src/model/ledger.rs +++ b/rs/moq-net/src/model/ledger.rs @@ -122,6 +122,15 @@ impl Ledger { (run.groups.start <= sequence).then_some(run.groups.end) } + /// The accounted runs overlapping `groups`, clipped to it, lowest first. + pub fn accounted(&self, groups: Range) -> impl Iterator> + '_ { + let first = self.runs.partition_point(|run| run.groups.end <= groups.start); + self.runs[first..] + .iter() + .take_while(move |run| run.groups.start < groups.end) + .map(move |run| run.groups.start.max(groups.start)..run.groups.end.min(groups.end)) + } + /// Whether every group in `groups` is accounted for. pub fn covers(&self, groups: Range) -> bool { // Runs merge on insert, so one run covers the span or none does. diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index a5c9232e0c4..88946f7a03f 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1425,7 +1425,7 @@ impl Recover { // A held group or frame may be the only thing being polled. Mirror and watch // preferences here too, before judging its budget against the live edge. reader.sync(waiter); - Some(reader.mirrored.max_delay) + Some(reader.mirrored.budget()) } /// Commit the replacement only once the caller has acquired its cursor or frame. diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 98177f43798..05f920a0a89 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -19,18 +19,22 @@ pub(super) use window::{Containing, Held, Holdings, Resolved, Unheld, Window, la pub struct Subscription { /// Delivery priority. Higher values preempt lower ones when bandwidth is constrained. pub priority: u8, - /// How long a superseded group stays worth delivering to this subscriber. + /// How long a superseded group stays worth delivering to this subscriber, or `None` + /// for as long as the publisher keeps it. /// - /// [`Duration::ZERO`] (the default) skips immediately: group 8 arriving means group 7 - /// is abandoned, timed or not. A larger budget tolerates that much reordering before giving up. - /// This never *adds* delay, since the bound is only reached once newer data is - /// already that far ahead. + /// `Some(Duration::ZERO)` (the default) skips immediately: group 8 arriving means group + /// 7 is abandoned, timed or not. A larger budget tolerates that much reordering before + /// giving up. This never *adds* delay, since the bound is only reached once newer data + /// is already that far ahead. `None` is the publisher's + /// [`Info::max_age`](crate::track::Info::max_age) alone, what a reader reaching back + /// for history wants. /// /// This is the `Subscriber Max Age` on the wire, and it is stored here verbatim so /// what was asked for stays readable. Encoded as milliseconds in a QUIC varint, so - /// a duration of `2^62` milliseconds or more cannot be put on the wire. Clamped to - /// the publisher's [`Info::max_age`](crate::track::Info::max_age), since waiting for - /// a group longer than it is kept around cannot produce it. + /// a duration of `2^62` milliseconds or more cannot be put on the wire; `None` goes out + /// as the largest value every peer can read. Clamped to the publisher's + /// [`Info::max_age`](crate::track::Info::max_age), since waiting for a group longer + /// than it is kept around cannot produce it. /// /// It caps every one of [`Self::ranges`]: a range names what may be delivered, and this /// budget skips what inside it has gone stale. @@ -67,7 +71,7 @@ pub struct Subscription { /// when something collects the track's cache pool: an origin's driver does, and a /// standalone track needs [`Pool::gc`](crate::cache::Pool::gc) called, as its idle /// expiry already does. Otherwise it is judged only when something else wakes the read. - pub max_delay: Duration, + pub max_delay: Option, /// The spans of the track to deliver; see [`Range`]. /// /// The default is [`Range::oldest`]: everything [`Self::max_delay`] still considers @@ -262,7 +266,7 @@ impl Default for Subscription { fn default() -> Self { Self { priority: 0, - max_delay: Duration::ZERO, + max_delay: Some(Duration::ZERO), ranges: vec![Range::oldest()], order: Order::Descending, } @@ -276,13 +280,18 @@ impl Subscription { self } - /// Set how far a group may fall behind the live edge before it is skipped, returning - /// `self` for chaining. - pub fn with_max_delay(mut self, max_delay: Duration) -> Self { - self.max_delay = max_delay; + /// Set how far a group may fall behind the live edge before it is skipped, `None` for + /// the publisher's max age alone, returning `self` for chaining. + pub fn with_max_delay(mut self, max_delay: impl Into>) -> Self { + self.max_delay = max_delay.into(); self } + /// [`Self::max_delay`] as a budget: `None` waits as long as anything could. + pub(crate) fn budget(&self) -> Duration { + self.max_delay.unwrap_or(Duration::MAX) + } + /// Replace [`Self::ranges`], returning `self` for chaining. pub fn with_ranges(mut self, ranges: impl IntoIterator) -> Self { self.ranges = ranges.into_iter().collect(); @@ -445,7 +454,10 @@ impl Combined { return; }; combined.priority = combined.priority.max(sub.priority); - combined.max_delay = combined.max_delay.max(sub.max_delay); + combined.max_delay = match (combined.max_delay, sub.max_delay) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + }; combined.ranges.extend_from_slice(&sub.ranges); // Sequence-first prioritization is enabled only when every subscriber wants it. if let Order::Descending = sub.order { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index db1942a7c40..f5dcd68c765 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -18,7 +18,7 @@ use crate::{broadcast, cache, group, stats}; use super::delivered::Delivered; use super::ledger::{self, Ledger}; -use super::{Datagram, Requests}; +use super::Datagram; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; @@ -29,6 +29,13 @@ mod demand; pub(super) use demand::Preferences; use demand::Subscriptions; +mod fill; +mod miss; +pub use fill::Fill; +// private: capability gap +pub(crate) use fill::{GroupFill, Outcome as FillOutcome}; +use fill::FillState; + use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, ops::{self, Bound, RangeBounds}, @@ -451,9 +458,9 @@ pub(crate) struct TrackState { // Kept here (rather than threaded through every handle) so any holder reaches it. subscriptions: kio::Lock, - // The reverse fetch queue (see [`FetchState`]), same reasoning: cache-miss - // `fetch_group` calls enqueue here and a `Dynamic` drains. - fetch: kio::Shared, + // The fills readers queued for groups they miss (see [`Fill`]), same reasoning: a miss + // queues here and a `Dynamic` drains. + fills: kio::Shared, // A front's logical track: read straight from its routes' copies rather than this // cache, which stays empty; see [`super::resume`]. @@ -633,45 +640,6 @@ impl Slot { pub(super) const CACHE_OVERHEAD: u64 = 2 * (size_of::() + size_of::() + size_of::<(u64, Timestamp)>() + 2 * size_of::<(u64, u32)>()) as u64; -/// Reverse state for [`Consumer::fetch_group`], beside the track state in its own -/// [`kio::Shared`]: consumers enqueue (coalescing per sequence, so a relay opens one -/// upstream FETCH per group) and [`Dynamic`] handlers drain under one lock, without -/// write access to the track itself. -pub(super) type FetchState = Requests; - -/// One fetch attempt for a sequence, shared by every [`Fetching`] that joined it. -pub(super) struct PendingFetch { - // The most demanding delivery priority across the joined fetches. - priority: u8, - - // The most tolerant reader budget. None is unbounded and therefore dominates. - max_delay: Option, - - // The lowest start across the joined fetches, so serving the attempt once satisfies - // every one of them. Only widened while the request is still queued: once a handler - // has taken it, its range is already on the wire. - frame_start: u64, - - // Result channel for joined fetches. An accept resolves covered callers through - // the track cache and refuses uncovered ones. Dropping every producer closes - // an unserved attempt, which a [`Fetching`] reads as [`Error::NotFound`]. - result: kio::Producer, -} - -/// Whether a fetch attempt was accepted, or why it was rejected. -#[derive(Default)] -// pending: /quest/m1/public-model/deferred.md -pub(crate) struct FetchOutcome { - // private: test - pub(crate) rejected: Option, - // The budget the rejected attempt asked its handler for, so a looser joiner can tell - // that an `Old` refusal was not its own. - asked: Option, - // An accept still refuses joined fetches the cached group cannot cover, without - // giving the request's demand watcher a rejection reason. - accepted: bool, -} - /// Where the groups this hop holds start, for resolving time anchors: ordered lookups in /// the starts index and the cache, so no query walks more than the run it answers. impl Holdings for TrackState { @@ -1330,8 +1298,8 @@ 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 rejection, or no [`Dynamic`] at all) lives - /// in [`FetchState`]; [`Fetching`] polls both. + /// end-of-stream. The handler side (a refusal, or no [`Dynamic`] 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 // short) is a miss, not a hit that fails on its first read. @@ -1605,7 +1573,7 @@ impl TrackState { return; } let max_delay = snapshot_subscription(&self.subscriptions, self.max_age_bound()) - .map_or(Duration::ZERO, |subscription| subscription.max_delay); + .map_or(Duration::ZERO, |subscription| subscription.budget()); self.arrivals.set_grace(ledger::grace(max_delay)); self.arrivals.expire(now); } @@ -2247,7 +2215,7 @@ impl TrackState { Ok(()) } - /// Insert a group fetched for a [`group::Request`], setting the track's [`Info`] + /// Insert a group a [`Fill`] answered, setting the track's [`Info`] /// if it isn't accepted yet. The group's timescale comes from that info, so a /// fetch can serve an as-yet-unaccepted track (e.g. a relay with no live /// subscription). The group lands in the cache so a waiting @@ -3146,84 +3114,34 @@ impl Producer { } } -/// Pop the next queued group fetch off the fetch queue and wrap it in a -/// [`group::Request`] bound to a fresh producer handle. Shared by every -/// [`Dynamic`] handle on the track. -fn poll_requested_group( - state: &kio::Producer, - fetch: &kio::Shared, - waiter: &kio::Waiter, -) -> Poll> { - // Prefer serving a queued fetch, even if the track has since aborted. - if let Poll::Ready(mut guard) = fetch.poll(waiter, |fetch| { - if fetch.has_queued() { - Poll::Ready(()) - } else { - Poll::Pending - } - }) { - let sequence = guard.pop().expect("predicate guaranteed a request"); - // The popped attempt stays pending, so a fetch in the window between hand-off - // and accept joins it instead of queueing a duplicate. - // `group::Request::{accept, reject, drop}` removes the entry, as does the last - // `Fetching` leaving. - let pending = guard.get(&sequence).expect("popped key must be pending"); - let priority = pending.priority; - let max_delay = pending.max_delay; - let frame_start = pending.frame_start; - let result = pending.result.clone(); - drop(guard); - return Poll::Ready(Ok(group::Request { - state: state.clone(), - fetch: fetch.clone(), - sequence, - priority, - max_delay, - frame_start, - result, - done: false, - })); - } - - // No fetch queued: surface a track abort so the handler loop can exit. - match state.poll_ref(waiter, |state| match &state.abort { - Some(err) => Poll::Ready(err.clone()), - None => Poll::Pending, - }) { - Poll::Ready(Ok(err)) => Poll::Ready(Err(err)), - Poll::Ready(Err(closed)) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), - Poll::Pending => Poll::Pending, - } -} - -/// Serves on-demand fetches of uncached (old) groups for a track, the group-level -/// analogue of [`broadcast::Dynamic`]. +/// Serves the groups a track's readers miss, the group-level analogue of +/// [`broadcast::Dynamic`]. /// /// 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 -/// from upstream. While at least one is alive the track will block a cache-miss -/// [`Consumer::fetch_group`] waiting to be served; with none, an accepted track's -/// miss fails fast with [`Error::NotFound`]. +/// 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 { name: Arc, // Kept to insert served groups into the cache and observe track abort. state: kio::Producer, - // The fetch queue this handle drains; its `dynamic` count gates `fetch_group`. - fetch: kio::Shared, - // Shared with the track's producers: a handler still serving fetches keeps the + // The fills this handle drains; its handler count gates queueing. + fills: kio::Shared, + // Shared with the track's producers: a handler still serving fills keeps the // track alive, like a producer clone does. alive: Arc, } impl Dynamic { fn new(name: Arc, state: kio::Producer, alive: Arc) -> Self { - let fetch = state.read().fetch.clone(); - fetch.lock().add_handler(); + let fills = state.read().fills.clone(); + fills.lock().add_handler(); Self { name, state, - fetch, + fills, alive, } } @@ -3233,19 +3151,19 @@ impl Dynamic { &self.name } - /// Block until a consumer fetches a group that isn't cached, returning a - /// [`group::Request`] to serve via [`group::Request::accept`]. + /// Block until readers miss a run of groups, returning a [`Fill`] to serve via + /// [`Fill::accept`] and [`Fill::drop_groups`]. /// - /// A relay issues a wire FETCH first; an origin already has the group cached, so - /// the fetch resolves without ever reaching here. Errors once the track is aborted. + /// A relay fetches the run upstream; an origin already has its groups cached, so + /// nothing reaches here. Errors once the track is aborted. #[cfg(feature = "async")] - pub async fn requested_group(&self) -> Result { - kio::wait(|waiter| self.poll_requested_group(waiter)).await + pub async fn requested_fill(&self) -> Result { + kio::wait(|waiter| self.poll_requested_fill(waiter)).await } - /// Poll for a group request to serve. - pub fn poll_requested_group(&self, waiter: &kio::Waiter) -> Poll> { - poll_requested_group(&self.state, &self.fetch, waiter) + /// Poll for a fill to serve. + pub fn poll_requested_fill(&self, waiter: &kio::Waiter) -> Poll> { + fill::poll_requested(&self.state, &self.fills, waiter) } /// Watch subscriber demand without keeping the track alive. @@ -3257,11 +3175,11 @@ impl Dynamic { impl Clone for Dynamic { fn clone(&self) -> Self { // Count each live handle (mirrors `broadcast::Dynamic`). - self.fetch.lock().add_handler(); + self.fills.lock().add_handler(); Self { name: self.name.clone(), state: self.state.clone(), - fetch: self.fetch.clone(), + fills: self.fills.clone(), alive: self.alive.clone(), } } @@ -3270,14 +3188,10 @@ impl Clone for Dynamic { impl Drop for Dynamic { fn drop(&mut self) { // Unlike `broadcast::Dynamic`, dropping the last handle doesn't abort the track: - // a live `Producer` may still be serving the subscription. It just stops fetch - // serving. Queued attempts no handler will ever pop are dropped, closing their - // result channels so every joined `Fetching` resolves NotFound; an attempt - // already handed to a handler stays, resolved by its `group::Request` instead. - let mut fetch = self.fetch.lock(); - if fetch.remove_handler() { - fetch.drain_queued(); - } + // 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`. + self.fills.lock().remove_handler(); } } @@ -3443,7 +3357,9 @@ fn clamp_max_delay(mut max_delay: Duration, bound: Option) -> Duration /// Clamp the aggregate's max delay budget to the publisher's window; see [`clamp_max_delay`]. fn clamp_combined(combined: Option, bound: Option) -> Option { let mut combined = combined?; - combined.max_delay = clamp_max_delay(combined.max_delay, bound); + if bound.is_some() { + combined.max_delay = Some(clamp_max_delay(combined.budget(), bound)); + } Some(combined) } @@ -4184,8 +4100,8 @@ impl Consumer { /// (past the final sequence, or no [`Dynamic`] 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`](crate::StreamError::NotFound)), - /// or the track's abort error if it's already closed. Concurrent fetches for the same sequence coalesce onto one - /// handler request. + /// or the track's abort error if it's already closed. A miss queues a one-group [`Fill`], + /// which concurrent fetches of the same sequence join. pub fn fetch_group(&self, sequence: u64, options: impl Into>) -> kio::Pending { let options = options.into().unwrap_or_default(); let state = self.resolve(None).0; @@ -4202,7 +4118,7 @@ impl Consumer { if let Some(resume) = resume { return kio::Pending::new(Fetching { state: state.clone(), - fetch: state.read().fetch.clone(), + fills: state.read().fills.clone(), sequence, frame_start: options.frame_start, priority: options.priority, @@ -4217,10 +4133,10 @@ impl Consumer { // Queue a request only when the group isn't already resolvable from the track // (cached, aborted, or past-final all resolve through `Fetching::poll` without // a queue entry). - let (fetch, cached) = { + let (fills, cached) = { let state = state.read(); ( - state.fetch.clone(), + state.fills.clone(), state.poll_fetch_cached(sequence, options.frame_start), ) }; @@ -4233,12 +4149,12 @@ impl Consumer { }; if unresolved { - result = queue(&fetch, sequence, &options); + result = queue(&fills, sequence, &options); } kio::Pending::new(Fetching { state, - fetch, + fills, sequence, frame_start: options.frame_start, priority: options.priority, @@ -4480,159 +4396,25 @@ impl kio::Task for Querying { } } -impl group::Request { - /// Watch the callers waiting for this fetch without keeping the attempt alive. - /// - /// The last caller to leave withdraws the attempt, so a later fetch of the group - /// queues a fresh request rather than joining this one: once unused, demand never - /// returns, so drop the request. - pub fn demand(&self) -> group::Demand { - group::Demand::fetch(self.sequence, self.result.weak()) - } - - /// The group sequence the consumer wants. - pub fn sequence(&self) -> u64 { - self.sequence - } - - /// The delivery priority the consumer requested for this group. - pub fn priority(&self) -> u8 { - self.priority - } - - /// The widest age budget among callers sharing this upstream fetch, for a handler that forwards it. - /// Each caller still enforces its own, so a handler serving locally can ignore it. - pub fn max_delay(&self) -> Option { - self.max_delay - } - - /// The first frame of the group the consumer wants; 0 is the whole group. - /// - /// The group [`accept`](Self::accept) returns already starts here, so the frames a - /// handler writes carry the indices they have in the group rather than restarting at - /// 0. A handler serving from elsewhere moves it with - /// [`start_at`](group::Producer::start_at) before the first frame. - /// - /// There is no end: the handler fetches through the end of the group so the result - /// is cacheable for anyone (see [`group::Fetch::frame_start`]). - pub fn frame_start(&self) -> u64 { - self.frame_start - } - - /// Insert the fetched group into the track cache, resolving the waiting - /// [`Consumer::fetch_group`], and return a [`group::Producer`] to fill. - /// - /// The group's timescale comes from the track's [`Info`]. `info` sets that - /// info if the track hasn't been accepted yet (a fetch with no live subscription), - /// and is ignored once accepted. Returns [`Error::Duplicate`] if the group is - /// already present, or the track's abort error if it closed while pending. - /// - /// Accepting after every caller left still caches the group, which a fetch that - /// queued a fresh request meanwhile resolves from; that request's own accept is then - /// [`Error::Duplicate`]. - pub fn accept(mut self, info: impl Into>) -> Result { - self.done = true; - // Cache the group before removing the attempt: the joined fetches resolve - // through the cache, and removal closes their result channel (which alone - // would read as NotFound). - let res = TrackState::modify(&self.state) - .and_then(|mut state| state.insert_group_request(self.sequence, self.frame_start, info.into())); - self.remove(); - // A joined fetch the cached group can't cover (a wider start that joined after - // the range went on the wire) fails rather than waits. Closing the channel says - // the same, but a handler tracking the joined demand keeps it open. - if let Ok(mut outcome) = self.result.write() { - outcome.accepted = true; - } - res - } - - /// Declare the track's exclusive final sequence, as the publisher answering this - /// fetch reported it. A no-op once the track declared an end, or holds a later group. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - // pending: /quest/m1/public-model/deferred.md - pub(crate) fn finish_track_at(&self, final_sequence: u64) { - if let Ok(mut state) = TrackState::modify(&self.state) - && state.end.is_none() - { - let _ = state.set_end(Position::group(final_sequence)); - } - } - - /// Reject the fetch, resolving every joined [`Consumer::fetch_group`] with `err`. - pub fn reject(mut self, err: Error) { - self.done = true; - // Remove before writing, so a fetch arriving now starts a fresh attempt - // instead of joining a rejected one. - self.remove(); - if let Ok(mut outcome) = self.result.write() { - outcome.rejected = Some(err); - outcome.asked = self.max_delay; - } - } - - /// Remove this attempt from the fetch state, unless a newer attempt for the same - /// sequence has already replaced it. - fn remove(&self) { - withdraw(&mut self.fetch.lock(), self.sequence, |pending| { - pending.result.same_channel(&self.result) - }); - } -} - -/// Join the attempt for `sequence`, or queue a new one; `None` when no handler exists. -fn queue(fetch: &kio::Shared, sequence: u64, options: &group::Fetch) -> Option> { - let mut fetch = fetch.lock(); - if let Some(pending) = fetch.join(&sequence) { - // Join the in-flight attempt for this sequence (queued or already being - // served): share its result channel, raising its priority if ours is higher, - // widening its range if ours starts earlier, and its budget if ours is looser. - // - // Widening only reaches the handler while the attempt is still queued; - // once popped, the `group::Request` holds an immutable copy that is already on - // the wire. What protects the late caller either way: the coverage check - // refuses a group starting above what it asked for, so it fails cleanly and its - // retry queues a fresh attempt, and `Fetching::poll` re-queues a looser budget - // that a tighter one got refused as too old. - pending.priority = pending.priority.max(options.priority); - pending.max_delay = match (pending.max_delay, options.max_delay) { - (Some(current), Some(joined)) => Some(current.max(joined)), - (None, _) | (_, None) => None, - }; - pending.frame_start = pending.frame_start.min(options.frame_start); - return Some(Joined::new(&pending.result)); - } - // Queue a new attempt. The handler gate is atomic with a handler dropping (no fetch - // stranded on a queue nobody drains); with no handler, `Fetching::poll` fails fast. - let producer = kio::Producer::::default(); - let joined = Joined::new(&producer); - let attempt = PendingFetch { +/// Join the fill covering `sequence`, or queue a one-group fill; `None` when no handler +/// exists to take one. +fn queue(fills: &kio::Shared, sequence: u64, options: &group::Fetch) -> Option> { + let end = sequence.checked_add(1)?; + let ask = fill::Ask { + start: Position { + group: sequence, + frame: options.frame_start, + }, + end, priority: options.priority, max_delay: options.max_delay, - frame_start: options.frame_start, - result: producer, + // One group either way; newest first is the default order. + order: Order::default(), }; - fetch.insert(sequence, attempt).ok().map(|()| joined) -} - -/// Remove the attempt for `sequence` if it is still `ours`. Reads first: the attempt is -/// often already gone, and a write would wake every handler parked on the queue. -fn withdraw(fetch: &mut kio::Mut<'_, FetchState>, sequence: u64, ours: impl Fn(&PendingFetch) -> bool + Copy) { - if fetch.get(&sequence).is_some_and(ours) { - fetch.remove_if(&sequence, ours); - } -} - -impl Drop for group::Request { - fn drop(&mut self) { - if self.done { - return; - } - self.remove(); - if let Ok(mut outcome) = self.result.write() { - outcome.rejected = Some(Error::Dropped); - } - } + // The handler gate is atomic with a handler dropping (no fill stranded on a queue + // nobody drains); with no handler, `Fetching::poll` fails fast. + let (joins, _) = fills.lock().join(fills, &ask); + joins.into_iter().next().map(Box::new) } /// The pollable state of a [`Consumer::fetch_group`]. @@ -4642,7 +4424,7 @@ impl Drop for group::Request { /// or produced after a wire FETCH), or [`Error::NotFound`] if it can never exist. pub struct Fetching { state: kio::Consumer, - fetch: kio::Shared, + fills: kio::Shared, sequence: u64, // This caller's own start, so a cached group that begins above it is a miss // rather than a short answer. @@ -4651,9 +4433,9 @@ pub struct Fetching { priority: u8, // This caller's own age cap. None means only the publisher's max age. max_delay: Option, - // The attempt this fetch joined; `None` on a cache hit, or when no handler - // existed to queue on. Boxed: a miss already allocates, and the handle stays small. - result: Option>, + // The fill this fetch joined; `None` on a cache hit, or when no handler existed to + // queue on. Boxed: a miss already allocates, and the handle stays small. + result: Option>, // The group already cached when the fetch was made, held so it stays wanted. // Boxed: a group consumer dwarfs the rest of the handle. hit: Option>, @@ -4664,42 +4446,6 @@ pub struct Fetching { resume: Option>, } -/// A [`Fetching`]'s place in a queued or in-flight attempt. -struct Joined { - // Counts this caller in the attempt's demand and carries its outcome. - outcome: kio::Consumer, - // Names the attempt in the fetch state, so the last caller out withdraws only its own. - attempt: kio::ProducerWeak, -} - -impl Joined { - fn new(result: &kio::Producer) -> Box { - Box::new(Self { - outcome: result.consume(), - attempt: result.weak(), - }) - } -} - -impl Drop for Fetching { - fn drop(&mut self) { - let Some(joined) = self.result.take() else { - return; - }; - let Joined { outcome, attempt } = *joined; - // Joins take the fetch lock, so leaving under it is atomic with one: the joiner - // either keeps the attempt wanted, or finds it withdrawn and queues a fresh one - // rather than joining one its handler is about to drop. - let mut fetch = self.fetch.lock(); - drop(outcome); - if !attempt.is_used() { - withdraw(&mut fetch, self.sequence, |pending| { - pending.result.weak().same_channel(&attempt) - }); - } - } -} - impl kio::Task for Fetching { type Output = Result; @@ -4720,9 +4466,9 @@ impl kio::Task for Fetching { let group = ready!(kio::Task::poll(&mut **resume, waiter))?; return Poll::Ready(Ok(group.with_meter(self.stats.meter()))); } - let (state, fetch, sequence, frame_start, result) = ( + let (state, fills, sequence, frame_start, result) = ( &self.state, - &self.fetch, + &self.fills, self.sequence, self.frame_start, self.result.as_ref(), @@ -4768,7 +4514,7 @@ impl kio::Task for Fetching { // Never queued: no handler existed when the fetch was made. Fail fast while // that's still true; a handler that appeared since may yet fill the cache. // A sequence this track sent as a datagram says so, rather than a plain miss. - return match fetch.poll(waiter, |fetch| match fetch.has_handlers() { + return match fills.poll(waiter, |fills| match fills.has_handlers() { false => Poll::Ready(()), true => Poll::Pending, }) { @@ -4780,11 +4526,11 @@ impl kio::Task for Fetching { }; }; - // An answered attempt fails joined fetches the cache cannot cover. The channel + // An answered fill fails a joined fetch the cache cannot cover. The channel // closing without an answer means its handlers went away. - let (err, asked) = match result.outcome.poll(waiter, |outcome| match &outcome.rejected { - Some(err) => Poll::Ready((err.clone(), outcome.asked)), - None if outcome.accepted => Poll::Ready((Error::NotFound, None)), + let (err, asked) = match result.outcome().poll(waiter, |outcome| match outcome.refused(sequence) { + Some(err) => Poll::Ready((err.clone(), outcome.asked())), + None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), None => Poll::Pending, }) { Poll::Ready(Ok(res)) => res, @@ -4798,7 +4544,7 @@ impl kio::Task for Fetching { } // Joined after a tighter budget went on the wire, which judged the group too old // for that budget, not ours: ask again with ours. Terminates, since the new - // attempt asks at least our budget. + // fill asks at least our budget. let looser = match (asked, self.max_delay) { (Some(asked), Some(ours)) => ours > asked, (Some(_), None) => true, @@ -4810,7 +4556,7 @@ impl kio::Task for Fetching { max_delay: self.max_delay, frame_start: self.frame_start, }; - self.result = queue(&self.fetch, self.sequence, &options); + self.result = queue(&self.fills, self.sequence, &options); return self.poll(waiter); } Poll::Ready(Err(err)) @@ -4964,7 +4710,7 @@ impl group::Expiry for GroupExpiry { let max_delay = max_delay.unwrap_or_else(|| { let mut max_delay = Duration::default(); let _ = self.subscription.poll(waiter, |subscription| { - max_delay = subscription.max_delay; + max_delay = subscription.budget(); Poll::<()>::Pending }); max_delay @@ -5139,14 +4885,27 @@ struct Cursor { seek_pending: BTreeMap, /// The drops a wire publisher forwards, once it first polls for one. drops: Option>, + /// What this cursor misses and asks fills for, once it first reads. + misses: Option>, } struct Peeked { group: group::Consumer, - index: Option, + taken: Taken, ordered: bool, } +/// Where a recv-order candidate came from, which taking it advances past. +#[derive(Clone, Copy)] +enum Taken { + /// The arrival walk, which moves to this index. + Walk(usize), + /// What a widening owes. + Owed, + /// What a fill answered. + Filled, +} + /// The sequences a [`DropCursor`] reports from: a cursor's span, narrowed to its window. struct Within<'a> { span: ops::Range, @@ -5274,7 +5033,7 @@ impl Cursor { // subsequent delivery stays in arrival order, including late datagrams. let newest = state.datagrams.iter().filter_map(|datagram| datagram.timestamp).max(); let start = newest.map_or(0, |newest| { - let cutoff = Duration::from(newest).saturating_sub(preferences.max_delay); + let cutoff = Duration::from(newest).saturating_sub(preferences.budget()); state .datagrams .iter() @@ -5312,6 +5071,7 @@ impl Cursor { stale: stats::Content::default(), seek_pending: BTreeMap::new(), drops: None, + misses: None, }; cursor.update_servable(); cursor @@ -5320,6 +5080,20 @@ impl Cursor { #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll> { self.sync(); + if self.drops.is_none() { + let mut drops = Box::new(DropCursor::new(&self.state.read())); + if let Some(misses) = &self.misses { + for run in misses.given_up().iter() { + drops.pending.insert(run.clone()); + } + } + self.drops = Some(drops); + } + if self.filter.refused.is_none() + && let Poll::Ready(Ok(drift)) = self.poll_drift(&self.servable, waiter) + { + self.probe(self.floor(), &drift, waiter); + } let within = Within { span: self.floor()..self.end_sequence.unwrap_or(u64::MAX), window: &self.filter.window, @@ -5547,7 +5321,7 @@ impl Cursor { fn poll_drift(&self, servable: &Servable, waiter: &kio::Waiter) -> Poll> { let mut max_delay = Duration::default(); let _ = self.subscription.poll_ref(waiter, |subscription| { - max_delay = subscription.max_delay; + max_delay = subscription.budget(); Poll::<()>::Pending }); self.poll(waiter, |state| { @@ -5574,7 +5348,7 @@ impl Cursor { if delivered.above_gap().is_none() { return; } - let max_delay = self.subscription.read().max_delay; + let max_delay = self.subscription.read().budget(); let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); let edge = state.drift_edge(&self.servable); @@ -5616,9 +5390,65 @@ impl Cursor { })) } + /// Ask fills for what this cursor misses from `from`, and take in what they answered, + /// parking `waiter` on the fills still out. Whatever it gives up goes to the drop cursor. + fn probe(&mut self, from: u64, drift: &Drift, waiter: &kio::Waiter) { + let asking = { + let subscription = self.subscription.read(); + miss::Asking { + start: subscription.start(), + priority: subscription.priority, + max_delay: subscription.max_delay, + order: subscription.order, + } + }; + let fills = self.state.read().fills.clone(); + let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); + let misses = self.misses.get_or_insert_with(Box::default); + let mut given_up = Vec::new(); + if misses.lowest().is_some() { + misses.retain(from, &admitted(window, from, cap)); + } + let asks = { + let state = self.state.read(); + misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up) + }; + misses.ask(&fills, &asks, &asking, &mut given_up); + loop { + let retry = misses.resolve(&self.state, asking.max_delay, &mut given_up, waiter); + if retry.is_empty() { + break; + } + misses.ask(&fills, &retry, &asking, &mut given_up); + } + if let Some(drops) = &mut self.drops { + for run in given_up { + drops.pending.insert(run); + } + } + } + + /// The lowest group a fill answered for this cursor, cached and not handed out yet. + fn next_filled(&mut self) -> Option { + let misses = self.misses.as_mut()?; + let mut given_up = Vec::new(); + let group = misses.next_filled(&self.state.read(), &mut given_up); + if let Some(drops) = &mut self.drops { + for run in given_up { + drops.pending.insert(run); + } + } + group + } + + /// Whether a fill still owes this cursor a group, so the end of the feed is not its end. + fn awaits_fill(&self) -> bool { + self.misses.as_ref().is_some_and(|misses| misses.is_waiting()) + } + fn poll_recv_group(&mut self, take: bool, waiter: &kio::Waiter) -> Poll>> { if let Some(peeked) = self.peeked.take().filter(|peeked| take && !peeked.ordered) { - self.advance_arrival(peeked.group.sequence, peeked.index); + self.advance_arrival(peeked.group.sequence, peeked.taken); peeked.group.cache_refresh(); return Poll::Ready(Ok(Some(self.with_expiry(peeked.group)))); } @@ -5630,6 +5460,7 @@ impl Cursor { // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.probe(self.floor(), &drift, waiter); self.poll_recv_with(take, drift, waiter) } @@ -5645,15 +5476,20 @@ impl Cursor { // Re-offer what a widening newly covers ahead of the arrival walk, lowest first: // it is the oldest thing still owed. A group the walk has yet to reach comes // again from it, and the subscriber's delivered set drops the repeat. - let (consumer, next_index) = match self.next_owed() { - Some(producer) => { + let owed = match self.next_owed() { + Some(producer) => Some((producer, Taken::Owed)), + // A fill answered what the walk never reaches: backfill is not in it. + None => self.next_filled().map(|producer| (producer, Taken::Filled)), + }; + let (consumer, taken) = match owed { + Some((producer, taken)) => { let consumer = producer.consume(); // A re-offer is a delivery: stamp it like a fresh hand-out. Peeking is // not an access. if take { consumer.cache_refresh(); } - (consumer, None) + (consumer, taken) } None => { let floor = self.floor(); @@ -5665,6 +5501,10 @@ impl Cursor { continue; } let Some((producer, found_index)) = ready!(polled)? else { + // The feed ended, but a fill still owes a group. Its answer wakes us. + if self.awaits_fill() { + return Poll::Pending; + } return Poll::Ready(Ok(None)); }; let consumer = producer.consume(); @@ -5696,7 +5536,7 @@ impl Cursor { self.index = found_index + 1; continue; } - (consumer, Some(found_index + 1)) + (consumer, Taken::Walk(found_index + 1)) } }; @@ -5706,7 +5546,7 @@ impl Cursor { // A peek discards what cannot be delivered, but keeps its candidate at the // same cursor position until the reader chooses this copy. if take || stale { - self.advance_arrival(consumer.sequence, next_index); + self.advance_arrival(consumer.sequence, taken); } if stale { self.stale.add(consumer.content()); @@ -5722,7 +5562,7 @@ impl Cursor { } else { self.peeked = Some(Box::new(Peeked { group: consumer.clone(), - index: next_index, + taken, ordered: false, })); consumer @@ -5732,10 +5572,15 @@ impl Cursor { /// Move the arrival cursor past a group it read: from the walk, to `index`; from what /// a widening owes, out of the owed spans. - fn advance_arrival(&mut self, sequence: u64, index: Option) { - match index { - Some(index) => self.index = index, - None => self.filter.owed.remove(sequence..=sequence), + fn advance_arrival(&mut self, sequence: u64, taken: Taken) { + match taken { + Taken::Walk(index) => self.index = index, + Taken::Owed => self.filter.owed.remove(sequence..=sequence), + Taken::Filled => { + if let Some(misses) = &mut self.misses { + misses.take_filled(sequence); + } + } } } @@ -5790,6 +5635,7 @@ impl Cursor { let floor = next.max(self.floor()); // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.probe(floor, &drift, waiter); self.poll_next_with(take, floor, drift, waiter) } @@ -5812,12 +5658,26 @@ impl Cursor { continue; } let Some(producer) = ready!(polled)? else { + // The feed ended, but a fill still owes a group. Its answer wakes us. + if self.awaits_fill() { + return Poll::Pending; + } // Deliberately no flush of `seek_pending` here: only a delivery commit // may count a conviction. This `None` can be an artifact of a floor // that will lower again. A conviction never committed is dropped // uncounted with the cursor. return Poll::Ready(Ok(None)); }; + // In order means a group a fill still owes below this one comes first. Its + // answer, or giving it up, wakes us. + if self + .misses + .as_ref() + .and_then(|misses| misses.lowest_wanted()) + .is_some_and(|wanted| wanted < producer.sequence) + { + return Poll::Pending; + } let group = producer.consume(); // The first group to arrive resolves a relative floor, which may land above it, and @@ -5857,7 +5717,7 @@ impl Cursor { } else { self.peeked = Some(Box::new(Peeked { group: group.clone(), - index: None, + taken: Taken::Owed, ordered: true, })); group @@ -6288,7 +6148,8 @@ impl Subscriber { /// Poll for where the source's feed starts, raised to this cursor's floor, once /// resolved; see [`Subscriber::poll_start`]. A feed starting below the floor serves the - /// floor's group too. `None` when the source declares none. + /// floor's group too, and a fill this cursor waits on starts it lower still. `None` + /// when the source declares none and no fill is out. #[cfg_attr(not(feature = "async"), allow(dead_code))] // pending: /quest/m1/public-model/serve.md pub(crate) fn poll_start(&mut self, waiter: &kio::Waiter) -> Poll> { @@ -6298,7 +6159,12 @@ impl Subscriber { }; let start = ready!(Consumer::poll_state_start(&cursor.state, waiter)); cursor.sync(); - Poll::Ready(start.map(|start| start.max(cursor.floor()))) + let start = start.map(|start| start.max(cursor.floor())); + let filling = cursor.misses.as_ref().and_then(|misses| misses.lowest()); + Poll::Ready(match (start, filling) { + (Some(start), Some(filling)) => Some(start.min(filling)), + (start, filling) => start.or(filling), + }) } /// The lowest group this subscriber's ranges name, as resolved here when it last read: diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index b415c026944..d447fd463c2 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -18,6 +18,7 @@ use ranges::Ranges; pub(super) struct Subscriptions { count: usize, priority: BTreeMap, + // Keyed by budget, so `None` counts as the widest of all. max_delay: BTreeMap, ranges: Ranges, descending: usize, @@ -32,7 +33,7 @@ pub(super) struct Subscriptions { } /// Everything the aggregate holds besides its ranges. -type Scalars = (u8, Duration, Order); +type Scalars = (u8, Option, Order); impl Subscriptions { pub(super) fn combined(&self) -> Option { @@ -56,7 +57,7 @@ impl Subscriptions { (self.count != 0).then(|| { ( *self.priority.last_key_value().unwrap().0, - *self.max_delay.last_key_value().unwrap().0, + Some(*self.max_delay.last_key_value().unwrap().0).filter(|budget| *budget != Duration::MAX), if self.descending == 0 { Order::Ascending } else { @@ -140,7 +141,7 @@ impl Subscriptions { let moved = old.map(|old| &old.ranges) != new.map(|new| &new.ranges); if let Some(new) = new { add(&mut self.priority, new.priority); - add(&mut self.max_delay, new.max_delay); + add(&mut self.max_delay, new.budget()); if moved { for range in &new.ranges { reshaped |= self.ranges.add(*range); @@ -151,7 +152,7 @@ impl Subscriptions { } if let Some(old) = old { remove(&mut self.priority, old.priority); - remove(&mut self.max_delay, old.max_delay); + remove(&mut self.max_delay, old.budget()); if moved { for range in &old.ranges { reshaped |= self.ranges.remove(*range); diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs new file mode 100644 index 00000000000..0ec4fd1150c --- /dev/null +++ b/rs/moq-net/src/model/track/fill.rs @@ -0,0 +1,717 @@ +//! Fills: the groups readers miss, asked of a [`Dynamic`](super::Dynamic) 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 +//! handler serves each fill it takes as one bounded run: [`Fill::accept`] for each group it +//! has, [`Fill::drop_groups`] for the rest. + +use std::{ + collections::{HashMap, VecDeque}, + ops, + sync::Arc, + task::Poll, + time::Duration, +}; + +use rangemap::{RangeMap, RangeSet}; + +use super::{Info, Order, Position, TrackState, clamp_max_delay}; +use crate::{Error, Result, 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. + handlers: usize, + next: u64, + // Every fill not yet ended, queued or taken, by id. + pending: HashMap, + // The queued subset, oldest first. + queue: VecDeque, + // The fill each unresolved sequence waits on, so a second reader joins it rather than + // asking again. + covers: RangeMap, +} + +/// One fill until it ends. +struct Pending { + start: Position, + end: u64, + priority: u8, + max_delay: Option, + order: Order, + queued: bool, + // Readers hold consumers, so it counts the fill's demand; the handler's copy says what + // became of each sequence. + result: kio::Producer, +} + +/// What became of a fill's sequences, shared with every reader that joined it. +#[derive(Default)] +// pending: /quest/m1/public-model/deferred.md +pub(crate) struct Outcome { + accepted: RangeSet, + // Why each refused run was refused, in the order the handler refused them. + refused: Vec<(ops::Range, Error)>, + // The budget the fill asked its handler for, so a looser reader can tell that an + // [`Error::Old`] was not its own. + asked: Option, +} + +impl Outcome { + /// Why `sequence` was refused, if it was. + pub(super) fn refused(&self, sequence: u64) -> Option<&Error> { + self.refused + .iter() + .rev() + .find(|(groups, _)| groups.contains(&sequence)) + .map(|(_, err)| err) + } + + pub(super) fn is_accepted(&self, sequence: u64) -> bool { + self.accepted.contains(&sequence) + } + + pub(super) fn asked(&self) -> Option { + self.asked + } + + /// What was answered within `wanted`: accepted, or refused with why and the budget + /// the fill asked with. + #[allow(clippy::type_complexity)] + pub(super) fn within( + &self, + wanted: &RangeSet, + ) -> Vec<(ops::Range, std::result::Result<(), (Error, Option)>)> { + let mut answers = Vec::new(); + for run in wanted.iter() { + for accepted in self.accepted.overlapping(run) { + answers.push((accepted.start.max(run.start)..accepted.end.min(run.end), Ok(()))); + } + for (refused, err) in &self.refused { + let groups = refused.start.max(run.start)..refused.end.min(run.end); + if !groups.is_empty() { + answers.push((groups, Err((err.clone(), self.asked)))); + } + } + } + answers + } + + fn resolved(&self) -> RangeSet { + let mut resolved = self.accepted.clone(); + for (groups, _) in &self.refused { + resolved.insert(groups.clone()); + } + resolved + } + + fn refuse(&mut self, groups: ops::Range, err: Error) { + if groups.is_empty() { + return; + } + self.refused.push((groups, err)); + } +} + +/// A reader's request: the run `start.group..end`, from `start.frame` in its first group. +pub(super) struct Ask { + pub start: Position, + pub end: u64, + pub priority: u8, + pub max_delay: Option, + pub order: Order, +} + +/// A reader's place in one fill: the part of it the reader wants. +pub(super) struct Join { + fills: kio::Shared, + id: u64, + /// The sequences of the fill this reader asked for. + pub groups: ops::Range, + // Counts this reader in the fill's demand; `None` only while dropping. + outcome: Option>, + attempt: kio::ProducerWeak, +} + +impl Join { + pub(super) fn outcome(&self) -> &kio::Consumer { + self.outcome.as_ref().expect("held until dropped") + } +} + +impl Drop for Join { + fn drop(&mut self) { + // Joins take the lock, so leaving under it is atomic with one: a joiner either keeps + // the fill wanted, or finds it withdrawn and asks afresh rather than joining a fill + // its handler is about to drop. + let mut fills = self.fills.lock(); + drop(self.outcome.take()); + if !self.attempt.is_used() && fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + fills.withdraw(self.id); + } + } +} + +impl Join { + fn is_ours(&self, pending: &Pending) -> bool { + pending.result.weak().same_channel(&self.attempt) + } +} + +impl FillState { + pub(super) fn add_handler(&mut self) { + self.handlers += 1; + } + + /// Count a handler out. The last one leaving drops what it never took, so every reader + /// waiting on those fills finds them gone; a fill already taken stays with its handler. + pub(super) fn remove_handler(&mut self) { + self.handlers -= 1; + if self.handlers > 0 { + return; + } + for id in std::mem::take(&mut self.queue) { + self.end(id); + } + } + + pub(super) fn has_handlers(&self) -> bool { + self.handlers > 0 + } + + pub(super) fn has_queued(&self) -> bool { + !self.queue.is_empty() + } + + /// Join every fill covering `ask`, queueing a new one for each run none covers. Returns + /// the joins, and the runs nobody can fill: no handler is alive to take a new fill. + pub(super) fn join(&mut self, shared: &kio::Shared, ask: &Ask) -> (Vec, Vec>) { + let span = ask.start.group..ask.end; + let mut joins = Vec::new(); + let mut unfillable = Vec::new(); + if span.is_empty() { + return (joins, unfillable); + } + let covered: Vec<(ops::Range, u64)> = self + .covers + .overlapping(&span) + .map(|(groups, id)| (groups.start.max(span.start)..groups.end.min(span.end), *id)) + .collect(); + let gaps: Vec> = self.covers.gaps(&span).collect(); + for (groups, id) in covered { + let pending = self.pending.get_mut(&id).expect("a covered sequence names a pending fill"); + // Widened only while queued: once taken, the handler holds what it was asked. + if pending.queued { + pending.priority = pending.priority.max(ask.priority); + pending.max_delay = looser(pending.max_delay, ask.max_delay); + if groups.start == pending.start.group && ask.start.group == groups.start { + pending.start.frame = pending.start.frame.min(ask.start.frame); + } + } + joins.push(Join { + fills: shared.clone(), + id, + groups, + outcome: Some(pending.result.consume()), + attempt: pending.result.weak(), + }); + } + for groups in gaps { + if self.handlers == 0 { + unfillable.push(groups); + continue; + } + let id = self.next; + self.next += 1; + let start = match groups.start == ask.start.group { + true => ask.start, + false => Position::group(groups.start), + }; + let result = kio::Producer::::default(); + joins.push(Join { + fills: shared.clone(), + id, + groups: groups.clone(), + outcome: Some(result.consume()), + attempt: result.weak(), + }); + self.covers.insert(groups.clone(), id); + self.pending.insert( + id, + Pending { + start, + end: groups.end, + priority: ask.priority, + max_delay: ask.max_delay, + order: ask.order, + queued: true, + result, + }, + ); + self.queue.push_back(id); + } + (joins, unfillable) + } + + /// Take the oldest queued fill, still pending (joinable) until it ends. + fn pop(&mut self) -> Option { + let id = self.queue.pop_front()?; + let pending = self.pending.get_mut(&id).expect("a queued fill is pending"); + pending.queued = false; + Some(id) + } + + /// Drop fill `id`: nobody wants it, so it records nothing. + fn withdraw(&mut self, id: u64) { + let Some(pending) = self.pending.get(&id) else { + return; + }; + if pending.queued { + self.queue.retain(|queued| *queued != id); + } + self.end(id); + } + + /// The parts of `groups` routed to fill `id`. + fn routed(&self, id: u64, groups: &ops::Range) -> Vec> { + self.covers + .overlapping(groups) + .filter(|(_, covering)| **covering == id) + .map(|(range, _)| range.start.max(groups.start)..range.end.min(groups.end)) + .collect() + } + + /// Forget fill `id`, closing it for its readers. + fn end(&mut self, id: u64) { + let Some(pending) = self.pending.remove(&id) else { + return; + }; + for range in self.routed(id, &(pending.start.group..pending.end)) { + self.covers.remove(range); + } + } +} + +/// Stop routing readers wanting `groups` to fill `id`: they were answered. Reads first, so +/// an answer that changes nothing wakes no handler parked on the queue. +fn uncover(fills: &kio::Shared, id: u64, groups: ops::Range) { + let mut fills = fills.lock(); + let routed = fills.routed(id, &groups); + for range in routed { + fills.covers.remove(range); + } +} + +/// The more tolerant of two budgets: `None` is publisher max-age alone, which no explicit +/// budget can relax. +fn looser(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + } +} + +/// Pop the next queued fill, or surface the track's abort once none is queued. +pub(super) fn poll_requested( + state: &kio::Producer, + fills: &kio::Shared, + waiter: &kio::Waiter, +) -> Poll> { + // Prefer serving a queued fill, even if the track has since aborted. + if let Poll::Ready(mut guard) = fills.poll(waiter, |fills| match fills.has_queued() { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + let id = guard.pop().expect("predicate guaranteed a fill"); + let pending = guard.pending.get(&id).expect("popped fill is pending"); + let (start, end, priority, max_delay, order) = ( + pending.start, + pending.end, + pending.priority, + pending.max_delay, + pending.order, + ); + let result = pending.result.clone(); + drop(guard); + // Built with the fills unlocked, so no other lock nests under theirs. + let inner = Inner { + state: state.clone(), + fills: fills.clone(), + id, + start, + end, + priority, + max_delay, + order, + result, + }; + // What it asked for decides whose `Old` it is. + if let Ok(mut outcome) = inner.result.write() { + outcome.asked = inner.max_delay; + } + return Poll::Ready(Ok(Fill { inner: Arc::new(inner) })); + } + + // No fill queued: surface a track abort so the handler loop can exit. + match state.poll_ref(waiter, |state| match &state.abort { + Some(err) => Poll::Ready(err.clone()), + None => Poll::Pending, + }) { + Poll::Ready(Ok(err)) => Poll::Ready(Err(err)), + Poll::Ready(Err(closed)) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), + Poll::Pending => Poll::Pending, + } +} + +/// A run of groups readers miss, for a [`Dynamic`](super::Dynamic) 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 +/// it has and [`drop_groups`](Self::drop_groups) for the rest. Dropping the fill names +/// whatever is left as dropped while a reader still wants it; once nobody does, the fill is +/// withdrawn and records nothing, so stop serving it when [`unused`](Self::unused) resolves. +pub struct Fill { + inner: Arc, +} + +/// The state a [`Fill`] and its per-group handles share. The last one dropping settles what +/// was left. +struct Inner { + state: kio::Producer, + fills: kio::Shared, + id: u64, + start: Position, + end: u64, + priority: u8, + max_delay: Option, + order: Order, + result: kio::Producer, +} + +impl Fill { + /// The first group to fill, and the frame within it to start from. + pub fn start(&self) -> Position { + self.inner.start + } + + /// The exclusive end group. + pub fn end(&self) -> u64 { + self.inner.end + } + + /// The most urgent delivery priority among the readers waiting on it. + pub fn priority(&self) -> u8 { + self.inner.priority + } + + /// The most tolerant age budget among the readers waiting on it, or `None` for the + /// publisher's max age alone. Each reader still enforces its own. + pub fn max_delay(&self) -> Option { + self.inner.max_delay + } + + /// Which end to fill first: [`Order::Descending`] is newest first. + pub fn order(&self) -> Order { + self.inner.order + } + + /// Insert group `sequence` into the track's cache, handing it to the readers waiting on + /// it, and return a [`group::Producer`] to fill. + /// + /// The group starts at [`start`](Self::start)'s frame when it is the first group, so the + /// frames written keep the indices they have in the group. `info` sets the track's + /// [`Info`] if it hasn't been accepted yet (a fetch with no live subscription), and is + /// ignored otherwise. Errors with [`Error::InvalidRange`] outside the fill, + /// [`Error::Duplicate`] if the group was already answered or cached, or the track's abort + /// error. + pub fn accept(&self, sequence: u64, info: impl Into>) -> Result { + self.inner.accept(sequence, info.into()) + } + + /// Name `groups` as never to be delivered: the source no longer has them. Readers stop + /// waiting for them, and the track records them so nobody asks again. + pub fn drop_groups(&self, groups: ops::Range) { + self.inner.drop_groups(groups, Error::NotFound); + } + + /// Whether a reader still waits on this fill. + pub fn is_used(&self) -> bool { + self.inner.result.is_used() + } + + /// Wait until no reader waits on this fill: it is withdrawn, so stop serving it. + #[cfg(feature = "async")] + pub async fn unused(&self) { + kio::wait(|waiter| self.poll_unused(waiter)).await + } + + /// Poll until no reader waits on this fill. + pub fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.inner.result.poll_unused(waiter).map(|_| ()) + } + + /// The next group to fill in the fill's order, skipping what was answered. + // private: capability gap + pub(crate) fn next(&self) -> Option { + loop { + let resolved = self.inner.result.read().resolved(); + let span = self.inner.start.group..self.inner.end; + let mut gaps = resolved.gaps(&span); + let sequence = match self.inner.order { + Order::Ascending => gaps.next().map(|gap| gap.start), + Order::Descending => gaps.last().map(|gap| gap.end - 1), + }?; + // The live feed may have delivered it since: answered by the cache, not the source. + let cached = self + .inner + .state + .read() + .covering_group(sequence, self.inner.frame_start(sequence)) + .is_some(); + if !cached { + return Some(sequence); + } + let Ok(mut outcome) = self.inner.result.write() else { + return Some(sequence); + }; + outcome.accepted.insert(sequence..sequence + 1); + drop(outcome); + uncover(&self.inner.fills, self.inner.id, sequence..sequence + 1); + } + } + + /// A handle on group `sequence` alone, for a session serving the fill one group at a time. + // private: capability gap + pub(crate) fn group(&self, sequence: u64) -> GroupFill { + GroupFill { + inner: self.inner.clone(), + sequence, + frame_start: match sequence == self.inner.start.group { + true => self.inner.start.frame, + false => 0, + }, + } + } +} + +impl Inner { + fn contains(&self, sequence: u64) -> bool { + (self.start.group..self.end).contains(&sequence) + } + + fn frame_start(&self, sequence: u64) -> u64 { + match sequence == self.start.group { + true => self.start.frame, + false => 0, + } + } + + fn accept(&self, sequence: u64, info: Option) -> Result { + if !self.contains(sequence) { + return Err(Error::InvalidRange); + } + let Some(end) = sequence.checked_add(1) else { + return Err(Error::InvalidRange); + }; + if self.result.read().resolved().contains(&sequence) { + return Err(Error::Duplicate); + } + // Cache the group before answering: readers resolve an accept through the cache. + let res = TrackState::modify(&self.state) + .and_then(|mut state| state.insert_group_request(sequence, self.frame_start(sequence), info)); + if let Ok(mut outcome) = self.result.write() { + outcome.accepted.insert(sequence..end); + } + uncover(&self.fills, self.id, sequence..end); + res + } + + /// Refuse `groups` with `err`, recording them in the track's drops when the source no + /// longer has them, which binds every reader. + fn drop_groups(&self, groups: ops::Range, err: Error) { + let groups = groups.start.max(self.start.group)..groups.end.min(self.end); + if groups.is_empty() { + return; + } + let unresolved = self.unresolved(groups); + if unresolved.is_empty() { + return; + } + if let Ok(mut state) = TrackState::modify(&self.state) { + for groups in unresolved.iter() { + state.drop_groups(groups.clone()); + } + } + self.refuse(&unresolved, err); + } + + /// The part of `groups` nothing answered yet. + fn unresolved(&self, groups: ops::Range) -> RangeSet { + let resolved = self.result.read().resolved(); + resolved.gaps(&groups).collect() + } + + fn refuse(&self, groups: &RangeSet, err: Error) { + if groups.is_empty() { + return; + } + if let Ok(mut outcome) = self.result.write() { + for groups in groups.iter() { + outcome.refuse(groups.clone(), err.clone()); + } + } + for groups in groups.iter() { + uncover(&self.fills, self.id, groups.clone()); + } + } + + /// Whether an [`Error::Old`] answer is the publisher's max age, which binds every reader, + /// rather than this fill's own budget, which a more tolerant reader may exceed. + fn old_binds_everyone(&self) -> bool { + let Some(max_delay) = self.max_delay else { + return true; + }; + let bound = self.state.read().max_age_bound(); + bound.is_some_and(|bound| clamp_max_delay(max_delay, Some(bound)) >= bound) + } + + /// Refuse group `sequence` with `err`, as its source answered. + fn reject(&self, sequence: u64, err: Error) { + let Some(end) = sequence.checked_add(1) else { + return; + }; + match err { + // The source no longer has it. + Error::NotFound | Error::NotFetchable => self.drop_groups(sequence..end, err), + // Stale under the fill's budget, and so is every group older than it: a timed + // track's starts never rewind. Only the publisher's max age binds every reader; + // under the fill's own budget they are withdrawn, for a reader with a wider one. + Error::Old => { + let older = self.start.group..end; + match self.old_binds_everyone() { + true => self.drop_groups(older, Error::Old), + false => { + let unresolved = self.unresolved(older); + self.refuse(&unresolved, Error::Old); + } + } + } + // Anything else says nothing about the group: refused for now, recorded nowhere. + err => { + let unresolved = self.unresolved(sequence..end); + self.refuse(&unresolved, err); + } + } + } +} + +impl Drop for Inner { + fn drop(&mut self) { + // Whatever the handler left unanswered is named dropped, but only while a reader still + // waits: once demand left, the fill was withdrawn and records nothing. A group cached + // since (the live feed delivered it) is no drop at all. + let left = self.unresolved(self.start.group..self.end); + if !left.is_empty() && self.result.is_used() { + let mut dropped = RangeSet::new(); + if let Ok(mut state) = TrackState::modify(&self.state) { + for groups in left.iter() { + let mut from = groups.start; + let cached: Vec = state + .lookup + .range(groups.clone()) + .filter(|(_, slot)| !slot.is_aborted()) + .map(|(sequence, _)| *sequence) + .collect(); + for sequence in cached { + if from < sequence { + dropped.insert(from..sequence); + } + from = sequence + 1; + } + if from < groups.end { + dropped.insert(from..groups.end); + } + } + for groups in dropped.iter() { + state.drop_groups(groups.clone()); + } + } + if let Ok(mut outcome) = self.result.write() { + for groups in left.iter() { + outcome.refuse(groups.clone(), Error::Dropped); + } + } + } + self.fills.lock().end(self.id); + } +} + +/// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers +/// that group of the fill; dropping it unanswered leaves the group to the fill. +// private: capability gap +pub(crate) struct GroupFill { + inner: Arc, + sequence: u64, + frame_start: u64, +} + +impl GroupFill { + /// The group sequence to fetch. + pub(crate) fn sequence(&self) -> u64 { + self.sequence + } + + /// The delivery priority the readers asked for. + pub(crate) fn priority(&self) -> u8 { + self.inner.priority + } + + /// The widest budget among the readers, for a handler that forwards it. + pub(crate) fn max_delay(&self) -> Option { + self.inner.max_delay + } + + /// The first frame of the group the readers want; 0 is the whole group. + pub(crate) fn frame_start(&self) -> u64 { + self.frame_start + } + + /// Poll until no reader waits on the fill. + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.inner.result.poll_unused(waiter).map(|_| ()) + } + + /// The fill's readers, for a session keeping a group alive while any still wait. + pub(crate) fn joined(&self) -> kio::Producer { + self.inner.result.clone() + } + + /// The track the fill serves. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn track(&self) -> kio::Producer { + self.inner.state.clone() + } + + /// Insert the group into the cache, as [`Fill::accept`]. + pub(crate) fn accept(self, info: impl Into>) -> Result { + self.inner.accept(self.sequence, info.into()) + } + + /// Refuse the group with the source's answer. A miss names it dropped, and + /// [`Error::Old`] every older group of the fill too. + pub(crate) fn reject(self, err: Error) { + self.inner.reject(self.sequence, err); + } + + /// Declare the track's exclusive final sequence, as the source answering this group + /// reported it. A no-op once the track declared an end, or holds a later group. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn finish_track_at(&self, final_sequence: u64) { + if let Ok(mut state) = TrackState::modify(&self.inner.state) + && state.end.is_none() + { + let _ = state.set_end(Position::group(final_sequence)); + } + } +} diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs new file mode 100644 index 00000000000..0f6b22cec78 --- /dev/null +++ b/rs/moq-net/src/model/track/miss.rs @@ -0,0 +1,386 @@ +//! A reader's misses: the sequences it reads that the cache doesn't hold and the live feed +//! will never deliver, asked of the track's fills until each arrives or is given up. +//! +//! Nothing above the newest group the feed produced is a miss, since it arrives live, so a +//! fill is bounded by construction. Below it, a hole is a miss once the feed is past it: +//! under where the feed starts, or accounted for by the arrival ledger without being cached +//! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and +//! judged again as the feed moves. + +use std::{ + collections::BTreeSet, + ops::{self, RangeInclusive}, + task::Poll, + time::Duration, +}; + +use rangemap::{RangeInclusiveSet, RangeSet}; + +use super::fill::{Ask, FillState, Join}; +use super::{Drift, Order, Position, TrackState}; +use crate::Error; + +/// What one reader asks its fills with. +pub(super) struct Asking { + /// Where the reader's own start falls, for the frame of a run beginning at its group. + pub start: Option, + pub priority: u8, + pub max_delay: Option, + pub order: Order, +} + +/// The misses of one cursor, judged as what it reads or the feed moves. +#[derive(Default)] +pub(super) struct Misses { + /// What the last probe judged against: the span read, the feed's newest group, and + /// where the feed starts and readers start. A probe that sees the same skips judging. + at: Option<(u64, u64, Option, Option, Option, Option, u64)>, + /// The sequences judged already, within what the reader reads up to the newest group. + judged: RangeSet, + /// Holes the live feed may still deliver, judged again on every probe. + deferred: RangeSet, + /// The fills this reader waits on. + joins: Vec, + /// The sequences of `joins` still unanswered. + wanted: RangeSet, + /// Answered by a fill and cached, not handed out yet. + filled: BTreeSet, + /// Never asked again: named dropped to the reader. + given_up: RangeSet, +} + +/// A run of misses the reader gives up, for the drop cursor to name. +pub(super) type GivenUp = Vec>; + +impl Misses { + /// The lowest sequence still to come from a fill: asked, or answered and not handed out. + pub fn lowest(&self) -> Option { + let wanted = self.wanted.iter().next().map(|run| run.start); + let filled = self.filled.first().copied(); + match (wanted, filled) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } + } + + /// Whether a fill still owes this reader a group. + pub fn is_waiting(&self) -> bool { + !self.wanted.is_empty() + } + + /// The lowest sequence asked and not answered yet. + pub fn lowest_wanted(&self) -> Option { + self.wanted.iter().next().map(|run| run.start) + } + + /// Everything given up so far, for a drop cursor created after. + pub fn given_up(&self) -> &RangeSet { + &self.given_up + } + + /// The lowest answered group still cached, forgetting one evicted before it was read. + pub fn next_filled(&mut self, state: &TrackState, given_up: &mut GivenUp) -> Option { + while let Some(&sequence) = self.filled.first() { + if let Some(slot) = state.lookup.get(&sequence).filter(|slot| !slot.is_aborted()) { + return Some(slot.serving().clone()); + } + self.filled.remove(&sequence); + self.give_up(sequence..sequence + 1, given_up); + } + None + } + + /// Take `sequence` as handed out. + pub fn take_filled(&mut self, sequence: u64) { + self.filled.remove(&sequence); + } + + /// Forget what the reader no longer reads: below `from`, or outside `admitted`. A fill + /// nobody waits on is withdrawn once its last join goes. + pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { + if self.wanted.is_empty() && self.filled.is_empty() { + return; + } + let outside = |sequence: u64| sequence < from || !admitted.contains(&sequence); + self.filled.retain(|sequence| !outside(*sequence)); + let mut kept = RangeSet::new(); + for run in self.wanted.iter() { + for inside in clip(run.clone(), from, admitted) { + kept.insert(inside); + } + } + self.wanted = kept; + let wanted = &self.wanted; + self.joins.retain(|join| wanted.overlaps(&join.groups)); + } + + /// Take in what the joined fills answered, parking `waiter` on the rest. Returns the + /// runs to ask again: refused as too old for a budget tighter than this reader's. + pub fn resolve( + &mut self, + state: &kio::Consumer, + max_delay: Option, + given_up: &mut GivenUp, + waiter: &kio::Waiter, + ) -> RangeSet { + let mut retry = RangeSet::new(); + let mut i = 0; + while i < self.joins.len() { + let join = &self.joins[i]; + let wanted: RangeSet = self + .wanted + .overlapping(&join.groups) + .map(|run| run.start.max(join.groups.start)..run.end.min(join.groups.end)) + .collect(); + if wanted.is_empty() { + self.joins.swap_remove(i); + continue; + } + let answers = match join.outcome().poll(waiter, |outcome| { + let answered = outcome.within(&wanted); + match answered.is_empty() { + true => Poll::Pending, + false => Poll::Ready(answered), + } + }) { + Poll::Ready(Ok(answers)) => answers, + // Ended without answering the rest: its handlers went away. Whatever the cache + // holds by now is served from it, and the rest is given up, as for an accept. + Poll::Ready(Err(closed)) => { + let mut answers = closed.within(&wanted); + let mut left = wanted.clone(); + for (groups, _) in &answers { + left.remove(groups.clone()); + } + answers.extend(left.iter().map(|run| (run.clone(), Ok(())))); + answers + } + Poll::Pending => { + i += 1; + continue; + } + }; + for (groups, answer) in answers { + self.wanted.remove(groups.clone()); + match answer { + Ok(()) => { + let state = state.read(); + for sequence in groups { + match state.lookup.get(&sequence).is_some_and(|slot| !slot.is_aborted()) { + true => { + self.filled.insert(sequence); + } + false => self.give_up(sequence..sequence + 1, given_up), + } + } + } + Err((Error::Old, asked)) if looser(max_delay, asked) => retry.insert(groups), + // A drop the track recorded is named by the drop cursor already. + Err(_) => self.give_up(groups, given_up), + } + } + // Judged again: the same join may have more to answer, or park the waiter now. + } + retry + } + + /// Judge the holes in `target` the last probe has not, returning the runs to ask a + /// fill for. Gives up a run already stale under this reader's budget. + pub fn judge( + &mut self, + state: &TrackState, + key: (u64, u64, Option), + target: impl FnOnce() -> RangeInclusiveSet, + drift: &Drift, + given_up: &mut GivenUp, + ) -> RangeSet { + let mut asks = RangeSet::new(); + // The feed's start is unknown until the serving session resolves it, and readers + // take nothing from a cache that does not reflect the feed. + if state.start_pending || !state.readable() { + return asks; + } + let Some(newest) = state.newest_produced else { + return asks; + }; + let at = ( + key.0, + key.1, + key.2, + Some(newest), + state.live_floor, + state.start_sequence, + state.drops_revision, + ); + if self.at == Some(at) && self.deferred.is_empty() { + return asks; + } + self.at = Some(at); + + let low = state.live_floor.unwrap_or(0); + let mut span = RangeSet::new(); + if low <= newest { + for run in target().overlapping(&(low..=newest)) { + span.insert((*run.start()).max(low)..(*run.end()).min(newest) + 1); + } + } + // What left the span is forgotten, so it is judged afresh if it comes back. + let judged: RangeSet = self + .judged + .iter() + .flat_map(|run| span.overlapping(run).map(move |s| s.start.max(run.start)..s.end.min(run.end))) + .collect(); + let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); + for run in std::mem::take(&mut self.deferred).iter() { + for s in span.overlapping(run) { + candidates.insert(s.start.max(run.start)..s.end.min(run.end)); + } + } + self.judged = judged; + for run in candidates.iter() { + self.judged.insert(run.clone()); + } + + let start = state.start_sequence.or(state.arrivals.first()); + let datagrams: BTreeSet = state.datagrams.iter().map(|datagram| datagram.sequence).collect(); + for run in candidates.iter() { + for hole in holes(state, run.clone()) { + for hole in self.unclaimed(state, hole) { + let Some(start) = start else { + self.deferred.insert(hole); + continue; + }; + // Below where the feed starts: it will never come live. + if hole.start < start { + asks.insert(hole.start..hole.end.min(start)); + } + let above = hole.start.max(start)..hole.end; + if above.is_empty() { + continue; + } + // Accounted for by the feed and not cached: evicted, or skipped. A buffered + // datagram arrived and is served as one. + let mut from = above.start; + for arrived in state.arrivals.accounted(above.clone()) { + if from < arrived.start { + self.deferred.insert(from..arrived.start); + } + asks.insert(arrived.clone()); + for sequence in datagrams.range(arrived.clone()) { + asks.remove(*sequence..*sequence + 1); + } + from = arrived.end; + } + if from < above.end { + self.deferred.insert(from..above.end); + } + } + } + } + + // Starts never rewind, so a run whose newest group is stale under this reader's + // budget is stale throughout. + let stale: Vec> = asks + .iter() + .filter(|run| state.drifted(run.end - 1, &drift.edge, drift.budget)) + .cloned() + .collect(); + for run in stale { + asks.remove(run.clone()); + self.give_up(run, given_up); + } + asks + } + + /// Ask the fills for `runs`, giving up what no handler can take. + pub fn ask(&mut self, fills: &kio::Shared, runs: &RangeSet, asking: &Asking, given_up: &mut GivenUp) { + for run in runs.iter() { + let frame = asking + .start + .filter(|start| start.group == run.start) + .map_or(0, |start| start.frame); + let ask = Ask { + start: Position { + group: run.start, + frame, + }, + end: run.end, + priority: asking.priority, + max_delay: asking.max_delay, + order: asking.order, + }; + let (joins, unfillable) = fills.lock().join(fills, &ask); + self.wanted.insert(run.clone()); + for groups in unfillable { + self.wanted.remove(groups.clone()); + self.give_up(groups, given_up); + } + self.joins.extend(joins); + } + } + + /// The part of `hole` nobody has a verdict on yet. + fn unclaimed(&self, state: &TrackState, hole: ops::Range) -> Vec> { + let mut left = RangeSet::new(); + left.insert(hole); + for claimed in [&state.drops, &self.given_up, &self.wanted] { + for run in claimed.iter() { + left.remove(run.clone()); + } + } + for sequence in &self.filled { + left.remove(*sequence..*sequence + 1); + } + left.iter().cloned().collect() + } + + fn give_up(&mut self, groups: ops::Range, given_up: &mut GivenUp) { + if groups.is_empty() { + return; + } + self.given_up.insert(groups.clone()); + given_up.push(groups); + } +} + +/// The runs of `run` the cache holds nothing servable for. +fn holes(state: &TrackState, run: ops::Range) -> Vec> { + let mut holes = Vec::new(); + let mut from = run.start; + for (&sequence, slot) in state.lookup.range(run.clone()) { + if slot.is_aborted() { + continue; + } + if from < sequence { + holes.push(from..sequence); + } + from = sequence + 1; + } + if from < run.end { + holes.push(from..run.end); + } + holes +} + +/// The parts of `run` at or above `from` inside `admitted`. +fn clip(run: ops::Range, from: u64, admitted: &RangeInclusiveSet) -> Vec> { + let run = run.start.max(from)..run.end; + if run.is_empty() { + return Vec::new(); + } + let inclusive: RangeInclusive = run.start..=run.end - 1; + admitted + .overlapping(&inclusive) + .map(|inside| (*inside.start()).max(run.start)..(*inside.end()).min(run.end - 1) + 1) + .collect() +} + +/// Whether `ours` tolerates more than the budget a fill `asked` with, so its [`Error::Old`] +/// was not this reader's. +fn looser(ours: Option, asked: Option) -> bool { + match (asked, ours) { + (Some(asked), Some(ours)) => ours > asked, + (Some(_), None) => true, + (None, _) => false, + } +} From feb903ed0157c2a478b306d98ee53be1850effbc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:35:04 -0700 Subject: [PATCH 03/49] wip: misses judged against the arrival ledger --- rs/moq-net/src/ietf/publisher.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 18 +- rs/moq-net/src/lite/publisher.rs | 31 +- rs/moq-net/src/lite/subscriber.rs | 20 +- rs/moq-net/src/model/broadcast.rs | 2 +- rs/moq-net/src/model/origin.rs | 2 +- rs/moq-net/src/model/resume.rs | 20 +- rs/moq-net/src/model/track.rs | 267 ++++++++++-------- rs/moq-net/src/model/track/demand.rs | 2 +- rs/moq-net/src/model/track/fill.rs | 58 ++-- rs/moq-net/src/model/track/miss.rs | 141 ++++++--- rs/moq-net/tests/integration/fetch_cancel.rs | 17 +- rs/moq-net/tests/integration/fetch_caps.rs | 12 +- .../tests/integration/subscriber_max_age.rs | 2 +- rs/moq-net/tests/integration/untimed.rs | 4 +- rs/moq-net/tests/loom.rs | 8 +- 16 files changed, 354 insertions(+), 252 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 2e8d64d1082..4c939ab4dae 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -10137,7 +10137,7 @@ mod serve_tests { let request = h._broadcast.reserve_track("cam").unwrap(); let dynamic = request.dynamic(); let fetching = request.consume().fetch_group(4, None); - let mut fetched = dynamic.requested_group().await.unwrap().accept(None).unwrap(); + let mut fetched = dynamic.requested_fill_group().await.unwrap().accept(None).unwrap(); for object in 0..2 { fetched.write_frame(None, format!("4-{object}").into_bytes()).unwrap(); } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index b7dfaeadaa3..c1a3b7f5be7 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -7107,7 +7107,7 @@ mod tests { let requested = track.dynamic(); let mut pending = Box::pin(track.consume().fetch_group(0, None)); assert!(futures::poll!(pending.as_mut()).is_pending()); - let group = requested.requested_group().await.expect("group request"); + let group = requested.requested_fill_group().await.expect("group request"); fetch = Some((track, pending)); moq_clock::sim::spawn(subscriber.run_group_fetch( Path::new("broadcast").to_owned(), @@ -14713,8 +14713,8 @@ mod stitch_tests { let dynamic = self.track.dynamic(); let mut fetch = Box::pin(self.track.consume().fetch_group(SEQUENCE, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); - let track = request.state.clone(); + let request = dynamic.requested_fill_group().await.expect("group request"); + let track = request.track(); let producer = request.accept(None).unwrap(); let consumer = producer.consume(); let slot = kio::Producer::new(GroupFetch::Ready { @@ -15313,8 +15313,8 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = Box::pin(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); - let outcome = request.result.clone(); + let request = dynamic.requested_fill_group().await.expect("group request"); + let outcome = request.joined(); let mut run = Box::pin(subscriber.clone().run_group_fetch( Path::new("broadcast").to_owned(), "video".into(), @@ -15335,7 +15335,7 @@ mod joining_fetch_tests { "{version:?}: unanswered FETCH stays alive" ); assert!(matches!( - outcome.read().rejected, + outcome.read().refused(GROUP).cloned(), Some(Error::Cancel | Error::Unroutable) )); } else { @@ -15556,7 +15556,7 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); subscriber .clone() @@ -15623,7 +15623,7 @@ mod joining_fetch_tests { let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, group::Fetch::default().with_frame_start(START))); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); subscriber .clone() @@ -15738,7 +15738,7 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, group::Fetch::default().with_frame_start(START))); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); let serving = moq_clock::sim::spawn(subscriber.clone().run_group_fetch( Path::new("broadcast").to_owned(), diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 9d14651c18c..e493b3d2096 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -98,14 +98,11 @@ pub(super) const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// /// A version without the field decodes as [`Duration::ZERO`], which is /// indistinguishable from a peer genuinely asking for the live edge. Serving that -/// as real time would discard backlog a legacy subscriber never declined, so fall -/// back to a window wide enough not to drop and leave enforcement to the receiver, -/// exactly as the IETF path does for the same reason. +/// as real time would discard backlog a legacy subscriber never declined, so serve the +/// publisher's max age alone and leave enforcement to the receiver, exactly as the IETF +/// path does for the same reason. fn serving_max_delay(version: Version, requested: Duration) -> Option { - match version.carries_max_delay() { - true => Some(requested), - false => Some(Duration::from_millis(MAX_SAFE_DELAY_MS)), - } + version.carries_max_delay().then_some(requested) } /// Position a subscription's read cursor for the wire serving it. @@ -4980,17 +4977,14 @@ mod serve_group_test { fn a_version_without_the_field_serves_a_non_dropping_budget() { for version in [Version::Lite01, Version::Lite02] { let max_delay = serving_max_delay(version, Duration::ZERO); - assert!( - max_delay >= Duration::from_secs(86_400), - "{version:?} must not be served as real time: {max_delay:?}" - ); + assert_eq!(max_delay, None, "{version:?} must not be served as real time"); } // A version that does carry it is taken at its word, zero included. - assert_eq!(serving_max_delay(Version::Lite05, Duration::ZERO), Duration::ZERO); + assert_eq!(serving_max_delay(Version::Lite05, Duration::ZERO), Some(Duration::ZERO)); assert_eq!( serving_max_delay(Version::Lite05, Duration::from_secs(3)), - Duration::from_secs(3) + Some(Duration::from_secs(3)) ); } @@ -6070,7 +6064,9 @@ mod serve_group_test { drop(track); run.await.unwrap(); } - assert_eq!(drops(&mut writer, &log, version).await, [(4, 4), (0, 1), (3, 3)]); + // The feed gave up on 0, 1 and 3 before the update, so they are misses named as the + // cursor judges them, ahead of the stale group. + assert_eq!(drops(&mut writer, &log, version).await, [(0, 1), (3, 3), (4, 4)]); } /// After a raised start, lowering it again restarts the gaps below the raised floor, not @@ -6117,7 +6113,8 @@ mod serve_group_test { track.finish_at(track::Position::group(6)).unwrap(); drop(track); kio::wait(|waiter| run.poll(&mut writer, waiter)).await.unwrap(); - assert_eq!(drops(&mut writer, &log, version).await, [(1, 4)]); + // The feed gives up on 2..5 once its grace passes, a miss nothing fills. + assert_eq!(drops(&mut writer, &log, version).await, [(2, 4), (1, 1)]); } /// A raised floor sends no early drop: a later update may lower it again, and the @@ -7718,12 +7715,12 @@ mod tests { } } let _request = groups - .requested_group() + .requested_fill_group() .now_or_never() .expect("one shared fetch") .unwrap(); assert!( - groups.requested_group().now_or_never().is_none(), + groups.requested_fill_group().now_or_never().is_none(), "same group coalesces in the model" ); assert_eq!( diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c606cff201c..936196f4aa4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1953,12 +1953,12 @@ mod tests { const VERSION: Version = Version::Lite05; /// A pre-lite-05 relay's track, untimed, with a fetch of `sequence` queued on it. - fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, group::Request) { + fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::GroupFill) { 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 fetching = track.consume().fetch_group(sequence, None); - let req = dynamic.requested_group().now_or_never().expect("queued").unwrap(); + let req = dynamic.requested_fill_group().now_or_never().expect("queued").unwrap(); (track, fetching, req) } @@ -2127,7 +2127,7 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> (track::Producer, Vec>, Vec) { + ) -> (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(); @@ -2135,7 +2135,7 @@ mod tests { let mut requests = Vec::new(); for sequence in sequences { callers.push(track.consume().fetch_group(sequence, None)); - requests.push(dynamic.requested_group().now_or_never().expect("queued").unwrap()); + requests.push(dynamic.requested_fill_group().now_or_never().expect("queued").unwrap()); } (track, callers, requests) } @@ -2329,7 +2329,7 @@ mod tests { drop(caller); let next = track.consume().fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("replacement queued") .unwrap(); @@ -2346,7 +2346,7 @@ mod tests { #[ignore = "release benchmark, run by nightly"] async fn benchmark_feed_fetch_wakes() { struct ScanFetches { - held: Vec<(group::Request, track::Consumer)>, + held: Vec<(track::GroupFill, track::Consumer)>, /// Armed at the earliest instant the feed's ledger gives up on a held group, so one /// that never arrives still fails. fold: crate::time::Deadline, @@ -2382,7 +2382,7 @@ mod tests { let tail = active.tail.read(); let mut fold: Option = None; for (req, feed) in std::mem::take(&mut self.held) { - if req.demand().poll_unused(waiter).is_ready() { + if req.poll_unused(waiter).is_ready() { continue; } let sequence = req.sequence(); @@ -6922,15 +6922,11 @@ impl ServeLoop { active.served = Some(start.group); // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that - // still arrives is delivered. - // Recorded as dropped too, so a reader asking for them - // later is told so rather than fetching what the publisher - // already gave up. + // still arrives is delivered, so they are not dropped. if let Some(requested) = active.requested { if let Ok(mut tail) = active.tail.write() { tail.account(requested.group..start.group, serve.subscriber.runtime.now()); } - self.serving.drop_groups(requested.group..start.group); } } // The publisher will never send these groups, so they diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index c84c445b631..15683deeec2 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -2073,7 +2073,7 @@ mod test { let dynamic = request.dynamic(); let track = consumer.track("track1").unwrap(); let pending_fetch = track.fetch_group(0, None); - let fetch = dynamic.requested_group().await.unwrap(); + let fetch = dynamic.requested_fill_group().await.unwrap(); let mut group = fetch.accept(crate::track::Info::timed()).unwrap(); group.finish().unwrap(); pending_fetch.await.unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index dcea246547d..059135e772e 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -8706,7 +8706,7 @@ mod tests { let fetched = Arc::new(std::sync::Mutex::new(Vec::::new())); let serving = fetched.clone(); let _fetching = moq_clock::sim::spawn(async move { - while let Ok(request) = fetches.requested_group().await { + while let Ok(request) = fetches.requested_fill_group().await { let mut group = request.accept(crate::track::Info::timed()).unwrap(); for frame in [b"a", b"b", b"c"] { group.write_frame(crate::Timestamp::ZERO, frame.as_ref()).unwrap(); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 88946f7a03f..d24e2a3d368 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1615,7 +1615,7 @@ mod test { open.abort(Error::SessionClosed).unwrap(); a.abort(Error::SessionClosed).unwrap(); assert!(held.poll_read_frame(&waiter).is_pending()); - let Poll::Ready(request) = dynamic.poll_requested_group(&waiter) else { + let Poll::Ready(request) = dynamic.poll_requested_fill_group(&waiter) else { panic!("recovery fetch") }; request.unwrap().reject(Error::NotFound); @@ -1810,7 +1810,7 @@ mod test { routes.serve(b.consume()); assert_eq!(read(&mut reading), Some(b"b".to_vec())); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 2)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(2), b"c".as_ref()).unwrap(); @@ -1821,7 +1821,7 @@ mod test { let whole = [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]; assert_eq!(read_cached(&b.consume(), 0), whole); assert_eq!(read_cached(&logical.consume(), 0), whole); - assert!(dynamic.requested_group().now_or_never().is_none(), "no second fetch"); + assert!(dynamic.requested_fill_group().now_or_never().is_none(), "no second fetch"); } /// A replaced route that only stalled lends the replacement its head the same way. @@ -1843,7 +1843,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -1876,7 +1876,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -1885,7 +1885,7 @@ mod test { assert_eq!(read(&mut fetched), None); let _whole = b.consume().fetch_group(0, None); assert!( - dynamic.requested_group().now_or_never().is_some(), + dynamic.requested_fill_group().now_or_never().is_some(), "the head was not kept" ); } @@ -3237,7 +3237,7 @@ mod test { let wake = Arc::new(Wake::default()); let waiter = kio::Waiter::new(wake.clone().into()); assert!(kio::Task::poll(&mut *fetching, &waiter).is_pending()); - let _request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let _request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); wake.0.store(false, std::sync::atomic::Ordering::Relaxed); let b = copy(); let mut group = b.create_group(group::Info { sequence: 0 }).unwrap(); @@ -3506,7 +3506,7 @@ mod test { arriving.abort(Error::Stream(StreamError::Old)).unwrap(); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 1)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -3562,7 +3562,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.frame_start(), request.max_delay()), (1, Some(budget))); // The new route runs a full second past the group's successor while its fetch is unanswered. @@ -4156,7 +4156,7 @@ mod test { ); let demand = copies[0].subscription().expect("surviving reader"); assert_eq!(demand.priority, 7, "{events:?}"); - assert_eq!(demand.max_delay, Duration::from_secs(2), "{events:?}"); + assert_eq!(demand.max_delay, Some(Duration::from_secs(2)), "{events:?}"); drop(groups); drop(subscriptions); drop(control); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index f5dcd68c765..510d6a9d5e9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -3166,6 +3166,21 @@ impl Dynamic { fill::poll_requested(&self.state, &self.fills, waiter) } + /// The next fill, which a test expects to name a single group. + #[cfg(test)] + pub(crate) fn poll_requested_fill_group(&self, waiter: &kio::Waiter) -> Poll> { + self.poll_requested_fill(waiter).map_ok(|fill| { + assert_eq!(fill.end() - fill.start().group, 1, "a one-group fill"); + fill.group(fill.start().group) + }) + } + + /// [`Self::poll_requested_fill_group`], awaited. + #[cfg(all(test, feature = "async"))] + pub(crate) async fn requested_fill_group(&self) -> Result { + kio::wait(|waiter| self.poll_requested_fill_group(waiter)).await + } + /// Watch subscriber demand without keeping the track alive. pub fn demand(&self) -> Demand { Demand::new(self.name.clone(), self.state.weak()) @@ -5411,7 +5426,7 @@ impl Cursor { } let asks = { let state = self.state.read(); - misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up) + misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up, waiter) }; misses.ask(&fills, &asks, &asking, &mut given_up); loop { @@ -8733,19 +8748,19 @@ mod test { // waited for longer than the publisher keeps it. The subscriber's own preference // is stored verbatim, so what it asked for stays readable. let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(subscriber.subscription().max_delay, Duration::from_secs(10)); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(subscriber.subscription().max_delay, Some(Duration::from_secs(10))); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); // A budget within the cache is left alone, and ZERO (skip immediately) stays ZERO. subscriber .update(Subscription::default().with_max_delay(Duration::from_millis(500))) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_millis(500)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_millis(500))); subscriber .update(Subscription::default().with_max_delay(Duration::ZERO)) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::ZERO); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::ZERO)); } /// Mint a track under an origin whose retention ceiling is `cap`, so the @@ -8822,13 +8837,13 @@ mod test { // The clamp lives in the aggregation, so it applies no matter which entry point // wrote the raw preference. Previously only `Subscriber::update` clamped. let mut subscriber = producer.subscribe(over.clone()); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); subscriber.control().update(over.clone()).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); subscriber.update(over).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); } #[test] @@ -8840,7 +8855,7 @@ mod test { let _a = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); let _b = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); } #[test] @@ -10322,7 +10337,7 @@ mod test { let dynamic = request.dynamic(); let consumer = request.consume(); let mut pending = consumer.fetch_group(2, None); - let Poll::Ready(Ok(req)) = dynamic.poll_requested_group(&waiter) else { + let Poll::Ready(Ok(req)) = dynamic.poll_requested_fill_group(&waiter) else { panic!("fetch request should be ready"); }; let mut fetched = req.accept(None).unwrap(); @@ -10363,7 +10378,7 @@ mod test { let pending = consumer.fetch_group(100, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("fetch request is ready") .unwrap(); @@ -11288,7 +11303,7 @@ mod test { assert_eq!(arrival.assert_group().sequence, 1); let _pending = consumer.fetch_group(0, None); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); req.accept(None).unwrap().finish().unwrap(); assert!( arrival.recv_group().now_or_never().is_none(), @@ -12570,7 +12585,7 @@ mod test { assert_eq!(&g.read_frame().await.unwrap().unwrap().payload[..], b"hello"); // Nothing was queued for the dynamic handler to serve. - assert!(dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending()); + assert!(dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending()); } #[moq_clock::test] @@ -12588,7 +12603,7 @@ mod test { assert!(kio::Task::poll(&mut *pending, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12616,14 +12631,14 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); req.reject(Error::Cancel); assert!(matches!(pending.await, Err(Error::Cancel))); - let fetch = producer.state.read().fetch.clone(); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -12636,7 +12651,7 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12656,25 +12671,25 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); let second = consumer.fetch_group(5, None); drop(first); - assert!(req.demand().poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(req.poll_unused(&kio::Waiter::noop()).is_pending()); drop(second); - assert!(req.demand().poll_unused(&kio::Waiter::noop()).is_ready()); + assert!(req.poll_unused(&kio::Waiter::noop()).is_ready()); let retry = consumer.fetch_group(5, None); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); drop(req); - assert!(fresh.demand().poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(fresh.poll_unused(&kio::Waiter::noop()).is_pending()); fresh.accept(Info::timed()).unwrap().finish().unwrap(); assert_eq!(retry.await.unwrap().sequence, 5); } @@ -12689,8 +12704,8 @@ mod test { let consumer = producer.consume(); drop(consumer.fetch_group(5, None)); - assert!(dynamic.requested_group().now_or_never().is_none()); - let fetch = producer.state.read().fetch.clone(); + assert!(dynamic.requested_fill_group().now_or_never().is_none()); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -12705,7 +12720,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12716,7 +12731,7 @@ mod test { assert!(kio::Task::poll(&mut *retry, &kio::Waiter::noop()).is_pending()); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -12735,7 +12750,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12743,7 +12758,7 @@ mod test { let retry = consumer.fetch_group(5, None); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -12756,7 +12771,7 @@ mod test { let mut fetched = retry.await.unwrap(); assert_eq!(&fetched.read_frame().await.unwrap().unwrap().payload[..], b"hi"); - assert!(!fresh.demand().is_used(), "the retry left the fresh request"); + assert!(!fresh.is_used(), "the retry left the fresh request"); assert!(matches!(fresh.accept(Info::timed()), Err(Error::Duplicate))); } @@ -12780,7 +12795,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12808,7 +12823,7 @@ mod test { let _tail = consumer.fetch_group(5, group::Fetch::default().with_frame_start(3)); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12816,7 +12831,7 @@ mod test { let mut whole = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the wider fetch misses and queues its own request") .unwrap(); @@ -12835,7 +12850,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12845,7 +12860,7 @@ mod test { let retry = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -12862,7 +12877,7 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12871,7 +12886,7 @@ mod test { let retry = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12916,7 +12931,7 @@ mod test { "must not answer from the tail" ); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 0)); // Serving it replaces the too-narrow entry rather than colliding with it. @@ -12950,7 +12965,7 @@ mod test { .unwrap(); let fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -12994,7 +13009,7 @@ mod test { live.start_at(1).unwrap(); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); for _ in 0..3 { head.write_frame(Timestamp::ZERO, b"x".as_slice()).unwrap(); } @@ -13020,7 +13035,7 @@ mod test { .unwrap(); let fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -13051,7 +13066,7 @@ mod test { ); let _fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -13143,7 +13158,7 @@ mod test { .unwrap(); live.finish().unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 10_000])) .unwrap(); head.finish().unwrap(); @@ -13194,7 +13209,7 @@ mod test { let mut wider = std::pin::pin!(_wider); assert!(futures::poll!(wider.as_mut()).is_pending()); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); assert_eq!(request.frame_start(), 2, "widened while queued"); // Handed off now: the handler holds its own copy of the range, so a later wider @@ -13206,7 +13221,7 @@ mod test { // A handler may keep tracking the joined demand past the accept, as the lite // subscriber does, which holds the result channel open. - let _joined = request.result.clone(); + let _joined = request.joined(); let mut group = request.accept(Info::timed()).unwrap(); // A handler numbers the frames from where it was asked to start, as `serve_fetch` // does; that offset is what makes the cached group too narrow for `widest`. @@ -13248,7 +13263,7 @@ mod test { assert!(kio::Task::poll(&mut *first, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13256,7 +13271,7 @@ mod test { assert_eq!(req.priority(), 7); assert_eq!(req.max_delay(), Some(Duration::from_secs(10))); assert!( - dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending(), + dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending(), "the second fetch queued a duplicate request" ); @@ -13285,7 +13300,7 @@ mod test { let consumer = producer.consume(); let tight = consumer.fetch_group(5, group::Fetch::default().with_max_delay(Duration::from_secs(1))); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(req.max_delay(), Some(Duration::from_secs(1))); let mut loose = consumer.fetch_group(5, None); assert!(kio::Task::poll(&mut *loose, &kio::Waiter::noop()).is_pending()); @@ -13294,7 +13309,7 @@ mod test { assert!(matches!(tight.await, Err(Error::Old))); assert!(kio::Task::poll(&mut *loose, &kio::Waiter::noop()).is_pending()); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(req.max_delay(), None); let mut group = req.accept(Info::timed()).unwrap(); group @@ -13314,7 +13329,7 @@ mod test { let first = consumer.fetch_group(5, None); let second = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13327,7 +13342,7 @@ mod test { let mut retry = consumer.fetch_group(5, None); assert!(kio::Task::poll(&mut *retry, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13348,7 +13363,7 @@ mod test { assert!(matches!(pending.await, Err(Error::NotFound))); // And the attempt didn't leak. - let fetch = producer.state.read().fetch.clone(); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -13377,7 +13392,7 @@ mod test { assert!(matches!(consumer.fetch_group(5, None).await, Err(Error::NotFound))); // And it doesn't signal the dynamic handler. - assert!(dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending()); + assert!(dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending()); } /// Mint a track whose groups charge into a bounded [`cache::Pool`]. @@ -13608,7 +13623,7 @@ mod test { // Serve a backfill before the track is accepted, then grow it. let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13802,7 +13817,7 @@ mod test { // Serve backfill seq 0 before the track is accepted. let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13864,7 +13879,7 @@ mod test { for sequence in 1..=5u64 { let pending = consumer.fetch_group(sequence, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14004,7 +14019,7 @@ mod test { // old arrival entry for seq 1 now resolves to it. let pending = consumer.fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14036,7 +14051,7 @@ mod test { for sequence in [2u64, 3u64] { let pending = consumer.fetch_group(sequence, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14100,7 +14115,7 @@ mod test { // Re-fetch it: the replacement takes over max_sequence. let pending = consumer.fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14141,7 +14156,7 @@ mod test { let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14207,7 +14222,7 @@ mod test { let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14259,7 +14274,7 @@ mod test { // Fetch the gap: it lands in the cache and resolves the fetch... let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14293,7 +14308,7 @@ mod test { // Serve a backfill fetch for an old sequence. let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14503,93 +14518,101 @@ mod test { )); } + /// Every fetch that joined a fill counts as its demand, and the last to leave + /// withdraws it. #[test] - #[cfg(feature = "async")] - fn fetch_request_demand_counts_every_joined_caller() { + fn fill_demand_counts_every_joined_caller() { let producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let consumer = producer.consume(); let first = consumer.fetch_group(3, None); let second = consumer.fetch_group(3, None); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); - let demand = request.demand(); - assert_eq!(demand.sequence(), 3); - assert!(demand.is_used()); + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("one fill for both fetches"); + }; + assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "the second joined"); + assert_eq!((fill.start(), fill.end()), (Position::group(3), 4)); + assert!(fill.is_used()); drop(first); - assert!(demand.poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(fill.poll_unused(&waiter).is_pending()); drop(second); - assert!(matches!(demand.poll_unused(&kio::Waiter::noop()), Poll::Ready(Ok(())))); - request.reject(Error::Cancel); - assert!(matches!(demand.used().now_or_never(), Some(Err(Error::Cancel)))); - - // The async close helper reports clean acceptance separately from rejection. - for accepted in [true, false] { - let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); - let consumer = producer.consume(); - let fetch = consumer.fetch_group(3, None); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); - let demand = request.demand(); - let group = if accepted { - Some(request.accept(Info::timed()).unwrap()) - } else { - request.reject(Error::NotFound); - None - }; - assert!(matches!( - (accepted, demand.closed().now_or_never()), - (true, Some(Error::Dropped)) | (false, Some(Error::NotFound)) - )); - drop(fetch); - if let Some(mut group) = group { - group.finish().unwrap(); - } - } + assert!(fill.poll_unused(&waiter).is_ready()); + drop(fill); + assert!( + !producer.state.read().drops.contains(&3), + "a withdrawn fill records nothing" + ); } + /// A fill accepting a group resolves the fetch from the cache. #[test] - fn accepted_group_request_demand_ends_cleanly() { + fn accepted_fill_resolves_its_fetch() { let producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(&waiter) else { - panic!("the fetch request is ready"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the fill is ready"); }; - let demand = request.demand(); - let mut group = request.accept(Info::timed()).unwrap(); - - assert!(!demand.is_used()); - assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Err(Error::Dropped)))); - assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Err(Error::Dropped)))); + let mut group = fill.accept(3, Info::timed()).unwrap(); + assert!(matches!(fill.accept(3, None), Err(Error::Duplicate))); + assert!(matches!(fill.accept(4, None), Err(Error::InvalidRange))); assert!( matches!(kio::Task::poll(&mut *fetch, &waiter), Poll::Ready(Ok(_))), - "the fetch still gets the accepted group" + "the fetch gets the accepted group" ); group.finish().unwrap(); } - #[test] - fn rejected_group_request_demand_keeps_its_reason() { - let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + /// 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 { let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(&waiter) else { - panic!("the fetch request is ready"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the fill is ready"); }; - let demand = request.demand(); - request.reject(Error::NotFound); + match drop_all { + true => fill.drop_groups(0..10), + false => drop(fill), + } + match kio::Task::poll(&mut *fetch, &waiter) { + Poll::Ready(Err(err)) => err, + _ => panic!("the fetch fails"), + } + } - assert!(!demand.is_used()); - assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Err(Error::NotFound)))); - assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Err(Error::NotFound)))); - assert!(matches!( - kio::Task::poll(&mut *fetch, &waiter), - Poll::Ready(Err(Error::NotFound)) - )); + fn drops(producer: &Producer) -> Vec> { + producer.state.read().drops.iter().cloned().collect() + } + + /// A group the source no longer has fails its fetch, and is named dropped for good only + /// once the live feed gave up on it too: a fetch answered before a late group header + /// arrives says nothing about the feed. + #[moq_clock::test] + async fn dropped_fill_group_fails_its_fetch() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + 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"); + + moq_clock::sim::advance(Duration::from_secs(60)).await; + assert!(matches!(drop_through_fill(&producer, &dynamic, true), Error::NotFound)); + assert_eq!(drops(&producer), vec![3..4], "clipped to the fill"); + } + + /// Dropping a fill a reader still waits on names what it left as dropped. + #[moq_clock::test] + async fn fill_dropped_unanswered_names_the_rest_dropped() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + write_group(&producer, 5); + moq_clock::sim::advance(Duration::from_secs(60)).await; + assert!(matches!(drop_through_fill(&producer, &dynamic, false), Error::Dropped)); + assert_eq!(drops(&producer), vec![3..4]); } #[moq_clock::test] @@ -14912,7 +14935,7 @@ mod test { let consumer = producer.consume(); let mut early = producer.subscribe(replay().with_ranges([Range::live()])); let _fetching = consumer.fetch_group(5, None); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); req.accept(None).unwrap().finish().unwrap(); producer .create_group(group::Info { sequence: 0 }) diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index d447fd463c2..f3e060fcb58 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -320,7 +320,7 @@ mod tests { .collect(); let next = Subscription { priority: rng.random_range(0..8), - max_delay: Duration::from_millis(rng.random_range(0..8)), + max_delay: Some(Duration::from_millis(rng.random_range(0..8))), ranges, order: if rng.random_bool(0.5) { Order::Ascending diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 0ec4fd1150c..e6fd4d8a18e 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -60,7 +60,7 @@ pub(crate) struct Outcome { impl Outcome { /// Why `sequence` was refused, if it was. - pub(super) fn refused(&self, sequence: u64) -> Option<&Error> { + pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { self.refused .iter() .rev() @@ -184,6 +184,12 @@ impl FillState { !self.queue.is_empty() } + /// Whether no fill is pending, queued or taken. + #[cfg(test)] + pub(super) fn is_empty(&self) -> bool { + self.pending.is_empty() && self.queue.is_empty() && self.covers.is_empty() + } + /// Join every fill covering `ask`, queueing a new one for each run none covers. Returns /// the joins, and the runs nobody can fill: no handler is alive to take a new fill. pub(super) fn join(&mut self, shared: &kio::Shared, ask: &Ask) -> (Vec, Vec>) { @@ -540,7 +546,7 @@ impl Inner { } if let Ok(mut state) = TrackState::modify(&self.state) { for groups in unresolved.iter() { - state.drop_groups(groups.clone()); + state.drop_unfed(groups.clone()); } } self.refuse(&unresolved, err); @@ -613,28 +619,9 @@ impl Drop for Inner { // since (the live feed delivered it) is no drop at all. let left = self.unresolved(self.start.group..self.end); if !left.is_empty() && self.result.is_used() { - let mut dropped = RangeSet::new(); if let Ok(mut state) = TrackState::modify(&self.state) { for groups in left.iter() { - let mut from = groups.start; - let cached: Vec = state - .lookup - .range(groups.clone()) - .filter(|(_, slot)| !slot.is_aborted()) - .map(|(sequence, _)| *sequence) - .collect(); - for sequence in cached { - if from < sequence { - dropped.insert(from..sequence); - } - from = sequence + 1; - } - if from < groups.end { - dropped.insert(from..groups.end); - } - } - for groups in dropped.iter() { - state.drop_groups(groups.clone()); + state.drop_unfed(groups.clone()); } } if let Ok(mut outcome) = self.result.write() { @@ -647,6 +634,27 @@ impl Drop for Inner { } } +impl TrackState { + /// Record `groups` as dropped, as a fill's source answered, except what the cache holds + /// or the live feed may still deliver: a fill refused is no verdict on the feed. + fn drop_unfed(&mut self, groups: ops::Range) { + // Until the serving session resolves where the feed starts, it may deliver anything. + if self.start_pending { + return; + } + let now = self.cache.pool().now_wall(); + let mut dropped = RangeSet::new(); + for hole in super::miss::holes(self, groups) { + for groups in self.unfed(hole, now).0.iter() { + dropped.insert(groups.clone()); + } + } + for groups in dropped.iter() { + self.drop_groups(groups.clone()); + } + } +} + /// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers /// that group of the fill; dropping it unanswered leaves the group to the fill. // private: capability gap @@ -682,6 +690,12 @@ impl GroupFill { self.inner.result.poll_unused(waiter).map(|_| ()) } + /// Whether a reader still waits on the fill. + #[cfg(test)] + pub(crate) fn is_used(&self) -> bool { + self.inner.result.is_used() + } + /// The fill's readers, for a session keeping a group alive while any still wait. pub(crate) fn joined(&self) -> kio::Producer { self.inner.result.clone() diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 0f6b22cec78..7e09ef9083c 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -6,6 +6,10 @@ //! under where the feed starts, or accounted for by the arrival ledger without being cached //! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and //! judged again as the feed moves. +//! +//! A start is no promise, since a group below it may still arrive late. A hole under it is +//! asked at once, but a refusal is final only once the ledger gives up on it too; until +//! then the live feed may still deliver it. use std::{ collections::BTreeSet, @@ -39,6 +43,9 @@ pub(super) struct Misses { judged: RangeSet, /// Holes the live feed may still deliver, judged again on every probe. deferred: RangeSet, + /// Asked under the feed's start and refused, never asked again: given up once the feed + /// gives up on them, unless they arrive first. + refused: RangeSet, /// The fills this reader waits on. joins: Vec, /// The sequences of `joins` still unanswered. @@ -160,25 +167,27 @@ impl Misses { continue; } }; + // Read only after the outcome's lock is released. + let state = state.read(); for (groups, answer) in answers { self.wanted.remove(groups.clone()); match answer { Ok(()) => { - let state = state.read(); for sequence in groups { match state.lookup.get(&sequence).is_some_and(|slot| !slot.is_aborted()) { true => { self.filled.insert(sequence); } - false => self.give_up(sequence..sequence + 1, given_up), + false => self.settle(&state, sequence..sequence + 1, given_up), } } } Err((Error::Old, asked)) if looser(max_delay, asked) => retry.insert(groups), // A drop the track recorded is named by the drop cursor already. - Err(_) => self.give_up(groups, given_up), + Err(_) => self.settle(&state, groups, given_up), } } + drop(state); // Judged again: the same join may have more to answer, or park the waiter now. } retry @@ -193,6 +202,7 @@ impl Misses { target: impl FnOnce() -> RangeInclusiveSet, drift: &Drift, given_up: &mut GivenUp, + waiter: &kio::Waiter, ) -> RangeSet { let mut asks = RangeSet::new(); // The feed's start is unknown until the serving session resolves it, and readers @@ -218,10 +228,12 @@ impl Misses { self.at = Some(at); let low = state.live_floor.unwrap_or(0); + // A run's exclusive end must exist, so the last sequence is never judged. + let top = newest.min(u64::MAX - 1); let mut span = RangeSet::new(); - if low <= newest { - for run in target().overlapping(&(low..=newest)) { - span.insert((*run.start()).max(low)..(*run.end()).min(newest) + 1); + if low <= top { + for run in target().overlapping(&(low..=top)) { + span.insert((*run.start()).max(low)..(*run.end()).min(top) + 1); } } // What left the span is forgotten, so it is judged afresh if it comes back. @@ -241,42 +253,50 @@ impl Misses { self.judged.insert(run.clone()); } - let start = state.start_sequence.or(state.arrivals.first()); - let datagrams: BTreeSet = state.datagrams.iter().map(|datagram| datagram.sequence).collect(); + let now = state.cache.pool().now_wall(); + let start = state.start_sequence.unwrap_or(0); + let mut wake: Option = None; for run in candidates.iter() { for hole in holes(state, run.clone()) { for hole in self.unclaimed(state, hole) { - let Some(start) = start else { - self.deferred.insert(hole); - continue; - }; - // Below where the feed starts: it will never come live. - if hole.start < start { - asks.insert(hole.start..hole.end.min(start)); - } - let above = hole.start.max(start)..hole.end; - if above.is_empty() { - continue; - } - // Accounted for by the feed and not cached: evicted, or skipped. A buffered - // datagram arrived and is served as one. - let mut from = above.start; - for arrived in state.arrivals.accounted(above.clone()) { - if from < arrived.start { - self.deferred.insert(from..arrived.start); - } - asks.insert(arrived.clone()); - for sequence in datagrams.range(arrived.clone()) { - asks.remove(*sequence..*sequence + 1); + let (unfed, awaited) = state.unfed(hole, now); + for run in unfed.iter() { + for refused in self.refused.overlapping(run).cloned().collect::>() { + let gone = refused.start.max(run.start)..refused.end.min(run.end); + self.refused.remove(gone.clone()); + self.give_up(gone, given_up); } - from = arrived.end; + asks.insert(run.clone()); } - if from < above.end { - self.deferred.insert(from..above.end); + for (run, folds) in awaited { + // Under the feed's start: asked now rather than after the grace. + let early = run.start..run.end.min(start); + if !early.is_empty() { + asks.insert(early); + } + self.deferred.insert(run); + wake = match (wake, folds) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; } } } } + for run in self.refused.iter() { + asks.remove(run.clone()); + } + for run in asks.iter() { + self.deferred.remove(run.clone()); + } + // A buffered datagram arrived, and is served as one. + for datagram in &state.datagrams { + asks.remove(datagram.sequence..datagram.sequence.saturating_add(1)); + } + // A gap the feed still waits for becomes a miss when it folds. + if let Some(at) = wake { + state.cache.watch_wall(at, waiter); + } // Starts never rewind, so a run whose newest group is stale under this reader's // budget is stale throughout. @@ -334,6 +354,22 @@ impl Misses { left.iter().cloned().collect() } + /// Take in a fill's refusal of `groups`: final where the feed gave up on them too, + /// otherwise held until it does, since the live feed may still deliver them. + fn settle(&mut self, state: &TrackState, groups: ops::Range, given_up: &mut GivenUp) { + let now = state.cache.pool().now_wall(); + let mut left = RangeSet::new(); + left.insert(groups.clone()); + for run in state.unfed(groups, now).0.iter() { + left.remove(run.clone()); + self.give_up(run.clone(), given_up); + } + for run in left.iter() { + self.refused.insert(run.clone()); + self.deferred.insert(run.clone()); + } + } + fn give_up(&mut self, groups: ops::Range, given_up: &mut GivenUp) { if groups.is_empty() { return; @@ -343,8 +379,45 @@ impl Misses { } } +impl TrackState { + /// Split `hole`, which the cache holds nothing for, into what the live feed will never + /// deliver and the gaps it still waits for, with when each stops being waited for. + /// + /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its + /// grace gave up on. A declared start is no promise: a group below it may still arrive. + #[allow(clippy::type_complexity)] + pub(super) fn unfed( + &self, + hole: ops::Range, + now: crate::time::Instant, + ) -> (RangeSet, Vec<(ops::Range, Option)>) { + let mut unfed = RangeSet::new(); + let mut awaited = Vec::new(); + let mut from = hole.start; + let grace = self.arrivals.grace(); + let mut gap = |groups: ops::Range, unfed: &mut RangeSet| match self + .arrivals + .folds_at(groups.start, grace) + { + Some(at) if at <= now => unfed.insert(groups), + folds => awaited.push((groups, folds)), + }; + for arrived in self.arrivals.accounted(from..hole.end) { + if from < arrived.start { + gap(from..arrived.start, &mut unfed); + } + from = arrived.end; + unfed.insert(arrived); + } + if from < hole.end { + gap(from..hole.end, &mut unfed); + } + (unfed, awaited) + } +} + /// The runs of `run` the cache holds nothing servable for. -fn holes(state: &TrackState, run: ops::Range) -> Vec> { +pub(super) fn holes(state: &TrackState, run: ops::Range) -> Vec> { let mut holes = Vec::new(); let mut from = run.start; for (&sequence, slot) in state.lookup.range(run.clone()) { diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index 37cc7f447c3..a3df1220d47 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -67,7 +67,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage }; let groups = request.dynamic(); let _track = request.accept(timed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + 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"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; @@ -77,14 +77,13 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage drop(waiting); moq_clock::timeout( Duration::from_secs(5), - kio::wait(|waiter| request.demand().poll_unused(waiter)), + kio::wait(|waiter| request.poll_unused(waiter)), ) .await - .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")) - .expect("the pending fetch is still open"); + .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); } Stage::MidResponse => { - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"head")).unwrap(); let mut fetched = waiting.await.unwrap(); @@ -112,7 +111,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage assert_eq!(payloads, [&b"head"[..], &b"tail"[..]], "{ctx}"); } Stage::Drained => { - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"head")).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"tail")).unwrap(); @@ -186,11 +185,11 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { }; let groups = request.dynamic(); let _track = request.accept(timed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + 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"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group .write_frame(Timestamp::ZERO, Bytes::from_static(b"segment")) .unwrap(); @@ -206,7 +205,7 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { let mut refetch = Box::pin(consumer.track("video").unwrap().fetch_group(0, None)); let track_request = std::pin::pin!(dynamic.requested_track()); - let group_request = std::pin::pin!(groups.requested_group()); + let group_request = std::pin::pin!(groups.requested_fill()); let upstream = futures::future::select(track_request, group_request); let mut fetched = match futures::future::select(&mut refetch, upstream).await { futures::future::Either::Left((fetched, _)) => fetched.expect("the relay serves the group again"), diff --git a/rs/moq-net/tests/integration/fetch_caps.rs b/rs/moq-net/tests/integration/fetch_caps.rs index 5cb05f87b3b..fbf26cdec4c 100644 --- a/rs/moq-net/tests/integration/fetch_caps.rs +++ b/rs/moq-net/tests/integration/fetch_caps.rs @@ -53,7 +53,7 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { let mut a = Box::pin(peers[0].2.track("video").unwrap().fetch_group(7, None)); let request_a = match moq_clock::timeout( TIMEOUT, - futures::future::select(std::pin::pin!(groups.requested_group()), &mut a), + futures::future::select(std::pin::pin!(groups.requested_fill()), &mut a), ) .await .unwrap() @@ -64,7 +64,7 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { let mut b = Box::pin(peers[1].2.track("video").unwrap().fetch_group(8, None)); let request_b = match moq_clock::timeout( TIMEOUT, - futures::future::select(std::pin::pin!(groups.requested_group()), &mut b), + futures::future::select(std::pin::pin!(groups.requested_fill()), &mut b), ) .await .unwrap() @@ -72,8 +72,8 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { futures::future::Either::Left((request, _)) => request.unwrap(), futures::future::Either::Right(_) => panic!("{version}: fetch answered before the publisher"), }; - assert_eq!(request_a.sequence(), 7); - assert_eq!(request_b.sequence(), 8); + assert_eq!(request_a.start().group, 7); + assert_eq!(request_b.start().group, 8); let extra = peers[0].2.track("video").unwrap().fetch_group(9, None); let (err, refused) = moq_clock::timeout(TIMEOUT, futures::future::join(peers[0].1.client.closed(), extra)) .await @@ -84,10 +84,10 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { "{version}: {err}" ); assert!( - request_b.demand().is_used(), + request_b.is_used(), "{version}: the other peer still holds its request" ); - let mut group = request_b.accept(info).unwrap(); + let mut group = request_b.accept(8, info).unwrap(); group.write_frame(None, &b"other peer"[..]).unwrap(); group.finish().unwrap(); let mut served = moq_clock::timeout(TIMEOUT, b).await.unwrap().unwrap(); diff --git a/rs/moq-net/tests/integration/subscriber_max_age.rs b/rs/moq-net/tests/integration/subscriber_max_age.rs index 249d6a3d5c9..1b53354830f 100644 --- a/rs/moq-net/tests/integration/subscriber_max_age.rs +++ b/rs/moq-net/tests/integration/subscriber_max_age.rs @@ -19,7 +19,7 @@ async fn budget(track: &mut track::Producer, millis: u64, version: Version) { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Duration::from_millis(millis)) + .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(millis))) { return; } diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index fce1facdfa8..342df7ce100 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -258,11 +258,11 @@ async fn a_fetched_untimed_frame_arrives_untimed() { }; let groups = request.dynamic(); let _track = request.accept(untimed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + 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"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(None, Bytes::from_static(b"segment")).unwrap(); group.finish().unwrap(); diff --git a/rs/moq-net/tests/loom.rs b/rs/moq-net/tests/loom.rs index 5623ac5b4f6..bdc63818b76 100644 --- a/rs/moq-net/tests/loom.rs +++ b/rs/moq-net/tests/loom.rs @@ -462,11 +462,11 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { let consumer = track.consume(); let first = consumer.fetch_group(5, None); - let request = block_on(dynamic.requested_group()).expect("requested"); + let request = block_on(dynamic.requested_fill()).expect("requested"); let leaver = thread::spawn(move || { drop(first); - request.demand().is_used().then_some(request) + request.is_used().then_some(request) }); let mut retry = consumer.fetch_group(5, None); let request = match leaver.join().unwrap() { @@ -476,10 +476,10 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { if let std::task::Poll::Ready(Err(err)) = polled { panic!("the retry joined a withdrawn attempt: {err}"); } - block_on(dynamic.requested_group()).expect("the retry queued a fresh request") + block_on(dynamic.requested_fill()).expect("the retry queued a fresh request") } }; - request.accept(None).expect("accept").finish().expect("finish"); + request.accept(5, None).expect("accept").finish().expect("finish"); block_on(retry).expect("the retry resolves"); }); } From 5bfd23d000b658ebc04da323d77cebddb3fba0d5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:39:52 -0700 Subject: [PATCH 04/49] wip: refusals below a start wait on the feed; miss tests --- rs/moq-net/src/model/track.rs | 140 ++++++++++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 48 +++++++--- 2 files changed, 173 insertions(+), 15 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 510d6a9d5e9..50946f3cadf 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5428,13 +5428,13 @@ impl Cursor { let state = self.state.read(); misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up, waiter) }; - misses.ask(&fills, &asks, &asking, &mut given_up); + misses.ask(&self.state, &fills, &asks, &asking, &mut given_up); loop { let retry = misses.resolve(&self.state, asking.max_delay, &mut given_up, waiter); if retry.is_empty() { break; } - misses.ask(&fills, &retry, &asking, &mut given_up); + misses.ask(&self.state, &fills, &retry, &asking, &mut given_up); } if let Some(drops) = &mut self.drops { for run in given_up { @@ -14566,6 +14566,142 @@ mod test { group.finish().unwrap(); } + /// A feed declared to start at group 2, with groups 2 and 3 written, read from group 0. + fn started_late() -> (Producer, Dynamic, Subscriber) { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(2).unwrap(); + write_group(&producer, 2); + write_group(&producer, 3); + (producer, dynamic, subscriber) + } + + fn write_filled(group: Result, millis: u64) { + let mut group = group.unwrap(); + group.write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]).unwrap(); + group.finish().unwrap(); + } + + fn drained_drops(subscriber: &mut Subscriber) -> Vec> { + let mut dropped = Vec::new(); + while let Poll::Ready(range) = subscriber.poll_drop(&kio::Waiter::noop()) { + dropped.push(range); + } + dropped + } + + /// What a reader asks for under the feed's start is a miss, asked at once: the reader + /// reads what the handler accepts, and what it refuses is named dropped only once the + /// feed gives up on it too, since it may still arrive late. + #[test] + #[cfg(feature = "async")] + fn a_miss_below_the_start_is_filled() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + assert_eq!(read, [2, 3]); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 2)); + write_filled(fill.accept(0, None), 0); + fill.drop_groups(1..2); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 0); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(drained_drops(&mut subscriber).is_empty(), "group 1 may still arrive"); + + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [1..2]); + assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "a refusal is not asked again"); + } + + /// A group the handler refused that still arrives live is delivered. + #[test] + #[cfg(feature = "async")] + fn a_refused_miss_that_arrives_late_is_read() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + fill.drop_groups(0..2); + write_group(&producer, 1); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the late group is read"); + }; + assert_eq!(group.sequence, 1); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [0..1]); + } + + /// With no handler to fill it, a miss is named dropped once the feed gives up on it. + #[test] + #[cfg(feature = "async")] + fn a_miss_with_no_handler_is_named_dropped() { + let (producer, dynamic, mut subscriber) = started_late(); + drop(dynamic); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + assert_eq!(read, [2, 3]); + assert!(drained_drops(&mut subscriber).is_empty()); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [0..2]); + } + + /// An ordered reader waits on a fill for its miss rather than skipping past it. + #[test] + #[cfg(feature = "async")] + fn an_ordered_read_waits_on_a_fill() { + let (_producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + let mut next = || match subscriber.poll_next_group(&waiter) { + Poll::Ready(Ok(Some(group))) => Some(group.sequence), + Poll::Pending => None, + _ => panic!("the read ended"), + }; + assert_eq!(next(), None, "group 0 is being filled"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + write_filled(fill.accept(0, None), 0); + assert_eq!(next(), Some(0)); + assert_eq!(next(), None, "group 1 is being filled"); + fill.drop_groups(1..2); + assert_eq!(next(), Some(2)); + assert_eq!(next(), Some(3)); + } + + /// A fill nobody waits on any more is withdrawn: it names nothing dropped, so a later + /// reader can still ask for the same history. + #[test] + #[cfg(feature = "async")] + fn a_withdrawn_fill_names_nothing_dropped() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the gap is a miss"); + }; + drop(subscriber); + assert!(!fill.is_used()); + drop(fill); + assert!(drops(&producer).is_empty()); + } + /// 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 { let consumer = producer.consume(); diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 7e09ef9083c..e4d373d5a23 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -253,6 +253,16 @@ impl Misses { self.judged.insert(run.clone()); } + // A refused group that arrived live is the feed's, and no longer refused. + let arrived: Vec = self + .refused + .iter() + .flat_map(|run| state.lookup.range(run.clone()).map(|(sequence, _)| *sequence)) + .collect(); + for sequence in arrived { + self.refused.remove(sequence..sequence + 1); + } + let now = state.cache.pool().now_wall(); let start = state.start_sequence.unwrap_or(0); let mut wake: Option = None; @@ -261,12 +271,14 @@ impl Misses { for hole in self.unclaimed(state, hole) { let (unfed, awaited) = state.unfed(hole, now); for run in unfed.iter() { + asks.insert(run.clone()); + // Refused already: given up now the feed gave up too, not asked again. for refused in self.refused.overlapping(run).cloned().collect::>() { let gone = refused.start.max(run.start)..refused.end.min(run.end); self.refused.remove(gone.clone()); + asks.remove(gone.clone()); self.give_up(gone, given_up); } - asks.insert(run.clone()); } for (run, folds) in awaited { // Under the feed's start: asked now rather than after the grace. @@ -312,8 +324,15 @@ impl Misses { asks } - /// Ask the fills for `runs`, giving up what no handler can take. - pub fn ask(&mut self, fills: &kio::Shared, runs: &RangeSet, asking: &Asking, given_up: &mut GivenUp) { + /// Ask the fills for `runs`, settling what no handler can take as refused. + pub fn ask( + &mut self, + state: &kio::Consumer, + fills: &kio::Shared, + runs: &RangeSet, + asking: &Asking, + given_up: &mut GivenUp, + ) { for run in runs.iter() { let frame = asking .start @@ -333,7 +352,7 @@ impl Misses { self.wanted.insert(run.clone()); for groups in unfillable { self.wanted.remove(groups.clone()); - self.give_up(groups, given_up); + self.settle(&state.read(), groups, given_up); } self.joins.extend(joins); } @@ -358,15 +377,18 @@ impl Misses { /// otherwise held until it does, since the live feed may still deliver them. fn settle(&mut self, state: &TrackState, groups: ops::Range, given_up: &mut GivenUp) { let now = state.cache.pool().now_wall(); - let mut left = RangeSet::new(); - left.insert(groups.clone()); - for run in state.unfed(groups, now).0.iter() { - left.remove(run.clone()); - self.give_up(run.clone(), given_up); - } - for run in left.iter() { - self.refused.insert(run.clone()); - self.deferred.insert(run.clone()); + // What arrived meanwhile is the feed's to serve. + for hole in holes(state, groups) { + let mut left = RangeSet::new(); + left.insert(hole.clone()); + for run in state.unfed(hole, now).0.iter() { + left.remove(run.clone()); + self.give_up(run.clone(), given_up); + } + for run in left.iter() { + self.refused.insert(run.clone()); + self.deferred.insert(run.clone()); + } } } From 3b400df15b764f960ecc6eae0ea641c444b51d53 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:56:46 -0700 Subject: [PATCH 05/49] wip: callers and moq-ffi/moq-c move to fills --- rs/moq-archive/src/reader/mod.rs | 129 +++++++++++++++----------- rs/moq-audio/src/decode/consumer.rs | 2 +- rs/moq-c/src/api.rs | 5 +- rs/moq-c/src/publish.rs | 100 +++++++++++++++----- rs/moq-c/src/test.rs | 4 +- rs/moq-ffi/src/consumer.rs | 16 +++- rs/moq-ffi/src/demand.rs | 50 +--------- rs/moq-ffi/src/producer.rs | 114 ++++++++++++++--------- rs/moq-ffi/src/test.rs | 83 +++++------------ rs/moq-flate/src/snapshot/consumer.rs | 2 +- rs/moq-flate/src/snapshot/mod.rs | 2 +- rs/moq-flate/src/stream/consumer.rs | 2 +- rs/moq-json/src/snapshot/consumer.rs | 2 +- rs/moq-json/src/snapshot/mod.rs | 2 +- rs/moq-json/src/stream/consumer.rs | 2 +- rs/moq-mux/src/container/consumer.rs | 3 +- rs/moq-mux/src/container/source.rs | 2 +- rs/moq-net/src/model/track/fill.rs | 6 +- rs/moq-relay/tests/drills.rs | 2 +- rs/moq-transcode/src/lib.rs | 29 +++--- rs/moq-transcode/src/rung.rs | 111 ++++++++++------------ rs/moq-video/src/decode/consumer.rs | 7 +- 22 files changed, 348 insertions(+), 327 deletions(-) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 8eed95d0f09..b09a16c4f73 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -38,7 +38,7 @@ use futures::TryStreamExt; use futures::future::BoxFuture; use hang::timeline::{Position, Record}; use moq_json::window; -use moq_net::{Timescale, Timestamp, broadcast, group, track}; +use moq_net::{Timescale, Timestamp, broadcast, track}; use object_store::ObjectStore; use tokio::sync::watch; @@ -98,12 +98,6 @@ struct TimelineRequests { dynamic: track::Dynamic, } -struct TimelineRequest { - name: String, - timescale: Option, - request: group::Request, -} - struct Shared { store: Store, index: Mutex, @@ -358,52 +352,65 @@ async fn serve(shared: Arc>, mut dynamic: broadcast::D /// Serve a timeline's evicted groups from their original stored objects. async fn serve_timeline(shared: Arc>, requests: TimelineRequests) { - let mut groups = kio::Tasks::new(); + let mut fills = kio::Tasks::new(); kio::wait(|waiter| { loop { - match requests.dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(request)) => { - groups.push(task(Box::pin(serve_timeline_group( - shared.clone(), - TimelineRequest { - name: requests.name.clone(), - timescale: requests.timescale, - request, - }, - )))); + match requests.dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { + let (shared, name, timescale) = (shared.clone(), requests.name.clone(), requests.timescale); + fills.push(task(Box::pin(async move { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_timeline_group(&shared, &name, timescale, &fill, sequence).await; + } + }))); } Poll::Ready(Err(_)) => return Poll::Ready(()), Poll::Pending => break, } } - let _ = groups.poll(waiter); + let _ = fills.poll(waiter); Poll::Pending }) .await; } -async fn serve_timeline_group(shared: Arc>, request: TimelineRequest) { - let TimelineRequest { - name, - timescale, - request, - } = request; - let sequence = request.sequence(); +/// Where group `sequence` of `fill` starts. +fn frame_start(fill: &track::Fill, sequence: u64) -> u64 { + let start = fill.start(); + match start.group == sequence { + true => start.frame, + false => 0, + } +} + +async fn serve_timeline_group( + shared: &Shared, + name: &str, + timescale: Option, + fill: &track::Fill, + sequence: u64, +) { + let reject = || fill.drop_groups(sequence..sequence + 1); let segment = shared .timelines .lock() .unwrap() - .get(&name) + .get(name) .and_then(|groups| groups.get(&sequence)) .copied(); let Some(segment) = segment else { - request.reject(moq_net::Error::NotFound); + reject(); return; }; - let key = match Key::segments(&name, segment) { + let key = match Key::segments(name, segment) { Ok(key) => key, Err(_) => { - request.reject(moq_net::Error::NotFound); + reject(); return; } }; @@ -412,7 +419,7 @@ async fn serve_timeline_group(shared: Arc>, request: T .get_or_insert_async(&key, async { shared .store - .get_segments(&name, segment, timescale.is_some()) + .get_segments(name, segment, timescale.is_some()) .await .map(Arc::new) }) @@ -421,20 +428,20 @@ async fn serve_timeline_group(shared: Arc>, request: T Ok(object) => object, Err(err) => { tracing::warn!(track = name, sequence, %err, "archived timeline group is unavailable"); - request.reject(moq_net::Error::NotFound); + reject(); return; } }; - let start = request.frame_start(); + let start = frame_start(fill, sequence); let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { - request.reject(moq_net::Error::NotFound); + reject(); return; }; if object.frame_start != 0 { - request.reject(moq_net::Error::NotFound); + reject(); return; } - let Ok(mut producer) = request.accept(None) else { + let Ok(mut producer) = fill.accept(sequence, None) else { return; }; if producer.start_at(start).is_err() { @@ -452,7 +459,7 @@ async fn serve_timeline_group(shared: Arc>, request: T let _ = producer.finish(); } -/// Accept a track a timeline indexes, then serve its group requests until nobody uses it. +/// Accept a track a timeline indexes, then serve its fills until nobody uses it. async fn serve_track(shared: Arc>, request: track::Request, known: bool) { let name = request.name().to_string(); if !known { @@ -480,20 +487,30 @@ async fn serve_track(shared: Arc>, request: track::Req let dynamic = request.dynamic(); let mut demand = dynamic.demand(); let _producer = request.accept(info); - let mut groups = kio::Tasks::new(); + let mut fills = kio::Tasks::new(); kio::wait(|waiter| { loop { - match dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(request)) => { - let serve = serve_group(shared.clone(), name.clone(), timescale, request); - groups.push(task(Box::pin(serve))); + match dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { + let (shared, name) = (shared.clone(), name.clone()); + fills.push(task(Box::pin(async move { + // One group at a time: a fill's groups precede its readers' live edge. + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_group(&shared, &name, timescale, &fill, sequence).await; + } + }))); } Poll::Ready(Err(_)) => return Poll::Ready(()), Poll::Pending => break, } } - if groups.poll(waiter).is_pending() { + if fills.poll(waiter).is_pending() { return Poll::Pending; } // Drain the changes so the waiter stays registered, then read where demand settled. @@ -506,41 +523,41 @@ async fn serve_track(shared: Arc>, request: track::Req .await } -/// Answer one group request from the records holding it, or reject it as never delivered. +/// Answer one group of a fill from the records holding it, or drop it as never delivered. /// /// A group whose records stop short of its end keeps its producer open and grows as later records /// commit, until one reaches the group's end or the caller finishes the reader. async fn serve_group( - shared: Arc>, - track: String, + shared: &Shared, + track: &str, timescale: Option, - request: group::Request, + fill: &track::Fill, + sequence: u64, ) { - let sequence = request.sequence(); - let start = request.frame_start(); + let start = frame_start(fill, sequence); let mut changed = shared.changed.subscribe(); - let mut spans = shared.index.lock().unwrap().group(&track, sequence); + let mut spans = shared.index.lock().unwrap().group(track, sequence); // The earliest retained record must hold the requested frame; an expired head is gone. if spans .first() .is_none_or(|span| span.start > Position::new(sequence, start)) { - request.reject(moq_net::Error::NotFound); + fill.drop_groups(sequence..sequence + 1); return; } // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, // so a cache too small to hold it costs no second GET. let timed = timescale.is_some(); - let mut first = match object(&shared, &track, timed, &spans[0]).await { + let mut first = match object(shared, track, timed, &spans[0]).await { Ok(object) => Some((spans[0].sequence, object)), Err(err) => { tracing::warn!(track, sequence, %err, "archived group is unavailable"); - request.reject(moq_net::Error::NotFound); + fill.drop_groups(sequence..sequence + 1); return; } }; - let Ok(mut producer) = request.accept(None) else { + let Ok(mut producer) = fill.accept(sequence, None) else { return; }; if producer.start_at(start).is_err() { @@ -555,7 +572,7 @@ async fn serve_group( } let loaded = match first.take_if(|(loaded, _)| *loaded == span.sequence) { Some((_, object)) => Ok(object), - None => object(&shared, &track, timed, span).await, + None => object(shared, track, timed, span).await, }; let object = match loaded { Ok(object) if span.start <= Position::new(sequence, next) => object, @@ -612,7 +629,7 @@ async fn serve_group( } } changed.mark_unchanged(); - spans = shared.index.lock().unwrap().group(&track, sequence); + spans = shared.index.lock().unwrap().group(track, sequence); } } diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index a77ad17ebe7..9bf818afdeb 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -2126,7 +2126,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(observed.subscription().unwrap().max_delay, max_delay); + assert_eq!(observed.subscription().unwrap().max_delay, Some(max_delay)); let samples = [0.25f32, -0.5, 0.75, -1.0]; let payload: Vec = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect(); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 74c55fa2952..01002e85b2b 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -3061,9 +3061,10 @@ pub extern "C" fn moq_group_request_accept(request: u32) -> i32 { }) } -/// Reject a group request with an application error code, failing the waiting fetches. +/// Reject a group request, failing the waiting fetches as not found. /// -/// Consumes the request handle. Returns a zero on success, or a negative code on failure. +/// The error code is not delivered. Consumes the request handle. Returns a zero on success, +/// or a negative code on failure. #[unsafe(no_mangle)] pub extern "C" fn moq_group_request_abort(request: u32, error_code: u16) -> i32 { ffi::enter(move || { diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 6ed31705677..dd43ec7c5b8 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::sync::Arc; use moq_mux::catalog::hang::Extra; use moq_mux::import; @@ -22,10 +23,30 @@ struct TrackRequest { } /// A request a handler pulled, before it has a handle. A track request is boxed: it is -/// several times the size of a group request. +/// several times the size of a fill. enum Request { Track(Box), - Group(moq_net::group::Request), + Fill(moq_net::track::Fill), +} + +/// One group of a fill, handed to C as a group request: the frozen ABI answers fills a +/// group at a time. +struct GroupRequest { + fill: Arc, + sequence: u64, + /// Dropped once the request is answered or freed, so its fill's next group follows. + _pending: oneshot::Sender<()>, +} + +impl GroupRequest { + /// Where the group starts: the fill's start frame for its first group. + fn frame_start(&self) -> u64 { + let start = self.fill.start(); + match start.group == self.sequence { + true => start.frame, + false => 0, + } + } } /// What a request handler serves. @@ -105,7 +126,7 @@ pub struct Publish { track_request: NonZeroSlab, /// Group requests delivered to a handler, freed on accept, abort, or free. - group_request: NonZeroSlab, + group_request: NonZeroSlab, } impl Publish { @@ -589,17 +610,22 @@ impl Publish { mut dynamic: Dynamic, mut close: oneshot::Receiver<()>, ) -> Result<(), Error> { + // Each fill hands out its groups one at a time, alongside the other fills. + let mut fills = kio::Tasks::new(); loop { // The handler is owned here, so returning drops it and rejects whatever is pending. // `biased` so a pending close always wins over a ready request. let res = tokio::select! { biased; _ = &mut close => return Ok(()), - res = kio::wait(|waiter| match &mut dynamic { - Dynamic::Broadcast(dynamic, broadcast) => dynamic - .poll_requested_track(waiter) - .map_ok(|request| Request::Track(Box::new(TrackRequest { broadcast: *broadcast, request }))), - Dynamic::Track(dynamic) => dynamic.poll_requested_group(waiter).map_ok(Request::Group), + res = kio::wait(|waiter| { + let _ = fills.poll(waiter); + match &mut dynamic { + Dynamic::Broadcast(dynamic, broadcast) => dynamic + .poll_requested_track(waiter) + .map_ok(|request| Request::Track(Box::new(TrackRequest { broadcast: *broadcast, request }))), + Dynamic::Track(dynamic) => dynamic.poll_requested_fill(waiter).map_ok(Request::Fill), + } }) => res, }; @@ -611,13 +637,42 @@ impl Publish { }; // Hold the lock only to buffer the request; release it before the callback. - let mut state = State::lock(); let id = match request { - Request::Track(request) => state.publish.track_request.insert(*request)?, - Request::Group(request) => state.publish.group_request.insert(request)?, + Request::Track(request) => State::lock().publish.track_request.insert(*request)?, + Request::Fill(fill) => { + let mut serve = Box::pin(Self::serve_fill(callback, Arc::new(fill))); + fills.push(move |waiter: &kio::Waiter| waiter.poll_future(serve.as_mut())); + continue; + } + }; + callback.call(id); + } + } + + /// Hand `fill` to C a group at a time, each once the one before it is answered, until + /// every group is answered or nobody waits on it. + async fn serve_fill(callback: OnStatus, fill: Arc) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + let (pending, answered) = oneshot::channel(); + let request = GroupRequest { + fill: fill.clone(), + sequence, + _pending: pending, + }; + let Ok(id) = State::lock().publish.group_request.insert(request) else { + return; }; - drop(state); callback.call(id); + let unused = kio::wait(|waiter| fill.poll_unused(waiter)); + tokio::select! { + _ = answered => {} + _ = unused => return, + } } } @@ -675,7 +730,7 @@ impl Publish { /// The sequence, priority, and first frame of a requested group. pub fn group_request_info(&self, request: Id) -> Result<(u64, u8, u64), Error> { let request = self.group_request.get(request).ok_or(Error::NotFound)?; - Ok((request.sequence(), request.priority(), request.frame_start())) + Ok((request.sequence, request.fill.priority(), request.frame_start())) } /// Accept a group request, returning a group handle like [`Self::track_group`]. @@ -684,25 +739,20 @@ impl Publish { /// indices they have in the group rather than restarting at 0. pub fn group_request_accept(&mut self, request: Id) -> Result { let request = self.group_request.remove(request).ok_or(Error::NotFound)?; - let frame_start = request.frame_start(); - let mut group = request.accept(None)?; - if let Err(err) = group.start_at(frame_start) { - let _ = group.abort(err.clone()); - return Err(err.into()); - } + let group = request.fill.accept(request.sequence, None)?; self.groups.insert(group) } - /// Reject a group request, failing every fetch waiting on it with `error_code`. - pub fn group_request_abort(&mut self, request: Id, error_code: u16) -> Result<(), Error> { - let request = self.group_request.remove(request).ok_or(Error::NotFound)?; - request.reject(moq_net::Error::App(error_code)); - Ok(()) + /// Reject a group request, failing every fetch waiting on it. A fill has no error code + /// to carry, so `error_code` is not delivered: the fetches fail as not found. + pub fn group_request_abort(&mut self, request: Id, _error_code: u16) -> Result<(), Error> { + self.group_request_free(request) } /// Drop a group request, which rejects it. pub fn group_request_free(&mut self, request: Id) -> Result<(), Error> { - self.group_request.remove(request).ok_or(Error::NotFound)?; + let request = self.group_request.remove(request).ok_or(Error::NotFound)?; + request.fill.drop_groups(request.sequence..request.sequence + 1); Ok(()) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index b270deb4f76..ac1e33954d3 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3138,7 +3138,7 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_publish_group_finish(group), 0); assert_eq!(fetch.recv_timeout(TIMEOUT).unwrap().unwrap(), (0, payload.to_vec())); - // A rejected fetch fails with the application code. + // A rejected fetch fails as not found. let fetch = fetch_group(broadcast, "data", 6, 0, 0); let request = id(group_cb.recv()); assert_eq!(moq_group_request_abort(request, 9), 0); @@ -3146,7 +3146,7 @@ fn track_dynamic_serves_a_fetch_miss() { .recv_timeout(TIMEOUT) .unwrap() .expect_err("a rejected fetch fails"); - assert!(matches!(err, moq_net::Error::App(9)), "got {err:?}"); + assert!(matches!(err, moq_net::Error::NotFound), "got {err:?}"); assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 40be2cadba4..1fd646fdd62 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -29,12 +29,13 @@ pub struct MoqSubscription { #[uniffi(default = 0)] pub priority: u8, /// Maximum age of a non-latest group before it is skipped, in microseconds. - /// `0` skips immediately; a larger value tolerates that much reordering. + /// `0` skips immediately; a larger value tolerates that much reordering; null leaves + /// the publisher's max age alone to bound it. /// /// Enforced both by the publisher's cache (sent on the wire) and by any local /// buffering, such as [`MoqMediaTrackConsumer`]'s jitter buffer. It caps every range. #[uniffi(default = 0)] - pub max_delay_us: u64, + pub max_delay_us: Option, /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. /// @@ -116,6 +117,15 @@ impl From for moq_net::track::Order { } } +impl From for MoqOrder { + fn from(order: moq_net::track::Order) -> Self { + match order { + moq_net::track::Order::Ascending => Self::Ascending, + moq_net::track::Order::Descending => Self::Descending, + } + } +} + /// Options for fetching one past group by sequence. #[derive(Clone, uniffi::Record)] pub struct MoqFetchGroupOptions { @@ -143,7 +153,7 @@ impl TryFrom for moq_net::track::Subscription { fn try_from(s: MoqSubscription) -> Result { let mut subscription = moq_net::track::Subscription::default() .with_priority(s.priority) - .with_max_delay(std::time::Duration::from_micros(s.max_delay_us)); + .with_max_delay(s.max_delay_us.map(std::time::Duration::from_micros)); if let Some(ranges) = s.ranges { subscription = subscription.with_ranges( ranges diff --git a/rs/moq-ffi/src/demand.rs b/rs/moq-ffi/src/demand.rs index f06fa74d663..c5808271a23 100644 --- a/rs/moq-ffi/src/demand.rs +++ b/rs/moq-ffi/src/demand.rs @@ -1,4 +1,4 @@ -//! Watching what subscribers want of a published track, or whether a requested group is still wanted. +//! Watching what subscribers want of a published track. use std::sync::Arc; @@ -63,52 +63,10 @@ impl MoqTrackDemand { } } -/// A watch-only handle to the callers waiting on a requested group. +/// Report a track released without an abort as [`MoqError::Closed`]. /// -/// Weak: holding it does not keep the request alive. The last caller to leave withdraws the -/// request, and a later fetch of the group queues a fresh one, so once unused, demand never -/// returns: drop the request. Waits fail with `Closed` after the request is accepted or dropped, -/// and keep the rejection reason after it is rejected. -#[derive(uniffi::Object)] -pub struct MoqGroupDemand { - inner: moq_net::group::Demand, -} - -impl MoqGroupDemand { - pub(crate) fn new(inner: moq_net::group::Demand) -> Arc { - Arc::new(Self { inner }) - } -} - -#[uniffi::export] -impl MoqGroupDemand { - /// The sequence of the group this watches. - pub fn sequence(&self) -> u64 { - self.inner.sequence() - } - - /// Whether the group has at least one waiting caller right now, without waiting. - pub fn is_used(&self) -> bool { - self.inner.is_used() - } - - /// Wait until the group has at least one waiting caller. - pub async fn used(&self) -> Result<(), MoqError> { - let demand = self.inner.clone(); - crate::ffi::detached(async move { gone(demand.used().await) }).await - } - - /// Wait until the group has no waiting callers. - pub async fn unused(&self) -> Result<(), MoqError> { - let demand = self.inner.clone(); - crate::ffi::detached(async move { gone(demand.unused().await) }).await - } -} - -/// Report a track or group released without an abort as [`MoqError::Closed`]. -/// -/// That is how a finished track or request ends, which a demand watcher expects, not the internal -/// failure `Dropped` means to a consumer. An aborted track or rejected request keeps its reason. +/// That is how a finished track ends, which a demand watcher expects, not the internal failure +/// `Dropped` means to a consumer. An aborted track keeps its reason. fn gone(result: Result) -> Result { match result { Err(moq_net::Error::Dropped) => Err(MoqError::Closed), diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 5c032c16dce..6bd8c9a164f 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use moq_mux::catalog::hang::Extra; -use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqSubscription, MoqTrackConsumer}; -use crate::demand::{MoqGroupDemand, MoqTrackDemand}; +use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqOrder, MoqSubscription, MoqTrackConsumer}; +use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::{Background, Task}; use crate::media::MoqFrame; @@ -134,9 +134,9 @@ impl DynamicProducer { } impl TrackDynamicProducer { - async fn requested_group(&mut self) -> Result, MoqError> { - let request = self.inner.requested_group().await?; - Ok(Arc::new(MoqGroupRequest::new(request))) + async fn requested_fill(&mut self) -> Result, MoqError> { + let fill = self.inner.requested_fill().await?; + Ok(Arc::new(MoqFill { inner: Arc::new(fill) })) } } @@ -319,17 +319,17 @@ impl MoqBroadcastDynamic { #[uniffi::export] impl MoqTrackDynamic { - /// Wait for the next fetch of an uncached group. + /// 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. /// - /// Accept the returned request to produce the group, or abort it with an - /// application error. Cached groups are served without reaching this method. - pub async fn requested_group(&self) -> Result, MoqError> { + /// Cached groups are served without reaching this method. + pub async fn requested_fill(&self) -> Result, MoqError> { self.task - .run(|mut state| async move { state.requested_group().await }) + .run(|mut state| async move { state.requested_fill().await }) .await } - /// Cancel all current and future `requested_group()` calls. + /// Cancel all current and future `requested_fill()` calls. /// /// Terminal: the dynamic track is released here, not when the handle is, so any pending /// fetch is rejected. @@ -338,61 +338,87 @@ impl MoqTrackDynamic { } } -/// An uncached group requested by a fetch consumer. +/// A run of groups readers miss, mirroring [`moq_net::track::Fill`]. +/// +/// Call [`accept`](Self::accept) for each group you have and [`drop_groups`](Self::drop_groups) +/// for the rest; [`next`](Self::next) names what is still owed, in the fill's order. Releasing +/// it names whatever is left as dropped while a reader still wants it; once nobody does, it is +/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. #[derive(uniffi::Object)] -pub struct MoqGroupRequest { - sequence: u64, - priority: u8, - inner: std::sync::Mutex>, +pub struct MoqFill { + inner: Arc, } -impl MoqGroupRequest { - fn new(request: moq_net::group::Request) -> Self { - Self { - sequence: request.sequence(), - priority: request.priority(), - inner: std::sync::Mutex::new(Some(request)), - } +#[uniffi::export] +impl MoqFill { + /// The first group to fill. + pub fn start_group(&self) -> u64 { + self.inner.start().group } - fn take(&self) -> Result { - self.inner.lock().unwrap().take().ok_or(MoqError::Closed) + /// The frame within the first group to start from. + pub fn start_frame(&self) -> u64 { + self.inner.start().frame } -} -#[uniffi::export] -impl MoqGroupRequest { - /// The requested group sequence within the track. - pub fn sequence(&self) -> u64 { - self.sequence + /// The exclusive end group. + pub fn end_group(&self) -> u64 { + self.inner.end() } - /// The consumer's delivery priority for this fetch. + /// The most urgent delivery priority among the readers waiting on it. pub fn priority(&self) -> u8 { - self.priority + self.inner.priority() } - /// A handle that watches whether any caller still wants this group. - pub fn demand(&self) -> Result, MoqError> { - let guard = self.inner.lock().unwrap(); - Ok(MoqGroupDemand::new(guard.as_ref().ok_or(MoqError::Closed)?.demand())) + /// The most tolerant age budget among the readers waiting on it, in microseconds, or null + /// for the publisher's max age alone. + pub fn max_delay_us(&self) -> Option { + self.inner + .max_delay() + .map(|max_delay| u64::try_from(max_delay.as_micros()).unwrap_or(u64::MAX)) } - /// Accept the request and return a producer for filling the fetched group. - pub fn accept(&self) -> Result, MoqError> { + /// Which end to fill first. + pub fn order(&self) -> MoqOrder { + self.inner.order().into() + } + + /// The next group still owed in the fill's order, or null once every group is answered. + pub fn next(&self) -> Option { + self.inner.next() + } + + /// Insert group `sequence` and return a producer for filling it. The first group starts + /// at [`start_frame`](Self::start_frame), so its frames keep their indices. + pub fn accept(&self, sequence: u64) -> Result, MoqError> { let _guard = crate::ffi::enter(); - let group = self.take()?.accept(None)?; + let group = self.inner.accept(sequence, None)?; Ok(Arc::new(MoqGroupProducer { sequence: group.sequence, inner: std::sync::Mutex::new(Some(group)), })) } - /// Reject the fetch with an application error code. - pub fn abort(&self, error_code: u16) -> Result<(), MoqError> { + /// Name groups `start` up to the exclusive `end` as never to be delivered. + pub fn drop_groups(&self, start: u64, end: u64) { let _guard = crate::ffi::enter(); - self.take()?.reject(moq_net::Error::App(error_code)); - Ok(()) + self.inner.drop_groups(start..end); + } + + /// Whether a reader still waits on this fill. + pub fn is_used(&self) -> bool { + self.inner.is_used() + } + + /// Wait until no reader waits on this fill. + pub async fn unused(&self) -> Result<(), MoqError> { + let fill = self.inner.clone(); + crate::ffi::detached(async move { + fill.unused().await; + Ok::<_, MoqError>(()) + }) + .await } } diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index ed8ccdd37e9..0931a3f7f68 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -918,7 +918,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { consumer .update(MoqSubscription { priority: 10, - max_delay_us: 25_000, + max_delay_us: Some(25_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Absolute { group: 0, frame: 0 }, end: None, @@ -1990,14 +1990,15 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { .await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let request = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - assert_eq!(request.sequence(), 5); + assert_eq!((request.start_group(), request.start_frame(), request.end_group()), (5, 0, 6)); assert_eq!(request.priority(), 11); + assert_eq!(request.next(), Some(5)); - let group = request.accept().unwrap(); + let group = request.accept(5).unwrap(); group .write_frame(MoqFrame { payload: b"fetched".to_vec(), @@ -2018,52 +2019,25 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { } #[tokio::test] -async fn group_request_demand_ends_when_the_fetcher_leaves() { +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 consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - let demand = request.demand().unwrap(); - assert_eq!(demand.sequence(), 5); - assert!(demand.is_used()); + assert!(fill.is_used()); fetch.abort(); - tokio::time::timeout(TIMEOUT, demand.unused()) + tokio::time::timeout(TIMEOUT, fill.unused()) .await .expect("timed out waiting for the abandoned fetch to become unused") .unwrap(); - assert!(!demand.is_used()); - - request.abort(404).unwrap(); - assert!(matches!(request.demand(), Err(MoqError::Closed))); -} - -/// An accepted request's demand closes cleanly while its fetch gets the group. -#[tokio::test] -async fn group_request_demand_closes_after_accept() { - tokio::time::pause(); - let broadcast = MoqBroadcastProducer::new().unwrap(); - let track = broadcast.publish_track("events".into(), None).unwrap(); - let dynamic = track.dynamic().unwrap(); - let consumer = broadcast.consume().unwrap(); - - let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = dynamic.requested_group().await.unwrap(); - let demand = request.demand().unwrap(); - let group = request.accept().unwrap(); - group.finish().unwrap(); - - for result in [demand.used().await, demand.unused().await] { - assert!(matches!(result, Err(MoqError::Closed)), "got {result:?}"); - } - assert!(!demand.is_used()); - fetch.await.expect("fetch task panicked").unwrap(); + assert!(!fill.is_used()); } #[tokio::test] @@ -2074,26 +2048,19 @@ async fn dynamic_track_rejects_fetch_miss() { let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - request.abort(404).unwrap(); + fill.drop_groups(5, 6); + assert_eq!(fill.next(), None, "the dropped group is answered"); let result = tokio::time::timeout(TIMEOUT, fetch) .await .expect("timed out waiting for rejected fetch") .expect("fetch task panicked"); - match result { - Err(MoqError::Protocol { details: protocol }) => { - assert_eq!(protocol.scope, crate::error::MoqErrorScope::Stream); - assert_eq!(protocol.code, 64 + 404); - assert_eq!(protocol.kind, crate::error::MoqProtocolKind::App); - } - Err(other) => panic!("expected Protocol App(404), got {other:?}"), - Ok(_) => panic!("expected Protocol App(404), got a group"), - } - assert!(matches!(request.accept(), Err(MoqError::Closed))); + assert!(matches!(result, Err(MoqError::NotFound)), "got {:?}", result.err()); + assert!(fill.accept(5).is_err(), "a dropped group is answered already"); } #[tokio::test] @@ -2129,12 +2096,12 @@ async fn requested_track_dynamic_survives_accept() { let track_dynamic = request.dynamic().unwrap(); let _track = request.accept(Some(timed_info())).unwrap(); - let group_request = tokio::time::timeout(TIMEOUT, track_dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, track_dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - assert_eq!(group_request.sequence(), 9); - let group = group_request.accept().unwrap(); + assert_eq!(fill.start_group(), 9); + let group = fill.accept(9).unwrap(); group .write_frame(MoqFrame { payload: b"archive".to_vec(), @@ -3941,7 +3908,7 @@ async fn raw_track_update_during_pending_group_still_reads_datagram() { consumer .update(MoqSubscription { priority: 10, - max_delay_us: 25_000, + max_delay_us: Some(25_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Absolute { group: 0, frame: 0 }, end: None, @@ -6742,7 +6709,7 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { let subscription = MoqSubscription { priority: 0, - max_delay_us: 10_000_000, + max_delay_us: Some(10_000_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Relative { groups: 2 }, end: None, @@ -6790,7 +6757,7 @@ async fn time_anchors_start_at_the_containing_group() { let since = |timestamp_us| MoqSubscription { priority: 0, - max_delay_us: 10_000_000, + max_delay_us: Some(10_000_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Time { timestamp_us }, end: None, diff --git a/rs/moq-flate/src/snapshot/consumer.rs b/rs/moq-flate/src/snapshot/consumer.rs index 0fea756618d..47f37af75c5 100644 --- a/rs/moq-flate/src/snapshot/consumer.rs +++ b/rs/moq-flate/src/snapshot/consumer.rs @@ -139,7 +139,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::snapshot::Config { compression }; let mut producer = crate::snapshot::Producer::new(track, config.clone()); diff --git a/rs/moq-flate/src/snapshot/mod.rs b/rs/moq-flate/src/snapshot/mod.rs index 91887e02d3c..a8e5d2de83c 100644 --- a/rs/moq-flate/src/snapshot/mod.rs +++ b/rs/moq-flate/src/snapshot/mod.rs @@ -124,7 +124,7 @@ mod test { fn next_preserves_intermediate_states() { let (mut producer, mut track) = producer(true); let mut subscription = track.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; track.update(subscription).unwrap(); for value in [b"one".as_slice(), b"two", b"three"] { producer.update(value).unwrap(); diff --git a/rs/moq-flate/src/stream/consumer.rs b/rs/moq-flate/src/stream/consumer.rs index e52d48e678e..a555b242e54 100644 --- a/rs/moq-flate/src/stream/consumer.rs +++ b/rs/moq-flate/src/stream/consumer.rs @@ -125,7 +125,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config { compression }; let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index e630536ce56..d6ef6215989 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -173,7 +173,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let mut config = crate::snapshot::Config::default().with_delta_ratio(100); config.compression = compression; diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 6bc03283839..12128d30a5d 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -213,7 +213,7 @@ mod test { fn next_preserves_intermediate_states() { let (mut producer, mut track) = producer(cfg(100)); let mut subscription = track.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; track.update(subscription).unwrap(); for n in [1, 2, 3] { producer diff --git a/rs/moq-json/src/stream/consumer.rs b/rs/moq-json/src/stream/consumer.rs index 12c0a548924..be0adcf89b5 100644 --- a/rs/moq-json/src/stream/consumer.rs +++ b/rs/moq-json/src/stream/consumer.rs @@ -120,7 +120,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config { compression }; let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 150766f8345..758e45760c0 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -178,6 +178,7 @@ impl Consumer { let start = track.floor(); let max_delay = subscription .max_delay + .unwrap_or(std::time::Duration::MAX) .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); Self { name: track.name().into(), @@ -1108,7 +1109,7 @@ mod tests { let mut consumer = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); assert_eq!(consumer.max_delay, max_delay); - assert_eq!(consumer.subscriber().subscription().max_delay, max_delay); + assert_eq!(consumer.subscriber().subscription().max_delay, Some(max_delay)); } #[tokio::test] diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 7a77072a90b..77e4a771f45 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -684,7 +684,7 @@ mod tests { }) .await; - assert_eq!(observed, max_delay, "live start: {live_start}"); + assert_eq!(observed, Some(max_delay), "live start: {live_start}"); } } diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index e6fd4d8a18e..bd5bcf6d12f 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -457,9 +457,9 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } - /// The next group to fill in the fill's order, skipping what was answered. - // private: capability gap - pub(crate) fn next(&self) -> Option { + /// The next group still owed in the fill's order: neither answered nor delivered live + /// since it was asked. `None` once every group is answered. + pub fn next(&self) -> Option { loop { let resolved = self.inner.result.read().resolved(); let span = self.inner.start.group..self.inner.end; diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index 69ac35ea3d2..151eebd1a15 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -1442,7 +1442,7 @@ async fn ietf_subscriber_max_age_skips_bufferbloat() { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Duration::from_millis(100)) + .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(100))) { break; } diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index ece4aa834b3..f29867df956 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1318,7 +1318,7 @@ mod tests { /// 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 /// fetch at exactly the point in question: past the rung's handler, before - /// its `GroupRequest::accept`. + /// its `Fill::accept`. #[tokio::test] async fn retirement_waits_for_an_unclaimed_fetch() { let mut source = source_catalog(320, 240); @@ -1365,9 +1365,9 @@ mod tests { let fetching = rung.fetch_group(7, None); // The rung's fetch task is now inside its source read, which is upstream of - // both its decoder and the `GroupRequest::accept` that claims output group 7. - let request = source_fetches.requested_group().await.expect("the source track closed"); - assert_eq!(request.sequence(), 7); + // both its decoder and the `Fill::accept` that claims output group 7. + let request = source_fetches.requested_fill().await.expect("the source track closed"); + assert_eq!(request.start().group, 7); // Retire the rung with the fetch parked there, and let the retirement land // before the source group exists. @@ -1383,7 +1383,7 @@ mod tests { // Release the fetch: it opens its decoder and only now claims output group // 7, which retirement had to leave writable. - let mut group = request.accept(None).unwrap(); + let mut group = request.accept(7, None).unwrap(); write_keyframe(&mut group); group.finish().unwrap(); @@ -1411,8 +1411,8 @@ mod tests { // something to transcode and resolves. let source_fetches = source._track.dynamic(); let server = tokio::spawn(async move { - while let Ok(request) = source_fetches.requested_group().await { - let mut group = request.accept(None).unwrap(); + while let Ok(request) = source_fetches.requested_fill().await { + let mut group = request.accept(request.start().group, None).unwrap(); write_keyframe(&mut group); group.finish().unwrap(); } @@ -1504,32 +1504,31 @@ mod tests { rung.query().await.unwrap(); let fetching = rung.fetch_group(7, None); - let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_group()) + let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) .await .expect("the fetch never reached the source") .expect("the source track closed"); - assert_eq!(abandoned.sequence(), 7); + assert_eq!(abandoned.start().group, 7); // Leave before a single source frame exists. Encoding can only happen // if the handler keeps this fetch. drop(fetching); - tokio::time::timeout(std::time::Duration::from_secs(5), abandoned.demand().unused()) + tokio::time::timeout(std::time::Duration::from_secs(5), abandoned.unused()) .await - .expect("the abandoned fetch kept its source fetch") - .expect("the source fetch closed instead of going unused"); + .expect("the abandoned fetch kept its source fetch"); // The handler dropped the output request with the caller, so this is a // fresh request rather than a join of the one still encoding. let retry = rung.fetch_group(7, None); - let fresh = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_group()) + let fresh = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) .await .expect("the retry never fetched the source") .expect("the source track closed"); - assert_eq!(fresh.sequence(), 7); + assert_eq!(fresh.start().group, 7); drop(abandoned); let mut group = fresh - .accept(None) + .accept(7, None) .expect("the fresh request lost to the abandoned encode"); write_keyframe(&mut group); group.finish().unwrap(); diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 76df66d8b0c..d6db0af6a59 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -8,7 +8,7 @@ //! group for group until the track goes `unused` again. The group open at //! that point is finished first, so a later reader of that sequence gets the //! whole group rather than an aborted one. -//! - A fetch of a specific group (`requested_group`) fetches that same group +//! - A fetch of a specific group (`requested_fill`) fetches that same group //! from the source and transcodes just that group with a fresh encoder. A //! fetch that starts mid-group is refused: a fresh encode's frames are only //! valid after the head that same encode produced. A fetch every caller @@ -388,9 +388,9 @@ async fn live(rung: &Rung, producer: &mut moq_net::track::Producer) -> Result permit.expect("the semaphore stays open"), }; - let request = tokio::select! { + let fill = tokio::select! { biased; () = retire.fired() => { retired = true; break; }, _ = finishing.wait_for(|finished| *finished) => break, - request = dynamic.requested_group() => match request { - Ok(request) => request, + fill = dynamic.requested_fill() => match fill { + Ok(fill) => fill, // The output track closed; nothing more to serve. Err(_) => break, }, }; - spawn_fetch(&mut tasks, rung.clone(), request, permit); + spawn_fill(&mut tasks, rung.clone(), fill, permit); } if retired { // A consumer that asked for a group before retirement is already waiting on - // it, whether or not this loop had reached it yet: the request queues on the + // it, whether or not this loop had reached it yet: the fill queues on the // dynamic handler the moment the fetch is made. Take what is queued now, so // dropping the handler below does not cancel a fetch that beat retirement. // @@ -452,14 +452,14 @@ async fn fetches( // handler admits a cache miss for as long as it is alive, so a drain that // waited for a slot with it still open would keep taking those in too and // the rung would never finish. Dropping it closes admission and releases - // whatever arrived after the pass; the requests already popped are held + // whatever arrived after the pass; the fills already popped are held // here, so they are unaffected. let mut queued = Vec::new(); loop { tokio::select! { biased; - request = dynamic.requested_group() => match request { - Ok(request) => queued.push(request), + fill = dynamic.requested_fill() => match fill { + Ok(fill) => queued.push(fill), // The output track closed; nothing left to serve. Err(_) => break, }, @@ -469,9 +469,9 @@ async fn fetches( } drop(dynamic); - for request in queued { + for fill in queued { let permit = limit.clone().acquire_owned().await.expect("the semaphore stays open"); - spawn_fetch(&mut tasks, rung.clone(), request, permit); + spawn_fill(&mut tasks, rung.clone(), fill, permit); } // Finish every group the handler accepted, or its producer stays open and @@ -491,66 +491,61 @@ async fn fetches( Ok(()) } -/// Spawn one fetch, holding its concurrency slot for the life of the task. -fn spawn_fetch( +/// Spawn one fill, holding its concurrency slot for the life of the task. +fn spawn_fill( tasks: &mut tokio::task::JoinSet<()>, rung: Rung, - request: moq_net::group::Request, + fill: moq_net::track::Fill, permit: tokio::sync::OwnedSemaphorePermit, ) { tasks.spawn(async move { let _permit = permit; - let sequence = request.sequence(); - if let Err(err) = fetch(rung, request).await { - tracing::warn!(%err, sequence, "transcode fetch failed"); + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + if let Err(err) = fetch(&rung, &fill, sequence).await { + tracing::warn!(%err, sequence, "transcode fetch failed"); + } } }); } -/// Transcode one specifically requested group, fetching it from the source. +/// Transcode group `sequence` of a fill, fetching it from the source. /// -/// A failure while someone still wants the group rejects the request: a source -/// fetch error passes through, and a pipeline or container error rejects with -/// [`moq_net::Error::Cancel`]. Dropping it would auto-reject with [`moq_net::Error::Dropped`], which -/// reads as "the handler vanished" and hides the actual failure. Once nobody -/// wants the group, the request is dropped instead: the last caller already -/// withdrew the attempt, and accepting afterwards would cache the group under a -/// fresh request for the same sequence, whose own accept is then -/// [`moq_net::Error::Duplicate`]. -async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error> { +/// A failure while someone still wants the group drops it. Once nobody wants the +/// fill, it is left unanswered instead: the last caller already withdrew it, and +/// accepting afterwards would cache the group a fresh fill is about to serve. +async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Result<(), Error> { + let reject = || fill.drop_groups(sequence..sequence + 1); // A fresh encode of this group need not match the bytes the reader's head // came from (the live encoder, or an earlier fetch), so its tail cannot // continue that head. Refuse it so the reader moves on to the next group // boundary instead of decoding a splice. - if request.frame_start() != 0 { - tracing::debug!( - sequence = request.sequence(), - frame = request.frame_start(), - "refusing a fetch that starts mid-group" - ); - request.reject(moq_net::Error::NotFound); + let start = fill.start(); + if start.group == sequence && start.frame != 0 { + tracing::debug!(sequence, frame = start.frame, "refusing a fetch that starts mid-group"); + reject(); return Ok(()); } - // Watched until accept. After that the callers resolve from the cache, and - // this handle going unused is them picking the group up, not abandoning it. - let demand = request.demand(); - let track = match rung.broadcast.track(&rung.source) { Ok(track) => track, Err(err) => { - request.reject(err.clone()); + reject(); return Err(err.into()); } }; - let options = moq_net::group::Fetch::default().with_priority(request.priority()); + let options = moq_net::group::Fetch::default().with_priority(fill.priority()); let mut source = tokio::select! { biased; - _ = demand.unused() => return abandon(request), - source = track.fetch_group(request.sequence(), options) => match source { + _ = fill.unused() => return abandon(sequence), + source = track.fetch_group(sequence, options) => match source { Ok(source) => source, Err(err) => { - request.reject(err.clone()); + reject(); return Err(err.into()); } }, @@ -565,7 +560,7 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error let pipeline = match rung.pipeline().await { Ok(pipeline) => pipeline, Err(err) => { - request.reject(moq_net::Error::Cancel); + reject(); return Err(err); } }; @@ -573,18 +568,18 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error let container = match rung.container() { Ok(container) => container, Err(err) => { - request.reject(moq_net::Error::Cancel); + reject(); return Err(err); } }; // Accepting is what publishes the group. One last look, so a caller who - // left while the pipeline was opening does not race a fresh request. - if !demand.is_used() { - return abandon(request); + // left while the pipeline was opening does not race a fresh fill. + if !fill.is_used() { + return abandon(sequence); } - let output = match request.accept(None) { + let output = match fill.accept(sequence, None) { Ok(output) => output, Err(err) => return Err(err.into()), }; @@ -596,14 +591,10 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error Ok(()) } -/// Drop a fetch nobody is waiting on. -/// -/// Dropping rejects with [`moq_net::Error::Dropped`]. No caller is left to see -/// it, and keeping the request would let this encode publish the group a fresh -/// request is about to serve. -fn abandon(request: moq_net::group::Request) -> Result<(), Error> { - tracing::debug!(sequence = request.sequence(), "dropping a fetch nobody wants"); - drop(request); +/// Leave a fill nobody is waiting on: it was withdrawn, so this encode must not publish +/// the group a fresh fill is about to serve. +fn abandon(sequence: u64) -> Result<(), Error> { + tracing::debug!(sequence, "leaving a fetch nobody wants"); Ok(()) } diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index e982a8ac425..386d13e52a0 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -672,7 +672,8 @@ mod tests { let subscription = published.subscription().expect("the consumer subscribed"); assert_eq!( - subscription.max_delay, max_delay, + subscription.max_delay, + Some(max_delay), "the age budget did not reach the publisher" ); @@ -1126,7 +1127,7 @@ mod tests { ) .await; assert_eq!( - published.subscription().map(|sub| sub.max_delay), + published.subscription().and_then(|sub| sub.max_delay), Some(WARMUP), "the two subscriptions did not share one request at the larger budget" ); @@ -1283,7 +1284,7 @@ mod tests { .await .unwrap(); assert!(matches!(consumer.lanes[Lane::Max as usize], Slot::Closed)); - assert_eq!(track.subscription().map(|sub| sub.max_delay), Some(max_delay)); + assert_eq!(track.subscription().and_then(|sub| sub.max_delay), Some(max_delay)); } } From 867d6eac01e28e00f999d81e7a3053a0f5c80ff8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:05:50 -0700 Subject: [PATCH 06/49] wip: bindings serve fills --- dart/moq/lib/src/aliases.dart | 9 +- dart/moq/lib/src/durations.dart | 15 +- dart/moq_ffi/lib/src/moq.dart | 621 +++++++++--------- go/wrapper/doc.go | 2 +- go/wrapper/moq_test.go | 21 +- go/wrapper/publish.go | 114 ++-- go/wrapper/records.go | 13 +- .../kotlin/dev/moq/Aliases.kt | 8 +- .../kotlin/dev/moq/Durations.kt | 10 +- .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 4 +- py/moq-rs/docs/index.md | 3 +- py/moq-rs/moq/__init__.py | 6 +- py/moq-rs/moq/_records.py | 7 +- py/moq-rs/moq/publish.py | 110 ++-- py/moq-rs/tests/test_local.py | 28 +- rs/moq-ffi/src/consumer.rs | 2 +- swift/Sources/Moq/Dynamic.swift | 104 +-- 17 files changed, 532 insertions(+), 545 deletions(-) diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index ee9d97750c1..fde946a5f41 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -87,7 +87,7 @@ typedef TrackProducer = MoqTrackProducer; /// A subscriber-requested track not yet accepted: accept it for a [TrackProducer] or abort it. typedef TrackRequest = MoqTrackRequest; -/// A stream of uncached group requests for one track, for serving fetches on demand. +/// A stream of the fills readers of one track need: runs of groups the cache lacks. typedef TrackDynamic = MoqTrackDynamic; /// A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. @@ -96,11 +96,8 @@ typedef TrackDemand = MoqTrackDemand; /// The read side of a raw track: yields groups in sequence order, skipping ahead if it falls behind. typedef TrackConsumer = MoqTrackConsumer; -/// A request to produce one uncached group for a fetch consumer. -typedef GroupRequest = MoqGroupRequest; - -/// A watch-only handle to whether any caller still wants a requested group; holding it keeps nothing open. -typedef GroupDemand = MoqGroupDemand; +/// A run of groups readers miss: accept each group you have and drop the rest. +typedef Fill = MoqFill; /// The write side of a single group: append frames to it. typedef GroupProducer = MoqGroupProducer; diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index ca766268bbc..c41889dfa1f 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -28,8 +28,19 @@ extension BackoffDuration on MoqBackoff { /// Duration views over the subscription knobs. extension SubscriptionDuration on MoqSubscription { - /// Upper bound on buffering before a stalled group is skipped. - Duration get maxDelay => Duration(microseconds: maxDelayUs); + /// Upper bound on buffering before a stalled group is skipped, or null for the + /// publisher's max age alone. + Duration? get maxDelay => + maxDelayUs == null ? null : Duration(microseconds: maxDelayUs!); +} + +/// Duration views over a fill. +extension FillDuration on MoqFill { + /// The most tolerant age budget among its readers, or null for the publisher's max age alone. + Duration? get maxDelay { + final us = maxDelayUs(); + return us == null ? null : Duration(microseconds: us); + } } /// Duration views over the publisher-side track settings. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index a8739d693a7..e7b3144871b 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -617,7 +617,7 @@ class FfiConverterMoqRange { class MoqSubscription { final int priority; - final int maxDelayUs; + final int? maxDelayUs; final List? ranges; final MoqOrder? order; MoqSubscription({ @@ -640,7 +640,7 @@ class FfiConverterMoqSubscription { ); final priority = priority_lifted.value; new_offset += priority_lifted.bytesRead; - final maxDelayUs_lifted = FfiConverterUInt64.read( + final maxDelayUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final maxDelayUs = maxDelayUs_lifted.value; @@ -669,7 +669,7 @@ class FfiConverterMoqSubscription { static RustBuffer lower(MoqSubscription value) { final total_length = FfiConverterUInt8.allocationSize(value.priority) + - FfiConverterUInt64.allocationSize(value.maxDelayUs) + + FfiConverterOptionalUInt64.allocationSize(value.maxDelayUs) + FfiConverterOptionalSequenceMoqRange.allocationSize(value.ranges) + FfiConverterOptionalMoqOrder.allocationSize(value.order) + 0; @@ -684,7 +684,7 @@ class FfiConverterMoqSubscription { value.priority, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.maxDelayUs, Uint8List.view(buf.buffer, new_offset), ); @@ -701,7 +701,7 @@ class FfiConverterMoqSubscription { static int allocationSize(MoqSubscription value) { return FfiConverterUInt8.allocationSize(value.priority) + - FfiConverterUInt64.allocationSize(value.maxDelayUs) + + FfiConverterOptionalUInt64.allocationSize(value.maxDelayUs) + FfiConverterOptionalSequenceMoqRange.allocationSize(value.ranges) + FfiConverterOptionalMoqOrder.allocationSize(value.order) + 0; @@ -8428,116 +8428,6 @@ class FfiConverterMoqTrackConsumer { } } -abstract class MoqGroupDemandInterface { - bool isUsed(); - int sequence(); - Future unused(); - Future used(); -} - -final _MoqGroupDemandFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupdemand(ptr, status)); -}); - -class MoqGroupDemand implements MoqGroupDemandInterface { - late final Pointer _ptr; - bool _isDisposed = false; - MoqGroupDemand._(this._ptr) { - _MoqGroupDemandFinalizer.attach(this, _ptr, detach: this); - } - factory MoqGroupDemand.lift(Pointer ptr) { - return MoqGroupDemand._(ptr); - } - Pointer uniffiClonePointer() { - if (_isDisposed) { - throw StateError("Object has already been disposed"); - } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgroupdemand(_ptr, status), - ); - } - - void dispose() { - if (_isDisposed) { - return; - } - _isDisposed = true; - _MoqGroupDemandFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupdemand(_ptr, status)); - } - - bool isUsed() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupdemand_is_used( - uniffiClonePointer(), - status, - ), - FfiConverterBool.lift, - null, - ); - } - - int sequence() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupdemand_sequence( - uniffiClonePointer(), - status, - ), - FfiConverterUInt64.lift, - null, - ); - } - - Future unused() { - return uniffiRustCallAsync( - () => - uniffi_moq_ffi_fn_method_moqgroupdemand_unused(uniffiClonePointer()), - ffi_moq_ffi_rust_future_poll_void, - ffi_moq_ffi_rust_future_complete_void, - ffi_moq_ffi_rust_future_free_void, - (_) {}, - moqExceptionErrorHandler, - ); - } - - Future used() { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqgroupdemand_used(uniffiClonePointer()), - ffi_moq_ffi_rust_future_poll_void, - ffi_moq_ffi_rust_future_complete_void, - ffi_moq_ffi_rust_future_free_void, - (_) {}, - moqExceptionErrorHandler, - ); - } -} - -class FfiConverterMoqGroupDemand { - static MoqGroupDemand lift(Pointer ptr) { - return MoqGroupDemand.lift(ptr); - } - - static Pointer lower(MoqGroupDemand value) { - return value.uniffiClonePointer(); - } - - static int allocationSize(MoqGroupDemand value) { - return 8; - } - - static LiftRetVal read(Uint8List buf) { - final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); - final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqGroupDemand.lift(pointer), 8); - } - - static int write(MoqGroupDemand value, Uint8List buf) { - final handle = lower(value); - buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); - return 8; - } -} - abstract class MoqTrackDemandInterface { String name(); int? wanted(); @@ -11057,34 +10947,38 @@ class FfiConverterMoqBroadcastProducer { } } -abstract class MoqGroupProducerInterface { - void abort({required int errorCode}); - MoqGroupConsumer consume(); - void finish(); - int sequence(); - void writeFrame({required MoqFrame frame}); +abstract class MoqFillInterface { + MoqGroupProducer accept({required int sequence}); + void dropGroups({required int start, required int end}); + int endGroup(); + bool isUsed(); + int? maxDelayUs(); + int? next(); + MoqOrder order(); + int priority(); + int startFrame(); + int startGroup(); + Future unused(); } -final _MoqGroupProducerFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(ptr, status)); +final _MoqFillFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqfill(ptr, status)); }); -class MoqGroupProducer implements MoqGroupProducerInterface { +class MoqFill implements MoqFillInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqGroupProducer._(this._ptr) { - _MoqGroupProducerFinalizer.attach(this, _ptr, detach: this); + MoqFill._(this._ptr) { + _MoqFillFinalizer.attach(this, _ptr, detach: this); } - factory MoqGroupProducer.lift(Pointer ptr) { - return MoqGroupProducer._(ptr); + factory MoqFill.lift(Pointer ptr) { + return MoqFill._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgroupproducer(_ptr, status), - ); + return rustCall((status) => uniffi_moq_ffi_fn_clone_moqfill(_ptr, status)); } void dispose() { @@ -11092,43 +10986,98 @@ class MoqGroupProducer implements MoqGroupProducerInterface { return; } _isDisposed = true; - _MoqGroupProducerFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(_ptr, status)); + _MoqFillFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqfill(_ptr, status)); } - void abort({required int errorCode}) { + MoqGroupProducer accept({required int sequence}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_accept( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + status, + ), + FfiConverterMoqGroupProducer.lift, + moqExceptionErrorHandler, + ); + } + + void dropGroups({required int start, required int end}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_abort( + uniffi_moq_ffi_fn_method_moqfill_drop_groups( uniffiClonePointer(), - FfiConverterUInt16.lower(errorCode), + FfiConverterUInt64.lower(start), + FfiConverterUInt64.lower(end), status, ); - }, moqExceptionErrorHandler); + }, null); } - MoqGroupConsumer consume() { + int endGroup() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_consume( + (status) => uniffi_moq_ffi_fn_method_moqfill_end_group( uniffiClonePointer(), status, ), - FfiConverterMoqGroupConsumer.lift, - moqExceptionErrorHandler, + FfiConverterUInt64.lift, + null, ); } - void finish() { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_finish( + bool isUsed() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_is_used( uniffiClonePointer(), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterBool.lift, + null, + ); } - int sequence() { + int? maxDelayUs() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( + (status) => uniffi_moq_ffi_fn_method_moqfill_max_delay_us( + uniffiClonePointer(), + status, + ), + FfiConverterOptionalUInt64.lift, + null, + ); + } + + int? next() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqfill_next(uniffiClonePointer(), status), + FfiConverterOptionalUInt64.lift, + null, + ); + } + + MoqOrder order() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqfill_order(uniffiClonePointer(), status), + FfiConverterMoqOrder.lift, + null, + ); + } + + int priority() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_priority( + uniffiClonePointer(), + status, + ), + FfiConverterUInt8.lift, + null, + ); + } + + int startFrame() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_start_frame( uniffiClonePointer(), status, ), @@ -11137,70 +11086,82 @@ class MoqGroupProducer implements MoqGroupProducerInterface { ); } - void writeFrame({required MoqFrame frame}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( + int startGroup() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_start_group( uniffiClonePointer(), - FfiConverterMoqFrame.lower(frame), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterUInt64.lift, + null, + ); + } + + Future unused() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqfill_unused(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); } } -class FfiConverterMoqGroupProducer { - static MoqGroupProducer lift(Pointer ptr) { - return MoqGroupProducer.lift(ptr); +class FfiConverterMoqFill { + static MoqFill lift(Pointer ptr) { + return MoqFill.lift(ptr); } - static Pointer lower(MoqGroupProducer value) { + static Pointer lower(MoqFill value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqGroupProducer value) { + static int allocationSize(MoqFill 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(MoqGroupProducer.lift(pointer), 8); + return LiftRetVal(MoqFill.lift(pointer), 8); } - static int write(MoqGroupProducer value, Uint8List buf) { + static int write(MoqFill value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; } } -abstract class MoqGroupRequestInterface { +abstract class MoqGroupProducerInterface { void abort({required int errorCode}); - MoqGroupProducer accept(); - MoqGroupDemand demand(); - int priority(); + MoqGroupConsumer consume(); + void finish(); int sequence(); + void writeFrame({required MoqFrame frame}); } -final _MoqGroupRequestFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgrouprequest(ptr, status)); +final _MoqGroupProducerFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(ptr, status)); }); -class MoqGroupRequest implements MoqGroupRequestInterface { +class MoqGroupProducer implements MoqGroupProducerInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqGroupRequest._(this._ptr) { - _MoqGroupRequestFinalizer.attach(this, _ptr, detach: this); + MoqGroupProducer._(this._ptr) { + _MoqGroupProducerFinalizer.attach(this, _ptr, detach: this); } - factory MoqGroupRequest.lift(Pointer ptr) { - return MoqGroupRequest._(ptr); + factory MoqGroupProducer.lift(Pointer ptr) { + return MoqGroupProducer._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgrouprequest(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqgroupproducer(_ptr, status), ); } @@ -11209,13 +11170,13 @@ class MoqGroupRequest implements MoqGroupRequestInterface { return; } _isDisposed = true; - _MoqGroupRequestFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgrouprequest(_ptr, status)); + _MoqGroupProducerFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(_ptr, status)); } void abort({required int errorCode}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgrouprequest_abort( + uniffi_moq_ffi_fn_method_moqgroupproducer_abort( uniffiClonePointer(), FfiConverterUInt16.lower(errorCode), status, @@ -11223,71 +11184,68 @@ class MoqGroupRequest implements MoqGroupRequestInterface { }, moqExceptionErrorHandler); } - MoqGroupProducer accept() { + MoqGroupConsumer consume() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_accept( + (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_consume( uniffiClonePointer(), status, ), - FfiConverterMoqGroupProducer.lift, + FfiConverterMoqGroupConsumer.lift, moqExceptionErrorHandler, ); } - MoqGroupDemand demand() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_demand( + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqgroupproducer_finish( uniffiClonePointer(), status, - ), - FfiConverterMoqGroupDemand.lift, - moqExceptionErrorHandler, - ); + ); + }, moqExceptionErrorHandler); } - int priority() { + int sequence() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_priority( + (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( uniffiClonePointer(), status, ), - FfiConverterUInt8.lift, + FfiConverterUInt64.lift, null, ); } - int sequence() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_sequence( + void writeFrame({required MoqFrame frame}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( uniffiClonePointer(), + FfiConverterMoqFrame.lower(frame), status, - ), - FfiConverterUInt64.lift, - null, - ); + ); + }, moqExceptionErrorHandler); } } -class FfiConverterMoqGroupRequest { - static MoqGroupRequest lift(Pointer ptr) { - return MoqGroupRequest.lift(ptr); +class FfiConverterMoqGroupProducer { + static MoqGroupProducer lift(Pointer ptr) { + return MoqGroupProducer.lift(ptr); } - static Pointer lower(MoqGroupRequest value) { + static Pointer lower(MoqGroupProducer value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqGroupRequest value) { + static int allocationSize(MoqGroupProducer 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(MoqGroupRequest.lift(pointer), 8); + return LiftRetVal(MoqGroupProducer.lift(pointer), 8); } - static int write(MoqGroupRequest value, Uint8List buf) { + static int write(MoqGroupProducer value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -11296,7 +11254,7 @@ class FfiConverterMoqGroupRequest { abstract class MoqTrackDynamicInterface { void cancel(); - Future requestedGroup(); + Future requestedFill(); } final _MoqTrackDynamicFinalizer = Finalizer>((ptr) { @@ -11339,15 +11297,15 @@ class MoqTrackDynamic implements MoqTrackDynamicInterface { }, null); } - Future requestedGroup() { + Future requestedFill() { return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group( + () => 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_free_u64, - (ptr) => FfiConverterMoqGroupRequest.lift(Pointer.fromAddress(ptr)), + (ptr) => FfiConverterMoqFill.lift(Pointer.fromAddress(ptr)), moqExceptionErrorHandler, ); } @@ -15502,48 +15460,6 @@ external void uniffi_moq_ffi_fn_method_moqtrackconsumer_update( Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_clone_moqgroupdemand( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_free_moqgroupdemand( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external int uniffi_moq_ffi_fn_method_moqgroupdemand_is_used( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external int uniffi_moq_ffi_fn_method_moqgroupdemand_sequence( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqgroupdemand_unused( - Pointer ptr, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqgroupdemand_used( - Pointer ptr, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -16667,7 +16583,7 @@ external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_unannounce( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( +external Pointer uniffi_moq_ffi_fn_clone_moqfill( Pointer handle, Pointer uniffiStatus, ); @@ -16675,32 +16591,74 @@ external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqgroupproducer( +external void uniffi_moq_ffi_fn_free_moqfill( Pointer handle, Pointer uniffiStatus, ); -@Native, Uint16, Pointer)>( +@Native Function(Pointer, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_abort( +external Pointer uniffi_moq_ffi_fn_method_moqfill_accept( Pointer ptr, - int error_code, + int sequence, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Uint64, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_consume( +external void uniffi_moq_ffi_fn_method_moqfill_drop_groups( Pointer ptr, + int start, + int end, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( +external int uniffi_moq_ffi_fn_method_moqfill_end_group( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_is_used( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_max_delay_us( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_next( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_order( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_priority( Pointer ptr, Pointer uniffiStatus, ); @@ -16708,24 +16666,28 @@ external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( +external int uniffi_moq_ffi_fn_method_moqfill_start_frame( Pointer ptr, Pointer uniffiStatus, ); -@Native, RustBuffer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( +external int uniffi_moq_ffi_fn_method_moqfill_start_group( Pointer ptr, - RustBuffer frame, Pointer uniffiStatus, ); +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqfill_unused( + Pointer ptr, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqgrouprequest( +external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( Pointer handle, Pointer uniffiStatus, ); @@ -16733,7 +16695,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqgrouprequest( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqgrouprequest( +external void uniffi_moq_ffi_fn_free_moqgroupproducer( Pointer handle, Pointer uniffiStatus, ); @@ -16741,7 +16703,7 @@ external void uniffi_moq_ffi_fn_free_moqgrouprequest( @Native, Uint16, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgrouprequest_abort( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_abort( Pointer ptr, int error_code, Pointer uniffiStatus, @@ -16750,32 +16712,33 @@ external void uniffi_moq_ffi_fn_method_moqgrouprequest_abort( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgrouprequest_accept( +external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_consume( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgrouprequest_demand( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgrouprequest_priority( +external int uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgrouprequest_sequence( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( Pointer ptr, + RustBuffer frame, Pointer uniffiStatus, ); @@ -16804,7 +16767,7 @@ external void uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel( ); @Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group( +external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( Pointer ptr, ); @@ -17954,18 +17917,6 @@ external int uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackconsumer_update(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_is_used(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_sequence(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_unused(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_used(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackdemand_name(); @@ -18180,40 +18131,58 @@ uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track(); external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); +external int uniffi_moq_ffi_checksum_method_moqfill_accept(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); +external int uniffi_moq_ffi_checksum_method_moqfill_drop_groups(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); +external int uniffi_moq_ffi_checksum_method_moqfill_end_group(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); +external int uniffi_moq_ffi_checksum_method_moqfill_is_used(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame(); +external int uniffi_moq_ffi_checksum_method_moqfill_max_delay_us(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_next(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_order(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_abort(); +external int uniffi_moq_ffi_checksum_method_moqfill_priority(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_accept(); +external int uniffi_moq_ffi_checksum_method_moqfill_start_frame(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_demand(); +external int uniffi_moq_ffi_checksum_method_moqfill_start_group(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_priority(); +external int uniffi_moq_ffi_checksum_method_moqfill_unused(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence(); +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); + +@Native(assetId: _uniffiAssetId) +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_group(); +external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_abort(); @@ -18605,18 +18574,6 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_update() != 39083) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_is_used() != 55523) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_sequence() != 34558) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_unused() != 1305) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_used() != 16710) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqtrackdemand_name() != 14603) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -18845,41 +18802,59 @@ void _checkApiChecksums() { 49647) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { + if (uniffi_moq_ffi_checksum_method_moqfill_accept() != 63990) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_consume() != 53274) { + if (uniffi_moq_ffi_checksum_method_moqfill_drop_groups() != 7289) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { + if (uniffi_moq_ffi_checksum_method_moqfill_end_group() != 42504) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { + if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { + if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_next() != 7725) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_order() != 32232) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_abort() != 26970) { + if (uniffi_moq_ffi_checksum_method_moqfill_priority() != 60321) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_accept() != 48242) { + if (uniffi_moq_ffi_checksum_method_moqfill_start_frame() != 29713) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_demand() != 31288) { + if (uniffi_moq_ffi_checksum_method_moqfill_start_group() != 8622) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_priority() != 1745) { + if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence() != 29523) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_consume() != 53274) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + 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() != 57913) { + if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel() != 15841) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group() != - 63983) { + 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) { diff --git a/go/wrapper/doc.go b/go/wrapper/doc.go index e8294ca06e9..ad9b446e490 100644 --- a/go/wrapper/doc.go +++ b/go/wrapper/doc.go @@ -25,7 +25,7 @@ // // What a cancel tears down depends on the call. A one-shot call (a subscribe, a // fetch, RequestBroadcast, Resolve, a TrackDemand's WantedChanged, a -// GroupDemand's Used/Unused, Server.Accept) aborts on its own and leaves the +// Fill's Unused, Server.Accept) aborts on its own and leaves the // object it was made on usable, so the same // broadcast, producer, or server takes the next call. A stream read (any Next, // RecvGroup, ReadFrame, or the iterators over them) instead cancels the stream diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 02858ed518e..a8fb07fbe86 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -532,25 +532,21 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { result <- fetchResult{group: group, err: err} }() - request, err := dynamic.RequestedGroup(ctx) + fill, err := dynamic.RequestedFill(ctx) if err != nil { t.Fatal(err) } - if request.Sequence() != 7 || request.Priority() != 11 { - t.Fatalf("unexpected request: sequence=%d priority=%d", request.Sequence(), request.Priority()) + if fill.StartGroup() != 7 || fill.EndGroup() != 8 || fill.Priority() != 11 { + t.Fatalf("unexpected fill: start=%d end=%d priority=%d", fill.StartGroup(), fill.EndGroup(), fill.Priority()) } - demand, err := request.Demand() - if err != nil { - t.Fatal(err) - } - if demand.Sequence() != 7 || !demand.IsUsed() { - t.Fatalf("unexpected demand: sequence=%d used=%v", demand.Sequence(), demand.IsUsed()) + if next := fill.Next(); next == nil || *next != 7 || !fill.IsUsed() { + t.Fatalf("unexpected fill: next=%v used=%v", next, fill.IsUsed()) } - produced, err := request.Accept() + produced, err := fill.Accept(7) if err != nil { t.Fatal(err) } - if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(time.Duration(request.Sequence()*20_000) * time.Microsecond)}); err != nil { + if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(7 * 20_000 * time.Microsecond)}); err != nil { t.Fatal(err) } if err := produced.Finish(); err != nil { @@ -767,8 +763,9 @@ func TestAscendingSubscriptionReadsCachedBacklogOldestFirst(t *testing.T) { } order := moq.OrderAscending + maxDelay := 10 * time.Second consumer, err := track.Consume(&moq.Subscription{ - MaxDelay: 10 * time.Second, + MaxDelay: &maxDelay, Ranges: []moq.Range{{Start: moq.AnchorRelative{Groups: 2}}}, Order: &order, }) diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 172a43dc645..ee1d5f1131c 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -3,6 +3,7 @@ package moq import ( "context" "iter" + "time" ffi "moq.dev/moq-ffi/moq" "moq.dev/moq/internal/bridge" @@ -302,94 +303,97 @@ func (g *GroupProducer) Abort(errorCode uint16) error { return g.inner.Abort(errorCode) } -// TrackDynamic yields uncached groups requested by fetch consumers. +// TrackDynamic yields the fills readers of one track need: runs of groups the cache lacks. type TrackDynamic struct { inner *ffi.MoqTrackDynamic } -// RequestedGroup waits for the next uncached group request. -func (d *TrackDynamic) RequestedGroup(ctx context.Context) (*GroupRequest, error) { - inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedGroup) +// RequestedFill waits for the next fill. +func (d *TrackDynamic) RequestedFill(ctx context.Context) (*Fill, error) { + inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedFill) if err != nil { return nil, err } - return &GroupRequest{inner: inner}, nil + return &Fill{inner: inner}, nil } -// All ranges over uncached group requests until the dynamic source ends. -func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*GroupRequest, error] { - return bridge.Seq(ctx, d.RequestedGroup) +// All ranges over fills until the dynamic source ends. +func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*Fill, error] { + return bridge.Seq(ctx, d.RequestedFill) } -// Cancel stops current and future requested-group waits. +// Cancel stops current and future requested-fill waits. func (d *TrackDynamic) Cancel() { d.inner.Cancel() } -// GroupRequest requests one uncached group from a track producer. -type GroupRequest struct { - inner *ffi.MoqGroupRequest +// Fill is a run of groups readers miss, for a TrackDynamic 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 +// while a reader still wants it; once nobody does, it is withdrawn, so stop +// serving it when Unused returns. +type Fill struct { + inner *ffi.MoqFill } -// Sequence is the requested group sequence within the track. -func (r *GroupRequest) Sequence() uint64 { - return r.inner.Sequence() +// StartGroup is the first group to fill. +func (f *Fill) StartGroup() uint64 { + return f.inner.StartGroup() } -// Priority is the consumer's delivery priority for this fetch. -func (r *GroupRequest) Priority() uint8 { - return r.inner.Priority() +// StartFrame is the frame within the first group to start from. +func (f *Fill) StartFrame() uint64 { + return f.inner.StartFrame() } -// Demand returns a watch-only handle to whether any caller still wants the group. -func (r *GroupRequest) Demand() (*GroupDemand, error) { - inner, err := r.inner.Demand() - if err != nil { - return nil, err - } - return &GroupDemand{inner: inner}, nil +// EndGroup is the exclusive end group. +func (f *Fill) EndGroup() uint64 { + return f.inner.EndGroup() } -// Accept accepts the request and returns a producer for the group. -func (r *GroupRequest) Accept() (*GroupProducer, error) { - inner, err := r.inner.Accept() - if err != nil { - return nil, err - } - return &GroupProducer{inner: inner}, nil +// Priority is the most urgent delivery priority among the readers waiting on it. +func (f *Fill) Priority() uint8 { + return f.inner.Priority() } -// Abort rejects the fetch with an application error code. -func (r *GroupRequest) Abort(errorCode uint16) error { - return r.inner.Abort(errorCode) +// MaxDelay is the most tolerant age budget among its readers, or nil for the +// publisher's max age alone. +func (f *Fill) MaxDelay() *time.Duration { + return optFromMicros(f.inner.MaxDelayUs()) } -// GroupDemand watches the callers waiting on a requested group. -// -// It is weak: holding it does not keep the request alive. The last caller to -// leave withdraws the request, so once unused, demand never returns: drop the -// request. Waits fail with ErrClosed after the request is accepted or dropped, -// and keep the rejection reason after it is rejected. -type GroupDemand struct { - inner *ffi.MoqGroupDemand +// Order is which end to fill first. +func (f *Fill) Order() Order { + return f.inner.Order() +} + +// Next is the next group still owed in the fill's order, or nil once every group is answered. +func (f *Fill) Next() *uint64 { + return f.inner.Next() } -// Sequence is the sequence of the group this watches. -func (d *GroupDemand) Sequence() uint64 { - return d.inner.Sequence() +// Accept inserts group sequence and returns a producer for it. The first group +// starts at StartFrame, so its frames keep their indices. +func (f *Fill) Accept(sequence uint64) (*GroupProducer, error) { + inner, err := f.inner.Accept(sequence) + if err != nil { + return nil, err + } + return &GroupProducer{inner: inner}, nil } -// IsUsed reports whether the group has at least one waiting caller right now. -func (d *GroupDemand) IsUsed() bool { - return d.inner.IsUsed() +// DropGroups names groups start up to the exclusive end as never to be delivered. +func (f *Fill) DropGroups(start, end uint64) { + f.inner.DropGroups(start, end) } -// Used blocks until the group has at least one waiting caller. -func (d *GroupDemand) Used(ctx context.Context) error { - return d.inner.Used(ctx) +// IsUsed reports whether a reader still waits on this fill. +func (f *Fill) IsUsed() bool { + return f.inner.IsUsed() } -// Unused blocks until the group has no waiting callers. -func (d *GroupDemand) Unused(ctx context.Context) error { - return d.inner.Unused(ctx) +// Unused blocks until no reader waits on this fill. +func (f *Fill) Unused(ctx context.Context) error { + return f.inner.Unused(ctx) } diff --git a/go/wrapper/records.go b/go/wrapper/records.go index b6a5e827e71..2d2b96d918e 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -58,19 +58,20 @@ func datagramFromFFI(d *ffi.MoqDatagram) *Datagram { } // Subscription holds subscriber-side delivery preferences: priority, the max delay -// a non-latest group may fall behind before it is skipped (zero skips at once, and it -// caps every range), the ranges of the track to deliver (nil for everything MaxDelay -// still allows, while an empty non-nil slice asks for nothing), and the order the -// publisher sends pending groups in (nil for newest first). +// a non-latest group may fall behind before it is skipped (zero skips at once, nil +// leaves the publisher's max age alone to bound it, and it caps every range), the +// ranges of the track to deliver (nil for everything MaxDelay still allows, while an +// empty non-nil slice asks for nothing), and the order the publisher sends pending +// groups in (nil for newest first). type Subscription struct { Priority uint8 - MaxDelay time.Duration + MaxDelay *time.Duration Ranges []Range Order *Order } func (s Subscription) ffi() (ffi.MoqSubscription, error) { - maxDelay, err := bridge.Duration(s.MaxDelay, "max delay") + maxDelay, err := bridge.OptionalDuration(s.MaxDelay, "max delay") var ranges *[]ffi.MoqRange if s.Ranges != nil { ranges = &s.Ranges diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 6430cf04fc7..903548140f0 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -77,16 +77,14 @@ typealias BroadcastDynamic = uniffi.moq.MoqBroadcastDynamic 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 uncached group requests for one track, for serving fetches on demand. */ +/** A stream of the fills readers of one track need: runs of groups the cache lacks. */ typealias TrackDynamic = uniffi.moq.MoqTrackDynamic /** 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. */ typealias TrackConsumer = uniffi.moq.MoqTrackConsumer -/** A request to produce one uncached group for a fetch consumer. */ -typealias GroupRequest = uniffi.moq.MoqGroupRequest -/** A watch-only handle to whether any caller still wants a requested group; holding it keeps nothing open. */ -typealias GroupDemand = uniffi.moq.MoqGroupDemand +/** A run of groups readers miss: accept each group you have and drop the rest. */ +typealias Fill = uniffi.moq.MoqFill /** The write side of a single group: append frames to it. */ typealias GroupProducer = uniffi.moq.MoqGroupProducer /** The read side of a single group: yields timestamped raw frames. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 6dd59ce6e05..822b58eef95 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -49,9 +49,13 @@ val QuicConfig.idleTimeout: Duration? val QuicConfig.keepAlive: Duration? get() = keepAliveUs?.toLong()?.microseconds -/** Upper bound on buffering before a stalled group is skipped. */ -val Subscription.maxDelay: Duration - get() = maxDelayUs.toLong().microseconds +/** Upper bound on buffering before a stalled group is skipped, or null for the publisher's max age alone. */ +val Subscription.maxDelay: Duration? + get() = maxDelayUs?.toLong()?.microseconds + +/** The most tolerant age budget among a fill's readers, or null for the publisher's max age alone. */ +val Fill.maxDelay: Duration? + get() = maxDelayUs()?.toLong()?.microseconds /** Maximum age of a non-latest group before the publisher evicts it, or null for the default. */ val TrackInfo.maxAge: Duration? diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 5dc5e412567..1ca547500d8 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -39,8 +39,8 @@ fun TrackConsumer.datagrams(): Flow = pull({ cancel() }) { recvDatagra /** Stream of tracks requested by subscribers. */ fun BroadcastDynamic.requestedTracks(): Flow = pull({ cancel() }) { requestedTrack() } -/** Stream of uncached group requests for one track. */ -fun TrackDynamic.requestedGroups(): Flow = pull({ cancel() }) { requestedGroup() } +/** Stream of the fills readers of one track need. */ +fun TrackDynamic.requestedFills(): Flow = pull({ cancel() }) { requestedFill() } /** Stream of broadcasts requested by consumers. */ fun OriginDynamic.requestedBroadcasts(): Flow = pull({ cancel() }) { requestedBroadcast() } diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 94050e1c24c..55fa80be81b 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -59,8 +59,7 @@ asyncio.run(main()) TrackDynamic TrackRequest GroupProducer - GroupRequest - GroupDemand + Fill ``` ## Subscribing diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index f564c37f84c..59cb74cba89 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -32,9 +32,8 @@ from .publish import ( BroadcastDynamic, BroadcastProducer, - GroupDemand, + Fill, GroupProducer, - GroupRequest, TrackDemand, TrackDynamic, TrackProducer, @@ -104,9 +103,8 @@ "ProtocolError", "ProtocolKind", "FetchGroupOptions", + "Fill", "GroupConsumer", - "GroupDemand", - "GroupRequest", "GroupProducer", "OriginConsumer", "OriginDynamic", diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index a065d3f7255..5e8783dde6a 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -93,20 +93,21 @@ class Subscription: """Subscriber-side delivery preferences, mirroring moq-net's ``track::Subscription``. ``max_delay`` is how far a non-latest group may fall behind before it is skipped; - zero skips at once, and it caps every range. ``ranges`` are the spans of the track to + zero skips at once, ``None`` leaves the publisher's max age alone to bound it, and it + caps every range. ``ranges`` are the spans of the track to deliver, ``None`` for everything ``max_delay`` still allows. ``order`` is which pending group the publisher sends first, ``None`` for newest first. """ priority: int = 0 - max_delay: timedelta = timedelta(0) + max_delay: timedelta | None = timedelta(0) ranges: Sequence[MoqRange] | None = None order: MoqOrder | None = None def _ffi(self) -> MoqSubscription: return MoqSubscription( priority=self.priority, - max_delay_us=_to_us(self.max_delay, "max_delay"), + max_delay_us=_opt_us(self.max_delay, "max_delay"), ranges=None if self.ranges is None else list(self.ranges), order=self.order, ) diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index c041bb2aeb7..ba3a59e994c 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -8,16 +8,16 @@ from moq_ffi import ( MoqBroadcastDynamic, MoqBroadcastProducer, - MoqGroupDemand, + MoqFill, MoqGroupProducer, - MoqGroupRequest, + MoqOrder, MoqTrackDemand, MoqTrackDynamic, MoqTrackProducer, MoqTrackRequest, ) -from ._records import _subscription, _track_info +from ._records import _opt_from_us, _subscription, _track_info from ._stream import _Stream from .types import ( Frame, @@ -190,68 +190,74 @@ def abort(self, error_code: int) -> None: self._inner.abort(error_code) -class GroupDemand: - """A watch-only handle to the callers waiting on a requested group. +class Fill: + """A run of groups readers miss, for a :class:`TrackDynamic` to serve. - Returned by ``GroupRequest.demand()``. Weak: holding it does not keep the - request alive. The last caller to leave withdraws the request, so once - unused, demand never returns: drop the request. Waits raise - ``moq.Error.Closed`` after the request is accepted or dropped, and keep - the rejection reason after it is rejected. + 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 + whatever is left as dropped while a reader still wants it; once nobody does, it is + withdrawn, so stop serving it when :meth:`unused` returns. """ - def __init__(self, inner: MoqGroupDemand) -> None: + def __init__(self, inner: MoqFill) -> None: self._inner = inner @property - def sequence(self) -> int: - """The sequence of the group this watches.""" - return self._inner.sequence() + def start_group(self) -> int: + """The first group to fill.""" + return self._inner.start_group() - def is_used(self) -> bool: - """Whether the group has at least one waiting caller right now.""" - return self._inner.is_used() + @property + def start_frame(self) -> int: + """The frame within the first group to start from.""" + return self._inner.start_frame() - async def used(self) -> None: - """Wait until the group has at least one waiting caller.""" - await self._inner.used() + @property + def end_group(self) -> int: + """The exclusive end group.""" + return self._inner.end_group() - async def unused(self) -> None: - """Wait until the group has no waiting callers.""" - await self._inner.unused() + @property + def priority(self) -> int: + """The most urgent delivery priority among the readers waiting on it.""" + return self._inner.priority() + @property + def max_delay(self) -> timedelta | None: + """The most tolerant age budget among its readers, ``None`` for the publisher's max age alone.""" + return _opt_from_us(self._inner.max_delay_us()) -class GroupRequest: - """A request to produce one uncached group for a fetch consumer.""" + @property + def order(self) -> MoqOrder: + """Which end to fill first.""" + return self._inner.order() - def __init__(self, inner: MoqGroupRequest) -> None: - self._inner = inner + def next(self) -> int | None: + """The next group still owed in the fill's order, ``None`` once every group is answered.""" + return self._inner.next() - @property - def sequence(self) -> int: - """The requested group sequence within the track.""" - return self._inner.sequence() + def accept(self, sequence: int) -> GroupProducer: + """Insert group ``sequence`` and return a producer for it. - @property - def priority(self) -> int: - """The consumer's delivery priority for this fetch.""" - return self._inner.priority() + The first group starts at :attr:`start_frame`, so its frames keep their indices. + """ + return GroupProducer(self._inner.accept(sequence)) - def demand(self) -> GroupDemand: - """A watch-only handle to whether any caller still wants this group.""" - return GroupDemand(self._inner.demand()) + def drop_groups(self, start: int, end: int) -> None: + """Name groups ``start`` up to the exclusive ``end`` as never to be delivered.""" + self._inner.drop_groups(start, end) - def accept(self) -> GroupProducer: - """Accept the request and return a producer for the group.""" - return GroupProducer(self._inner.accept()) + def is_used(self) -> bool: + """Whether a reader still waits on this fill.""" + return self._inner.is_used() - def abort(self, error_code: int) -> None: - """Reject the fetch with an application error code.""" - self._inner.abort(error_code) + async def unused(self) -> None: + """Wait until no reader waits on this fill.""" + await self._inner.unused() -class TrackDynamic(_Stream[GroupRequest]): - """Async source of uncached group requests for one track. +class TrackDynamic(_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. """ @@ -259,15 +265,15 @@ class TrackDynamic(_Stream[GroupRequest]): def __init__(self, inner: MoqTrackDynamic) -> None: self._inner = inner - async def _next(self) -> GroupRequest: - return await self.requested_group() + async def _next(self) -> Fill: + return await self.requested_fill() - async def requested_group(self) -> GroupRequest: - """Wait for the next uncached group request.""" - return GroupRequest(await self._inner.requested_group()) + async def requested_fill(self) -> Fill: + """Wait for the next fill.""" + return Fill(await self._inner.requested_fill()) def cancel(self) -> None: - """Cancel current and future group request waits.""" + """Cancel current and future fill waits.""" self._inner.cancel() diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 38621566fa3..7dabda83b1f 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -106,25 +106,20 @@ def test_origin_lifecycle(): _consumer = origin.consume() -async def test_fetch_abort_is_a_stream_app_code(): +async def test_fetch_of_a_dropped_group_is_not_found(): broadcast = moq.BroadcastProducer() track = broadcast.publish_track("events") dynamic = track.dynamic() consumer = broadcast.consume() async def reject(): - request = await dynamic.requested_group() - request.abort(404) + fill = await dynamic.requested_fill() + fill.drop_groups(5, 6) task = asyncio.create_task(reject()) - with pytest.raises(moq.Error.Protocol) as raised: # type: ignore[attr-defined] + with pytest.raises(moq.Error.NotFound): # type: ignore[attr-defined] await consumer.fetch_group("events", 5) await task - protocol = moq.protocol_error(raised.value) - assert protocol is not None - assert protocol.scope == moq.ErrorScope.STREAM - assert protocol.code == 64 + 404 - assert protocol.kind == moq.ProtocolKind.APP def test_protocol_error_helper_covers_known_app_and_unknown(): @@ -471,14 +466,13 @@ async def test_fetch_group_and_serve_dynamic_miss(): dynamic = track.dynamic() pending = asyncio.create_task(consumer.fetch_group("events", 7, moq.FetchGroupOptions(priority=11))) - request = await asyncio.wait_for(dynamic.requested_group(), timeout=5.0) - assert request.sequence == 7 - assert request.priority == 11 - demand = request.demand() - assert demand.sequence == 7 - assert demand.is_used() + fill = await asyncio.wait_for(dynamic.requested_fill(), timeout=5.0) + assert (fill.start_group, fill.start_frame, fill.end_group) == (7, 0, 8) + assert fill.priority == 11 + assert fill.next() == 7 + assert fill.is_used() - produced = request.accept() + produced = fill.accept(7) produced.write_frame(b"archive", timedelta(microseconds=140_000)) produced.finish() @@ -1555,7 +1549,7 @@ async def assert_cancelled(awaitable) -> None: track = broadcast.publish_track("events") async with track.dynamic() as track_dynamic: pass - await assert_cancelled(track_dynamic.requested_group()) + await assert_cancelled(track_dynamic.requested_fill()) snapshot = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("state")) async with moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("state")) as snapshot_consumer: diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 1fd646fdd62..ac963bbcf8e 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -34,7 +34,7 @@ pub struct MoqSubscription { /// /// Enforced both by the publisher's cache (sent on the wire) and by any local /// buffering, such as [`MoqMediaTrackConsumer`]'s jitter buffer. It caps every range. - #[uniffi(default = 0)] + #[uniffi(default = Some(0))] pub max_delay_us: Option, /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index 8dc8c5539f9..964147375f4 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -37,78 +37,80 @@ public final class TrackRequest: Sendable { } } -/// A watch-only handle to the callers waiting on a requested group. +/// A run of groups readers miss, for a `TrackDynamic` to serve. /// -/// Returned by `GroupRequest.demand()`. Weak: holding it does not keep the request alive. The last -/// caller to leave withdraws the request, so once unused, demand never returns: drop the request. -/// Waits throw `MoqError.Closed` after the request is accepted or dropped, and keep the rejection -/// reason after it is rejected. -public final class GroupDemand: Sendable { - let ffi: MoqGroupDemand - - init(_ ffi: MoqGroupDemand) { +/// 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 +/// wants it; once nobody does, it is withdrawn, so stop serving it when `unused()` returns. +public final class Fill: Sendable { + let ffi: MoqFill + + init(_ ffi: MoqFill) { self.ffi = ffi } - /// The sequence of the group this watches. - public var sequence: UInt64 { - ffi.sequence() + /// The first group to fill. + public var startGroup: UInt64 { + ffi.startGroup() } - /// Whether the group has at least one waiting caller right now. - public var isUsed: Bool { - ffi.isUsed() + /// The frame within the first group to start from. + public var startFrame: UInt64 { + ffi.startFrame() } - /// Suspend until the group has at least one waiting caller. - public func used() async throws { - try await ffi.used() + /// The exclusive end group. + public var endGroup: UInt64 { + ffi.endGroup() } - /// Suspend until the group has no waiting callers. - public func unused() async throws { - try await ffi.unused() + /// The most urgent delivery priority among the readers waiting on it. + public var priority: UInt8 { + ffi.priority() } -} -/// A request to produce one uncached group for a fetch consumer. -public final class GroupRequest: Sendable { - let ffi: MoqGroupRequest + /// The most tolerant age budget among its readers in microseconds, or nil for the + /// publisher's max age alone. + public var maxDelayUs: UInt64? { + ffi.maxDelayUs() + } - init(_ ffi: MoqGroupRequest) { - self.ffi = ffi + /// Which end to fill first. + public var order: Subscription.Order { + ffi.order() } - /// The requested group sequence within the track. - public var sequence: UInt64 { - ffi.sequence() + /// The next group still owed in the fill's order, or nil once every group is answered. + public func next() -> UInt64? { + ffi.next() } - /// The consumer's delivery priority for this fetch. - public var priority: UInt8 { - ffi.priority() + /// Insert group `sequence` and return a producer for it. The first group starts at + /// `startFrame`, so its frames keep their indices. + public func accept(sequence: UInt64) throws -> GroupProducer { + GroupProducer(try ffi.accept(sequence: sequence)) } - /// A watch-only handle to whether any caller still wants this group. - public func demand() throws -> GroupDemand { - GroupDemand(try ffi.demand()) + /// Name groups `start` up to the exclusive `end` as never to be delivered. + public func dropGroups(start: UInt64, end: UInt64) { + ffi.dropGroups(start: start, end: end) } - /// Accept the request and return a producer for the group. - public func accept() throws -> GroupProducer { - GroupProducer(try ffi.accept()) + /// Whether a reader still waits on this fill. + public var isUsed: Bool { + ffi.isUsed() } - /// Reject the fetch with an application error code. - public func abort(errorCode: UInt16) throws { - try ffi.abort(errorCode: errorCode) + /// Suspend until no reader waits on this fill. + public func unused() async throws { + try await ffi.unused() } } -/// A stream of uncached group requests for one track. +/// A stream of the fills readers of one track need: runs of groups the cache lacks. public final class TrackDynamic: AsyncSequence, Sendable { - /// The group request emitted by this sequence. - public typealias Element = GroupRequest + /// The fill emitted by this sequence. + public typealias Element = Fill let ffi: MoqTrackDynamic @@ -116,20 +118,20 @@ public final class TrackDynamic: AsyncSequence, Sendable { self.ffi = ffi } - /// Wait for the next uncached group request. - public func requestedGroup() async throws -> GroupRequest { - GroupRequest(try await ffi.requestedGroup()) + /// Wait for the next fill. + public func requestedFill() async throws -> Fill { + Fill(try await ffi.requestedFill()) } - /// Cancel all current and future group request waits. + /// Cancel all current and future fill waits. public func cancel() { ffi.cancel() } /// Create an iterator that cancels native waits when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in - GroupRequest(try await ffi.requestedGroup()) + Fill(try await ffi.requestedFill()) }.makeAsyncIterator() } } From 823aca08ad9924885cc69f2886bf0b0547f3ae35 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:07:49 -0700 Subject: [PATCH 07/49] wip: migration notes, docs, and quest references --- dart/moq/MIGRATE.md | 7 ++ doc/concept/moq-lite.md | 7 +- go/wrapper/MIGRATE.md | 11 +++ kt/moq/MIGRATE.md | 7 ++ py/moq-rs/MIGRATE.md | 7 ++ quest/m1/archive/consumer.md | 8 +- quest/m1/public-model/README.md | 4 +- quest/m1/public-model/deferred.md | 2 +- quest/m1/resolved-demand.md | 4 - quest/m1/subscribe-ranges/README.md | 6 +- quest/m1/subscribe-ranges/dynamic.md | 95 --------------------- quest/m1/subscribe-ranges/fetch-range.md | 5 +- quest/m1/subscribe-ranges/frame-bounds.md | 4 - quest/m1/subscribe-ranges/ietf.md | 4 - quest/m1/subscribe-ranges/js.md | 2 +- quest/m1/subscribe-ranges/lite.md | 3 - quest/m2/unbounded-groups/README.md | 4 +- quest/m2/unbounded-groups/front-eviction.md | 4 +- rs/moq-c/MIGRATE.md | 6 ++ rs/moq-ffi/MIGRATE.md | 11 +++ rs/moq-net/MIGRATE.md | 14 +++ swift/MIGRATE.md | 7 ++ 22 files changed, 89 insertions(+), 133 deletions(-) delete mode 100644 quest/m1/subscribe-ranges/dynamic.md diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index b280596e611..fcf0172812d 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`). +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +`Subscription.maxDelayUs` and `maxDelay` are nullable, where null leaves the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Anchor` gains a time variant, so exhaustive switches need a case for it. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index d7a9f893ca2..6b5aa382b69 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -237,7 +237,7 @@ Each subscription carries the knobs that decide behavior under congestion: | --- | --- | | **Priority** (0..255) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | | **Order** | Which group to send first when several are pending. Newest first for live (the default), oldest first for catch-up. Needs moq-lite 07 or moq-transport; older moq-lite always sends newest first. | -| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. | +| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. Unset leaves the publisher's retention window alone to bound it. | | **Ranges** | Which groups to ask for: from a group, a number of groups back from the latest, or the group containing a presentation time, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | On moq-lite 05 and later, a publisher explicitly reports groups that will not arrive. @@ -248,7 +248,10 @@ Ranges are both a request and a filter. A subscriber reads only the groups its own ranges cover, resolved again whenever it replaces them, even when another subscriber of the same track asked for more. Widening them, including lowering the start, also serves the cached groups they newly cover, never one already -served. A relay merges its subscribers' ranges into one upstream request, which +served. A group they cover below the live feed that the cache lacks (evicted, +skipped, or below where the feed starts) is asked of the track's dynamic handler +as a fill, which a relay serves by fetching upstream one group at a time; with +no handler it is reported as dropped once the feed stops waiting for it. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A time anchor is resolved from the groups the serving hop holds; upstream it asks for everything max delay allows, and an untimed track refuses it. At the live edge, a time end diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index b5bfc663b1d..90843c4fab9 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.RequestedGroup` is now `RequestedFill`, returning a `*Fill` (`StartGroup`, `StartFrame`, `EndGroup`, `Priority`, `MaxDelay`, `Order`, `Next`, `Accept(sequence)`, `DropGroups(start, end)`, `IsUsed`, `Unused`), and `All` ranges over fills. +`GroupRequest` and `GroupDemand` are removed: serve each group `Next` names, and watch `IsUsed` or `Unused` on the fill. +A refusal carries no application code: `DropGroups` fails the waiting fetches with `ErrNotFound`. + +### `Subscription.MaxDelay` is a pointer + +`Subscription.MaxDelay` is a `*time.Duration`, where nil leaves the publisher's max age alone to bound delivery. +A zero-value `Subscription` now means that rather than live edge only: point `MaxDelay` at a zero duration to keep skipping at once. + ### Anchors can name a time (#6074) `AnchorTime` names the group containing a presentation time, and type switches over `Anchor` need a case for it. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 9a3ad22f29e..34361b421b7 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`), and `requestedGroups()` is now `requestedFills()`. +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +`Subscription.maxDelayUs` is a `ULong?` and `maxDelay` a `Duration?`, where null leaves the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Anchor` gains `AnchorTime`, so exhaustive `when` expressions need a branch for it. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 59f06c9963e..0c4f0be917f 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group`, `start_frame`, `end_group`, `priority`, `max_delay`, `order`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`), and iterating a `TrackDynamic` yields fills. +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A refusal carries no application code: `drop_groups` fails the waiting fetches with `moq.Error.NotFound`. +`Subscription.max_delay` also accepts `None`, leaving the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Anchor` gains a `TIME` variant, the group containing a presentation time. diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index a71663eee6f..17266f1ae05 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -60,10 +60,10 @@ Guidance: the same way; history-mode callers rely on the start answering with what remains ([History from the retained floor](/quest/m1/archive/history-floor.md)). - Keep a byte-bounded object LRU so neighbouring groups come from one GET. -- When [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) lands, the - `track::Dynamic` handler resolves a `Time(pts)` end by picking the object - from the key listing, then the group containing pts from that object's - group timestamps, whichever quest lands second. +- A `track::Fill` names group positions only, so a `Time(pts)` end the cache + can't resolve never reaches the handler. Resolving one from the key listing + (the object, then the group containing pts from its group timestamps) needs + a fill that carries the anchor. - Move `rs/moq-hls/src/export/archive_tests.rs` onto a consumer broadcast with its archived catalog, deleting the hand-built catalog. - Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, diff --git a/quest/m1/public-model/README.md b/quest/m1/public-model/README.md index 3f5981bacde..7c7dc0808c2 100644 --- a/quest/m1/public-model/README.md +++ b/quest/m1/public-model/README.md @@ -43,8 +43,8 @@ Rules every child follows: the mark as the item goes public; the lint fails on a mark left behind, or on one naming a deleted quest. - Items replaced by another quest's new API wait for that quest instead of - growing a public shape it deletes: fetch and per-group requests - ([Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md)), announce + growing a public shape it deletes: fetch + ([Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md)), announce interest ([Rust lazy discovery](/quest/m1/lazy-discovery/rust.md)). Inventory method, to rerun before each child starts (counts drift). Rust: diff --git a/quest/m1/public-model/deferred.md b/quest/m1/public-model/deferred.md index 87c752e6700..6360ba01bba 100644 --- a/quest/m1/public-model/deferred.md +++ b/quest/m1/public-model/deferred.md @@ -45,7 +45,7 @@ built-in sessions moved onto the public calls. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - replaces per-group fetch requests, the peeks, and `fork` +- [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - replaces per-group fetches, the peeks, and `fork` - [Public ingest](/quest/m1/public-model/ingest.md) - settles the received-end shape JS mirrors - [JS ranges](/quest/m1/subscribe-ranges/js.md) - the JS `Track.Request`/`Track.Dynamic` range requests that answer track info and fetches - [Public routes and answering](/quest/m1/public-model/answer.md) - the public `Broadcast.requested` handout this completes diff --git a/quest/m1/resolved-demand.md b/quest/m1/resolved-demand.md index a00ee625d33..783b119d524 100644 --- a/quest/m1/resolved-demand.md +++ b/quest/m1/resolved-demand.md @@ -168,10 +168,6 @@ Public API: none. Wire: no encoding change. A publisher now re-offers cached groups it never reported dropped when a SUBSCRIBE_UPDATE widens the max age or the range. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - #5929 lands the re-offer and serving path and the range miss fill this reuses - ## Related - [Archive producer](/quest/m1/archive/producer.md) - its resume floor crosses relays that viewers may have warmed diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index b6399fc948e..9b3b0cf85fe 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -34,9 +34,8 @@ 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 range requests for misses, replacing per-group - `requested_group`, and `fetch_group` becomes a one-range subscription or is - removed. This breaks published moq-net APIs. + `track::Dynamic` 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 locally missing groups becomes one standalone range FETCH, capped below the @@ -95,7 +94,6 @@ every sequence delivered or dropped. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, replacing `requested_group` - [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - `fetch_group` becomes a one-range subscription that ends once its range is accounted for - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) - the lite-07 wire, Rust publisher and subscriber, and the draft - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) - range FETCH upstream per missing run, and non-blocking FETCH served downstream diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md deleted file mode 100644 index 2a96fd1bb92..00000000000 --- a/quest/m1/subscribe-ranges/dynamic.md +++ /dev/null @@ -1,95 +0,0 @@ -# [L] Dynamic range requests - -## Goal - -A publisher's `track::Dynamic` receives range requests for misses instead of -one request per group, and every sequence in a subscription's range either -arrives or is named as dropped, so a reader never waits on a miss. -`requested_group` is replaced by `requested_fill`, not kept beside it, in every -caller and binding (the frozen moq-c ABI excepted, per the moq-c decision -below). Removing `fetch_group` is the follow-up -[fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md). - -## Plan - -A subscriber already reads only its own ranges, re-resolved when they are -replaced. What remains is the miss: one below the cache becomes a range request -on `Dynamic` rather than a group request. A time anchor the cache can't -resolve passes to the handler unresolved in its range request. - -- Replace `requested_group` rather than keep both. Update every caller - (sessions, relay, moq-archive, hls, the ladder) in the same PR, including - the archive consumer's time resolution from its key listing if it has - landed, plus the published surfaces built on it, per the cross-package - table: moq-ffi (`requested_group`), every binding wrapper and its `doc/lib` - page. Until the follow-up lands, a `fetch_group` miss queues a one-group - fill. -- Every sequence in range either arrives or is named as dropped. Share the - accounting with SUBSCRIBE_DROP rather than adding a second one: the cursor's - drop scan already names only sequences inside the reader's own ranges. -- Decided in the 2026-10-10 audit: `max_delay` caps every range, including - historical FETCH replacements, and publisher `max_age` always clamps it. - A FETCH with no reader delay uses publisher max-age alone, not the live - SUBSCRIBE zero-delay default. Sharing a range never relaxes a caller's - budget or lets one expired caller cancel another still-valid reader. - -Decided by the maintainer on 2026-10-20, when the quest was first started -([moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)): - -- **Split.** This quest replaces `requested_group`; removing `fetch_group` - is [fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md), so each - PR breaks one API once. That half needs head fills, which - [frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) provides. -- **Handler shape.** `Dynamic::requested_fill` yields a `track::Fill` naming - one bounded run: `start: Position`, exclusive end group, priority, the most - tolerant budget among its readers, and order. Each reader's cursor still - skips what is stale under its own `max_delay`, so sharing never relaxes it. - The handler calls `accept(sequence, info)` for each group it has and - `drop_groups(range)` for the rest. Dropping the `Fill` - names whatever is left as dropped, in the track's existing `drops`, but only - while a reader still wants it: once demand leaves, the fill is withdrawn like - a `group::Request` today and records nothing, so `drops` (durable) never - hides still-fetchable history from a later reader. - Rejected: writing through `Producer::create_group`, since a fetch-only copy - or an archive track has no live producer, and backfill must stay out of the - arrival walk. -- **What a miss is.** A sequence in a reader's window, below its cap, that - the cache doesn't cover and the feed will never deliver: below the feed's - start, or at or below its newest group and not awaited by the arrival - ledger (evicted or skipped). Nothing above that frontier is a miss, since it - arrives live, so a fill is bounded by construction. A relay records the - span a SUBSCRIBE_START skipped past its requested floor as dropped (today it - only settles the tail), or it would FETCH what upstream already gave up. -- **Fetch budget.** `Subscription::max_delay` becomes `Option`: - `None` means publisher max-age alone, the FETCH default above. This breaks - `Subscription` in every binding, with a `MIGRATE.md` entry in each affected - package. Rejected (recommended, but overruled): a `Duration::MAX` sentinel - that the wire saturates, which avoids the break but encodes the meaning in a - magic value. -- **Sessions.** A session serves a fill as it serves a group request today: - one FETCH per group, one in flight per fill, newest first for a descending - reader, withdrawing (not dropping) the fill when demand leaves. Once a - fetched group is stale under the fill's budget, the groups older than it - are too (a timed track's starts never rewind), so the fill stops fetching - them; in either order, newer groups still arrive. They are withdrawn, not - dropped, and each reader's cursor names them stale under its own budget, - since a later reader with a wider one may still fetch them. Only staleness - under publisher max-age, which binds every reader, goes in `drops`. - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) turns lite-07 - fills into range subscriptions, and - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) into one FETCH - per run. Until then, one SUBSCRIBE range can cost up to frontier minus floor - serialized upstream FETCHes, each bounded by the fill's budget and stopped - when demand leaves, the same cost per-group requests carry today; accepted - rather than blocking on those quests. -- **moq-c.** Whichever lands first decides it. If the generated C package - ([moq-dev/moq#6052](https://github.com/moq-dev/moq/pull/6052)) has landed, - `poll_requested_group` leaves the hand-written ABI with a `MIGRATE.md` entry - and the fill reaches C through the generated API. Otherwise the frozen crate - splits each fill into its per-group requests internally, keeping its ABI - unchanged, and maps `moq_subscription.max_delay_us` to `Some`: a frozen C - subscriber cannot ask for max-age alone, as today. Not a blocker. - -## Related - -- [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - removes `fetch_group` once fills land diff --git a/quest/m1/subscribe-ranges/fetch-range.md b/quest/m1/subscribe-ranges/fetch-range.md index f1caf9f25f7..9b1eff1871e 100644 --- a/quest/m1/subscribe-ranges/fetch-range.md +++ b/quest/m1/subscribe-ranges/fetch-range.md @@ -10,8 +10,8 @@ filling over range count and span as separate axes. ## Plan -Split out of [dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -by the maintainer on 2026-10-20 +Split out of dynamic range requests, which replaced `requested_group` with +`requested_fill`, by the maintainer on 2026-10-20 ([moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)), so each PR breaks one API once. @@ -34,5 +34,4 @@ breaks one API once. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `requested_fill` and subscriptions filling their misses - [Frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) - head fills for a range starting mid-group diff --git a/quest/m1/subscribe-ranges/frame-bounds.md b/quest/m1/subscribe-ranges/frame-bounds.md index 23c940af714..527e97b7413 100644 --- a/quest/m1/subscribe-ranges/frame-bounds.md +++ b/quest/m1/subscribe-ranges/frame-bounds.md @@ -31,7 +31,3 @@ shape in `js/net/src/lite/publisher.ts`). Regression test: a start at frame 5 and an end at frame 3 of a cached group, each read through `recv_group` and `ordered()`. - -## Related - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the per-subscriber ranges this extends to frames diff --git a/quest/m1/subscribe-ranges/ietf.md b/quest/m1/subscribe-ranges/ietf.md index 2ad5580291d..5f374123b7e 100644 --- a/quest/m1/subscribe-ranges/ietf.md +++ b/quest/m1/subscribe-ranges/ietf.md @@ -48,10 +48,6 @@ moq-transport has no time filter, so a downstream `Time(pts)` end is resolved against the relay's cache before it goes upstream as a group range, or refused when the cache cannot resolve it. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the range requests this answers - ## Related - [Pipelined first FETCH](/quest/m2/pipeline-requests/fetch.md) - sends TRACK_STATUS alongside the FETCH in adjacent code (`ietf/subscriber.rs`, `model/origin.rs`); an independent m2 optimization that adapts to these ranges and does not gate them diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 831ab0ee0c1..6bf3cfa9ec5 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -14,7 +14,7 @@ Mirror the Rust names, including `Time(pts)` ends and their refusal on an untime Decided in the 2026-10-05 audit: the producer half of [JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so `@moq/net`'s on-demand request surface takes range requests from the start instead of the -per-group `requested_group` shape Rust is replacing. That quest keeps only +per-group `requested_group` shape Rust replaced with `requested_fill`. That quest keeps only IETF FETCH dispatch. Resized from [M] to [L] for it. - Expose an owned request handle rather than a storage callback; dropping a diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index ea10d101460..664cd0bb4d1 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -53,6 +53,3 @@ the priority table in `doc/concept/moq-lite.md`: it now describes the field FETCH, answered through the model's ranges. Run `just drafts check` and `just test interop --all`. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the misses a relay fills upstream by range diff --git a/quest/m2/unbounded-groups/README.md b/quest/m2/unbounded-groups/README.md index b6fe76b0422..4dceddab351 100644 --- a/quest/m2/unbounded-groups/README.md +++ b/quest/m2/unbounded-groups/README.md @@ -65,9 +65,7 @@ Decided 2026-10-16 (maintainer interview): the relay cache docs, the json/flate docs, and the e2ee draft's 32 MiB interop cap. There is no new page. -The refill API is the -[Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -`track::Dynamic` range request. A handler serves a range by writing into a +The refill API is the `track::Dynamic` 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/m2/unbounded-groups/front-eviction.md b/quest/m2/unbounded-groups/front-eviction.md index 260c33b889c..8f55bffba9e 100644 --- a/quest/m2/unbounded-groups/front-eviction.md +++ b/quest/m2/unbounded-groups/front-eviction.md @@ -30,8 +30,7 @@ See the [epic](/quest/m2/unbounded-groups/README.md) for the decisions. takes it: hand its first demanded frame to that reader, or backpressure the refill on the reader's cursor. Test a `track::Dynamic` handler that writes more than the pool synchronously, so the reader still progresses. -- Refill goes through - [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md). Over +- Refill goes through a `track::Dynamic` 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, @@ -47,4 +46,3 @@ groups. A reader that used to see `GroupTooLarge` now refills or sees ## Required - [Segmented frames](/quest/m2/unbounded-groups/segmented-frames.md) - frame storage the cache can account for without whole-frame preallocation -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `track::Dynamic` receives frame-precise range requests for misses diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 6ce1689b31f..817fe496753 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Group requests come from fills + +The ABI is unchanged. A track's dynamic handler now also gets group requests for history a subscription covers that the cache lacks, not only for fetches; each run arrives one group at a time, the next once the previous is accepted, aborted, or freed. +`moq_group_request_abort` no longer delivers its error code: the waiting fetches fail as not found. +`moq_subscription.max_delay_us` always sets a budget, so a C subscriber cannot ask for the publisher's max age alone. + ### Anchors can name a time (#6074) `moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index eca047ebf96..dfd67282270 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,17 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Dynamic tracks serve fills + +`MoqTrackDynamic::requested_group` is now `requested_fill`, returning a `MoqFill`: `start_group`, `start_frame`, `end_group`, `priority`, `max_delay_us`, `order`, `next`, `accept(sequence)`, `drop_groups(start, end)`, `is_used`, and `unused`. +`MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `next()` names, and watch `is_used` or `unused` on the fill. +A refusal carries no application code: `drop_groups` fails the waiting fetches with `NotFound`. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. + +### `MoqSubscription::max_delay_us` is optional + +`max_delay_us` is now nullable, where null leaves the publisher's max age alone to bound delivery. It still defaults to 0. + ### Anchors can name a time (#6074) `MoqAnchor` gains `Time { timestamp_us }`, the group containing that presentation time, so exhaustive matches need an arm for it. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 8009db2c319..40e168c0218 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,20 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Dynamic` serves fills, not group requests + +`track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, with `priority()`, `max_delay()`, and `order()`. +Call `accept(sequence, info)` for each group you have (the first starts at `start().frame`) and `drop_groups(range)` for the rest; `next()` names the next group still owed, in the fill's order. +`group::Request` is removed, and its `demand()` with it: watch the fill's `is_used()` and `unused()` instead. +A fill also arrives for history a subscriber's ranges cover below the live feed that the cache lacks, not only for `fetch_group`. +Dropping a fill with groups unanswered names them dropped, where the live feed won't deliver them, while a reader still waits; a fill nobody waits on is withdrawn and records nothing. + +### `Subscription::max_delay` is optional + +`Subscription::max_delay` is an `Option`, where `None` leaves the publisher's max age alone to bound delivery. +The default stays `Some(Duration::ZERO)`, and `with_max_delay` takes `impl Into>`, so `with_max_delay(delay)` still compiles; reads and assignments of the field need the `Option`. +A lite SUBSCRIBE carries `None` as the largest delay the wire allows. + ### Anchors can name a time (#6074) `track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 2f6ffc91e7d..f71fccd4481 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup`, `startFrame`, `endGroup`, `priority`, `maxDelayUs`, `order`, `next()`, `accept(sequence:)`, `dropGroups(start:end:)`, `isUsed`, `unused()`), and iterating a `TrackDynamic` yields fills. +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed` or `unused()` on the fill. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +`Subscription.maxDelayUs` is a `UInt64?`, where nil leaves the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. From 93910359c1d07a7b7324abebaa4e34585435dab2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:56:47 -0700 Subject: [PATCH 08/49] wip: finished copies serve fills; misses end with the feed; lints Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/test.rs | 5 +- rs/moq-hls/src/export/archive_tests.rs | 7 +- rs/moq-net/src/ietf/publisher.rs | 5 +- rs/moq-net/src/ietf/subscriber.rs | 58 +++++++----- rs/moq-net/src/lite/publisher.rs | 5 +- rs/moq-net/src/lite/subscriber.rs | 20 +++-- rs/moq-net/src/model/resume.rs | 5 +- rs/moq-net/src/model/subscription.rs | 1 + rs/moq-net/src/model/track.rs | 88 +++++++++++++++---- rs/moq-net/src/model/track/fill.rs | 17 +++- rs/moq-net/src/model/track/miss.rs | 34 ++++--- rs/moq-net/tests/integration/fetch_cancel.rs | 9 +- rs/moq-net/tests/integration/fetch_caps.rs | 5 +- .../subscriber-max-age-ignored.patch | 2 +- 14 files changed, 183 insertions(+), 78 deletions(-) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 0931a3f7f68..9bcbd5593b0 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -1994,7 +1994,10 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { .await .expect("timed out waiting for fill") .unwrap(); - assert_eq!((request.start_group(), request.start_frame(), request.end_group()), (5, 0, 6)); + assert_eq!( + (request.start_group(), request.start_frame(), request.end_group()), + (5, 0, 6) + ); assert_eq!(request.priority(), 11); assert_eq!(request.next(), Some(5)); diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index cd224230316..1477477e0ee 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -775,7 +775,12 @@ async fn history_after_expiry(finished: bool) { .await .unwrap() .ordered(); - let retained = retained.next_group().await.unwrap(); + // Polled once, before the reader serves anything: a fill would answer group 0 from the + // archive, so only what the cache still holds is ready now. + let retained = match retained.poll_next_group(&kio::Waiter::noop()) { + std::task::Poll::Ready(group) => group.unwrap(), + std::task::Poll::Pending => None, + }; assert!( retained.is_none_or(|group| group.sequence > 0), "the initial timeline groups must really be evicted" diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4c939ab4dae..090cc4087ac 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -12818,7 +12818,10 @@ mod tests { } let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay, None, "no Max Age is the publisher's retention alone"); + assert_eq!( + subscription.max_delay, None, + "no Max Age is the publisher's retention alone" + ); let mut subscriber = producer.subscribe(subscription); for sequence in [0, 1] { let group = subscriber diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c1a3b7f5be7..2cab3e50123 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -497,6 +497,8 @@ struct Idle { dynamic: track::Dynamic, demand: track::Demand, timescale: Option, + /// The publisher ended the track, so a returning subscriber reads the copy as it is. + finished: bool, } struct TrackState { @@ -2675,8 +2677,8 @@ where } /// Subscribe upstream for `target`, until the subscription ends: `Some` once nobody - /// subscribes any more, with the copy kept for [`Self::linger`], `None` once the track - /// ended or failed. + /// subscribes any more or the track ended, with the copy kept for [`Self::linger`], + /// `None` once it failed. async fn subscribe_once( &mut self, broadcast_path: &Path<'_>, @@ -2752,13 +2754,8 @@ where let max_age_supported = self.peer_setup.get().await.max_age; let aggregate = target.subscription(); let priority = aggregate.as_ref().map(|s| s.priority).unwrap_or(0); - let max_age = max_age_supported.then(|| { - super::max_age::millis( - aggregate - .as_ref() - .map_or(Duration::MAX, |s| s.budget()), - ) - }); + let max_age = + max_age_supported.then(|| super::max_age::millis(aggregate.as_ref().map_or(Duration::MAX, |s| s.budget()))); let order = aggregate.map(|s| s.order).unwrap_or_default(); // Without an Established subscription, a relay includes the request it holds. let new_group = target @@ -3096,6 +3093,8 @@ where let mut fetch_done = fetching.is_none(); // Our grant stopped covering the track, which is aborted rather than kept lingering. let mut revoked = false; + // The publisher ended the track cleanly: the copy lingers for its history's fills. + let mut finished = false; let mut demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); @@ -3210,9 +3209,12 @@ where break true; } End::Fill(requested) => { - let fetch = self - .clone() - .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), requested, timescale); + let fetch = self.clone().serve_fill( + broadcast_path.to_owned(), + track_name.to_owned(), + requested, + timescale, + ); group_fetches.push(fetch); } End::Idle => { @@ -3257,6 +3259,7 @@ where track.set_tail_pending(false); // A no-op once an END_OF_TRACK declared the end. let _ = track.finish(); + finished = true; } Err(err) => { tracing::debug!(%err, "subscribe ended with error"); @@ -3279,7 +3282,15 @@ where if !idle { // The publisher already ended the request, so a FIN is all we owe it. stream.writer.finish().ok(); - return None; + // A reader may still miss history the publisher holds, such as a group whose + // fetch was in flight as the track ended, so a finished copy keeps serving fills. + return (finished && !aborted).then(|| Idle { + demand: track.demand(), + track, + dynamic, + timescale, + finished: true, + }); } // What the copy cached goes stale from here. Marked before the cancel, which waits // on the publisher: it stops serving as soon as the cancel lands, and a reader @@ -3297,12 +3308,13 @@ where track, dynamic, timescale, + finished: false, }) } /// Keep a copy nobody subscribes to, cache and all, for fetches and a subscriber /// returning soon: `Some` once one does, to subscribe again, `None` once nobody held - /// it through the linger, or the session ended. + /// it through the linger, or the session ended. A finished copy never subscribes again. async fn linger( &mut self, broadcast_path: &Path<'_>, @@ -3343,7 +3355,7 @@ where while let Poll::Ready(Ok(subscription)) = idle.track.poll_subscription_changed(waiter) { subscribed = subscription.is_some(); } - if subscribed { + if subscribed && !idle.finished { return Poll::Ready(Step::Subscribe); } // Nobody holds it: let it go after the linger. A reader waiting on a fetch holds @@ -3364,12 +3376,9 @@ where match step { Step::Fill(fill) => { - let fetch = self.clone().serve_fill( - broadcast_path.to_owned(), - track_name.to_owned(), - fill, - idle.timescale, - ); + let fetch = + self.clone() + .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), fill, idle.timescale); group_fetches.push(fetch); } Step::Subscribe => break true, @@ -3872,6 +3881,7 @@ where track, dynamic, timescale: ok.properties.timescale, + finished: false, }) } @@ -15259,7 +15269,11 @@ mod joining_fetch_tests { assert_eq!(group.read_frame().await.unwrap().unwrap().payload.as_ref(), b"x"); // The grace runs out with the head still being written, ending the subscription. - (&mut run.serving).await.unwrap(); + // The finished copy lingers for fills while the reader holds it. + while !run.subscriber.state.lock().subscribes.is_empty() { + moq_clock::sleep(Duration::from_millis(1)).await; + } + assert!(!run.serving.is_finished(), "{version}: the finished copy lingers"); data.close(crate::lite::test_transport::Close::Fin); assert!( diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index e493b3d2096..4fd47bc91e7 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -5432,8 +5432,9 @@ mod serve_group_test { track.finish().unwrap(); run.await.unwrap(); - // SUBSCRIBE_START at 0, SUBSCRIBE_END at 3, and a DROP for the skipped group 1. - assert_eq!(*log.writes.lock().unwrap(), [0, 3, 0, 1, 0, 1, 1, 3, 2, 3, 1, 1, 0]); + // SUBSCRIBE_START at 0, a DROP for the skipped group 1, which the end gives up on at + // once, and SUBSCRIBE_END at 3. + assert_eq!(*log.writes.lock().unwrap(), [0, 3, 0, 1, 0, 2, 3, 1, 1, 0, 1, 1, 3]); } /// A relay that holds only the first group of a backlog answers with the largest its diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 936196f4aa4..93091907882 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2127,7 +2127,11 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> (track::Producer, Vec>, Vec) { + ) -> ( + 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(); @@ -6923,10 +6927,10 @@ impl ServeLoop { // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that // still arrives is delivered, so they are not dropped. - if let Some(requested) = active.requested { - if let Ok(mut tail) = active.tail.write() { - tail.account(requested.group..start.group, serve.subscriber.runtime.now()); - } + if let Some(requested) = active.requested + && let Ok(mut tail) = active.tail.write() + { + tail.account(requested.group..start.group, serve.subscriber.runtime.now()); } } // The publisher will never send these groups, so they @@ -7087,11 +7091,11 @@ enum FetchRunState { }, Ingest { stream: Stream, - producer: crate::recv::Group, + // Boxed, as is the ingest, so the other states stay small. + producer: Box, // The accepted request's demand: fetches that joined it but have not yet // picked the group up from the cache. joined: kio::Producer, - // Boxed so the other states stay small; one allocation per fetch. ingest: Box, }, Done, @@ -7340,7 +7344,7 @@ impl kio::Task for FetchServeRun { self.state = FetchRunState::Ingest { stream, - producer: crate::recv::Group::new(producer), + producer: Box::new(crate::recv::Group::new(producer)), joined, ingest: Box::new(FrameIngest::new( &self.serve.subscriber, diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index d24e2a3d368..d7f5b1f614e 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1821,7 +1821,10 @@ mod test { let whole = [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]; assert_eq!(read_cached(&b.consume(), 0), whole); assert_eq!(read_cached(&logical.consume(), 0), whole); - assert!(dynamic.requested_fill_group().now_or_never().is_none(), "no second fetch"); + assert!( + dynamic.requested_fill_group().now_or_never().is_none(), + "no second fetch" + ); } /// A replaced route that only stalled lends the replacement its head the same way. diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 05f920a0a89..26e6fc6bba4 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -288,6 +288,7 @@ impl Subscription { } /// [`Self::max_delay`] as a budget: `None` waits as long as anything could. + // private: passthrough pub(crate) fn budget(&self) -> Duration { self.max_delay.unwrap_or(Duration::MAX) } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 50946f3cadf..e889e25748b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16,9 +16,9 @@ use crate::{Error, Result, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; +use super::Datagram; use super::delivered::Delivered; use super::ledger::{self, Ledger}; -use super::Datagram; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; @@ -32,9 +32,10 @@ use demand::Subscriptions; mod fill; mod miss; pub use fill::Fill; +use fill::FillState; + // private: capability gap pub(crate) use fill::{GroupFill, Outcome as FillOutcome}; -use fill::FillState; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -3168,6 +3169,7 @@ impl Dynamic { /// The next fill, which a test expects to name a single group. #[cfg(test)] + // private: test pub(crate) fn poll_requested_fill_group(&self, waiter: &kio::Waiter) -> Poll> { self.poll_requested_fill(waiter).map_ok(|fill| { assert_eq!(fill.end() - fill.start().group, 1, "a one-group fill"); @@ -3177,6 +3179,7 @@ impl Dynamic { /// [`Self::poll_requested_fill_group`], awaited. #[cfg(all(test, feature = "async"))] + // private: test pub(crate) async fn requested_fill_group(&self) -> Result { kio::wait(|waiter| self.poll_requested_fill_group(waiter)).await } @@ -4543,11 +4546,13 @@ impl kio::Task for Fetching { // An answered fill fails a joined fetch the cache cannot cover. The channel // closing without an answer means its handlers went away. - let (err, asked) = match result.outcome().poll(waiter, |outcome| match outcome.refused(sequence) { - Some(err) => Poll::Ready((err.clone(), outcome.asked())), - None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), - None => Poll::Pending, - }) { + let (err, asked) = match result + .outcome() + .poll(waiter, |outcome| match outcome.refused(sequence) { + Some(err) => Poll::Ready((err.clone(), outcome.asked())), + None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), + None => Poll::Pending, + }) { Poll::Ready(Ok(res)) => res, Poll::Ready(Err(_closed)) => (Error::NotFound, None), Poll::Pending => return Poll::Pending, @@ -5426,7 +5431,14 @@ impl Cursor { } let asks = { let state = self.state.read(); - misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up, waiter) + misses.judge( + &state, + (from, revision, cap), + || admitted(window, from, cap), + drift, + &mut given_up, + waiter, + ) }; misses.ask(&self.state, &fills, &asks, &asking, &mut given_up); loop { @@ -8755,7 +8767,10 @@ mod test { subscriber .update(Subscription::default().with_max_delay(Duration::from_millis(500))) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_millis(500))); + assert_eq!( + producer.subscription().unwrap().max_delay, + Some(Duration::from_millis(500)) + ); subscriber .update(Subscription::default().with_max_delay(Duration::ZERO)) @@ -13009,7 +13024,12 @@ mod test { live.start_at(1).unwrap(); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic + .requested_fill_group() + .await + .unwrap() + .accept(Info::timed()) + .unwrap(); for _ in 0..3 { head.write_frame(Timestamp::ZERO, b"x".as_slice()).unwrap(); } @@ -13158,7 +13178,12 @@ mod test { .unwrap(); live.finish().unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic + .requested_fill_group() + .await + .unwrap() + .accept(Info::timed()) + .unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 10_000])) .unwrap(); head.finish().unwrap(); @@ -14579,14 +14604,16 @@ mod test { fn write_filled(group: Result, millis: u64) { let mut group = group.unwrap(); - group.write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]).unwrap(); + group + .write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]) + .unwrap(); group.finish().unwrap(); } - fn drained_drops(subscriber: &mut Subscriber) -> Vec> { + fn drained_drops(subscriber: &mut Subscriber) -> Vec<(u64, u64)> { let mut dropped = Vec::new(); while let Poll::Ready(range) = subscriber.poll_drop(&kio::Waiter::noop()) { - dropped.push(range); + dropped.push((range.start, range.end)); } dropped } @@ -14619,8 +14646,11 @@ mod test { assert!(drained_drops(&mut subscriber).is_empty(), "group 1 may still arrive"); elapse(&producer, Duration::from_secs(31)); - assert_eq!(drained_drops(&mut subscriber), [1..2]); - assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "a refusal is not asked again"); + assert_eq!(drained_drops(&mut subscriber), [(1, 2)]); + assert!( + dynamic.poll_requested_fill(&waiter).is_pending(), + "a refusal is not asked again" + ); } /// A group the handler refused that still arrives live is delivered. @@ -14641,7 +14671,29 @@ mod test { }; assert_eq!(group.sequence, 1); elapse(&producer, Duration::from_secs(31)); - assert_eq!(drained_drops(&mut subscriber), [0..1]); + assert_eq!(drained_drops(&mut subscriber), [(0, 1)]); + } + + /// A refused miss is named dropped as soon as the feed ends, since nothing more arrives, + /// and the read ends with it. + #[test] + #[cfg(feature = "async")] + fn a_refused_miss_is_dropped_once_the_feed_ends() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + fill.drop_groups(0..2); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!( + drained_drops(&mut subscriber).is_empty(), + "the feed may still deliver them" + ); + producer.finish().unwrap(); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); + assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); } /// With no handler to fill it, a miss is named dropped once the feed gives up on it. @@ -14659,7 +14711,7 @@ mod test { assert_eq!(read, [2, 3]); assert!(drained_drops(&mut subscriber).is_empty()); elapse(&producer, Duration::from_secs(31)); - assert_eq!(drained_drops(&mut subscriber), [0..2]); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); } /// An ordered reader waits on a fill for its miss rather than skipping past it. diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index bd5bcf6d12f..1665c4defc1 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -60,6 +60,7 @@ pub(crate) struct Outcome { impl Outcome { /// Why `sequence` was refused, if it was. + // private: capability gap pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { self.refused .iter() @@ -206,7 +207,10 @@ impl FillState { .collect(); let gaps: Vec> = self.covers.gaps(&span).collect(); for (groups, id) in covered { - let pending = self.pending.get_mut(&id).expect("a covered sequence names a pending fill"); + let pending = self + .pending + .get_mut(&id) + .expect("a covered sequence names a pending fill"); // Widened only while queued: once taken, the handler holds what it was asked. if pending.queued { pending.priority = pending.priority.max(ask.priority); @@ -666,54 +670,64 @@ pub(crate) struct GroupFill { impl GroupFill { /// The group sequence to fetch. + // private: capability gap pub(crate) fn sequence(&self) -> u64 { self.sequence } /// The delivery priority the readers asked for. + // private: capability gap pub(crate) fn priority(&self) -> u8 { self.inner.priority } /// The widest budget among the readers, for a handler that forwards it. + // private: capability gap pub(crate) fn max_delay(&self) -> Option { self.inner.max_delay } /// The first frame of the group the readers want; 0 is the whole group. + // private: capability gap pub(crate) fn frame_start(&self) -> u64 { self.frame_start } /// Poll until no reader waits on the fill. + // private: capability gap pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { self.inner.result.poll_unused(waiter).map(|_| ()) } /// Whether a reader still waits on the fill. #[cfg(test)] + // private: capability gap pub(crate) fn is_used(&self) -> bool { self.inner.result.is_used() } /// The fill's readers, for a session keeping a group alive while any still wait. + // private: capability gap pub(crate) fn joined(&self) -> kio::Producer { self.inner.result.clone() } /// The track the fill serves. #[cfg_attr(not(feature = "async"), allow(dead_code))] + // private: capability gap pub(crate) fn track(&self) -> kio::Producer { self.inner.state.clone() } /// Insert the group into the cache, as [`Fill::accept`]. + // private: capability gap pub(crate) fn accept(self, info: impl Into>) -> Result { self.inner.accept(self.sequence, info.into()) } /// Refuse the group with the source's answer. A miss names it dropped, and /// [`Error::Old`] every older group of the fill too. + // private: capability gap pub(crate) fn reject(self, err: Error) { self.inner.reject(self.sequence, err); } @@ -721,6 +735,7 @@ impl GroupFill { /// Declare the track's exclusive final sequence, as the source answering this group /// reported it. A no-op once the track declared an end, or holds a later group. #[cfg_attr(not(feature = "async"), allow(dead_code))] + // private: capability gap pub(crate) fn finish_track_at(&self, final_sequence: u64) { if let Ok(mut state) = TrackState::modify(&self.inner.state) && state.end.is_none() diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index e4d373d5a23..881a7483290 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -33,12 +33,15 @@ pub(super) struct Asking { pub order: Order, } +/// What a probe judges against: the span read, the feed's newest group, where the feed +/// and readers start, and the drops revision. +type JudgedAt = (u64, u64, Option, Option, Option, Option, u64); + /// The misses of one cursor, judged as what it reads or the feed moves. #[derive(Default)] pub(super) struct Misses { - /// What the last probe judged against: the span read, the feed's newest group, and - /// where the feed starts and readers start. A probe that sees the same skips judging. - at: Option<(u64, u64, Option, Option, Option, Option, u64)>, + /// What the last probe judged against. A probe that sees the same skips judging. + at: Option, /// The sequences judged already, within what the reader reads up to the newest group. judged: RangeSet, /// Holes the live feed may still deliver, judged again on every probe. @@ -240,7 +243,10 @@ impl Misses { let judged: RangeSet = self .judged .iter() - .flat_map(|run| span.overlapping(run).map(move |s| s.start.max(run.start)..s.end.min(run.end))) + .flat_map(|run| { + span.overlapping(run) + .map(move |s| s.start.max(run.start)..s.end.min(run.end)) + }) .collect(); let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); for run in std::mem::take(&mut self.deferred).iter() { @@ -406,7 +412,8 @@ impl TrackState { /// deliver and the gaps it still waits for, with when each stops being waited for. /// /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its - /// grace gave up on. A declared start is no promise: a group below it may still arrive. + /// grace gave up on, or anything once the feed is over. A declared start is no promise: + /// a group below it may still arrive. #[allow(clippy::type_complexity)] pub(super) fn unfed( &self, @@ -415,15 +422,18 @@ impl TrackState { ) -> (RangeSet, Vec<(ops::Range, Option)>) { let mut unfed = RangeSet::new(); let mut awaited = Vec::new(); + // Nothing more arrives after an abort, a seal, or a settled end. + if self.abort.is_some() || self.sealed || (self.end.is_some() && !self.tail_pending) { + unfed.insert(hole); + return (unfed, awaited); + } let mut from = hole.start; let grace = self.arrivals.grace(); - let mut gap = |groups: ops::Range, unfed: &mut RangeSet| match self - .arrivals - .folds_at(groups.start, grace) - { - Some(at) if at <= now => unfed.insert(groups), - folds => awaited.push((groups, folds)), - }; + let mut gap = + |groups: ops::Range, unfed: &mut RangeSet| match self.arrivals.folds_at(groups.start, grace) { + Some(at) if at <= now => unfed.insert(groups), + folds => awaited.push((groups, folds)), + }; for arrived in self.arrivals.accounted(from..hole.end) { if from < arrived.start { gap(from..arrived.start, &mut unfed); diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index a3df1220d47..1609c164f85 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -75,12 +75,9 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage match stage { Stage::Unanswered => { drop(waiting); - moq_clock::timeout( - Duration::from_secs(5), - kio::wait(|waiter| request.poll_unused(waiter)), - ) - .await - .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); + moq_clock::timeout(Duration::from_secs(5), kio::wait(|waiter| request.poll_unused(waiter))) + .await + .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); } Stage::MidResponse => { let mut group = request.accept(0, timed()).unwrap(); diff --git a/rs/moq-net/tests/integration/fetch_caps.rs b/rs/moq-net/tests/integration/fetch_caps.rs index fbf26cdec4c..8d9050837ad 100644 --- a/rs/moq-net/tests/integration/fetch_caps.rs +++ b/rs/moq-net/tests/integration/fetch_caps.rs @@ -83,10 +83,7 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { matches!(err, moq_net::Error::Session(moq_net::SessionError::TooManyRequests)), "{version}: {err}" ); - assert!( - request_b.is_used(), - "{version}: the other peer still holds its request" - ); + assert!(request_b.is_used(), "{version}: the other peer still holds its request"); let mut group = request_b.accept(8, info).unwrap(); group.write_frame(None, &b"other peer"[..]).unwrap(); group.finish().unwrap(); diff --git a/test/drill/mutations/subscriber-max-age-ignored.patch b/test/drill/mutations/subscriber-max-age-ignored.patch index 40d5e93dbe3..5119debc7f1 100644 --- a/test/drill/mutations/subscriber-max-age-ignored.patch +++ b/test/drill/mutations/subscriber-max-age-ignored.patch @@ -9,7 +9,7 @@ // a join, which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(order); - if let Some(max_age) = msg.max_age { -- subscription.max_delay = Duration::from_millis(max_age); +- subscription.max_delay = Some(Duration::from_millis(max_age)); - } // A range that resolves without the live edge has a fixed start, which is no // join: bound the subscription before subscribing so the ratio never judges it. From 2b2313bd075efe5281b7b0ea1c309acc7ee12f57 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:40:28 -0700 Subject: [PATCH 09/49] fix(interop): point the Go client's MaxDelay at a duration Co-Authored-By: Claude Opus 5.5 --- test/interop/clients/go/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index 1763efd09df..a532aebbc2e 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -208,7 +208,8 @@ func subscribe(ctx context.Context, url, broadcast string, timeout time.Duration break } - media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: maxDelay}}) + delay := maxDelay + media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: &delay}}) if err != nil { return err } From 6dc31358f5e49aaa1e246b56ea8c8db1a3c6d682 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:37:54 -0700 Subject: [PATCH 10/49] fix(net): settle wide fills by run, not group by group A pre-lite-05 relay held a fill for the live feed by expanding it into one held request per group, so a peer that made a miss wide (a feed starting far past a reader's floor) made the serve loop walk every sequence. A held fill now waits on one group at a time and settles what the feed gave up by run, judged against the cache and ledger. A reader whose fill ended unanswered likewise settles the run instead of each sequence. A declared end ahead of the live edge no longer ends the feed for misses: until the edge reaches it, a group below it may still arrive. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 170 ++++++++++++++++++++--------- rs/moq-net/src/model/track.rs | 80 ++++++++++++++ rs/moq-net/src/model/track/fill.rs | 8 ++ rs/moq-net/src/model/track/miss.rs | 17 +-- 4 files changed, 213 insertions(+), 62 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 7af48db004e..6d6c24bf9a6 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1989,12 +1989,12 @@ mod tests { const VERSION: Version = Version::Lite05; /// A pre-lite-05 relay's track, untimed, with a fetch of `sequence` queued on it. - fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::GroupFill) { + 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 fetching = track.consume().fetch_group(sequence, None); - let req = dynamic.requested_fill_group().now_or_never().expect("queued").unwrap(); + let req = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); (track, fetching, req) } @@ -2038,7 +2038,7 @@ mod tests { let (track, fetching, req) = feed_fetch(100); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 1, "held while its caller waits"); @@ -2059,7 +2059,7 @@ mod tests { arrive(&track, &tail, 0, start); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); arrive(&track, &tail, 2, start); let late = start + crate::tail::GRACE / 2; fetches.poll(&sub, late, &kio::Waiter::noop()); @@ -2088,7 +2088,7 @@ mod tests { arrive(&track, &tail, 2, start); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start + crate::tail::GRACE, &kio::Waiter::noop()); assert!(fetches.held.is_empty()); assert!(matches!(fetching.now_or_never(), Some(Err(Error::NotFound)))); @@ -2140,7 +2140,7 @@ mod tests { let now = crate::time::Instant::now(); let clock = crate::time::Deadlines::new(now); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, now, &kio::Waiter::noop()); assert_eq!( fetches.held.len(), @@ -2166,7 +2166,7 @@ mod tests { ) -> ( track::Producer, Vec>, - Vec, + Vec, ) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); @@ -2175,7 +2175,7 @@ mod tests { let mut requests = Vec::new(); for sequence in sequences { callers.push(track.consume().fetch_group(sequence, None)); - requests.push(dynamic.requested_fill_group().now_or_never().expect("queued").unwrap()); + requests.push(dynamic.requested_fill().now_or_never().expect("queued").unwrap()); } (track, callers, requests) } @@ -2197,7 +2197,7 @@ mod tests { let wakes = Arc::new(Wakes::default()); let waiter = kio::Waiter::new(std::task::Waker::from(wakes.clone())); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &waiter); wakes.0.store(0, atomic::Ordering::Relaxed); drop(caller); @@ -2243,7 +2243,7 @@ mod tests { let mut fetches = FeedFetches::new(&clock); let (track, _callers, requests) = feed_batch(0..32); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2264,7 +2264,7 @@ mod tests { let (track, _callers, requests) = feed_batch([1, 3, 5, 1000]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } arrive(&track, &tail, 0, start); arrive(&track, &tail, 2, start); @@ -2292,7 +2292,7 @@ mod tests { let (mut track, _callers, requests) = feed_batch([1, 3, 5, 1000]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2321,7 +2321,7 @@ mod tests { let (track, mut callers, requests) = feed_batch([1, 3, 5]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2334,6 +2334,44 @@ mod tests { assert!(matches!(callers.remove(0).now_or_never(), Some(Err(Error::NotFound)))); } + /// A peer that starts its feed far past a reader's floor makes the reader's miss wide. + /// Holding it settles what the feed gave up by run, in one poll, rather than holding or + /// refusing its groups one at a time. + #[moq_clock::test] + async fn a_wide_feed_fill_settles_by_run() { + const WIDE: u64 = 1 << 40; + let start = crate::time::Instant::now(); + let clock = crate::time::Deadlines::new(start); + let tail = kio::Producer::new(Tail::default()); + 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 mut reader = track.subscribe( + Subscription::default() + .with_groups(0..) + .with_max_delay(Duration::from_secs(30)), + ); + track.start_at(WIDE).unwrap(); + arrive(&track, &tail, WIDE, start); + let waiter = kio::Waiter::noop(); + // Reading judges the reader's misses. + let _ = reader.poll_recv_group(&waiter); + let fill = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); + assert_eq!((fill.start().group, fill.end()), (0, WIDE)); + + let mut fetches = FeedFetches::new(&clock); + fetches.push(fill, &track.consume()); + fetches.poll(&sub, start, &waiter); + assert!(fetches.held.is_empty(), "the feed starts past every group of it"); + assert_eq!(fetches.polled, 1); + assert!(reader.poll_recv_group(&waiter).is_pending()); + assert!( + dynamic.requested_fill().now_or_never().is_none(), + "a refusal is not asked again" + ); + } + #[moq_clock::test] async fn the_feed_track_backstop_refuses_the_next_group() { let start = crate::time::Instant::now(); @@ -2344,7 +2382,7 @@ mod tests { let mut fetches = FeedFetches::new(&clock); fetches.max = 2; for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 2); @@ -2364,16 +2402,16 @@ mod tests { let (track, caller, req) = feed_fetch(1); let dynamic = track.dynamic(); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); drop(caller); let next = track.consume().fetch_group(1, None); let req = dynamic - .requested_fill_group() + .requested_fill() .now_or_never() .expect("replacement queued") .unwrap(); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 1); arrive(&track, &tail, 1, start); @@ -2471,9 +2509,9 @@ mod tests { let mut fetches = FeedFetches::new(&clock); for req in requests { if indexed { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } else { - scan.held.push((req, track.consume())); + scan.held.push((req.group(req.start().group), track.consume())); } } if indexed { @@ -6540,10 +6578,15 @@ enum ServeMode { }, } -/// Fetches a pre-lite-05 upstream has no FETCH for, held while the live subscription may -/// still deliver the group. A group the feed delivers lands in the cache, where a waiting -/// fetch reads it once its request is dropped. +/// Fills a pre-lite-05 upstream has no FETCH for, held while the live subscription may +/// still deliver their groups. A group the feed delivers lands in the cache, where the fill +/// answers it. +/// +/// A fill waits on one group at a time, the next it still owes. Each verdict on that group +/// also settles, by run, every group of the fill the feed gave up on, so a fill a peer +/// made wide costs what the cache and ledger hold rather than its width. struct FeedFetches { + /// Each fill, by the group it waits on. held: BTreeMap, ready: kio::Shared>, arrivals: Option, @@ -6556,8 +6599,10 @@ struct FeedFetches { } struct HeldFetch { - req: track::GroupFill, + fill: track::Fill, waiter: kio::Waiter, + /// Not judged yet: what the feed already gave up on is settled at once. + fresh: bool, } struct FetchWake { @@ -6587,16 +6632,10 @@ impl FeedFetches { } } - /// Hold every group of `fill` for the live feed. - fn push_fill(&mut self, fill: track::Fill, feed: &track::Consumer) { - for sequence in fill.start().group..fill.end() { - self.push(fill.group(sequence), feed.clone()); - } - } - - fn push(&mut self, req: track::GroupFill, feed: track::Consumer) { + /// Hold `fill` for the live feed. + fn push(&mut self, fill: track::Fill, feed: &track::Consumer) { let Some(arrivals) = feed.arrivals() else { - req.reject(Error::NotFound); + fill.drop_groups(fill.start().group..fill.end()); return; }; if let Some(previous) = &self.arrivals { @@ -6604,21 +6643,28 @@ impl FeedFetches { } else { self.arrivals = Some(arrivals); } - let sequence = req.sequence(); + if self.held.len() >= self.max { + fill.refuse(Error::TooManyRequests); + return; + } + self.hold(fill, true); + } + + /// Wait on the next group `fill` still owes, if any. + fn hold(&mut self, fill: track::Fill, fresh: bool) { + let Some(sequence) = fill.next() else { + return; + }; // The last caller may leave and a replacement attempt arrive before the // queued cancellation gets polled. The old attempt owns no callers now. if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.req.poll_unused(&kio::Waiter::noop()).is_ready()); - } - if self.held.len() >= self.max { - req.reject(Error::TooManyRequests); - return; + debug_assert!(previous.fill.poll_unused(&kio::Waiter::noop()).is_ready()); } let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { sequence, ready: self.ready.clone(), }))); - self.held.insert(sequence, HeldFetch { req, waiter }); + self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); self.ready.lock().insert(sequence); } @@ -6630,7 +6676,7 @@ impl FeedFetches { ) { let Sub::Active(_) = sub else { for (_, held) in std::mem::take(&mut self.held) { - held.req.reject(Error::NotFound); + held.fill.drop_groups(held.fill.start().group..held.fill.end()); } let mut ready = self.ready.lock(); if !ready.is_empty() { @@ -6671,37 +6717,53 @@ impl FeedFetches { Poll::Pending => BTreeSet::new(), }; let _ = self.ready.poll(waiter, |_| Poll::<()>::Pending); + let mut unused = Vec::new(); let mut settled = Vec::new(); for sequence in ready { - let Some(held) = self.held.remove(&sequence) else { + let Some(mut held) = self.held.remove(&sequence) else { continue; }; #[cfg(test)] { self.polled += 1; } - if held.req.poll_unused(&held.waiter).is_ready() { - settled.push((held.req, None)); + if held.fill.poll_unused(&held.waiter).is_ready() { + unused.push(held.fill); continue; } - match arrivals.poll_group(sequence, now, &held.waiter) { - Poll::Ready(Some(_)) => settled.push((held.req, None)), - Poll::Ready(None) => settled.push((held.req, Some(Error::NotFound))), - Poll::Pending => { - self.held.insert(sequence, held); - } + let verdict = arrivals.poll_group(sequence, now, &held.waiter); + if verdict.is_pending() && !held.fresh { + self.held.insert(sequence, held); + continue; } + let gone = arrivals.given_up(held.fill.start().group..held.fill.end(), now); + if verdict.is_pending() && gone.is_empty() { + held.fresh = false; + self.held.insert(sequence, held); + continue; + } + settled.push((held.fill, verdict.is_ready().then_some(sequence), gone)); } let fold = arrivals .folds() .filter_map(|(gap, at)| self.held.range(gap).next().map(|_| at)) .min(); - // Requests own track producers: releasing one takes the track lock too. + // Fills own track producers: answering or releasing one takes the track lock too. drop(arrivals); - for (req, err) in settled { - if let Some(err) = err { - req.reject(err); + drop(unused); + for (fill, decided, gone) in settled { + for groups in gone.iter() { + fill.drop_groups(groups.clone()); + } + // The group waited on is settled, so the fill moves past it: one landed without + // the frames the fill starts from is no answer, and the cache serves what it has. + if let Some(sequence) = decided + && fill.next() == Some(sequence) + { + fill.drop_groups(sequence..sequence + 1); } + // Waits on the next group it owes, judged on the poll its readiness wakes. + self.hold(fill, false); } self.fold.set(fold); let _ = self.fold.poll(waiter); @@ -6821,7 +6883,7 @@ impl ServeLoop { } else { // No FETCH on the wire: wait for the live feed, which a // subscription arriving alongside may still be opening. - self.feed_fetches.push_fill(fill, &self.serving.consume()); + self.feed_fetches.push(fill, &self.serving.consume()); } continue; } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 2b82af44017..cf080acdc21 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -3854,6 +3854,48 @@ impl ArrivalRead<'_> { res => res, } } + + /// The groups of `groups` [`Self::poll_group`] gives up on, judged by run, so a span a + /// peer made wide costs what the cache and ledger hold rather than its width. + // Legacy wires lack FETCH, so a held fill settles what the feed gave up in bulk. + // private: capability gap + pub(crate) fn given_up(&self, groups: ops::Range, now: crate::time::Instant) -> RangeSet { + let state = &*self.state; + let mut gone = RangeSet::new(); + let mut give_up = |groups: ops::Range| { + if !groups.is_empty() { + gone.insert(groups); + } + }; + let closed = self.state.is_closed(); + let start = state.start_sequence.unwrap_or(0); + let fin = state.final_sequence().unwrap_or(u64::MAX); + for hole in miss::holes(state, groups) { + if closed { + give_up(hole); + continue; + } + give_up(hole.start..hole.end.min(start)); + give_up(hole.start.max(fin)..hole.end); + for dropped in state.drops.overlapping(&hole) { + give_up(dropped.start.max(hole.start)..dropped.end.min(hole.end)); + } + // Every group of a gap folds at once: its grace runs from the run above it. + let folded = |gap: u64| state.arrivals.folds_at(gap, self.grace).is_some_and(|at| at <= now); + let mut from = hole.start; + for arrived in state.arrivals.accounted(hole.clone()) { + if from < arrived.start && folded(from) { + give_up(from..arrived.start); + } + from = arrived.end; + give_up(arrived); + } + if from < hole.end && folded(from) { + give_up(from..hole.end); + } + } + gone + } } /// A lazy handle to a single track within a broadcast. @@ -15022,6 +15064,24 @@ mod test { assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); } + /// An end declared ahead of the live edge does not end the feed yet: a group missing + /// below the edge may still arrive, so it is not named dropped. + #[test] + #[cfg(feature = "async")] + fn an_end_ahead_of_the_edge_keeps_a_late_group() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_groups(0..)); + for sequence in [0, 1, 2, 4, 5] { + write_group(&producer, sequence); + } + producer.finish_at(Position::group(10)).unwrap(); + assert_eq!(drain_groups(&mut subscriber), [0, 1, 2, 4, 5]); + assert!(drained_drops(&mut subscriber).is_empty(), "group 3 may still arrive"); + write_group(&producer, 3); + assert_eq!(drain_groups(&mut subscriber), [3]); + assert!(drained_drops(&mut subscriber).is_empty()); + } + /// With no handler to fill it, a miss is named dropped once the feed gives up on it. #[test] #[cfg(feature = "async")] @@ -15040,6 +15100,26 @@ mod test { assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); } + /// A fill whose handler leaves before taking it settles by run: a peer that starts its + /// feed far past the reader's floor makes the miss wide, and walking it group by group + /// would never finish. + #[test] + #[cfg(feature = "async")] + 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 mut subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(WIDE).unwrap(); + write_group(&producer, WIDE); + assert_eq!(drain_groups(&mut subscriber), [WIDE]); + drop(dynamic); + assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + assert!(drained_drops(&mut subscriber).is_empty(), "the feed may still deliver them"); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); + } + /// An ordered reader waits on a fill for its miss rather than skipping past it. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 1665c4defc1..91566c1fa9b 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -491,6 +491,14 @@ impl Fill { } } + /// Refuse every group still owed with `err`, which says nothing about whether the source + /// has them, so nothing is recorded dropped. + // private: capability gap + pub(crate) fn refuse(&self, err: Error) { + let unresolved = self.inner.unresolved(self.inner.start.group..self.inner.end); + self.inner.refuse(&unresolved, err); + } + /// A handle on group `sequence` alone, for a session serving the fill one group at a time. // private: capability gap pub(crate) fn group(&self, sequence: u64) -> GroupFill { diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 881a7483290..a14a4bf6a06 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -175,13 +175,13 @@ impl Misses { for (groups, answer) in answers { self.wanted.remove(groups.clone()); match answer { + // What the cache holds is filled, and the rest settled by run: a fill that + // ended unanswered may span far more groups than were ever cached. Ok(()) => { - for sequence in groups { - match state.lookup.get(&sequence).is_some_and(|slot| !slot.is_aborted()) { - true => { - self.filled.insert(sequence); - } - false => self.settle(&state, sequence..sequence + 1, given_up), + self.settle(&state, groups.clone(), given_up); + for (&sequence, slot) in state.lookup.range(groups) { + if !slot.is_aborted() { + self.filled.insert(sequence); } } } @@ -422,8 +422,9 @@ impl TrackState { ) -> (RangeSet, Vec<(ops::Range, Option)>) { let mut unfed = RangeSet::new(); let mut awaited = Vec::new(); - // Nothing more arrives after an abort, a seal, or a settled end. - if self.abort.is_some() || self.sealed || (self.end.is_some() && !self.tail_pending) { + // Nothing more arrives after an abort, a seal, or once the live edge reached a + // declared end with no tail owed: until then, a group below the end may still arrive. + if self.abort.is_some() || self.is_complete() { unfed.insert(hole); return (unfed, awaited); } From 3e5ca7d58778a83e2a6e0775123ae23cb34029b6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:46:01 -0700 Subject: [PATCH 11/49] perf(net): bench fills over readers and runs, and keep a miss run flat `track_fill` sweeps readers missing the same runs below the feed's start, each run asked as one fill they share. It showed each run costing more the more runs a reader held: judging a run's staleness looked up its successor, and with several ranges that walked every range past it, all empty. The search now leapfrogs the ranges and the cache, so a range holding nothing costs one lookup. Per run, 1024 runs now cost about 1.4x what 64 do, down from about 5x. Judging a hole also reads only the claims that overlap it. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/track.rs | 86 +++++++++++++++++++++++++++++- rs/moq-net/src/model/track.rs | 71 +++++++++++++++++++++--- rs/moq-net/src/model/track/miss.rs | 7 +-- 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index f471d193563..ecd53bc9813 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -70,6 +70,11 @@ //! the readers: an unsettled time end walks on from where it stopped, so a rescan of the //! groups behind the gap on every read shows up as a slope over them. //! +//! `track_fill` has readers miss the same runs below where the feed starts, each asked of the +//! track's handler as a fill, swept over the readers and the runs: readers share each +//! fill, so asking, dropping, and settling must cost each reader the same per run, and a +//! cost that grows with the audience or the runs beyond that shows up as a slope. +//! //! Run with `cargo bench -p moq-net --bench track`. // Custom timings measure the real time the work took, not the ambient clock. @@ -1343,6 +1348,84 @@ fn bench_time_end(c: &mut Criterion) { group.finish(); } +/// Readers missing the same runs of one track below where its feed starts, and the handler +/// they ask; see [`bench_fill`]. +struct Fills { + _broadcast: broadcast::Producer, + _track: track::Producer, + dynamic: track::Dynamic, + readers: Vec, + runs: u64, +} + +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(); + // The feed starts past every run, so each is a miss asked at once. + let start = runs * 2; + track.start_at(start).unwrap(); + let mut group = track.create_group(group::Info { sequence: start }).unwrap(); + group + .write_frame(Timestamp::from_millis(start).unwrap(), Bytes::from_static(b"x")) + .unwrap(); + group.finish().unwrap(); + // One range per run, with a sequence between each that no reader asks for. + let ranges = (0..runs) + .map(|run| track::Range::groups(run * 2..run * 2 + 1)) + .chain([track::Range::groups(start..)]); + let subscription = track::Subscription::default() + .with_ranges(ranges) + .with_max_delay(AGED_BUDGET); + let readers = (0..readers).map(|_| track.subscribe(subscription.clone())).collect(); + Self { + _broadcast: broadcast, + _track: track, + dynamic, + readers, + runs, + } + } + + /// Every reader reads what is cached and asks for its misses, the handler drops each + /// fill they share, and every reader settles the drops. + fn run(&mut self) { + let waiter = kio::Waiter::noop(); + for reader in &mut self.readers { + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + } + } + let mut fills = 0; + while let Poll::Ready(Ok(fill)) = self.dynamic.poll_requested_fill(&waiter) { + fill.drop_groups(fill.start().group..fill.end()); + fills += 1; + } + assert_eq!(fills, self.runs, "readers share each run's fill"); + for reader in &mut self.readers { + assert!(reader.poll_recv_group(&waiter).is_pending()); + } + } +} + +/// Readers missing the same runs below the feed's start, each asked as one fill they share, +/// swept over the readers and the runs. Each reader must pay the same per run, so a cost +/// that grows with the audience or the runs shows up as a slope. +fn bench_fill(c: &mut Criterion) { + let mut group = c.benchmark_group("track_fill"); + group.sample_size(10); + for readers in [1usize, 8, 64] { + for runs in [1u64, 64, 1024] { + group.throughput(Throughput::Elements(runs * readers as u64)); + group.bench_function(BenchmarkId::new(format!("{readers}r"), runs), |b| { + b.iter_batched_ref(|| Fills::new(readers, runs), Fills::run, BatchSize::PerIteration); + }); + } + } + group.finish(); +} + criterion_group!( benches, bench_range_widening, @@ -1365,6 +1448,7 @@ criterion_group!( bench_gap_flush, bench_wall, bench_empty_run, - bench_time_end + bench_time_end, + bench_fill ); criterion_main!(benches); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index cf080acdc21..815493a2c64 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1103,8 +1103,9 @@ impl TrackState { } /// The first cached group at or above `from` that `servable` covers for which `f` - /// answers, lowest first or, with `rev`, highest first: one lookup for a single run, - /// one per run otherwise. + /// answers, lowest first or, with `rev`, highest first: one lookup for a single run. + /// Several runs leapfrog the cache, so a run holding nothing (a reader's misses) costs + /// one lookup rather than a walk over every run past it. fn find_servable<'a, R>( &'a self, servable: &Servable, @@ -1127,13 +1128,31 @@ impl TrackState { scan(self.lookup.range((Bound::Included(from), end))) } Servable::Runs(runs) => { - let mut runs = runs.overlapping(from..=u64::MAX); - let each = - |run: &ops::RangeInclusive| scan(self.lookup.range((*run.start()).max(from)..=*run.end())); - match rev { - true => runs.rev().find_map(each), - false => runs.find_map(each), + // What is left to search, shrinking from the end the search starts at. + let (mut lo, mut hi) = (from, u64::MAX); + while lo <= hi { + let mut left = runs.overlapping(lo..=hi); + let run = match rev { + true => left.next_back(), + false => left.next(), + }?; + let (start, end) = ((*run.start()).max(lo), (*run.end()).min(hi)); + // The cache from the run on, toward the far end: one lookup either finds + // the run's groups or the next cached group past a run holding none. + let leap = match rev { + true => leap(self.lookup.range(lo..=end).rev(), start..=end, &mut f), + false => leap(self.lookup.range(start..=hi), start..=end, &mut f), + }; + match leap { + Leap::Found(found) => return Some(found), + Leap::Empty => return None, + Leap::Past(sequence) if rev => hi = sequence, + Leap::Past(sequence) => lo = sequence, + Leap::Exhausted if rev => hi = start.checked_sub(1)?, + Leap::Exhausted => lo = end.checked_add(1)?, + } } + None } } } @@ -4976,6 +4995,42 @@ impl Published { } } +/// What one run's lookup found, for [`TrackState::find_servable`] leapfrogging the cache. +enum Leap { + /// A group in the run that the search answered. + Found(R), + /// The run's groups, none answering. + Exhausted, + /// Nothing in the run: the nearest cached group past it. + Past(u64), + /// Nothing cached from the run on. + Empty, +} + +/// Search `cached`, ordered from the run toward the far end, for a group in `run` that `f` +/// answers. +fn leap<'a, R>( + mut cached: impl Iterator, + run: ops::RangeInclusive, + f: &mut impl FnMut(&'a Slot) -> Option, +) -> Leap { + let Some((&first, slot)) = cached.next() else { + return Leap::Empty; + }; + if !run.contains(&first) { + return Leap::Past(first); + } + let mut within = std::iter::once(slot).chain( + cached + .take_while(|(sequence, _)| run.contains(*sequence)) + .map(|(_, slot)| slot), + ); + match within.find_map(f) { + Some(found) => Leap::Found(found), + None => Leap::Exhausted, + } +} + /// The groups a reader can be served, the only ones its drift is measured against: one it /// never reads, past its cap or in a gap between its ranges, never stands in for one it does. #[derive(Clone, PartialEq)] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a14a4bf6a06..2f90e768387 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -367,13 +367,14 @@ impl Misses { /// The part of `hole` nobody has a verdict on yet. fn unclaimed(&self, state: &TrackState, hole: ops::Range) -> Vec> { let mut left = RangeSet::new(); - left.insert(hole); + left.insert(hole.clone()); + // Only what overlaps the hole, so judging every run costs each one the same. for claimed in [&state.drops, &self.given_up, &self.wanted] { - for run in claimed.iter() { + for run in claimed.overlapping(&hole) { left.remove(run.clone()); } } - for sequence in &self.filled { + for sequence in self.filled.range(hole) { left.remove(*sequence..*sequence + 1); } left.iter().cloned().collect() From 5e09300ec9c44b8bb9ed4a365d9fd91252b9f04b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:47:09 -0700 Subject: [PATCH 12/49] style(net): rustfmt the fill tests Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 6 +----- rs/moq-net/src/model/track.rs | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 6d6c24bf9a6..bcc2f384ed0 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2163,11 +2163,7 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> ( - track::Producer, - Vec>, - Vec, - ) { + ) -> (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(); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 815493a2c64..7453f999707 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15170,7 +15170,10 @@ mod test { assert_eq!(drain_groups(&mut subscriber), [WIDE]); drop(dynamic); assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); - assert!(drained_drops(&mut subscriber).is_empty(), "the feed may still deliver them"); + assert!( + drained_drops(&mut subscriber).is_empty(), + "the feed may still deliver them" + ); elapse(&producer, Duration::from_secs(31)); assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); } From 871214839837c943423a3971668644fcd4249d58 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:12:42 -0700 Subject: [PATCH 13/49] fix(net): a descending reader turns a queued fill newest first Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 24 ++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7453f999707..f2d46cfe254 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15178,6 +15178,30 @@ mod test { assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); } + /// A descending reader joining a queued fill makes it newest first, as any descending + /// subscriber does a subscription. + #[test] + fn a_descending_reader_turns_a_queued_fill_newest_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut ascending = producer.subscribe(replay().with_groups(0..).with_order(Order::Ascending)); + let mut descending = producer.subscribe(replay().with_groups(0..)); + producer.start_at(4).unwrap(); + write_group(&producer, 4); + let _ = ascending.poll_recv_group(&kio::Waiter::noop()); + let _ = descending.poll_recv_group(&kio::Waiter::noop()); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 4)); + assert_eq!(fill.order(), Order::Descending); + assert!( + dynamic.poll_requested_fill(&kio::Waiter::noop()).is_pending(), + "both readers share the fill" + ); + } + /// An ordered reader waits on a fill for its miss rather than skipping past it. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 91566c1fa9b..ed8e09aada8 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -215,6 +215,10 @@ impl FillState { if pending.queued { pending.priority = pending.priority.max(ask.priority); pending.max_delay = looser(pending.max_delay, ask.max_delay); + // Any descending reader makes it newest first, as it does a subscription. + if ask.order == Order::Descending { + pending.order = Order::Descending; + } if groups.start == pending.start.group && ask.start.group == groups.start { pending.start.frame = pending.start.frame.min(ask.start.frame); } From a65c8c676020911ddc6fcd941d7ab4678bfe7344 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:23:52 -0700 Subject: [PATCH 14/49] quest: point the resume-outage links at the PR that finished it Co-Authored-By: Claude Opus 5.5 --- quest/m1/js-start-parity.md | 2 +- quest/m2/lite-legacy-gap.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quest/m1/js-start-parity.md b/quest/m1/js-start-parity.md index 72659b6a1f4..81719b80957 100644 --- a/quest/m1/js-start-parity.md +++ b/quest/m1/js-start-parity.md @@ -18,4 +18,4 @@ Public API: none. Wire: none, only the value JS sends. ## Related - [lite-05/06 gap judgement](/quest/m2/lite-legacy-gap.md) - the same misjudgement on versions whose START cannot say it -- [Resume through an outage](/quest/m1/resume-outage.md) - the resume work that found it +- [Resume through an outage](https://github.com/moq-dev/moq/pull/6183) - the resume work that found it diff --git a/quest/m2/lite-legacy-gap.md b/quest/m2/lite-legacy-gap.md index e06bfac683b..8d31fa76701 100644 --- a/quest/m2/lite-legacy-gap.md +++ b/quest/m2/lite-legacy-gap.md @@ -22,4 +22,4 @@ Public API: none. Wire: none. ## Related - [JS answers SUBSCRIBE_START like Rust](/quest/m1/js-start-parity.md) - the lite-07 publisher half -- [Resume through an outage](/quest/m1/resume-outage.md) - the lite-07 fix +- [Resume through an outage](https://github.com/moq-dev/moq/pull/6183) - the lite-07 fix From 6ab75235a333798bf7c11960ccd0359f8ccdd0be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:52:12 -0700 Subject: [PATCH 15/49] fix(net): index a fill's answers so refusing it group by group stays linear Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 25 +++++++++++ rs/moq-net/src/model/track/fill.rs | 71 ++++++++++++++++++------------ 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index b6b1d8e2da6..d16c040cb8d 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15222,6 +15222,31 @@ mod test { assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); } + /// A handler refusing a wide fill one group at a time, as a FETCH-capable session does, + /// pays for each refusal rather than for every refusal before it. + #[test] + #[cfg(feature = "async")] + fn a_fill_refused_group_by_group_stays_linear() { + const WIDE: u64 = 1 << 40; + const REFUSED: u64 = 100_000; + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(WIDE).unwrap(); + write_group(&producer, WIDE); + assert_eq!(drain_groups(&mut subscriber), [WIDE]); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + for _ in 0..REFUSED { + let sequence = fill.next().unwrap(); + fill.drop_groups(sequence..sequence + 1); + assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + } + assert_eq!(fill.next(), Some(WIDE - REFUSED - 1)); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index ed8e09aada8..e8aa64c7dd3 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -51,8 +51,12 @@ struct Pending { // pending: /quest/m1/public-model/deferred.md pub(crate) struct Outcome { accepted: RangeSet, - // Why each refused run was refused, in the order the handler refused them. - refused: Vec<(ops::Range, Error)>, + // Why each refused run was refused; refusing a sequence again replaces why. + refused: RangeMap, + refusals: u64, + // Accepted or refused, kept as answers arrive so a fill answered group by group costs + // the runs each answer touches, not every answer before it. + resolved: RangeSet, // The budget the fill asked its handler for, so a looser reader can tell that an // [`Error::Old`] was not its own. asked: Option, @@ -62,11 +66,7 @@ impl Outcome { /// Why `sequence` was refused, if it was. // private: capability gap pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { - self.refused - .iter() - .rev() - .find(|(groups, _)| groups.contains(&sequence)) - .map(|(_, err)| err) + self.refused.get(&sequence).map(|refusal| &refusal.err) } pub(super) fn is_accepted(&self, sequence: u64) -> bool { @@ -89,32 +89,46 @@ impl Outcome { for accepted in self.accepted.overlapping(run) { answers.push((accepted.start.max(run.start)..accepted.end.min(run.end), Ok(()))); } - for (refused, err) in &self.refused { + for (refused, refusal) in self.refused.overlapping(run) { let groups = refused.start.max(run.start)..refused.end.min(run.end); - if !groups.is_empty() { - answers.push((groups, Err((err.clone(), self.asked)))); - } + answers.push((groups, Err((refusal.err.clone(), self.asked)))); } } answers } - fn resolved(&self) -> RangeSet { - let mut resolved = self.accepted.clone(); - for (groups, _) in &self.refused { - resolved.insert(groups.clone()); - } - resolved + fn accept(&mut self, groups: ops::Range) { + self.accepted.insert(groups.clone()); + self.resolved.insert(groups); } fn refuse(&mut self, groups: ops::Range, err: Error) { if groups.is_empty() { return; } - self.refused.push((groups, err)); + let id = self.refusals; + self.refusals += 1; + self.refused.insert(groups.clone(), Refusal { id, err }); + self.resolved.insert(groups); + } +} + +/// One refusal, equal only to itself: the map keeps a run it refused whole, but never merges +/// two refusals whose errors may differ. +#[derive(Clone)] +struct Refusal { + id: u64, + err: Error, +} + +impl PartialEq for Refusal { + fn eq(&self, other: &Self) -> bool { + self.id == other.id } } +impl Eq for Refusal {} + /// A reader's request: the run `start.group..end`, from `start.frame` in its first group. pub(super) struct Ask { pub start: Position, @@ -469,12 +483,14 @@ impl Fill { /// since it was asked. `None` once every group is answered. pub fn next(&self) -> Option { loop { - let resolved = self.inner.result.read().resolved(); let span = self.inner.start.group..self.inner.end; - let mut gaps = resolved.gaps(&span); - let sequence = match self.inner.order { - Order::Ascending => gaps.next().map(|gap| gap.start), - Order::Descending => gaps.last().map(|gap| gap.end - 1), + let sequence = { + let outcome = self.inner.result.read(); + let mut gaps = outcome.resolved.gaps(&span); + match self.inner.order { + Order::Ascending => gaps.next().map(|gap| gap.start), + Order::Descending => gaps.last().map(|gap| gap.end - 1), + } }?; // The live feed may have delivered it since: answered by the cache, not the source. let cached = self @@ -489,7 +505,7 @@ impl Fill { let Ok(mut outcome) = self.inner.result.write() else { return Some(sequence); }; - outcome.accepted.insert(sequence..sequence + 1); + outcome.accept(sequence..sequence + 1); drop(outcome); uncover(&self.inner.fills, self.inner.id, sequence..sequence + 1); } @@ -536,14 +552,14 @@ impl Inner { let Some(end) = sequence.checked_add(1) else { return Err(Error::InvalidRange); }; - if self.result.read().resolved().contains(&sequence) { + if self.result.read().resolved.contains(&sequence) { return Err(Error::Duplicate); } // Cache the group before answering: readers resolve an accept through the cache. let res = TrackState::modify(&self.state) .and_then(|mut state| state.insert_group_request(sequence, self.frame_start(sequence), info)); if let Ok(mut outcome) = self.result.write() { - outcome.accepted.insert(sequence..end); + outcome.accept(sequence..end); } uncover(&self.fills, self.id, sequence..end); res @@ -570,8 +586,7 @@ impl Inner { /// The part of `groups` nothing answered yet. fn unresolved(&self, groups: ops::Range) -> RangeSet { - let resolved = self.result.read().resolved(); - resolved.gaps(&groups).collect() + self.result.read().resolved.gaps(&groups).collect() } fn refuse(&self, groups: &RangeSet, err: Error) { From 42af07c78d3cb43b527766d551fdf6c87c3244b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 14:06:04 -0700 Subject: [PATCH 16/49] fix(net): a reader from an earlier group widens a queued fill to its head Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 20 ++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 9 +++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index d16c040cb8d..8ea3bb449e9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15247,6 +15247,26 @@ mod test { assert_eq!(fill.next(), Some(WIDE - REFUSED - 1)); } + /// A reader starting in an earlier group widens a queued fill to the whole of its first + /// group, not just one starting where it does. + #[test] + fn an_earlier_reader_widens_a_queued_fill_to_its_head() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let start = Anchor::Absolute(Position { group: 1, frame: 7 }); + let mut mid = producer.subscribe(replay().with_ranges([Range { start, end: None }])); + let mut whole = producer.subscribe(replay().with_groups(0..)); + producer.start_at(4).unwrap(); + write_group(&producer, 4); + let _ = mid.poll_recv_group(&kio::Waiter::noop()); + let _ = whole.poll_recv_group(&kio::Waiter::noop()); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + assert_eq!((fill.start(), fill.end()), (Position { group: 1, frame: 0 }, 4)); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index e8aa64c7dd3..29863ee3093 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -233,8 +233,13 @@ impl FillState { if ask.order == Order::Descending { pending.order = Order::Descending; } - if groups.start == pending.start.group && ask.start.group == groups.start { - pending.start.frame = pending.start.frame.min(ask.start.frame); + // A reader starting in an earlier group wants this one whole. + if groups.start == pending.start.group { + let frame = match ask.start.group == groups.start { + true => ask.start.frame, + false => 0, + }; + pending.start.frame = pending.start.frame.min(frame); } } joins.push(Join { From bf30e3883d96fee7265f722f37356184bf598117 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 14:12:39 -0700 Subject: [PATCH 17/49] quest: refill groups evicted after a reader judged them A reader judges each sequence once, so a group evicted after it was judged cached is skipped rather than filled. Say so in the docs, and plan the refill with per-reader eviction tracking and a thrash guard. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 8 ++++--- quest/m2/README.md | 1 + quest/m2/evicted-refill.md | 36 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 quest/m2/evicted-refill.md diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index bf127e3a3f9..1af1e40f38b 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -249,10 +249,12 @@ Ranges are both a request and a filter. A subscriber reads only the groups its own ranges cover, resolved again whenever it replaces them, even when another subscriber of the same track asked for more. Widening them, including lowering the start, also serves the cached groups they newly cover, never one already -served. A group they cover below the live feed that the cache lacks (evicted, -skipped, or below where the feed starts) is asked of the track's dynamic handler +served. A group they cover below the live feed that the cache lacks (evicted +before the subscriber first looked for it, skipped, or below where the feed +starts) is asked of the track's dynamic handler as a fill, which a relay serves by fetching upstream one group at a time; with -no handler it is reported as dropped once the feed stops waiting for it. A relay merges its subscribers' ranges into one upstream request, which +no handler it is reported as dropped once the feed stops waiting for it. One +evicted after the subscriber saw it cached, but before it read it, is skipped. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A time anchor is resolved from the groups the serving hop holds; upstream it asks for everything max delay allows, and an untimed track refuses it. At the live edge, a time end diff --git a/quest/m2/README.md b/quest/m2/README.md index 7f161f908cd..79b8c7923ca 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -68,6 +68,7 @@ ranked just below deterministic TS legs, which it requires. - [Ordered owed wait](/quest/m2/ordered-owed-wait.md) - an ordered reader waits within max_delay for a missing group only while the track still owes it - [lite-05/06 gap judgement](/quest/m2/lite-legacy-gap.md) - a resumed copy on lite-05/06 never reads a descending track's newest group as its start - [Unbounded groups](/quest/m2/unbounded-groups/README.md) - groups and frames lose their size caps; the cache sheds parts of a group under pressure and refills the holes in place +- [Refill evicted groups](/quest/m2/evicted-refill.md) - a group evicted after a reader judged it but before it took it is refilled, never delivered twice, and guarded against thrash - [Rust-backed @moq/net](/quest/m2/rs2ts/README.md) - measure browser baselines, complete WASM bindings, and adopt Rust behind @moq/net before the later JS-only stage - [Front parking](/quest/m2/origin-front-parks.md) - an unroutable request waits on a front instead of re-asking on every route-table move - [Route wakes](/quest/m2/route-wakes.md) - a route change wakes only the fronts it can move, so pool churn stops scaling with served paths diff --git a/quest/m2/evicted-refill.md b/quest/m2/evicted-refill.md new file mode 100644 index 00000000000..720891ce1fa --- /dev/null +++ b/quest/m2/evicted-refill.md @@ -0,0 +1,36 @@ +# [M] Refill groups evicted before a reader took them + +## Goal + +A reader behind the live edge still gets a group the cache evicted after the +reader judged it but before it took it: the group becomes a miss and is asked +of the track's fills, like one evicted before the reader judged it. A reader +never receives a group it already took twice, and slow readers under cache +pressure cannot make the relay refetch without bound. + +## Plan + +From [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171), where the +maintainer chose to land fills without this (2026-10-21). Today a reader +judges each sequence once: a group cached when judged and evicted later is +skipped silently. + +- Track evictions per reader. An ordered reader can judge again every + eviction at or above its next sequence. An arrival-order reader delivers + above its floor, so it must tell the groups it already walked past from + the ones still ahead, for example by keeping each evicted group's arrival + index. Bound what the track keeps by the slowest reader, not by the cache. +- Guard against thrash. Under pool pressure a refill evicts another group, + possibly the next one the same slow reader needs. Decide in the PR, with + options and a recommendation, how a reader stops refilling once its + refills only evict what it reads next: give up to the drop cursor, or rely + on its max delay. +- Test a slow ascending reader on a pooled track under bufferbloat: it + receives every group its budget covers, takes none twice, and refills stay + proportional to what it reads. + +Public API: none. Wire: none. + +## Related + +- [Unbounded groups](/quest/m2/unbounded-groups/README.md) - refills the parts of a group the cache sheds under pressure diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 2f90e768387..fc1d5233071 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -7,6 +7,9 @@ //! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and //! judged again as the feed moves. //! +//! Each sequence is judged once, so a group evicted after the reader judged it cached is +//! not a miss: judging it again would refill a group an arrival-order reader already took. +//! //! A start is no promise, since a group below it may still arrive late. A hole under it is //! asked at once, but a refusal is final only once the ledger gives up on it too; until //! then the live feed may still deliver it. From 9fa29791f8f432813ae6384c75246f0e7d3cec9a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 21:35:11 -0700 Subject: [PATCH 18/49] fix(net): hand filled groups out in the reader's order A fill answering several groups before the reader came back handed them out lowest first, so a descending reader with a warm cache read history oldest first. Pick the next filled group in the subscription's order. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 37 ++++++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 18 +++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 0435cd8f403..03bc400ed60 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5848,11 +5848,13 @@ impl Cursor { } } - /// The lowest group a fill answered for this cursor, cached and not handed out yet. + /// The next group a fill answered for this cursor in its order, cached and not handed + /// out yet. fn next_filled(&mut self) -> Option { let misses = self.misses.as_mut()?; + let order = self.subscription.read().order; let mut given_up = Vec::new(); - let group = misses.next_filled(&self.state.read(), &mut given_up); + let group = misses.next_filled(&self.state.read(), order, &mut given_up); if let Some(drops) = &mut self.drops { for run in given_up { drops.give_up(run); @@ -15353,6 +15355,37 @@ mod test { ); } + /// Groups a fill answered before the reader came back are handed out in the reader's + /// order, so a warm cache keeps a descending reader newest first. + #[test] + #[cfg(feature = "async")] + 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 mut subscriber = producer.subscribe(replay().with_groups(0..).with_order(order)); + producer.start_at(2).unwrap(); + write_group(&producer, 2); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + while let Some(sequence) = fill.next() { + write_filled(fill.accept(sequence, None), sequence); + } + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + let expected = match order { + Order::Descending => [1, 0], + Order::Ascending => [0, 1], + }; + assert_eq!(read, expected, "{order:?}"); + } + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 9dc821966be..beeaf24d704 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -91,16 +91,26 @@ impl Misses { &self.given_up } - /// The lowest answered group still cached, forgetting one evicted before it was read. - pub fn next_filled(&mut self, state: &TrackState, given_up: &mut GivenUp) -> Option { - while let Some(&sequence) = self.filled.first() { + /// The next answered group still cached in `order`, forgetting one evicted before it was + /// read. + pub fn next_filled( + &mut self, + state: &TrackState, + order: Order, + given_up: &mut GivenUp, + ) -> Option { + loop { + let sequence = match order { + Order::Ascending => self.filled.first(), + Order::Descending => self.filled.last(), + } + .copied()?; if let Some(slot) = state.lookup.get(&sequence).filter(|slot| !slot.is_aborted()) { return Some(slot.serving().clone()); } self.filled.remove(&sequence); self.give_up(sequence..sequence + 1, given_up); } - None } /// Take `sequence` as handed out. From ba5ffbcf2e197fbc32df7452cdc81e357777349f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:05:58 -0700 Subject: [PATCH 19/49] fix(net): build moq-net's tests without the async feature Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher/tracks.rs | 4 +++- rs/moq-net/src/model/track.rs | 3 +++ rs/moq-net/src/model/track/fill.rs | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs index 296edc5e237..9721636dd10 100644 --- a/rs/moq-net/src/ietf/publisher/tracks.rs +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -1186,7 +1186,9 @@ where // What an update changed while forwarding outlives a pause. let applied = serve.track.subscription(); params.priority = crate::ietf::priority::to_wire(applied.priority); - params.max_age = applied.max_delay.and_then(|max_delay| max_delay.as_millis().try_into().ok()); + params.max_age = applied + .max_delay + .and_then(|max_delay| max_delay.as_millis().try_into().ok()); let served = match served { Ok(Some(Served::Paused)) => Served::Paused, Ok(Some(served)) => { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index bf0e3b77e43..87ee02afd58 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15802,6 +15802,7 @@ 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) { let mut producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); @@ -15812,6 +15813,7 @@ mod test { (producer, dynamic, subscriber) } + #[cfg(feature = "async")] fn write_filled(group: Result, millis: u64) { let mut group = group.unwrap(); group @@ -15820,6 +15822,7 @@ mod test { group.finish().unwrap(); } + #[cfg(feature = "async")] fn drained_drops(subscriber: &mut Subscriber) -> Vec<(u64, u64)> { let mut dropped = Vec::new(); while let Poll::Ready((range, _)) = subscriber.poll_drop(&kio::Waiter::noop()) { diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index d831e86bdc2..0289a707d7e 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -200,7 +200,7 @@ impl FillState { } /// Whether no fill is pending, queued or taken. - #[cfg(test)] + #[cfg(all(test, feature = "async"))] pub(super) fn is_empty(&self) -> bool { self.pending.is_empty() && self.queue.is_empty() && self.covers.is_empty() } From a0981052fafe2e4a242104d04b419b92ea96d4b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:18:19 -0700 Subject: [PATCH 20/49] fix(bindings): saturate a peer's max delay instead of wrapping negative A fill's or subscription's delay comes from a peer and can pass Go's ~292-year time.Duration, or 2^63 microseconds in Kotlin and Dart, which read it back negative and so already expired. Go's conversion now saturates for every duration it lifts; Kotlin and Dart saturate their max delay views. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/durations.dart | 8 ++++-- go/wrapper/records.go | 6 +++++ go/wrapper/records_internal_test.go | 27 +++++++++++++++++++ .../kotlin/dev/moq/Durations.kt | 7 +++-- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 go/wrapper/records_internal_test.go diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index fc79c100e60..4f08fc96a88 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -38,7 +38,7 @@ extension SubscriptionDuration on MoqSubscription { /// Upper bound on buffering before a stalled group is skipped, or null for the /// publisher's max age alone. Duration? get maxDelay => - maxDelayUs == null ? null : Duration(microseconds: maxDelayUs!); + maxDelayUs == null ? null : _saturatingMicros(maxDelayUs!); } /// Duration views over a fill. @@ -46,10 +46,14 @@ extension FillDuration on MoqFill { /// The most tolerant age budget among its readers, or null for the publisher's max age alone. Duration? get maxDelay { final us = maxDelayUs(); - return us == null ? null : Duration(microseconds: us); + return us == null ? null : _saturatingMicros(us); } } +/// A u64 past 2^63 - 1 microseconds, such as a peer's delay, reads back negative. +Duration _saturatingMicros(int us) => + Duration(microseconds: us < 0 ? 0x7FFFFFFFFFFFFFFF : us); + /// Duration views over the publisher-side track settings. extension TrackInfoDuration on MoqTrackInfo { /// Maximum age of a non-latest group before the publisher evicts it, or null for the default. diff --git a/go/wrapper/records.go b/go/wrapper/records.go index 2247ced8511..ee629c07743 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -1,6 +1,7 @@ package moq import ( + "math" "time" ffi "moq.dev/moq-ffi/moq" @@ -11,7 +12,12 @@ import ( // target language has a duration type. These records mirror the generated ones // with time.Duration fields and convert at the boundary. +// fromMicros saturates past time.Duration's ~292 years, which a peer's delay or +// max age can exceed, rather than wrapping to a negative duration. func fromMicros(us uint64) time.Duration { + if us > uint64(math.MaxInt64/int64(time.Microsecond)) { + return time.Duration(math.MaxInt64) + } return time.Duration(us) * time.Microsecond } diff --git a/go/wrapper/records_internal_test.go b/go/wrapper/records_internal_test.go new file mode 100644 index 00000000000..ed4162a166d --- /dev/null +++ b/go/wrapper/records_internal_test.go @@ -0,0 +1,27 @@ +package moq + +import ( + "math" + "testing" + "time" +) + +// A peer's delay or max age can pass time.Duration's ~292 years, which must read as +// the longest duration rather than wrap to a negative one that is already expired. +func TestFromMicrosSaturates(t *testing.T) { + limit := uint64(math.MaxInt64 / int64(time.Microsecond)) + cases := []struct { + us uint64 + want time.Duration + }{ + {us: 1, want: time.Microsecond}, + {us: limit, want: time.Duration(limit) * time.Microsecond}, + {us: limit + 1, want: time.Duration(math.MaxInt64)}, + {us: math.MaxUint64, want: time.Duration(math.MaxInt64)}, + } + for _, c := range cases { + if got := fromMicros(c.us); got != c.want { + t.Errorf("fromMicros(%d) = %v, want %v", c.us, got, c.want) + } + } +} diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 67d5cfe4237..72a2df33db1 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -55,11 +55,14 @@ val QuicConfig.keepAlive: Duration? /** Upper bound on buffering before a stalled group is skipped, or null for the publisher's max age alone. */ val Subscription.maxDelay: Duration? - get() = maxDelayUs?.toLong()?.microseconds + get() = maxDelayUs?.let(::saturatingMicros) /** The most tolerant age budget among a fill's readers, or null for the publisher's max age alone. */ val Fill.maxDelay: Duration? - get() = maxDelayUs()?.toLong()?.microseconds + get() = maxDelayUs()?.let(::saturatingMicros) + +// A peer's delay can pass Long.MAX_VALUE microseconds, which toLong() would wrap negative. +private fun saturatingMicros(us: ULong): Duration = minOf(us, Long.MAX_VALUE.toULong()).toLong().microseconds /** Maximum age of a non-latest group before the publisher evicts it, or null for the default. */ val TrackInfo.maxAge: Duration? From 738ba580525c08f80ef7a3f8e1c9037884eaadf4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:58:39 -0700 Subject: [PATCH 21/49] fix(net): a narrowed reader leaves the rest of its fill A fill now counts how many joins still read each sequence. A reader narrowing its ranges releases what it dropped, so Fill::next walks only what some reader still reads, and dropping the fill names only those as dropped. Also adds the cpp/moq MIGRATE entry for fills. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 10 ++- rs/moq-net/src/model/track.rs | 35 ++++++++++ rs/moq-net/src/model/track/fill.rs | 105 +++++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 11 ++- 4 files changed, 150 insertions(+), 11 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 00ff7db554b..7025e3c372d 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,9 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic::requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group()`, `start_frame()`, `end_group()`, `priority()`, `max_delay_us()`, `order()`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`). +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A refusal carries no application code: `drop_groups` fails the waiting fetches as not found. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. +`Subscription::max_delay_us` is a `std::optional` that still defaults to 0, where `std::nullopt` leaves the publisher's max age alone to bound delivery. + ### Priorities are signed (#6142) -`TrackInfo::priority`, `Subscription::priority`, and `FetchGroupOptions::priority` are `int8_t`, and `GroupRequest::priority()` returns one. +`TrackInfo::priority`, `Subscription::priority`, and `FetchGroupOptions::priority` are `int8_t`, and `Fill::priority()` returns one. Higher is first and 0 is the unset midpoint; `TrackInfo::priority` defaulted to 127 and now defaults to 0. To keep a value's moq-lite byte, subtract 128 from it, widening first (`static_cast(int(old) - 128)`): 255 becomes 127, 128 becomes 0, and 0 becomes -128. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5d768a59a00..ff4a57ec483 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15995,6 +15995,41 @@ mod test { } } + /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the + /// handler is not asked for it, and dropping the fill names none of it dropped. + #[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 ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!(fill.next(), Some(0)); + + subscriber.update(ascending(8..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); + write_filled(fill.accept(8, None), 8); + assert_eq!(fill.next(), Some(9)); + // Over, so what the dropped fill left is a drop rather than a group still to come. + producer.finish().unwrap(); + drop(fill); + + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 8); + assert_eq!(drained_drops(&mut subscriber), [(9, 10)], "only what it still read"); + assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 0289a707d7e..128c90be2c7 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -41,11 +41,51 @@ struct Pending { max_delay: Option, order: Order, queued: bool, + // How many joins still read each sequence: one a reader narrowed away is no longer owed. + wants: RangeMap, // Readers hold consumers, so it counts the fill's demand; the handler's copy says what // became of each sequence. result: kio::Producer, } +impl Pending { + /// Count one more join reading `groups`. + fn want(&mut self, groups: ops::Range) { + let mut counts: Vec<(ops::Range, u32)> = self + .wants + .overlapping(&groups) + .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count + 1)) + .collect(); + counts.extend(self.wants.gaps(&groups).map(|gap| (gap, 1))); + for (run, count) in counts { + self.wants.insert(run, count); + } + } + + /// Count one join out of reading `groups`, which it counted in. + fn unwant(&mut self, groups: ops::Range) { + let counts: Vec<(ops::Range, u32)> = self + .wants + .overlapping(&groups) + .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count - 1)) + .collect(); + for (run, count) in counts { + match count { + 0 => self.wants.remove(run), + count => self.wants.insert(run, count), + } + } + } + + /// The runs of `span` a join still reads. + fn wanted(&self, span: &ops::Range) -> RangeSet { + self.wants + .overlapping(span) + .map(|(run, _)| run.start.max(span.start)..run.end.min(span.end)) + .collect() + } +} + /// What became of a fill's sequences, shared with every reader that joined it. #[derive(Default)] // pending: /quest/m1/public-model/deferred.md @@ -144,6 +184,8 @@ pub(super) struct Join { id: u64, /// The sequences of the fill this reader asked for. pub groups: ops::Range, + // The part of `groups` it still reads, counted in the fill's wants. + held: RangeSet, // Counts this reader in the fill's demand; `None` only while dropping. outcome: Option>, attempt: kio::ProducerWeak, @@ -162,7 +204,13 @@ impl Drop for Join { // its handler is about to drop. let mut fills = self.fills.lock(); drop(self.outcome.take()); - if !self.attempt.is_used() && fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + let Some(pending) = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)) else { + return; + }; + for run in std::mem::take(&mut self.held).iter() { + pending.unwant(run.clone()); + } + if !self.attempt.is_used() { fills.withdraw(self.id); } } @@ -172,6 +220,31 @@ impl Join { fn is_ours(&self, pending: &Pending) -> bool { pending.result.weak().same_channel(&self.attempt) } + + /// Stop reading `lost`, so the fill no longer owes this reader any of it. + pub(super) fn release(&mut self, lost: &RangeSet) { + let released: Vec> = lost + .overlapping(&self.groups) + .flat_map(|run| { + self.held + .overlapping(run) + .map(move |held| held.start.max(run.start)..held.end.min(run.end)) + }) + .collect(); + if released.is_empty() { + return; + } + let mut fills = self.fills.lock(); + let pending = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)); + for run in &released { + self.held.remove(run.clone()); + } + if let Some(pending) = pending { + for run in released { + pending.unwant(run); + } + } + } } impl FillState { @@ -242,9 +315,11 @@ impl FillState { pending.start.frame = pending.start.frame.min(frame); } } + pending.want(groups.clone()); joins.push(Join { fills: shared.clone(), id, + held: RangeSet::from_iter([groups.clone()]), groups, outcome: Some(pending.result.consume()), attempt: pending.result.weak(), @@ -266,6 +341,7 @@ impl FillState { fills: shared.clone(), id, groups: groups.clone(), + held: RangeSet::from_iter([groups.clone()]), outcome: Some(result.consume()), attempt: result.weak(), }); @@ -279,6 +355,7 @@ impl FillState { max_delay: ask.max_delay, order: ask.order, queued: true, + wants: RangeMap::from_iter([(groups.clone(), 1)]), result, }, ); @@ -484,14 +561,14 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } - /// The next group still owed in the fill's order: neither answered nor delivered live - /// since it was asked. `None` once every group is answered. + /// The next group still owed in the fill's order: a reader still reads it, and it was + /// neither answered nor delivered live since it was asked. `None` once none is owed. pub fn next(&self) -> Option { loop { - let span = self.inner.start.group..self.inner.end; + let wanted = self.inner.wanted(); let sequence = { let outcome = self.inner.result.read(); - let mut gaps = outcome.resolved.gaps(&span); + let mut gaps = wanted.iter().flat_map(|run| outcome.resolved.gaps(run)); match self.inner.order { Order::Ascending => gaps.next().map(|gap| gap.start), Order::Descending => gaps.last().map(|gap| gap.end - 1), @@ -545,6 +622,16 @@ impl Inner { (self.start.group..self.end).contains(&sequence) } + /// The runs of the fill a reader still reads, read before any other lock. + fn wanted(&self) -> RangeSet { + let span = self.start.group..self.end; + let fills = self.fills.lock(); + fills + .pending + .get(&self.id) + .map_or_else(RangeSet::new, |pending| pending.wanted(&span)) + } + fn frame_start(&self, sequence: u64) -> u64 { match sequence == self.start.group { true => self.start.frame, @@ -655,9 +742,13 @@ impl Inner { impl Drop for Inner { fn drop(&mut self) { // Whatever the handler left unanswered is named dropped, but only while a reader still - // waits: once demand left, the fill was withdrawn and records nothing. A group cached + // reads it: once demand left, the fill was withdrawn and records nothing. A group cached // since (the live feed delivered it) is no drop at all. - let left = self.unresolved(self.start.group..self.end); + let wanted = self.wanted(); + let left: RangeSet = { + let outcome = self.result.read(); + wanted.iter().flat_map(|run| outcome.resolved.gaps(run)).collect() + }; if !left.is_empty() && self.result.is_used() { if let Ok(mut state) = TrackState::modify(&self.state) { // Unanswered is a miss downstream, as an explicit `drop_groups` would say. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 0b47b17880a..a753410eb4d 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -118,8 +118,9 @@ impl Misses { self.filled.remove(&sequence); } - /// Forget what the reader no longer reads: below `from`, or outside `admitted`. A fill - /// nobody waits on is withdrawn once its last join goes. + /// Forget what the reader no longer reads: below `from`, or outside `admitted`. Its fills + /// no longer owe it those groups, and one nobody waits on is withdrawn once its last join + /// goes. pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { if self.wanted.is_empty() && self.filled.is_empty() { return; @@ -132,9 +133,13 @@ impl Misses { kept.insert(inside); } } + let lost: RangeSet = self.wanted.iter().flat_map(|run| kept.gaps(run)).collect(); self.wanted = kept; let wanted = &self.wanted; - self.joins.retain(|join| wanted.overlaps(&join.groups)); + self.joins.retain_mut(|join| { + join.release(&lost); + wanted.overlaps(&join.groups) + }); } /// Take in what the joined fills answered, parking `waiter` on the rest. Returns the From 1d3aced538cde6a93e1efe1018c47b071c0b024c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 17:18:59 -0700 Subject: [PATCH 22/49] fix(net): a fill owes only what its readers still read A group fetch is cancelled once no reader reads its group, and the sequence stops routing to that fill, so a later reader asks afresh rather than joining a cancelled fetch. drop_groups, including the Old refusal of older groups, records only groups a reader still reads. A descending reader asks for its disjoint misses newest first. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 60 ++++++++++++++++++++- rs/moq-net/src/model/track/fill.rs | 85 ++++++++++++++++++++++++------ rs/moq-net/src/model/track/miss.rs | 9 +++- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index ff4a57ec483..2ac53b4e5d1 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15996,7 +15996,8 @@ mod test { } /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the - /// handler is not asked for it, and dropping the fill names none of it dropped. + /// handler is not asked for it, a fetch of it in flight is cancelled, and neither a bulk + /// refusal nor dropping the fill names it dropped. #[test] #[cfg(feature = "async")] fn a_narrowed_reader_leaves_the_rest_of_its_fill() { @@ -16012,14 +16013,20 @@ mod test { panic!("below the start is a miss"); }; assert_eq!(fill.next(), Some(0)); + let fetching = fill.group(0); + assert!(fetching.poll_unused(&waiter).is_pending()); subscriber.update(ascending(8..)).unwrap(); assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(fetching.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); + drop(fetching); + assert!(fill.group(8).poll_unused(&waiter).is_pending()); assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); write_filled(fill.accept(8, None), 8); assert_eq!(fill.next(), Some(9)); - // Over, so what the dropped fill left is a drop rather than a group still to come. + // Over, so what the fill refuses is a drop rather than a group still to come. producer.finish().unwrap(); + fill.drop_groups(0..9); drop(fill); let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { @@ -16030,6 +16037,55 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } + /// What a fill stopped owing is asked afresh by a reader that wants it later, rather than + /// joining a fetch its handler is cancelling. + #[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 ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut narrowed = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = narrowed.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + narrowed.update(ascending(8..)).unwrap(); + assert!(narrowed.poll_recv_group(&waiter).is_pending()); + + let mut later = producer.subscribe(ascending(0..)); + while let Poll::Ready(Ok(Some(_))) = later.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(again)) = dynamic.poll_requested_fill(&waiter) else { + panic!("what the first fill stopped owing is asked afresh"); + }; + assert_eq!((again.start().group, again.end()), (0, 8)); + assert_eq!(fill.next(), Some(8), "the first fill still owes only 8 and up"); + assert!(fill.group(0).poll_unused(&waiter).is_ready()); + } + + /// A descending reader's disjoint misses queue newest first, so a handler serving one fill + /// at a time reaches the newer history first. + #[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 mut subscriber = + producer.subscribe(replay().with_ranges([Range::groups(0..2), Range::groups(4..6), Range::groups(8..)])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + asked.push((fill.start().group, fill.end())); + } + assert_eq!(asked, [(4, 6), (0, 2)]); + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 128c90be2c7..c92fa498681 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -62,19 +62,25 @@ impl Pending { } } - /// Count one join out of reading `groups`, which it counted in. - fn unwant(&mut self, groups: ops::Range) { + /// Count one join out of reading `groups`, which it counted in. Returns the runs nobody + /// reads any more. + fn unwant(&mut self, groups: ops::Range) -> Vec> { let counts: Vec<(ops::Range, u32)> = self .wants .overlapping(&groups) .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count - 1)) .collect(); + let mut unwanted = Vec::new(); for (run, count) in counts { match count { - 0 => self.wants.remove(run), + 0 => { + self.wants.remove(run.clone()); + unwanted.push(run); + } count => self.wants.insert(run, count), } } + unwanted } /// The runs of `span` a join still reads. @@ -204,11 +210,11 @@ impl Drop for Join { // its handler is about to drop. let mut fills = self.fills.lock(); drop(self.outcome.take()); - let Some(pending) = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)) else { + if !fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { return; - }; + } for run in std::mem::take(&mut self.held).iter() { - pending.unwant(run.clone()); + fills.unwant(self.id, run.clone()); } if !self.attempt.is_used() { fills.withdraw(self.id); @@ -234,15 +240,15 @@ impl Join { if released.is_empty() { return; } - let mut fills = self.fills.lock(); - let pending = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)); for run in &released { self.held.remove(run.clone()); } - if let Some(pending) = pending { - for run in released { - pending.unwant(run); - } + let mut fills = self.fills.lock(); + if !fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + return; + } + for run in released { + fills.unwant(self.id, run); } } } @@ -364,6 +370,20 @@ impl FillState { (joins, unfillable) } + /// Count a join out of reading `groups` of fill `id`. What nobody reads any more is no + /// longer routed to it: a reader wanting it later asks afresh rather than joining a fetch + /// being cancelled, so a sequence a fill stops owing stays unowed. + fn unwant(&mut self, id: u64, groups: ops::Range) { + let Some(pending) = self.pending.get_mut(&id) else { + return; + }; + for run in pending.unwant(groups) { + for range in self.routed(id, &run) { + self.covers.remove(range); + } + } + } + /// Take the oldest queued fill, still pending (joinable) until it ends. fn pop(&mut self) -> Option { let id = self.queue.pop_front()?; @@ -540,7 +560,8 @@ impl Fill { } /// Name `groups` as never to be delivered: the source no longer has them. Readers stop - /// waiting for them, and the track records them so nobody asks again. + /// waiting for them, and the track records them so nobody asks again. Only the groups a + /// reader still reads count; the rest of the fill was never owed. pub fn drop_groups(&self, groups: ops::Range) { self.inner.drop_groups(groups, Error::NotFound); } @@ -660,13 +681,27 @@ impl Inner { } /// Refuse `groups` with `err`, recording them in the track's drops when the source no - /// longer has them, which binds every reader. + /// longer has them, which binds every reader. Only what a reader still reads: a handler + /// refusing the whole fill never checked the rest. fn drop_groups(&self, groups: ops::Range, err: Error) { let groups = groups.start.max(self.start.group)..groups.end.min(self.end); if groups.is_empty() { return; } - let unresolved = self.unresolved(groups); + let wanted = self.wanted(); + let unresolved: RangeSet = { + let outcome = self.result.read(); + let mut unresolved = RangeSet::new(); + for run in wanted.overlapping(&groups) { + for gap in outcome + .resolved + .gaps(&(run.start.max(groups.start)..run.end.min(groups.end))) + { + unresolved.insert(gap); + } + } + unresolved + }; if unresolved.is_empty() { return; } @@ -823,10 +858,26 @@ impl GroupFill { self.frame_start } - /// Poll until no reader waits on the fill. + /// Poll until no reader waits on this group, as when every reader narrowed past it. // private: capability gap pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { - self.inner.result.poll_unused(waiter).map(|_| ()) + if self.inner.result.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + let (id, sequence) = (self.inner.id, self.sequence); + self.inner + .fills + .poll(waiter, |fills| { + match fills + .pending + .get(&id) + .is_some_and(|pending| pending.wants.contains_key(&sequence)) + { + true => Poll::Pending, + false => Poll::Ready(()), + } + }) + .map(drop) } /// Poll until the track closes, with its abort error. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a753410eb4d..f15ec56559a 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -348,7 +348,8 @@ impl Misses { asks } - /// Ask the fills for `runs`, settling what no handler can take as refused. + /// Ask the fills for `runs` in the reader's order, settling what no handler can take as + /// refused. Fills queue in the order asked, so a descending reader's newest run comes first. pub fn ask( &mut self, state: &kio::Consumer, @@ -357,7 +358,11 @@ impl Misses { asking: &Asking, given_up: &mut GivenUp, ) { - for run in runs.iter() { + let mut runs: Vec> = runs.iter().cloned().collect(); + if asking.order == Order::Descending { + runs.reverse(); + } + for run in runs { let frame = asking .start .filter(|start| start.group == run.start) From 1674f3845e457730470a7bbc644aea71ef1e3eba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 17:36:39 -0700 Subject: [PATCH 23/49] fix(net): a held feed fetch replaces an abandoned one under the limit The limit counted the attempt a replacement takes over, so a pre-lite-05 track at its limit refused the replacement with TooManyRequests. Release what nobody waits on before counting. A held fill whose readers left the replaced group moves on to the next group it owes rather than being dropped, which would name those groups dropped. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 81 +++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c0b4e4e3588..90798d716f9 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2512,6 +2512,37 @@ mod tests { assert!(next.now_or_never().unwrap().is_ok()); } + /// A replacement takes the place of the attempt it replaces under the limit, before any + /// poll releases that one. + #[moq_clock::test] + async fn a_feed_fetch_replaces_an_abandoned_attempt_at_the_limit() { + let start = crate::time::Instant::now(); + let clock = crate::time::Deadlines::new(start); + 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 mut fetches = FeedFetches::new(&clock); + fetches.max = 2; + for req in requests { + fetches.push(req, &track.consume()); + } + fetches.poll(&sub, start, &kio::Waiter::noop()); + drop(callers.remove(0)); + let mut next = track.consume().fetch_group(1, None); + let req = dynamic + .requested_fill() + .now_or_never() + .expect("replacement queued") + .unwrap(); + fetches.push(req, &track.consume()); + assert!((&mut next).now_or_never().is_none(), "held, not refused"); + assert_eq!(fetches.held.len(), 2); + arrive(&track, &tail, 1, start); + fetches.poll(&sub, start, &kio::Waiter::noop()); + assert!(next.now_or_never().unwrap().is_ok()); + } + /// Sweep held fetches, unrelated tracks, and live arrival runs independently. #[moq_clock::test] #[ignore = "release benchmark, run by nightly"] @@ -7023,29 +7054,41 @@ impl FeedFetches { } else { self.arrivals = Some(arrivals); } - if self.held.len() >= self.max { - fill.refuse(Error::TooManyRequests); - return; - } self.hold(fill, true); } - /// Wait on the next group `fill` still owes, if any. + /// Wait on the next group `fill` still owes, if any. A fresh fill counts against the + /// limit, once what nobody waits on any more is released. fn hold(&mut self, fill: track::Fill, fresh: bool) { - let Some(sequence) = fill.next() else { - return; - }; - // The last caller may leave and a replacement attempt arrive before the - // queued cancellation gets polled. The old attempt owns no callers now. - if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.fill.poll_unused(&kio::Waiter::noop()).is_ready()); - } - let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { - sequence, - ready: self.ready.clone(), - }))); - self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); - self.ready.lock().insert(sequence); + let mut next = Some((fill, fresh)); + while let Some((fill, fresh)) = next.take() { + let Some(sequence) = fill.next() else { + continue; + }; + // A replacement attempt may arrive before the poll that would see the held one's + // readers leave this group: that one moves on to the next group it owes, if any. + if let Some(previous) = self.held.remove(&sequence) + && previous.fill.is_used() + { + next = Some((previous.fill, false)); + } + if fresh { + let moving = usize::from(next.is_some()); + if self.held.len() + moving >= self.max { + self.held.retain(|_, held| held.fill.is_used()); + } + if self.held.len() + moving >= self.max { + fill.refuse(Error::TooManyRequests); + continue; + } + } + let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { + sequence, + ready: self.ready.clone(), + }))); + self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); + self.ready.lock().insert(sequence); + } } fn poll( From c9a7d7d1beb20382b69f520b8d6c8a0092cb7a8b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 18:43:03 -0700 Subject: [PATCH 24/49] feat(net): fill handlers watch demand for a single group Fill::is_wanted, unwanted, and poll_unwanted tell a handler when no reader reads one group any more, as when every reader narrowed past it while the fill stays used. moq-transcode's spawn_fill and the archive's group serving now abandon such a group before accepting it, releasing a transcode permit and moving on to the groups the fill still owes. moq-c withdraws the matching group request, so answering it fails as not found, and hands out the next one. Mirrored in moq-ffi, Go, Swift, Python, and the regenerated Dart bindings. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 1 + dart/moq/MIGRATE.md | 1 + dart/moq_ffi/lib/src/moq.dart | 57 +++++++++++++++++++++++- doc/concept/moq-lite.md | 3 +- go/wrapper/MIGRATE.md | 1 + go/wrapper/moq_test.go | 3 ++ go/wrapper/publish.go | 14 +++++- kt/moq/MIGRATE.md | 1 + py/moq-rs/MIGRATE.md | 1 + py/moq-rs/moq/publish.py | 14 +++++- py/moq-rs/tests/test_local.py | 2 + rs/moq-archive/src/reader/mod.rs | 26 ++++++++--- rs/moq-archive/src/reader/tests.rs | 69 ++++++++++++++++++++++++++++++ rs/moq-c/MIGRATE.md | 1 + rs/moq-c/src/api.rs | 2 + rs/moq-c/src/publish.rs | 11 +++-- rs/moq-c/src/test.rs | 53 +++++++++++++++++++++++ rs/moq-ffi/MIGRATE.md | 1 + rs/moq-ffi/src/producer.rs | 19 +++++++- rs/moq-ffi/src/test.rs | 7 +++ rs/moq-net/MIGRATE.md | 1 + rs/moq-net/src/model/track.rs | 13 +++--- rs/moq-net/src/model/track/fill.rs | 62 +++++++++++++++++++-------- rs/moq-transcode/src/rung.rs | 62 ++++++++++++++++++++++++--- swift/MIGRATE.md | 1 + swift/Sources/Moq/Dynamic.swift | 12 ++++++ 26 files changed, 392 insertions(+), 46 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 7025e3c372d..1d58634a377 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic::requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group()`, `start_frame()`, `end_group()`, `priority()`, `max_delay_us()`, `order()`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`). `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `drop_groups` fails the waiting fetches as not found. A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. `Subscription::max_delay_us` is a `std::optional` that still defaults to 0, where `std::nullopt` leaves the publisher's max age alone to bound delivery. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 7dd0e80ffea..40e44d7e0f4 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`). `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. `Subscription.maxDelayUs` and `maxDelay` are nullable, where null leaves the publisher's max age alone to bound delivery. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index ea0e01e2902..b3038a6a261 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"; @@ -11467,6 +11465,7 @@ abstract class MoqFillInterface { void dropGroups({required int start, required int end}); int endGroup(); bool isUsed(); + bool isWanted({required int sequence}); int? maxDelayUs(); int? next(); MoqOrder order(); @@ -11474,6 +11473,7 @@ abstract class MoqFillInterface { int startFrame(); int startGroup(); Future unused(); + Future unwanted({required int sequence}); } final _MoqFillFinalizer = Finalizer>((ptr) { @@ -11550,6 +11550,18 @@ class MoqFill implements MoqFillInterface { ); } + bool isWanted({required int sequence}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_is_wanted( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + status, + ), + FfiConverterBool.lift, + null, + ); + } + int? maxDelayUs() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqfill_max_delay_us( @@ -11622,6 +11634,20 @@ class MoqFill implements MoqFillInterface { moqExceptionErrorHandler, ); } + + Future unwanted({required int sequence}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqfill_unwanted( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + ), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } } class FfiConverterMoqFill { @@ -17361,6 +17387,15 @@ external int uniffi_moq_ffi_fn_method_moqfill_is_used( Pointer uniffiStatus, ); +@Native, Uint64, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_is_wanted( + Pointer ptr, + int sequence, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -17414,6 +17449,12 @@ external Pointer uniffi_moq_ffi_fn_method_moqfill_unused( Pointer ptr, ); +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqfill_unwanted( + Pointer ptr, + int sequence, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18924,6 +18965,9 @@ external int uniffi_moq_ffi_checksum_method_moqfill_end_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_is_used(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_is_wanted(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_max_delay_us(); @@ -18945,6 +18989,9 @@ external int uniffi_moq_ffi_checksum_method_moqfill_start_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_unused(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_unwanted(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); @@ -19639,6 +19686,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 35921) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -19660,6 +19710,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 58929) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6bba8def9a7..b6c07549d91 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -254,7 +254,8 @@ the start, also serves the cached groups they newly cover, never one already served. A group they cover below the live feed that the cache lacks (evicted before the subscriber first looked for it, skipped, or below where the feed starts) is asked of the track's dynamic handler -as a fill, which a relay serves by fetching upstream one group at a time; with +as a fill, which a relay serves by fetching upstream one group at a time, skipping +a group once every subscriber that asked for it narrows past it; with no handler it is reported as dropped once the feed stops waiting for it. One evicted after the subscriber saw it cached, but before it read it, is skipped. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A time anchor is diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 24021ae1f2c..6d8454a20f1 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.RequestedGroup` is now `RequestedFill`, returning a `*Fill` (`StartGroup`, `StartFrame`, `EndGroup`, `Priority`, `MaxDelay`, `Order`, `Next`, `Accept(sequence)`, `DropGroups(start, end)`, `IsUsed`, `Unused`), and `All` ranges over fills. `GroupRequest` and `GroupDemand` are removed: serve each group `Next` names, and watch `IsUsed` or `Unused` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `Next` skips it, and `IsWanted(sequence)` and `Unwanted(ctx, sequence)` say when to stop serving one. A refusal carries no application code: `DropGroups` fails the waiting fetches with `ErrNotFound`. ### `Subscription.MaxDelay` is a pointer diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index a2576725a63..2d23a4de7d1 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -596,6 +596,9 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if next := fill.Next(); next == nil || *next != 7 || !fill.IsUsed() { t.Fatalf("unexpected fill: next=%v used=%v", next, fill.IsUsed()) } + if !fill.IsWanted(7) || fill.IsWanted(8) { + t.Fatalf("unexpected wants: 7=%v 8=%v", fill.IsWanted(7), fill.IsWanted(8)) + } produced, err := fill.Accept(7) if err != nil { t.Fatal(err) diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 63d2b96b493..363e8189a43 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -379,7 +379,8 @@ func (d *TrackDynamic) Cancel() { // 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 // while a reader still wants it; once nobody does, it is withdrawn, so stop -// serving it when Unused returns. +// serving it when Unused returns. Stop serving a single group when Unwanted +// returns for it, and move on to Next. type Fill struct { inner *ffi.MoqFill } @@ -444,3 +445,14 @@ func (f *Fill) IsUsed() bool { func (f *Fill) Unused(ctx context.Context) error { return f.inner.Unused(ctx) } + +// IsWanted reports whether a reader still reads group sequence: false once every +// reader narrowed past it, or nobody waits on the fill. +func (f *Fill) IsWanted(sequence uint64) bool { + return f.inner.IsWanted(sequence) +} + +// Unwanted blocks until no reader reads group sequence, so stop serving it. +func (f *Fill) Unwanted(ctx context.Context, sequence uint64) error { + return f.inner.Unwanted(ctx, sequence) +} diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 273fc1e6738..7e344de861c 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`), and `requestedGroups()` is now `requestedFills()`. `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. `Subscription.maxDelayUs` is a `ULong?` and `maxDelay` a `Duration?`, where null leaves the publisher's max age alone to bound delivery. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 9fab8969e1d..5dbb9300de7 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group`, `start_frame`, `end_group`, `priority`, `max_delay`, `order`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`), and iterating a `TrackDynamic` yields fills. `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `drop_groups` fails the waiting fetches with `moq.Error.NotFound`. `Subscription.max_delay` also accepts `None`, leaving the publisher's max age alone to bound delivery. diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 96b90f3a0a6..ba7c2370ab0 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -199,7 +199,8 @@ class Fill: 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 whatever is left as dropped while a reader still wants it; once nobody does, it is - withdrawn, so stop serving it when :meth:`unused` returns. + withdrawn, so stop serving it when :meth:`unused` returns. Stop serving a single group + when :meth:`unwanted` returns for it, and move on to :meth:`next`. """ def __init__(self, inner: MoqFill) -> None: @@ -258,6 +259,17 @@ async def unused(self) -> None: """Wait until no reader waits on this fill.""" await self._inner.unused() + def is_wanted(self, sequence: int) -> bool: + """Whether a reader still reads group ``sequence``. + + False once every reader narrowed past it, or nobody waits on the fill. + """ + return self._inner.is_wanted(sequence) + + async def unwanted(self, sequence: int) -> None: + """Wait until no reader reads group ``sequence``, so stop serving it.""" + await self._inner.unwanted(sequence) + class TrackDynamic(_Stream[Fill]): """Async source of the fills readers of one track need: runs of groups the cache lacks. diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index b572a4e04f5..213bce020a9 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -515,6 +515,8 @@ async def test_fetch_group_and_serve_dynamic_miss(): assert fill.priority == 11 assert fill.next() == 7 assert fill.is_used() + assert fill.is_wanted(7) + assert not fill.is_wanted(8) produced = fill.accept(7) produced.write_frame(b"archive", timedelta(microseconds=140_000)) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index b09a16c4f73..4c9c11aafa0 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -414,16 +414,18 @@ async fn serve_timeline_group( return; } }; - let object = shared - .cache - .get_or_insert_async(&key, async { + let object = tokio::select! { + biased; + // Every reader narrowed past it: the fill moves on to the groups it still owes. + _ = fill.unwanted(sequence) => return, + object = shared.cache.get_or_insert_async(&key, async { shared .store .get_segments(name, segment, timescale.is_some()) .await .map(Arc::new) - }) - .await; + }) => object, + }; let object = match object { Ok(object) => object, Err(err) => { @@ -441,6 +443,9 @@ async fn serve_timeline_group( reject(); return; } + if !fill.is_wanted(sequence) { + return; + } let Ok(mut producer) = fill.accept(sequence, None) else { return; }; @@ -549,7 +554,13 @@ async fn serve_group( // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, // so a cache too small to hold it costs no second GET. let timed = timescale.is_some(); - let mut first = match object(shared, track, timed, &spans[0]).await { + let loaded = tokio::select! { + biased; + // Every reader narrowed past it: the fill moves on to the groups it still owes. + _ = fill.unwanted(sequence) => return, + loaded = object(shared, track, timed, &spans[0]) => loaded, + }; + let mut first = match loaded { Ok(object) => Some((spans[0].sequence, object)), Err(err) => { tracing::warn!(track, sequence, %err, "archived group is unavailable"); @@ -557,6 +568,9 @@ async fn serve_group( return; } }; + if !fill.is_wanted(sequence) { + return; + } let Ok(mut producer) = fill.accept(sequence, None) else { return; }; diff --git a/rs/moq-archive/src/reader/tests.rs b/rs/moq-archive/src/reader/tests.rs index d21f28732fe..86208657e40 100644 --- a/rs/moq-archive/src/reader/tests.rs +++ b/rs/moq-archive/src/reader/tests.rs @@ -763,6 +763,75 @@ async fn evicted_timeline_groups_remain_fetchable() { let _ = serve.await; } +/// A timeline group every reader narrowed past stops its GET, so the groups the fill still +/// owes are read rather than waiting behind it. +#[tokio::test(start_paused = true)] +async fn a_narrowed_timeline_read_skips_the_group_it_left() { + let mut archive = Archive::new().await; + for group in 0..3 { + archive.add("video", group..group + 1, 1).await; + } + let mut info = broadcast::Info::new(); + info.pool = moq_net::cache::Pool::new(moq_net::cache::Config::default().with_expiry(Duration::from_millis(100))); + let pool = info.pool.clone(); + let broadcast = info.produce(); + let reader = Reader::open(archive.store.clone(), &broadcast, archive.config()) + .await + .unwrap(); + let serve = tokio::spawn(reader.serve()); + let now = moq_net::time::Instant::now(); + pool.gc(now, &kio::Waiter::noop()); + pool.gc(now + Duration::from_secs(1), &kio::Waiter::noop()); + + let mock = archive.store.inner().clone(); + mock.take(); + let gate = mock.stall_gets("video%2Etimeline%2Ez/segments/"); + let ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_groups(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut subscriber = broadcast + .consume() + .track(&timeline("video")) + .unwrap() + .subscribe(ascending(0..)) + .await + .unwrap(); + let waiter = kio::Waiter::noop(); + let mut gets = Vec::new(); + let _ = subscriber.poll_recv_group(&waiter); + assert!( + segment_read(&mock, &mut gets, 0).await, + "group 0 is read from its segment" + ); + + subscriber.update(ascending(1..)).unwrap(); + let _ = subscriber.poll_recv_group(&waiter); + assert!( + segment_read(&mock, &mut gets, 1).await, + "group 1 waited behind group 0, which nobody reads" + ); + gate.add_permits(16); + serve.abort(); + let _ = serve.await; +} + +/// Whether the video timeline's `segment` is requested once the runtime settles, collecting +/// GETs. Bounded by yields rather than time, so the paused clock stays put. +async fn segment_read(mock: &Mock, gets: &mut Vec, segment: u64) -> bool { + let path = format!("rec/video%2Etimeline%2Ez/segments/{segment:019}"); + for _ in 0..1000 { + gets.extend(mock.gets()); + if gets.contains(&path) { + return true; + } + tokio::task::yield_now().await; + } + false +} + #[tokio::test(start_paused = true)] async fn enrolled_timeline_fetch_survives_finish() { let mut archive = Archive::new().await; diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index b4b9b8c37c8..ffa4dc9e7a3 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. The ABI is unchanged. A track's dynamic handler now also gets group requests for history a subscription covers that the cache lacks, not only for fetches; each run arrives one group at a time, the next once the previous is accepted, aborted, or freed. `moq_group_request_abort` no longer delivers its error code: the waiting fetches fail as not found. +A request whose group nobody reads any more, because every subscriber narrowed past it, is withdrawn and the next group follows: answering it then fails as not found. `moq_subscription.max_delay_us` always sets a budget, so a C subscriber cannot ask for the publisher's max age alone. ### JSON snapshot `delta_ratio` is `deltas` diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index e2af706c183..7272c591743 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -2990,6 +2990,8 @@ pub unsafe extern "C" fn moq_publish_dynamic( /// [moq_publish_dynamic_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 +/// found, so move on to the next request. /// /// Returns a non-zero handle on success, or a negative code on failure. /// diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index e4db1b17203..0dc5c7a828e 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -783,8 +783,8 @@ impl Publish { } } - /// Hand `fill` to C a group at a time, each once the one before it is answered, until - /// every group is answered or nobody waits on it. + /// Hand `fill` to C a group at a time, each once the one before it is answered or nobody + /// reads it any more, until every group is answered or nobody waits on the fill. async fn serve_fill(callback: OnStatus, fill: Arc) { let mut last = None; while fill.is_used() @@ -802,10 +802,13 @@ impl Publish { return; }; callback.call(id); - let unused = kio::wait(|waiter| fill.poll_unused(waiter)); + let unwanted = kio::wait(|waiter| fill.poll_unwanted(sequence, waiter)); tokio::select! { _ = answered => {} - _ = unused => return, + // Withdrawn, so answering it fails as not found, and the fill moves on. + _ = unwanted => { + State::lock().publish.group_request.remove(id); + } } } } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index c25b89cb882..b4779db0c47 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3248,6 +3248,59 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_origin_close(origin), 0); } +/// A group request every reader narrowed past is withdrawn, and the next group its fill +/// still owes is requested without waiting for an answer. +#[test] +fn a_group_request_nobody_reads_is_withdrawn() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"track-dynamic-narrowed"); + let name = b"data"; + 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 ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_groups(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut reader = { + let mut state = State::lock(); + let producer = state.publish.track_producer(Id::try_from(track).unwrap()).unwrap(); + let reader = producer.subscribe(ascending(7..)); + producer.start_at(10).unwrap(); + let mut group = producer.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); + group + .write_frame(moq_net::Timestamp::from_millis(10).unwrap(), &b"live"[..]) + .unwrap(); + group.finish().unwrap(); + reader + }; + let waiter = kio::Waiter::noop(); + while let std::task::Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + let first = id(group_cb.recv()); + let mut sequence = 0u64; + assert_eq!(unsafe { moq_group_request_sequence(first, &mut sequence) }, 0); + assert_eq!(sequence, 7); + + reader.update(ascending(8..)).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + let next = id(group_cb.recv()); + assert_eq!(unsafe { moq_group_request_sequence(next, &mut sequence) }, 0); + assert_eq!(sequence, 8, "group 7, which nobody reads, held the fill back"); + assert!(moq_group_request_accept(first) < 0, "the withdrawn request"); + + assert_eq!(moq_group_request_free(next), 0); + drop(reader); + assert_eq!(moq_publish_dynamic_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); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn track_dynamic_serves_a_fetch_from_frame_start() { let origin = id(moq_origin_create()); diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index be2cb6775e4..56f5ceb9cde 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -9,6 +9,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it `MoqTrackDynamic::requested_group` is now `requested_fill`, returning a `MoqFill`: `start_group`, `start_frame`, `end_group`, `priority`, `max_delay_us`, `order`, `next`, `accept(sequence)`, `drop_groups(start, end)`, `is_used`, and `unused`. `MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `next()` names, and watch `is_used` or `unused` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `drop_groups` fails the waiting fetches with `NotFound`. A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 3097fa79924..c245738b778 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -486,7 +486,8 @@ impl MoqTrackDynamic { /// Call [`accept`](Self::accept) for each group you have and [`drop_groups`](Self::drop_groups) /// for the rest; [`next`](Self::next) names what is still owed, in the fill's order. Releasing /// it names whatever is left as dropped while a reader still wants it; once nobody does, it is -/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. +/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. Stop serving a single +/// group when [`unwanted`](Self::unwanted) resolves for it, and move on to the next. #[derive(uniffi::Object)] pub struct MoqFill { inner: Arc, @@ -563,6 +564,22 @@ impl MoqFill { }) .await } + + /// Whether a reader still reads group `sequence`: false once every reader narrowed past + /// it, or nobody waits on the fill. + pub fn is_wanted(&self, sequence: u64) -> bool { + self.inner.is_wanted(sequence) + } + + /// Wait until no reader reads group `sequence`, so stop serving it. + pub async fn unwanted(&self, sequence: u64) -> Result<(), MoqError> { + let fill = self.inner.clone(); + crate::ffi::detached(async move { + fill.unwanted(sequence).await; + Ok::<_, MoqError>(()) + }) + .await + } } // ---- Track Request ---- diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 1d485b933d4..0c9cfe2ba88 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2067,6 +2067,8 @@ async fn fill_goes_unused_when_the_fetcher_leaves() { .expect("timed out waiting for fill") .unwrap(); assert!(fill.is_used()); + assert!(fill.is_wanted(5)); + assert!(!fill.is_wanted(6), "outside the fill"); fetch.abort(); tokio::time::timeout(TIMEOUT, fill.unused()) @@ -2074,6 +2076,11 @@ async fn fill_goes_unused_when_the_fetcher_leaves() { .expect("timed out waiting for the abandoned fetch to become unused") .unwrap(); assert!(!fill.is_used()); + tokio::time::timeout(TIMEOUT, fill.unwanted(5)) + .await + .expect("an unused fill wants none of its groups") + .unwrap(); + assert!(!fill.is_wanted(5)); } #[tokio::test] diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 0f6e83c589b..4fabbbb58a5 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -9,6 +9,7 @@ How to update your code for each breaking change, newest first. `track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, with `priority()`, `max_delay()`, and `order()`. Call `accept(sequence, info)` for each group you have (the first starts at `start().frame`) and `drop_groups(range)` for the rest; `next()` names the next group still owed, in the fill's order. `group::Request` is removed, and its `demand()` with it: watch the fill's `is_used()` and `unused()` instead. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)`, `unwanted(sequence)`, and `poll_unwanted(sequence, waiter)` say when to stop serving one. A fill also arrives for history a subscriber's ranges cover below the live feed that the cache lacks, not only for `fetch_group`. Dropping a fill with groups unanswered names them dropped, where the live feed won't deliver them, while a reader still waits; a fill nobody waits on is withdrawn and records nothing. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 2ac53b4e5d1..f8f738958f2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16013,14 +16013,15 @@ mod test { panic!("below the start is a miss"); }; assert_eq!(fill.next(), Some(0)); - let fetching = fill.group(0); - assert!(fetching.poll_unused(&waiter).is_pending()); + assert!(fill.is_wanted(0)); + assert!(fill.poll_unwanted(0, &waiter).is_pending()); subscriber.update(ascending(8..)).unwrap(); assert!(subscriber.poll_recv_group(&waiter).is_pending()); - assert!(fetching.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); - drop(fetching); - assert!(fill.group(8).poll_unused(&waiter).is_pending()); + assert!(fill.poll_unwanted(0, &waiter).is_ready(), "nobody reads group 0 now"); + assert!(!fill.is_wanted(0)); + assert!(fill.is_wanted(8)); + assert!(fill.poll_unwanted(8, &waiter).is_pending()); assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); write_filled(fill.accept(8, None), 8); assert_eq!(fill.next(), Some(9)); @@ -16063,7 +16064,7 @@ mod test { }; assert_eq!((again.start().group, again.end()), (0, 8)); assert_eq!(fill.next(), Some(8), "the first fill still owes only 8 and up"); - assert!(fill.group(0).poll_unused(&waiter).is_ready()); + assert!(!fill.is_wanted(0), "a group stops being wanted for good"); } /// A descending reader's disjoint misses queue newest first, so a handler serving one fill diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index c92fa498681..f8f05dff840 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -384,6 +384,13 @@ impl FillState { } } + /// Whether a join of fill `id` still reads `sequence`. + fn wants(&self, id: u64, sequence: u64) -> bool { + self.pending + .get(&id) + .is_some_and(|pending| pending.wants.contains_key(&sequence)) + } + /// Take the oldest queued fill, still pending (joinable) until it ends. fn pop(&mut self) -> Option { let id = self.queue.pop_front()?; @@ -501,6 +508,8 @@ pub(super) fn poll_requested( /// it has and [`drop_groups`](Self::drop_groups) for the rest. Dropping the fill names /// whatever is left as dropped while a reader still wants it; once nobody does, the fill is /// withdrawn and records nothing, so stop serving it when [`unused`](Self::unused) resolves. +/// A reader narrowing its ranges may leave a single group, so stop serving that one when +/// [`unwanted`](Self::unwanted) resolves and move on to [`next`](Self::next). pub struct Fill { inner: Arc, } @@ -582,6 +591,23 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } + /// Whether a reader still reads group `sequence`: false once every reader narrowed past + /// it, or nobody waits on the fill. A group stops being wanted for good. + pub fn is_wanted(&self, sequence: u64) -> bool { + self.inner.is_wanted(sequence) + } + + /// Wait until no reader reads group `sequence`, so stop serving it. + #[cfg(feature = "async")] + pub async fn unwanted(&self, sequence: u64) { + kio::wait(|waiter| self.poll_unwanted(sequence, waiter)).await + } + + /// Poll until no reader reads group `sequence`. + pub fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + self.inner.poll_unwanted(sequence, waiter) + } + /// The next group still owed in the fill's order: a reader still reads it, and it was /// neither answered nor delivered live since it was asked. `None` once none is owed. pub fn next(&self) -> Option { @@ -643,6 +669,22 @@ impl Inner { (self.start.group..self.end).contains(&sequence) } + fn is_wanted(&self, sequence: u64) -> bool { + self.result.is_used() && self.fills.read().wants(self.id, sequence) + } + + fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + if self.result.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + self.fills + .poll(waiter, |fills| match fills.wants(self.id, sequence) { + true => Poll::Pending, + false => Poll::Ready(()), + }) + .map(drop) + } + /// The runs of the fill a reader still reads, read before any other lock. fn wanted(&self) -> RangeSet { let span = self.start.group..self.end; @@ -858,26 +900,10 @@ impl GroupFill { self.frame_start } - /// Poll until no reader waits on this group, as when every reader narrowed past it. + /// Poll until no reader waits on this group, as [`Fill::poll_unwanted`]. // private: capability gap pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { - if self.inner.result.poll_unused(waiter).is_ready() { - return Poll::Ready(()); - } - let (id, sequence) = (self.inner.id, self.sequence); - self.inner - .fills - .poll(waiter, |fills| { - match fills - .pending - .get(&id) - .is_some_and(|pending| pending.wants.contains_key(&sequence)) - { - true => Poll::Pending, - false => Poll::Ready(()), - } - }) - .map(drop) + self.inner.poll_unwanted(self.sequence, waiter) } /// Poll until the track closes, with its abort error. diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 1725d426e6e..ed50c4a1951 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -516,8 +516,9 @@ fn spawn_fill( /// Transcode group `sequence` of a fill, fetching it from the source. /// /// A failure while someone still wants the group drops it. Once nobody wants the -/// fill, it is left unanswered instead: the last caller already withdrew it, and -/// accepting afterwards would cache the group a fresh fill is about to serve. +/// group, it is left unanswered instead, releasing the permit for the groups the +/// fill still owes: its last reader already left it, and accepting afterwards would +/// cache the group a fresh fill is about to serve. async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Result<(), Error> { let reject = || fill.drop_groups(sequence..sequence + 1); // A fresh encode of this group need not match the bytes the reader's head @@ -541,7 +542,7 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul let options = moq_net::group::Fetch::default().with_priority(fill.priority()); let mut source = tokio::select! { biased; - _ = fill.unused() => return abandon(sequence), + _ = fill.unwanted(sequence) => return abandon(sequence), source = track.fetch_group(sequence, options) => match source { Ok(source) => source, Err(err) => { @@ -575,7 +576,7 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul // Accepting is what publishes the group. One last look, so a caller who // left while the pipeline was opening does not race a fresh fill. - if !fill.is_used() { + if !fill.is_wanted(sequence) { return abandon(sequence); } @@ -591,8 +592,8 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul Ok(()) } -/// Leave a fill nobody is waiting on: it was withdrawn, so this encode must not publish -/// the group a fresh fill is about to serve. +/// Leave a group nobody is waiting on: its fill no longer owes it, so this encode must not +/// publish the group a fresh fill is about to serve. fn abandon(sequence: u64) -> Result<(), Error> { tracing::debug!(sequence, "leaving a fetch nobody wants"); Ok(()) @@ -891,6 +892,55 @@ mod tests { (rung, source, trigger) } + /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the + /// groups the fill still owes are fetched rather than waiting behind it. + #[tokio::test] + #[allow(clippy::disallowed_methods)] + 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 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 ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_groups(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut reader = track.subscribe(ascending(7..)); + track.start_at(10).unwrap(); + let mut live = track.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); + live.write_frame(moq_net::Timestamp::from_millis(10).unwrap(), &b"x"[..]) + .unwrap(); + live.finish().unwrap(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + let fill = fills.requested_fill().await.unwrap(); + assert_eq!((fill.start().group, fill.end()), (7, 10)); + + let mut tasks = tokio::task::JoinSet::new(); + let permit = Arc::new(Semaphore::new(1)).try_acquire_owned().unwrap(); + spawn_fill(&mut tasks, rung, fill, permit); + let wait = std::time::Duration::from_secs(5); + let first = tokio::time::timeout(wait, source_fills.requested_fill()) + .await + .expect("group 7 is fetched from the source") + .unwrap(); + assert_eq!(first.start().group, 7); + + reader.update(ascending(8..)).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + let next = tokio::time::timeout(wait, source_fills.requested_fill()) + .await + .expect("the fill waited behind group 7, which nobody reads") + .unwrap(); + assert_eq!(next.start().group, 8); + } + #[tokio::test(start_paused = true)] async fn internal_idle_source_end_is_cancel() { use std::future::Future; diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index bcf1fb6f016..e10b6445e22 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup`, `startFrame`, `endGroup`, `priority`, `maxDelayUs`, `order`, `next()`, `accept(sequence:)`, `dropGroups(start:end:)`, `isUsed`, `unused()`), and iterating a `TrackDynamic` yields fills. `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence:)` and `unwanted(sequence:)` say when to stop serving one. A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. `Subscription.maxDelayUs` is a `UInt64?`, where nil leaves the publisher's max age alone to bound delivery. diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index 588a55c5a80..e5b70e36f3a 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -42,6 +42,7 @@ public final class TrackRequest: Sendable { /// 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 /// wants it; once nobody does, it is withdrawn, so stop serving it when `unused()` returns. +/// Stop serving a single group when `unwanted(sequence:)` returns for it, and move on to `next()`. public final class Fill: Sendable { let ffi: MoqFill @@ -105,6 +106,17 @@ public final class Fill: Sendable { public func unused() async throws { try await ffi.unused() } + + /// Whether a reader still reads group `sequence`: false once every reader narrowed past + /// it, or nobody waits on the fill. + public func isWanted(sequence: UInt64) -> Bool { + ffi.isWanted(sequence: sequence) + } + + /// Suspend until no reader reads group `sequence`, so stop serving it. + public func unwanted(sequence: UInt64) async throws { + try await ffi.unwanted(sequence: sequence) + } } /// A stream of the fills readers of one track need: runs of groups the cache lacks. From 54e0a6e98986a0c056934590ad807e2d583f1621 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 19:22:30 -0700 Subject: [PATCH 25/49] fix(net): a SUBSCRIPTION_ENDED copy does not linger Only TRACK_ENDED marks the IETF copy finished, so after SUBSCRIPTION_ENDED the copy is released and a later reader subscribes afresh. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4cc39a6abff..27cd3f6ba40 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3566,7 +3566,9 @@ where // A no-op once an END_OF_TRACK declared the end, whose readers wait // for a missing group only as long as the grace. let _ = track.finish(); - finished = true; + // Only TRACK_ENDED ends the track: after SUBSCRIPTION_ENDED a + // later reader subscribes afresh. + finished = matches!(ended, Some(Ok(()))); } } Err(err) => { @@ -15979,6 +15981,33 @@ mod joining_fetch_tests { } } + /// SUBSCRIPTION_ENDED ends only our subscription, not the track, so the copy is released + /// for a later reader to subscribe afresh rather than lingering as if the track ended. + #[moq_clock::test] + async fn a_subscription_ended_copy_does_not_linger() { + for version in JOINING_DRAFTS { + let mut ok = subscribe_ok(version, None); + ok.extend(message_bytes( + ietf::PublishDone::ID, + &ietf::PublishDone { + request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + .then_some(RequestId(1)), + status_code: 0x3, + stream_count: 0, + reason_phrase: "done".into(), + }, + version, + )); + let mut run = JoinRun::start(version, None, ok, Vec::new()).await; + let _subscriber = (&mut run._hold.2).await.expect("subscribed"); + while !run.subscriber.state.lock().subscribes.is_empty() { + settle().await; + } + settle().await; + assert!(run.serving.is_finished(), "{version}: the copy is released"); + } + } + #[derive(Clone, Copy, PartialEq, Eq)] enum FetchStage { Unanswered, From b5d2930f44babe993d6db9b28d0817a437e3ced3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 19:39:17 -0700 Subject: [PATCH 26/49] doc(ffi): migrate the generated Go and Dart bindings to fills Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/MIGRATE.md | 12 +++++++++++- go/ffi/MIGRATE.md | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/dart/moq_ffi/MIGRATE.md b/dart/moq_ffi/MIGRATE.md index 8df537cb61c..fee8faa4f39 100644 --- a/dart/moq_ffi/MIGRATE.md +++ b/dart/moq_ffi/MIGRATE.md @@ -4,9 +4,19 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`MoqTrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `MoqFill`: `startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence:)`, `dropGroups(start:, end:)`, `isUsed()`, and `unused()`. +`MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence:)` and `unwanted(sequence:)` say when to stop serving one. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. +`MoqSubscription.maxDelayUs` is nullable, where null leaves the publisher's max age alone to bound delivery. +Regenerate the bindings with `just dart generate` for the changed types and checksums. + ### Priorities are signed (#6142) -`MoqTrackInfo.priority`, `MoqSubscription.priority`, `MoqFetchGroupOptions.priority`, and `MoqGroupRequest.priority()` take `-128..=127`, higher first, and 0 is the unset midpoint. +`MoqTrackInfo.priority`, `MoqSubscription.priority`, `MoqFetchGroupOptions.priority`, and `MoqFill.priority()` take `-128..=127`, higher first, and 0 is the unset midpoint. `MoqTrackInfo.priority` defaulted to 127 and now defaults to 0. To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. Regenerate the bindings for the changed checksums. diff --git a/go/ffi/MIGRATE.md b/go/ffi/MIGRATE.md index 5319a4adb04..da8b3af2338 100644 --- a/go/ffi/MIGRATE.md +++ b/go/ffi/MIGRATE.md @@ -4,9 +4,19 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`MoqTrackDynamic.RequestedGroup()` is now `RequestedFill()`, returning a `*MoqFill`: `StartGroup()`, `StartFrame()`, `EndGroup()`, `Priority()`, `MaxDelayUs()`, `Order()`, `Next()`, `Accept(sequence)`, `DropGroups(start, end)`, `IsUsed()`, and `Unused()`. +`MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `Next()` names, and watch `IsUsed()` or `Unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `Next()` skips it, and `IsWanted(sequence)` and `Unwanted(sequence)` say when to stop serving one. +A refusal carries no application code: `DropGroups` fails the waiting fetches with `NotFound`. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. +`MoqSubscription.MaxDelayUs` is `*uint64`, where `nil` leaves the publisher's max age alone to bound delivery. +Regenerate the bindings for the changed types and checksums. + ### Priorities are signed (#6142) -`MoqTrackInfo.Priority`, `MoqSubscription.Priority`, `MoqFetchGroupOptions.Priority`, and `MoqGroupRequest.Priority()` are `int8`, higher first, and 0 is the unset midpoint. +`MoqTrackInfo.Priority`, `MoqSubscription.Priority`, `MoqFetchGroupOptions.Priority`, and `MoqFill.Priority()` are `int8`, higher first, and 0 is the unset midpoint. `MoqTrackInfo.Priority` defaulted to 127 and now defaults to 0. To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. Regenerate the bindings for the changed checksums. From 0016babf7df6ace13de8b4e11498052d8ca62933 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 20:06:57 -0700 Subject: [PATCH 27/49] fix(net): a wide fill forgets refusals its readers took A reader releases each refusal it takes from its join, and a run nobody reads any more is dropped from the fill's answers, so a fill refused group by group with differing errors holds what its readers have yet to take, not one entry per refusal. Also makes the moq-c withdrawal test deterministic: its reader reads only group 8, so letting 8 go leaves the fill owing nothing. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/test.rs | 9 +++++---- rs/moq-net/src/model/track.rs | 24 ++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 26 ++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 7 +++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index b4779db0c47..12e67839e92 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3260,16 +3260,16 @@ fn a_group_request_nobody_reads_is_withdrawn() { let group_cb = Callback::new(); let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); - let ascending = |groups| { + let ascending = |start, end| { moq_net::track::Subscription::default() .with_max_delay(None) - .with_groups(groups) + .with_groups(start..end) .with_order(moq_net::track::Order::Ascending) }; let mut reader = { let mut state = State::lock(); let producer = state.publish.track_producer(Id::try_from(track).unwrap()).unwrap(); - let reader = producer.subscribe(ascending(7..)); + let reader = producer.subscribe(ascending(7, u64::MAX)); producer.start_at(10).unwrap(); let mut group = producer.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); group @@ -3285,7 +3285,8 @@ fn a_group_request_nobody_reads_is_withdrawn() { assert_eq!(unsafe { moq_group_request_sequence(first, &mut sequence) }, 0); assert_eq!(sequence, 7); - reader.update(ascending(8..)).unwrap(); + // Reading only 8, so the fill owes nothing once 8 is let go. + reader.update(ascending(8, 9)).unwrap(); assert!(reader.poll_recv_group(&waiter).is_pending()); let next = id(group_cb.recv()); assert_eq!(unsafe { moq_group_request_sequence(next, &mut sequence) }, 0); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3ffaa723e62..b13df210211 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16065,6 +16065,30 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } + /// A wide fill refused group by group, each with its own error, holds only the answers its + /// readers have yet to take, not one per refusal. + #[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 mut subscriber = producer.subscribe(replay().with_groups(0..).with_order(Order::Ascending)); + producer.start_at(100).unwrap(); + write_group(&producer, 100); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 100)); + for sequence in 0..50 { + assert_eq!(fill.next(), Some(sequence)); + fill.group(sequence).reject(Error::App(sequence as u16)); + let _ = subscriber.poll_recv_group(&waiter); + assert!(fill.answers() <= 1, "group {sequence}: {} answers held", fill.answers()); + } + } + /// What a fill stopped owing is asked afresh by a reader that wants it later, rather than /// joining a fetch its handler is cancelling. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index f8f05dff840..43111cafab5 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -372,12 +372,27 @@ impl FillState { /// Count a join out of reading `groups` of fill `id`. What nobody reads any more is no /// longer routed to it: a reader wanting it later asks afresh rather than joining a fetch - /// being cancelled, so a sequence a fill stops owing stays unowed. + /// being cancelled, so a sequence a fill stops owing stays unowed. Its answers are + /// forgotten too, so a wide fill holds the answers its readers have yet to take, not one + /// per group its source answered. fn unwant(&mut self, id: u64, groups: ops::Range) { let Some(pending) = self.pending.get_mut(&id) else { return; }; - for run in pending.unwant(groups) { + let unwanted = pending.unwant(groups); + let answered = { + let outcome = pending.result.read(); + unwanted + .iter() + .any(|run| outcome.refused.overlaps(run) || outcome.accepted.overlaps(run)) + }; + if answered && let Ok(mut outcome) = pending.result.write() { + for run in &unwanted { + outcome.refused.remove(run.clone()); + outcome.accepted.remove(run.clone()); + } + } + for run in unwanted { for range in self.routed(id, &run) { self.covers.remove(range); } @@ -608,6 +623,13 @@ impl Fill { self.inner.poll_unwanted(sequence, waiter) } + /// How many answer runs the fill still holds for its readers. + #[cfg(test)] + pub(super) fn answers(&self) -> usize { + let outcome = self.inner.result.read(); + outcome.refused.iter().count() + outcome.accepted.iter().count() + } + /// The next group still owed in the fill's order: a reader still reads it, and it was /// neither answered nor delivered live since it was asked. `None` once none is owed. pub fn next(&self) -> Option { diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index f15ec56559a..a2a640ac65f 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -190,8 +190,14 @@ impl Misses { }; // Read only after the outcome's lock is released. let state = state.read(); + // A refusal taken is no longer owed this reader. An accept stays held: its group may + // still be arriving, and is cancelled once nobody reads it. + let mut taken = RangeSet::new(); for (groups, answer) in answers { self.wanted.remove(groups.clone()); + if answer.is_err() { + taken.insert(groups.clone()); + } match answer { // What the cache holds is filled, and the rest settled by run: a fill that // ended unanswered may span far more groups than were ever cached. @@ -209,6 +215,7 @@ impl Misses { } } drop(state); + self.joins[i].release(&taken); // Judged again: the same join may have more to answer, or park the waiter now. } retry From 95b8f3570f3210de272f8bac80056b98fd35545b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 20:55:32 -0700 Subject: [PATCH 28/49] fix(net): a descending reader's split miss queues newest first A miss split by a fill already in flight queues its fragments in the reader's order. Also adds Fill::frame_start(sequence), replacing the copies in moq-archive, moq-c, and moq-transcode, and pauses the transcode test's clock. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/reader/mod.rs | 13 ++----------- rs/moq-c/src/publish.rs | 17 +++++------------ rs/moq-net/MIGRATE.md | 2 +- rs/moq-net/src/model/track.rs | 26 ++++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 12 +++++++++++- rs/moq-transcode/src/rung.rs | 8 ++++---- 6 files changed, 49 insertions(+), 29 deletions(-) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 4c9c11aafa0..7f6163cea51 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -379,15 +379,6 @@ async fn serve_timeline(shared: Arc>, requests: Timeli .await; } -/// Where group `sequence` of `fill` starts. -fn frame_start(fill: &track::Fill, sequence: u64) -> u64 { - let start = fill.start(); - match start.group == sequence { - true => start.frame, - false => 0, - } -} - async fn serve_timeline_group( shared: &Shared, name: &str, @@ -434,7 +425,7 @@ async fn serve_timeline_group( return; } }; - let start = frame_start(fill, sequence); + let start = fill.frame_start(sequence); let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { reject(); return; @@ -539,7 +530,7 @@ async fn serve_group( fill: &track::Fill, sequence: u64, ) { - let start = frame_start(fill, sequence); + let start = fill.frame_start(sequence); let mut changed = shared.changed.subscribe(); let mut spans = shared.index.lock().unwrap().group(track, sequence); diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 0dc5c7a828e..8d294842213 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -40,17 +40,6 @@ struct GroupRequest { _pending: oneshot::Sender<()>, } -impl GroupRequest { - /// Where the group starts: the fill's start frame for its first group. - fn frame_start(&self) -> u64 { - let start = self.fill.start(); - match start.group == self.sequence { - true => start.frame, - false => 0, - } - } -} - /// What a request handler serves. enum Dynamic { /// Track requests on a broadcast, remembered so media can be published onto them. @@ -884,7 +873,11 @@ impl Publish { /// The sequence, priority, and first frame of a requested group. pub fn group_request_info(&self, request: Id) -> Result<(u64, i8, u64), Error> { let request = self.group_request.get(request).ok_or(Error::NotFound)?; - Ok((request.sequence, request.fill.priority(), request.frame_start())) + Ok(( + request.sequence, + request.fill.priority(), + request.fill.frame_start(request.sequence), + )) } /// Accept a group request, returning a group handle like [`Self::track_group`]. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 0f21ba05cff..2c1ae6aeaed 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -6,7 +6,7 @@ How to update your code for each breaking change, newest first. ### `Dynamic` serves fills, not group requests -`track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, with `priority()`, `max_delay()`, and `order()`. +`track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, where `frame_start(sequence)` is the frame each group starts from, with `priority()`, `max_delay()`, and `order()`. Call `accept(sequence, info)` for each group you have (the first starts at `start().frame`) and `drop_groups(range)` for the rest; `next()` names the next group still owed, in the fill's order. `group::Request` is removed, and its `demand()` with it: watch the fill's `is_used()` and `unused()` instead. A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)`, `unwanted(sequence)`, and `poll_unwanted(sequence, waiter)` say when to stop serving one. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index b13df210211..09953e55dc2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16138,6 +16138,32 @@ mod test { assert_eq!(asked, [(4, 6), (0, 2)]); } + /// A descending reader's miss split by a fill already in flight queues its newer fragment + /// first too. + #[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 mut middle = producer.subscribe(replay().with_groups(5..6)); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = middle.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(taken)) = dynamic.poll_requested_fill(&waiter) else { + panic!("group 5 is a miss"); + }; + assert_eq!((taken.start().group, taken.end()), (5, 6)); + + let mut subscriber = producer.subscribe(replay().with_groups(0..).with_order(Order::Descending)); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + asked.push((fill.start().group, fill.end())); + } + assert_eq!(asked, [(6, 8), (0, 5)]); + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 43111cafab5..57432be60a5 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -298,7 +298,11 @@ impl FillState { .overlapping(&span) .map(|(groups, id)| (groups.start.max(span.start)..groups.end.min(span.end), *id)) .collect(); - let gaps: Vec> = self.covers.gaps(&span).collect(); + let mut gaps: Vec> = self.covers.gaps(&span).collect(); + // Queued in the reader's order, so a descending reader's newest fragment goes first. + if ask.order == Order::Descending { + gaps.reverse(); + } for (groups, id) in covered { let pending = self .pending @@ -549,6 +553,12 @@ impl Fill { self.inner.start } + /// The frame group `sequence` starts from: [`start`](Self::start)'s frame for the first + /// group, 0 for the rest. + pub fn frame_start(&self, sequence: u64) -> u64 { + self.inner.frame_start(sequence) + } + /// The exclusive end group. pub fn end(&self) -> u64 { self.inner.end diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index ed50c4a1951..ee230abc203 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -525,9 +525,9 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul // came from (the live encoder, or an earlier fetch), so its tail cannot // continue that head. Refuse it so the reader moves on to the next group // boundary instead of decoding a splice. - let start = fill.start(); - if start.group == sequence && start.frame != 0 { - tracing::debug!(sequence, frame = start.frame, "refusing a fetch that starts mid-group"); + let frame = fill.frame_start(sequence); + if frame != 0 { + tracing::debug!(sequence, frame, "refusing a fetch that starts mid-group"); reject(); return Ok(()); } @@ -894,7 +894,7 @@ mod tests { /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the /// groups the fill still owes are fetched rather than waiting behind it. - #[tokio::test] + #[tokio::test(start_paused = true)] #[allow(clippy::disallowed_methods)] async fn a_group_nobody_reads_releases_its_fill() { use std::task::Poll; From 24aadef28780e30660d0d456b2db9bbc25da6c3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 23:02:20 -0700 Subject: [PATCH 29/49] fix(net): a reader forgets misses behind its window Judging clips what a reader gave up and had refused to the span it judges, as it already clipped what it judged. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 32 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 22 +++++++++++--------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 09953e55dc2..5c6271656d8 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16138,6 +16138,38 @@ mod test { assert_eq!(asked, [(4, 6), (0, 2)]); } + /// A reader forgets the misses it gave up once its window moves past them, as it forgets + /// what it judged, so a feed skipping every other group costs it only the gaps it can + /// still read. + #[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 ascending = |groups| Subscription::default().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(0).unwrap(); + for sequence in (0..20).step_by(2) { + write_group(&producer, sequence); + drain_groups(&mut subscriber); + elapse(&producer, Duration::from_secs(2)); + drain_groups(&mut subscriber); + } + let given_up = |subscriber: &Subscriber| -> Vec<(u64, u64)> { + let Inner::Plain(cursor) = &subscriber.inner else { + unreachable!("a plain subscription"); + }; + let misses = cursor.misses.as_ref().expect("the reader judged its misses"); + misses.given_up().iter().map(|run| (run.start, run.end)).collect() + }; + assert_eq!(given_up(&subscriber).len(), 9, "each skipped group is given up"); + + subscriber.update(ascending(15..)).unwrap(); + write_group(&producer, 20); + drain_groups(&mut subscriber); + assert_eq!(given_up(&subscriber), [(15, 16), (17, 18)]); + } + /// A descending reader's miss split by a fill already in flight queues its newer fragment /// first too. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a2a640ac65f..25f684fa6b1 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -264,15 +264,19 @@ impl Misses { span.insert((*run.start()).max(low)..(*run.end()).min(top) + 1); } } - // What left the span is forgotten, so it is judged afresh if it comes back. - let judged: RangeSet = self - .judged - .iter() - .flat_map(|run| { - span.overlapping(run) - .map(move |s| s.start.max(run.start)..s.end.min(run.end)) - }) - .collect(); + // What left the span is forgotten, so it is judged afresh if it comes back, and a + // verdict behind the reader's floor stops costing it memory. + let clip = |set: &RangeSet| -> RangeSet { + set.iter() + .flat_map(|run| { + span.overlapping(run) + .map(move |s| s.start.max(run.start)..s.end.min(run.end)) + }) + .collect() + }; + let judged = clip(&self.judged); + self.refused = clip(&self.refused); + self.given_up = clip(&self.given_up); let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); for run in std::mem::take(&mut self.deferred).iter() { for s in span.overlapping(run) { From 4a89d3db62ecfa9757d39c65a45c5761d854b77c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 01:07:01 -0700 Subject: [PATCH 30/49] fix(interop): the Go finish client passes an optional max delay Co-Authored-By: Claude Opus 5.5 --- test/interop/clients/go/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index d65b6508fd3..69d926d2dc0 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -329,7 +329,8 @@ func finishSubscribe(ctx context.Context, client *moq.Client, broadcast string, for id, video = range catalog.Video { break } - media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: maxDelay}}) + delay := maxDelay + media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: &delay}}) if err != nil { return err } From 39dbe4476513ecb4bd26cbfa56f8abb73c39ea65 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 02:18:53 -0700 Subject: [PATCH 31/49] fix(net): narrowing releases accepted groups; time starts before the cache ask no older fills A reader narrowing its ranges now releases the accepted groups its fill is still writing, not only the unanswered ones, so `Fill::unwanted` fires for them. A time start that falls before every held group no longer asks fills below the oldest one: which of those groups it reaches is unknown until they start, so a cold relay backfilled history from group 0, before the anchor. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 31 +++++++++- rs/moq-net/src/model/track.rs | 67 ++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 8 ++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 0867c7e561e..8bd57e87b85 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -112,6 +112,8 @@ pub(in crate::model) struct Window { spans: Vec, /// The groups the spans cover, an unsettled time end through its cap. groups: RangeInclusiveSet, + /// The part of `groups` a miss may ask for: see [`Self::missable`]. + missable: RangeInclusiveSet, /// A relative anchor waits on a first group, so nothing is filtered yet. pending: bool, /// Nothing is left to resolve: see [`Self::is_settled`]. Kept so a read checks it once @@ -128,6 +130,9 @@ struct Span { end: End, /// Whether the start names a group, rather than reaching back to the oldest one. floored: bool, + /// The lowest group a miss asks for. A time start before every group held asks none + /// below the oldest: which of them it reaches is unknown until they start. + reach: u64, } impl Span { @@ -317,6 +322,7 @@ impl Window { ranges: ranges.to_vec(), spans: Vec::new(), groups: RangeInclusiveSet::new(), + missable: RangeInclusiveSet::new(), pending: false, settled: false, }; @@ -476,9 +482,23 @@ impl Window { /// The groups in range from `from` on, lowest first. pub fn runs(&self, from: u64) -> impl Iterator> + '_ { + self.from(&self.groups, from) + } + + /// The groups in range from `from` on that a miss may ask for, lowest first: all of + /// them but those below the oldest group held when a time start fell before it. + pub fn missable(&self, from: u64) -> impl Iterator> + '_ { + self.from(&self.missable, from) + } + + fn from<'a>( + &'a self, + groups: &'a RangeInclusiveSet, + from: u64, + ) -> impl Iterator> + 'a { let all = self.pending.then_some(from..=u64::MAX); let runs = (!self.pending).then(|| { - self.groups + groups .overlapping(from..=u64::MAX) .map(move |run| (*run.start()).max(from)..=*run.end()) }); @@ -546,11 +566,16 @@ impl Window { } end => end, }; + let reach = match range.start { + Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), + _ => start, + }; self.spans.push(Span { index, start, end, floored, + reach, }); } self.rebuild(); @@ -560,6 +585,7 @@ impl Window { fn rebuild(&mut self) { self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); self.groups = RangeInclusiveSet::new(); + self.missable = RangeInclusiveSet::new(); for span in &self.spans { let last = match span.limit() { Some(end) => match end.checked_sub(1) { @@ -569,6 +595,9 @@ impl Window { None => u64::MAX, }; self.groups.insert(span.start..=last); + if span.reach <= last { + self.missable.insert(span.reach..=last); + } } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 296f84dd1da..838f2d56848 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5953,14 +5953,14 @@ impl Cursor { let misses = self.misses.get_or_insert_with(Box::default); let mut given_up = Vec::new(); if misses.lowest().is_some() { - misses.retain(from, &admitted(window, from, cap)); + misses.retain(from, &below(window.missable(from), cap)); } let asks = { let state = self.state.read(); misses.judge( &state, (from, revision, cap), - || admitted(window, from, cap), + || below(window.missable(from), cap), drift, &mut given_up, waiter, @@ -16171,6 +16171,69 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } + /// A group the fill accepted but is still writing stops being wanted once its reader + /// narrows past it, though the fill still owes that reader later groups. + #[test] + #[cfg(feature = "async")] + fn a_narrowed_reader_leaves_an_accepted_group() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!(fill.next(), Some(0)); + let _writing = fill.accept(0, None).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_ready(), "the accepted group is read"); + assert!(fill.is_wanted(0)); + + subscriber.update(ascending(8..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(!fill.is_wanted(0), "nobody reads group 0 now"); + assert!(fill.poll_unwanted(0, &waiter).is_ready()); + assert!(fill.is_wanted(8)); + } + + /// A time start before every group held asks no fill below the oldest, since which of + /// those groups it reaches is unknown until they start; another range still asks for its + /// own misses. + #[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 at = |millis| Timestamp::from_millis(millis).unwrap(); + let since = Range { + start: Anchor::Time(at(5)), + end: None, + }; + let mut subscriber = producer.subscribe( + replay() + .with_ranges([Range::groups(0..2), since]) + .with_order(Order::Ascending), + ); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + let mut read = drain_groups(&mut subscriber); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the group range misses its groups"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 2), "nothing below 10 for the time start"); + while let Some(sequence) = fill.next() { + write_filled(fill.accept(sequence, None), sequence); + } + drop(fill); + read.extend(drain_groups(&mut subscriber)); + assert_eq!(read, [10, 0, 1]); + assert!(dynamic.poll_requested_fill(&waiter).is_pending()); + } + /// A wide fill refused group by group, each with its own error, holds only the answers its /// readers have yet to take, not one per refusal. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 25f684fa6b1..1b1f1245e99 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -122,7 +122,7 @@ impl Misses { /// no longer owe it those groups, and one nobody waits on is withdrawn once its last join /// goes. pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { - if self.wanted.is_empty() && self.filled.is_empty() { + if self.wanted.is_empty() && self.filled.is_empty() && self.joins.is_empty() { return; } let outside = |sequence: u64| sequence < from || !admitted.contains(&sequence); @@ -133,11 +133,13 @@ impl Misses { kept.insert(inside); } } - let lost: RangeSet = self.wanted.iter().flat_map(|run| kept.gaps(run)).collect(); self.wanted = kept; let wanted = &self.wanted; self.joins.retain_mut(|join| { - join.release(&lost); + // By the join's whole span, not just what is unanswered: an accepted group still + // being written is held too. + let inside: RangeSet = clip(join.groups.clone(), from, admitted).into_iter().collect(); + join.release(&inside.gaps(&join.groups).collect()); wanted.overlaps(&join.groups) }); } From f8cfe468c421528a9fa47a5da0bc3ba8f50334cf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 02:34:23 -0700 Subject: [PATCH 32/49] style(net): format the new fill tests Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 838f2d56848..598667e750e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16189,7 +16189,10 @@ mod test { }; assert_eq!(fill.next(), Some(0)); let _writing = fill.accept(0, None).unwrap(); - assert!(subscriber.poll_recv_group(&waiter).is_ready(), "the accepted group is read"); + assert!( + subscriber.poll_recv_group(&waiter).is_ready(), + "the accepted group is read" + ); assert!(fill.is_wanted(0)); subscriber.update(ascending(8..)).unwrap(); @@ -16224,7 +16227,11 @@ mod test { let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { panic!("the group range misses its groups"); }; - assert_eq!((fill.start().group, fill.end()), (0, 2), "nothing below 10 for the time start"); + assert_eq!( + (fill.start().group, fill.end()), + (0, 2), + "nothing below 10 for the time start" + ); while let Some(sequence) = fill.next() { write_filled(fill.accept(sequence, None), sequence); } From 30f0fda9223a4e6965595b5d03c26d7a523d99c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 03:02:58 -0700 Subject: [PATCH 33/49] fix(net): fill sessions watch an accepted group's own readers A lite or IETF session writing an accepted group stopped only once nobody read the whole fill, so a stalled group every reader narrowed past held up the fill's later groups. It now stops once nobody reads that group. To keep per-group demand exact, a reader's join holds each filled group until the reader takes it from the cache, rather than letting go as soon as the fill answered it in full. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 6 +-- rs/moq-net/src/lite/subscriber.rs | 20 +++++----- rs/moq-net/src/model/track.rs | 62 ++++++++++++++++++++++-------- rs/moq-net/src/model/track/fill.rs | 48 ++++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 35 ++++++++++++----- 5 files changed, 131 insertions(+), 40 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 7fd94a383f0..d72744519d5 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -5393,8 +5393,8 @@ where // stream also owes every object up to it. A whole-group answer has no object bound. let end = (end.group == sequence && !whole_group).then_some(end.object); - // Joined fetches still count until they pick the accepted group up from the cache. - let joined = request.joined(); + // The group's readers still count until they pick it up from the cache. + let readers = request.readers(); // The track was accepted before any fetch was asked, so it keeps that info. let track = request.track(); let mut producer = match request.accept(None) { @@ -5457,7 +5457,7 @@ where slot.poll(waiter, |state| match &**state { GroupFetch::Done => Poll::Ready(()), - _ if joined.poll_unused(waiter).is_ready() + _ if readers.poll_unused(waiter).is_ready() && demand.poll_unused(waiter).is_ready() && demand.abort_unused(Error::Cancel) => { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index f68cc7035ff..af6034341c6 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -7708,9 +7708,8 @@ enum FetchRunState { stream: Stream, // Boxed, as is the ingest, so the other states stay small. producer: Box, - // The accepted request's demand: fetches that joined it but have not yet - // picked the group up from the cache. - joined: kio::Producer, + // The accepted group's readers that have not yet picked it up from the cache. + readers: track::FillReaders, ingest: Box, }, Done, @@ -7937,9 +7936,9 @@ impl kio::Task for FetchServeRun { // (a fetch with no live subscription); otherwise the group inherits the // accepted info. let group_info = self.info.clone(); - // The joined fetches pick the group up from the cache only when next - // polled, so their demand outlives the request until then. - let joined = request.joined(); + // The group's readers pick it up from the cache only when next polled, + // so their demand outlives the request until then. + let readers = request.readers(); let mut producer = match request.accept(group_info) { Ok(producer) => producer, Err(err) => { @@ -7961,7 +7960,7 @@ impl kio::Task for FetchServeRun { self.state = FetchRunState::Ingest { stream, producer: Box::new(crate::recv::Group::new(producer)), - joined, + readers, ingest: Box::new(FrameIngest::new( lite::Framing::new(&self.info, self.serve.subscriber.version), self.serve.subscriber.version, @@ -7973,15 +7972,16 @@ impl kio::Task for FetchServeRun { FetchRunState::Ingest { stream, producer, - joined, + readers, ingest, } => { let Poll::Ready(res) = ingest.poll(&mut stream.reader, producer, waiter) else { // Still short of its end, so completion always wins. Once nobody - // wants the rest (no joined fetch left to pick it up, no reader), + // wants the rest (no reader left to pick it up, none reading it), // cancel upstream and abort the truncated group, never caching it // as complete. The abort is atomic with a new reader arriving. - if joined.poll_unused(waiter).is_pending() || producer.handle().poll_unused(waiter).is_pending() + if readers.poll_unused(waiter).is_pending() + || producer.handle().poll_unused(waiter).is_pending() { return Poll::Pending; } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 598667e750e..679bef30872 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -36,7 +36,7 @@ use fill::FillState; // private: capability gap #[cfg_attr(not(feature = "async"), allow(unused_imports))] -pub(crate) use fill::{GroupFill, Outcome as FillOutcome}; +pub(crate) use fill::{GroupFill, Readers as FillReaders}; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -14600,9 +14600,9 @@ mod test { assert!(futures::poll!(widest.as_mut()).is_pending()); assert_eq!(request.frame_start(), 2, "the in-flight range is already on the wire"); - // A handler may keep tracking the joined demand past the accept, as the lite + // A handler may keep tracking the group's readers past the accept, as the lite // subscriber does, which holds the result channel open. - let _joined = request.joined(); + let _readers = request.readers(); let mut group = request.accept(Info::timed()).unwrap(); // A handler numbers the frames from where it was asked to start, as `serve_fetch` // does; that offset is what makes the cached group too narrow for `widest`. @@ -16171,11 +16171,12 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } - /// A group the fill accepted but is still writing stops being wanted once its reader - /// narrows past it, though the fill still owes that reader later groups. + /// A group the fill accepted stays wanted until its reader takes it, even once the rest of + /// the fill is answered, so its source keeps writing it meanwhile; narrowing past it + /// before then leaves it. #[test] #[cfg(feature = "async")] - fn a_narrowed_reader_leaves_an_accepted_group() { + fn a_filled_group_is_wanted_until_taken() { let mut producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); @@ -16187,19 +16188,48 @@ mod test { let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { panic!("below the start is a miss"); }; - assert_eq!(fill.next(), Some(0)); - let _writing = fill.accept(0, None).unwrap(); - assert!( - subscriber.poll_recv_group(&waiter).is_ready(), - "the accepted group is read" - ); - assert!(fill.is_wanted(0)); + let _writing = [fill.accept(0, None).unwrap(), fill.accept(1, None).unwrap()]; + for sequence in 2..10 { + fill.group(sequence).reject(Error::NotFound); + } + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the first filled group is read"); + }; + assert_eq!(group.sequence, 0); + assert!(!fill.is_wanted(0), "taken: its reader holds the group itself"); + assert!(fill.is_wanted(1), "filled, not taken yet"); subscriber.update(ascending(8..)).unwrap(); assert!(subscriber.poll_recv_group(&waiter).is_pending()); - assert!(!fill.is_wanted(0), "nobody reads group 0 now"); - assert!(fill.poll_unwanted(0, &waiter).is_ready()); - assert!(fill.is_wanted(8)); + assert!(!fill.is_wanted(1), "nobody reads group 1 now"); + assert!(fill.poll_unwanted(1, &waiter).is_ready()); + } + + /// A session still writing an accepted group stops once every reader narrowed past it, + /// though the fill still owes them later groups. + #[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 ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let group = fill.group(0); + let readers = group.readers(); + let _writing = group.accept(None).unwrap(); + assert!(readers.poll_unused(&waiter).is_pending()); + + subscriber.update(ascending(1..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(readers.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); + assert!(fill.poll_unused(&waiter).is_pending(), "the fill still owes 1 and up"); } /// A time start before every group held asks no fill below the oldest, since which of diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 57432be60a5..f3769ed1999 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -227,6 +227,11 @@ impl Join { pending.result.weak().same_channel(&self.attempt) } + /// Whether the reader holds none of the fill any more. + pub(super) fn is_empty(&self) -> bool { + self.held.is_empty() + } + /// Stop reading `lost`, so the fill no longer owes this reader any of it. pub(super) fn release(&mut self, lost: &RangeSet) { let released: Vec> = lost @@ -896,6 +901,34 @@ impl TrackState { } } +/// The readers of one group of a fill, outliving its [`GroupFill`]. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +// private: capability gap +pub(crate) struct Readers { + fills: kio::Shared, + id: u64, + sequence: u64, + outcome: kio::Producer, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +impl Readers { + /// Poll until no reader waits on the group. Once the fill ends its per-group demand goes + /// with it, so then until none of its readers is left. + // private: capability gap + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + if self.outcome.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + self.fills + .poll(waiter, |fills| match fills.pending.get(&self.id) { + Some(pending) if !pending.wants.contains_key(&self.sequence) => Poll::Ready(()), + _ => Poll::Pending, + }) + .map(drop) + } +} + /// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers /// that group of the fill; dropping it unanswered leaves the group to the fill. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -951,12 +984,25 @@ impl GroupFill { self.inner.result.is_used() } - /// The fill's readers, for a session keeping a group alive while any still wait. + /// The fill's answers, kept open as long as this handle lives. + #[cfg(test)] // private: capability gap pub(crate) fn joined(&self) -> kio::Producer { self.inner.result.clone() } + /// The group's readers, for a session still writing it once accepted. Holds the fill's + /// answers open, as the group does not answer its readers until they take it. + // private: capability gap + pub(crate) fn readers(&self) -> Readers { + Readers { + fills: self.inner.fills.clone(), + id: self.inner.id, + sequence: self.sequence, + outcome: self.inner.result.clone(), + } + } + /// The track the fill serves. #[cfg_attr(not(feature = "async"), allow(dead_code))] // private: capability gap diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 1b1f1245e99..611c0d1be2a 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -109,6 +109,7 @@ impl Misses { return Some(slot.serving().clone()); } self.filled.remove(&sequence); + self.release(sequence); self.give_up(sequence..sequence + 1, given_up); } } @@ -116,6 +117,18 @@ impl Misses { /// Take `sequence` as handed out. pub fn take_filled(&mut self, sequence: u64) { self.filled.remove(&sequence); + self.release(sequence); + } + + /// Stop holding a filled `sequence` in its fill: handed out, the group's own reader + /// keeps it wanted, or nothing does. + fn release(&mut self, sequence: u64) { + let mut lost = RangeSet::new(); + lost.insert(sequence..sequence + 1); + self.joins.retain_mut(|join| { + join.release(&lost); + !join.is_empty() + }); } /// Forget what the reader no longer reads: below `from`, or outside `admitted`. Its fills @@ -134,13 +147,12 @@ impl Misses { } } self.wanted = kept; - let wanted = &self.wanted; self.joins.retain_mut(|join| { - // By the join's whole span, not just what is unanswered: an accepted group still - // being written is held too. + // By the join's whole span, not just what is unanswered: a filled group not + // taken yet is held too. let inside: RangeSet = clip(join.groups.clone(), from, admitted).into_iter().collect(); join.release(&inside.gaps(&join.groups).collect()); - wanted.overlaps(&join.groups) + !join.is_empty() }); } @@ -163,7 +175,11 @@ impl Misses { .map(|run| run.start.max(join.groups.start)..run.end.min(join.groups.end)) .collect(); if wanted.is_empty() { - self.joins.swap_remove(i); + // Holding only filled groups until they are taken, it has nothing to answer. + match join.is_empty() { + true => drop(self.joins.swap_remove(i)), + false => i += 1, + } continue; } let answers = match join.outcome().poll(waiter, |outcome| { @@ -192,14 +208,12 @@ impl Misses { }; // Read only after the outcome's lock is released. let state = state.read(); - // A refusal taken is no longer owed this reader. An accept stays held: its group may - // still be arriving, and is cancelled once nobody reads it. + // What is answered is no longer owed this reader, except a filled group: held until + // taken, so its source keeps writing it while the reader picks it up. let mut taken = RangeSet::new(); for (groups, answer) in answers { self.wanted.remove(groups.clone()); - if answer.is_err() { - taken.insert(groups.clone()); - } + taken.insert(groups.clone()); match answer { // What the cache holds is filled, and the rest settled by run: a fill that // ended unanswered may span far more groups than were ever cached. @@ -208,6 +222,7 @@ impl Misses { for (&sequence, slot) in state.lookup.range(groups) { if !slot.is_aborted() { self.filled.insert(sequence); + taken.remove(sequence..sequence + 1); } } } From 434fa51c7d45fdc66ba69bbddff8d78b00615a7a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 03:41:53 -0700 Subject: [PATCH 34/49] docs(net): a fill's per-group demand is what still waits for the group Fill::is_wanted and unwanted say whether a reader still waits for a group from the fill: once a reader takes an accepted group, the group's own demand says who reads it. Mirrored in moq-ffi, Go, Python, and Swift; Dart regenerated. Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 4 ++-- go/wrapper/publish.go | 7 ++++--- py/moq-rs/moq/publish.py | 6 +++--- rs/moq-ffi/src/producer.rs | 6 +++--- rs/moq-net/src/model/track/fill.rs | 10 ++++++---- swift/Sources/Moq/Dynamic.swift | 6 +++--- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 62be58375a4..bd83513ebcb 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -19686,7 +19686,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 35921) { + if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 47156) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { @@ -19710,7 +19710,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 58929) { + if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 13637) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 363e8189a43..26e802ee85f 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -446,13 +446,14 @@ func (f *Fill) Unused(ctx context.Context) error { return f.inner.Unused(ctx) } -// IsWanted reports whether a reader still reads group sequence: false once every -// reader narrowed past it, or nobody waits on the fill. +// IsWanted reports whether a reader still waits for group sequence from this fill: +// false once every reader took it or narrowed past it, or nobody waits on the fill. func (f *Fill) IsWanted(sequence uint64) bool { return f.inner.IsWanted(sequence) } -// Unwanted blocks until no reader reads group sequence, so stop serving it. +// Unwanted blocks until no reader waits for group sequence from this fill, so stop +// serving it. func (f *Fill) Unwanted(ctx context.Context, sequence uint64) error { return f.inner.Unwanted(ctx, sequence) } diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index ba7c2370ab0..89bc755c4b5 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -260,14 +260,14 @@ async def unused(self) -> None: await self._inner.unused() def is_wanted(self, sequence: int) -> bool: - """Whether a reader still reads group ``sequence``. + """Whether a reader still waits for group ``sequence`` from this fill. - False once every reader narrowed past it, or nobody waits on the fill. + False once every reader took it or narrowed past it, or nobody waits on the fill. """ return self._inner.is_wanted(sequence) async def unwanted(self, sequence: int) -> None: - """Wait until no reader reads group ``sequence``, so stop serving it.""" + """Wait until no reader waits for group ``sequence`` from this fill, so stop serving it.""" await self._inner.unwanted(sequence) diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index c245738b778..661adf0545a 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -565,13 +565,13 @@ impl MoqFill { .await } - /// Whether a reader still reads group `sequence`: false once every reader narrowed past - /// it, or nobody waits on the fill. + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. pub fn is_wanted(&self, sequence: u64) -> bool { self.inner.is_wanted(sequence) } - /// Wait until no reader reads group `sequence`, so stop serving it. + /// Wait until no reader waits for group `sequence` from this fill, so stop serving it. pub async fn unwanted(&self, sequence: u64) -> Result<(), MoqError> { let fill = self.inner.clone(); crate::ffi::detached(async move { diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index f3769ed1999..da7f46ab7c9 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -621,19 +621,21 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } - /// Whether a reader still reads group `sequence`: false once every reader narrowed past - /// it, or nobody waits on the fill. A group stops being wanted for good. + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. A group stops being + /// wanted for good; once accepted and taken, [`group::Producer::demand`](crate::group::Producer::demand) + /// says who reads it. pub fn is_wanted(&self, sequence: u64) -> bool { self.inner.is_wanted(sequence) } - /// Wait until no reader reads group `sequence`, so stop serving it. + /// Wait until no reader waits for group `sequence` from this fill, so stop serving it. #[cfg(feature = "async")] pub async fn unwanted(&self, sequence: u64) { kio::wait(|waiter| self.poll_unwanted(sequence, waiter)).await } - /// Poll until no reader reads group `sequence`. + /// Poll until no reader waits for group `sequence` from this fill. pub fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { self.inner.poll_unwanted(sequence, waiter) } diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index e5b70e36f3a..f31621b7536 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -107,13 +107,13 @@ public final class Fill: Sendable { try await ffi.unused() } - /// Whether a reader still reads group `sequence`: false once every reader narrowed past - /// it, or nobody waits on the fill. + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. public func isWanted(sequence: UInt64) -> Bool { ffi.isWanted(sequence: sequence) } - /// Suspend until no reader reads group `sequence`, so stop serving it. + /// Suspend until no reader waits for group `sequence` from this fill, so stop serving it. public func unwanted(sequence: UInt64) async throws { try await ffi.unwanted(sequence: sequence) } From c8354c52b9c95d6fd73516c45b07858c5529cf83 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 10:58:11 -0700 Subject: [PATCH 35/49] chore(lint): give the fill suppressions a reason Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 2 +- rs/moq-net/src/model/track/fill.rs | 25 ++++++++++++++----------- rs/moq-net/src/model/track/miss.rs | 5 ++++- rs/moq-transcode/src/rung.rs | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 17151e704d9..a7a27aca278 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -35,7 +35,7 @@ pub use fill::Fill; use fill::FillState; // private: capability gap -#[cfg_attr(not(feature = "async"), allow(unused_imports))] +#[cfg_attr(not(feature = "async"), allow(unused_imports, reason = "async-only"))] pub(crate) use fill::{GroupFill, Readers as FillReaders}; use std::{ diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index da7f46ab7c9..eaf74f362f6 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -125,7 +125,10 @@ impl Outcome { /// What was answered within `wanted`: accepted, or refused with why and the budget /// the fill asked with. - #[allow(clippy::type_complexity)] + #[expect( + clippy::type_complexity, + reason = "each answer pairs a run with its outcome; naming it would only move the type" + )] pub(super) fn within( &self, wanted: &RangeSet, @@ -681,7 +684,7 @@ impl Fill { /// Refuse every group still owed with `err`, which says nothing about whether the source /// has them, so nothing is recorded dropped. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn refuse(&self, err: Error) { let unresolved = self.inner.unresolved(self.inner.start.group..self.inner.end); @@ -689,7 +692,7 @@ impl Fill { } /// A handle on group `sequence` alone, for a session serving the fill one group at a time. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn group(&self, sequence: u64) -> GroupFill { GroupFill { @@ -815,7 +818,7 @@ impl Inner { /// Whether an [`Error::Old`] answer is the publisher's max age, which binds every reader, /// rather than this fill's own budget, which a more tolerant reader may exceed. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn old_binds_everyone(&self) -> bool { let Some(max_delay) = self.max_delay else { return true; @@ -825,7 +828,7 @@ impl Inner { } /// Refuse group `sequence` with `err`, as its source answered. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn reject(&self, sequence: u64, err: Error) { let Some(end) = sequence.checked_add(1) else { return; @@ -904,7 +907,7 @@ impl TrackState { } /// The readers of one group of a fill, outliving its [`GroupFill`]. -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) struct Readers { fills: kio::Shared, @@ -913,7 +916,7 @@ pub(crate) struct Readers { outcome: kio::Producer, } -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] impl Readers { /// Poll until no reader waits on the group. Once the fill ends its per-group demand goes /// with it, so then until none of its readers is left. @@ -933,7 +936,7 @@ impl Readers { /// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers /// that group of the fill; dropping it unanswered leaves the group to the fill. -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) struct GroupFill { inner: Arc, @@ -941,7 +944,7 @@ pub(crate) struct GroupFill { frame_start: u64, } -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] impl GroupFill { /// The group sequence to fetch. // private: capability gap @@ -1006,7 +1009,7 @@ impl GroupFill { } /// The track the fill serves. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn track(&self) -> kio::Producer { self.inner.state.clone() @@ -1027,7 +1030,7 @@ impl GroupFill { /// Declare the track's exclusive final sequence, as the source answering this group /// reported it. A no-op once the track declared an end, or holds a later group. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn finish_track_at(&self, final_sequence: u64) { if let Ok(mut state) = TrackState::modify(&self.inner.state) diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 611c0d1be2a..3c2a8ba1fa4 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -469,7 +469,10 @@ impl TrackState { /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its /// grace gave up on, or anything once the feed is over. A declared start is no promise: /// a group below it may still arrive. - #[allow(clippy::type_complexity)] + #[expect( + clippy::type_complexity, + reason = "the never-fed runs and the awaited gaps are returned together; naming them would only move the type" + )] pub(super) fn unfed( &self, hole: ops::Range, diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index ee230abc203..f4c6c24cde7 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -895,7 +895,7 @@ mod tests { /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the /// groups the fill still owes are fetched rather than waiting behind it. #[tokio::test(start_paused = true)] - #[allow(clippy::disallowed_methods)] + #[expect(clippy::disallowed_methods, reason = "tokio's paused clock bounds the waits")] async fn a_group_nobody_reads_releases_its_fill() { use std::task::Poll; let (rung, source, _retire) = idle_rung(); From 5f0d1175caaf2a82b5bf94e214758e101a434a19 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 13:11:34 -0700 Subject: [PATCH 36/49] fix(net): a fill starts at the frame of its own range A reader with disjoint ranges asked every fill from the lowest start of its ranges, so a miss in a later range that starts mid-group asked frame 0. Each run's first group now takes its frame from the reader's resolved window. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 39 ++++++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 14 +++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index a7a27aca278..8d24784926b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5943,17 +5943,17 @@ impl Cursor { /// Ask fills for what this cursor misses from `from`, and take in what they answered, /// parking `waiter` on the fills still out. Whatever it gives up goes to the drop cursor. fn probe(&mut self, from: u64, drift: &Drift, waiter: &kio::Waiter) { + let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); let asking = { let subscription = self.subscription.read(); miss::Asking { - start: subscription.start(), + window, priority: subscription.priority, max_delay: subscription.max_delay, order: subscription.order, } }; let fills = self.state.read().fills.clone(); - let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); let misses = self.misses.get_or_insert_with(Box::default); let mut given_up = Vec::new(); if misses.lowest().is_some() { @@ -16544,6 +16544,41 @@ mod test { assert_eq!((fill.start(), fill.end()), (Position { group: 1, frame: 0 }, 4)); } + /// Each fill starts at the frame its own range starts from, not the lowest start of + /// the reader's ranges. + #[test] + fn a_fill_starts_at_its_own_ranges_frame() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |group, frame| Anchor::Absolute(Position { group, frame }); + let mut reader = producer.subscribe(replay().with_ranges([ + Range { + start: at(0, 0), + end: Some(at(1, 0)), + }, + Range { + start: at(5, 7), + end: None, + }, + ])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let _ = reader.poll_recv_group(&kio::Waiter::noop()); + + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) { + asked.push((fill.start(), fill.end())); + } + asked.sort(); + assert_eq!( + asked, + [ + (Position { group: 0, frame: 0 }, 1), + (Position { group: 5, frame: 7 }, 8) + ] + ); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 3c2a8ba1fa4..2cda0eb9ce9 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -26,11 +26,12 @@ use rangemap::{RangeInclusiveSet, RangeSet}; use super::fill::{Ask, FillState, Join}; use super::{Drift, Order, Position, TrackState}; use crate::Error; +use crate::model::subscription::Window; /// What one reader asks its fills with. -pub(super) struct Asking { - /// Where the reader's own start falls, for the frame of a run beginning at its group. - pub start: Option, +pub(super) struct Asking<'a> { + /// The reader's resolved ranges, for the frame each run's first group starts from. + pub window: &'a Window, pub priority: i8, pub max_delay: Option, pub order: Order, @@ -383,7 +384,7 @@ impl Misses { state: &kio::Consumer, fills: &kio::Shared, runs: &RangeSet, - asking: &Asking, + asking: &Asking<'_>, given_up: &mut GivenUp, ) { let mut runs: Vec> = runs.iter().cloned().collect(); @@ -391,10 +392,7 @@ impl Misses { runs.reverse(); } for run in runs { - let frame = asking - .start - .filter(|start| start.group == run.start) - .map_or(0, |start| start.frame); + let (frame, _) = asking.window.frames(run.start); let ask = Ask { start: Position { group: run.start, From f44105284da67e100f6909a9782fd13a956f85c0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 13:21:20 -0700 Subject: [PATCH 37/49] fix(net): a range starting mid-group heads its own fill Adjacent ranges coalesce into one run, so a range starting inside the group after another range was read whole as a later group of one fill. Runs now split at every group a range starts inside, past its frame 0. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 11 +++++++ rs/moq-net/src/model/track.rs | 35 +++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 15 ++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 8bd57e87b85..fc50ec0415a 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -527,6 +527,17 @@ impl Window { hull.unwrap_or((0, None)) } + /// The groups some range starts inside, past their frame 0, that are read from there. + pub fn partial_starts(&self) -> impl Iterator + '_ { + self.ranges + .iter() + .filter_map(|range| match range.start { + Anchor::Absolute(start) if start.frame > 0 => Some(start.group), + _ => None, + }) + .filter(|group| self.frames(*group).0 > 0) + } + /// The first run of `range` that is in range. pub fn clip(&self, range: ops::Range) -> Option> { let last = range.end.checked_sub(1).filter(|last| *last >= range.start)?; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 8d24784926b..f42fd096218 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16579,6 +16579,41 @@ mod test { ); } + /// A range starting inside the group after another range heads its own fill, rather + /// than being read whole as the second group of one. + #[test] + fn an_adjacent_range_starting_mid_group_heads_its_own_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |group, frame| Anchor::Absolute(Position { group, frame }); + let mut reader = producer.subscribe(replay().with_ranges([ + Range { + start: at(4, 0), + end: Some(at(5, 0)), + }, + Range { + start: at(5, 7), + end: None, + }, + ])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let _ = reader.poll_recv_group(&kio::Waiter::noop()); + + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) { + asked.push((fill.start(), fill.end())); + } + asked.sort(); + assert_eq!( + asked, + [ + (Position { group: 4, frame: 0 }, 5), + (Position { group: 5, frame: 7 }, 8) + ] + ); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 2cda0eb9ce9..c4caa55f7aa 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -387,7 +387,20 @@ impl Misses { asking: &Asking<'_>, given_up: &mut GivenUp, ) { - let mut runs: Vec> = runs.iter().cloned().collect(); + // A group a range starts inside heads its own fill, since a fill reads every group + // after its first whole. + let mut cuts: Vec = asking.window.partial_starts().collect(); + cuts.sort_unstable(); + cuts.dedup(); + let mut runs: Vec> = runs + .iter() + .flat_map(|run| { + let inside = cuts.iter().copied().filter(|cut| run.start < *cut && *cut < run.end); + let starts = std::iter::once(run.start).chain(inside.clone()); + let ends = inside.chain(std::iter::once(run.end)); + starts.zip(ends).map(|(start, end)| start..end) + }) + .collect(); if asking.order == Order::Descending { runs.reverse(); } From 6930b8489c2d261ba285847611795d1772a8bf11 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 19:33:51 -0700 Subject: [PATCH 38/49] fix(net): a reader gives up a stalled fill once it is stale An ordered reader waited on a fill that still owed a lower group for as long as its handler took, even past the reader's own max delay, where a warm cache would have skipped the stale history. Each probe now re-judges what a fill still owes the reader against its drift budget, parked on when each run goes stale, and gives up the stale part, releasing its join. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 27 ++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 37 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 72432f769d1..37f64d2def6 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -6001,6 +6001,7 @@ impl Cursor { } misses.ask(&self.state, &fills, &retry, &asking, &mut given_up); } + misses.expire(&self.state.read(), drift, &mut given_up, waiter); if let Some(drops) = &mut self.drops { for run in given_up { drops.give_up(run); @@ -16291,6 +16292,32 @@ mod test { } } + /// An ordered reader waiting on a stalled fill gives up the groups it owes once they are + /// stale under its budget, as it skips a stale cached group, and reads on. + #[test] + #[cfg(feature = "async")] + fn a_stalled_fill_goes_stale_under_the_readers_budget() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + let next = |subscriber: &mut Subscriber| match subscriber.poll_next_group(&waiter) { + Poll::Ready(Ok(Some(group))) => Some(group.sequence), + Poll::Pending => None, + _ => panic!("the read ended"), + }; + assert_eq!(next(&mut subscriber), None, "groups 0 and 1 are being filled"); + // The handler takes the fill and never answers it. + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + elapse(&producer, Duration::from_secs(29)); + assert_eq!(next(&mut subscriber), None, "still within the budget"); + elapse(&producer, Duration::from_secs(2)); + // Group 2 is as stale as the groups below it, superseded by group 3 as long ago. + assert_eq!(next(&mut subscriber), Some(3)); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); + assert!(!fill.is_used(), "nobody waits on the stale fill"); + } + /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the /// handler is not asked for it, a fetch of it in flight is cancelled, and neither a bulk /// refusal nor dropping the fill names it dropped. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index c4caa55f7aa..757c821186f 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -377,6 +377,43 @@ impl Misses { asks } + /// Give up what a fill still owes this reader once it is stale under the reader's budget, + /// as the reader skips a stale cached group, parking `waiter` on each run going stale. + /// A fill answers on its handler's time, so an ordered reader would otherwise wait out + /// a stalled one for good. + pub fn expire(&mut self, state: &TrackState, drift: &Drift, given_up: &mut GivenUp, waiter: &kio::Waiter) { + let mut stale = RangeSet::new(); + for run in self.wanted.iter() { + if !state.poll_drifted(run.start, &drift.edge.servable, drift.budget, waiter) { + continue; + } + // Starts never rewind, so a run goes stale from its oldest group up: find where + // it stops, in as many checks as the run's width has bits. + let (mut stale_to, mut fresh) = (run.start + 1, run.end); + while stale_to < fresh { + let mid = stale_to + (fresh - stale_to) / 2; + match state.drifted(mid, &drift.edge, drift.budget) { + true => stale_to = mid + 1, + false => fresh = mid, + } + } + stale.insert(run.start..stale_to); + } + if stale.is_empty() { + return; + } + for run in stale.iter() { + self.wanted.remove(run.clone()); + } + self.joins.retain_mut(|join| { + join.release(&stale); + !join.is_empty() + }); + for run in stale.iter() { + self.give_up(run.clone(), given_up); + } + } + /// Ask the fills for `runs` in the reader's order, settling what no handler can take as /// refused. Fills queue in the order asked, so a descending reader's newest run comes first. pub fn ask( From d0388d59036f33a73de344039a607cd31ffb4e30 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 20:34:43 -0700 Subject: [PATCH 39/49] fix(net): judge a stalled fill by each stretch between its arrivals Expiring a stalled fill searched each owed run for where its stale prefix ends, but groups arrive out of order, so a fresh late group inside the run let the search give up the groups below it too. Each stretch between the groups cached inside an owed run shares one successor, so judge each one on its own, and let a group that arrived live leave the fill to the cache. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 37 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 37 +++++++++++++++++------------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 37f64d2def6..91f47d84479 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16318,6 +16318,43 @@ mod test { assert!(!fill.is_used(), "nobody waits on the stale fill"); } + /// Groups arriving out of order inside a stalled fill judge each stretch between them by + /// its own successor: a fresh late group keeps the groups below it owed while the stretches + /// around it go stale. + #[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 mut subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + assert!(subscriber.poll_next_group(&waiter).is_pending(), "group 0 is being filled"); + // The handler takes the fill and never answers it. + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 10)); + write_group(&producer, 2); + write_group(&producer, 8); + elapse(&producer, Duration::from_secs(20)); + write_group(&producer, 5); + elapse(&producer, Duration::from_secs(11)); + + let Poll::Ready(Ok(Some(group))) = subscriber.poll_next_group(&waiter) else { + panic!("groups 0 and 1 went stale behind group 2"); + }; + assert_eq!(group.sequence, 2, "superseded by group 5 only 11s ago"); + assert!(subscriber.poll_next_group(&waiter).is_pending(), "group 3 is still owed"); + for sequence in [3, 4] { + assert!(fill.is_wanted(sequence), "group {sequence} is superseded by group 5"); + } + for sequence in [0, 1, 6, 7, 9] { + assert!(!fill.is_wanted(sequence), "group {sequence} went stale"); + } + } + /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the /// handler is not asked for it, a fetch of it in flight is cancelled, and neither a bulk /// refusal nor dropping the fill names it dropped. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 757c821186f..e6d959ce886 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -382,31 +382,36 @@ impl Misses { /// A fill answers on its handler's time, so an ordered reader would otherwise wait out /// a stalled one for good. pub fn expire(&mut self, state: &TrackState, drift: &Drift, given_up: &mut GivenUp, waiter: &kio::Waiter) { - let mut stale = RangeSet::new(); + let (mut stale, mut arrived) = (RangeSet::new(), RangeSet::new()); for run in self.wanted.iter() { - if !state.poll_drifted(run.start, &drift.edge.servable, drift.budget, waiter) { - continue; - } - // Starts never rewind, so a run goes stale from its oldest group up: find where - // it stops, in as many checks as the run's width has bits. - let (mut stale_to, mut fresh) = (run.start + 1, run.end); - while stale_to < fresh { - let mid = stale_to + (fresh - stale_to) / 2; - match state.drifted(mid, &drift.edge, drift.budget) { - true => stale_to = mid + 1, - false => fresh = mid, + // Each stretch between the groups cached inside the run shares one successor, so + // one check judges it whole. Groups arrive out of order, so a fresh late group + // keeps the stretch below it owed while the ones around it go stale. + let mut from = run.start; + let cached = state.lookup.range(run.clone()).map(|(&sequence, _)| sequence); + for at in cached.chain(std::iter::once(run.end)) { + if from < at && state.poll_drifted(from, &drift.edge.servable, drift.budget, waiter) { + stale.insert(from..at); + } + // What arrived live is the feed's to serve, as the reader serves any cached group. + if at < run.end { + arrived.insert(at..at + 1); } + from = at.saturating_add(1); } - stale.insert(run.start..stale_to); } - if stale.is_empty() { + if stale.is_empty() && arrived.is_empty() { return; } - for run in stale.iter() { + for run in stale.iter().chain(arrived.iter()) { self.wanted.remove(run.clone()); } + let mut released = stale.clone(); + for run in arrived.iter() { + released.insert(run.clone()); + } self.joins.retain_mut(|join| { - join.release(&stale); + join.release(&released); !join.is_empty() }); for run in stale.iter() { From 2e57a53ecc39e846880142ef4df8b0090a858d5d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 20:38:01 -0700 Subject: [PATCH 40/49] style(net): format the stretch test Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 91f47d84479..d3214268b95 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16330,7 +16330,10 @@ mod test { producer.start_at(10).unwrap(); write_group(&producer, 10); let waiter = kio::Waiter::noop(); - assert!(subscriber.poll_next_group(&waiter).is_pending(), "group 0 is being filled"); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 0 is being filled" + ); // The handler takes the fill and never answers it. let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { panic!("below the start is a miss"); @@ -16346,7 +16349,10 @@ mod test { panic!("groups 0 and 1 went stale behind group 2"); }; assert_eq!(group.sequence, 2, "superseded by group 5 only 11s ago"); - assert!(subscriber.poll_next_group(&waiter).is_pending(), "group 3 is still owed"); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 3 is still owed" + ); for sequence in [3, 4] { assert!(fill.is_wanted(sequence), "group {sequence} is superseded by group 5"); } From c42d338311483eb08b8e739c24ddc2fb5cecc47d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 22:34:01 -0700 Subject: [PATCH 41/49] fix(net): an ordered reader leaves a filled group it takes Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 31 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 7 ++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index d3214268b95..e77768aa4b9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -6332,6 +6332,10 @@ impl Cursor { fn advance_sequence(&mut self, group: group::Consumer) -> group::Consumer { self.next_sequence = group.sequence.checked_add(1); + // The reader holds the group itself now, as on the arrival path. + if let Some(misses) = &mut self.misses { + misses.take_filled(group.sequence); + } // The delivery commits everything the seek stepped over to reach this group. self.commit_seek_stale(self.next_sequence.unwrap_or(u64::MAX)); group.cache_refresh(); @@ -16361,6 +16365,33 @@ mod test { } } + /// An ordered reader taking a filled group leaves the fill at once, so a reader that drops + /// the group without reading on stops holding the fill's source. + #[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 mut subscriber = producer.subscribe(replay().with_groups(0..1)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 0 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let _writing = fill.accept(0, None).unwrap(); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_next_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 0); + drop(group); + assert!(fill.poll_unused(&waiter).is_ready(), "nobody waits on the fill now"); + } + /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the /// handler is not asked for it, a fetch of it in flight is cancelled, and neither a bulk /// refusal nor dropping the fill names it dropped. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index e6d959ce886..5ecba2fb70e 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -115,10 +115,11 @@ impl Misses { } } - /// Take `sequence` as handed out. + /// Take `sequence` as handed out, if a fill answered it. pub fn take_filled(&mut self, sequence: u64) { - self.filled.remove(&sequence); - self.release(sequence); + if self.filled.remove(&sequence) { + self.release(sequence); + } } /// Stop holding a filled `sequence` in its fill: handed out, the group's own reader From 1be9105a36c47ee76017ea3579c4c15650e1f7a2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:01:41 -0700 Subject: [PATCH 42/49] fix(net): an aborted cached group stays owed to its fill Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 31 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 7 ++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7b7720bc275..c6b5276608b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16586,6 +16586,37 @@ mod test { } } + /// A fetched group aborted once its readers left is a hole, not an arrival: the fill asked + /// to replace it keeps it wanted. + #[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(); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + let fetch = producer.consume().fetch_group(3, None); + let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let mut group = first.accept(3, None).unwrap(); + drop(fetch); + group.abort(Error::Cancel).unwrap(); + drop(first); + + let mut subscriber = producer.subscribe(replay().with_groups(0..)); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 0 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the aborted group is a miss again"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 10)); + assert!(fill.is_wanted(3), "the aborted group never arrived"); + } + /// An ordered reader taking a filled group leaves the fill at once, so a reader that drops /// the group without reading on stops holding the fill's source. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 5ecba2fb70e..f43556ba4d1 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -389,7 +389,12 @@ impl Misses { // one check judges it whole. Groups arrive out of order, so a fresh late group // keeps the stretch below it owed while the ones around it go stale. let mut from = run.start; - let cached = state.lookup.range(run.clone()).map(|(&sequence, _)| sequence); + // An aborted group is a hole, so a fill still owes it. + let cached = state + .lookup + .range(run.clone()) + .filter(|(_, slot)| !slot.is_aborted()) + .map(|(&sequence, _)| sequence); for at in cached.chain(std::iter::once(run.end)) { if from < at && state.poll_drifted(from, &drift.edge.servable, drift.budget, waiter) { stale.insert(from..at); From f4ddf6d0a5880609f08b513fd8eb3c3286ebc630 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:44:00 -0700 Subject: [PATCH 43/49] test(net): drop an unneeded mut 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 c6b5276608b..5ba60493d59 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16600,7 +16600,7 @@ mod test { let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { panic!("below the start is a miss"); }; - let mut group = first.accept(3, None).unwrap(); + let group = first.accept(3, None).unwrap(); drop(fetch); group.abort(Error::Cancel).unwrap(); drop(first); From 043171abc1f23d4d9017cec01262b4b574a763ef Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 02:37:05 -0700 Subject: [PATCH 44/49] fix(net): only live arrivals retire a fill's misses Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 28 ++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 5 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5ba60493d59..2446afbfa0e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16617,6 +16617,34 @@ mod test { assert!(fill.is_wanted(3), "the aborted group never arrived"); } + /// A backfill cached before its fill publishes the answer is not a live arrival: the reader + /// keeps it owed, so the answer still reaches it. + #[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 ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + // The window inside `Fill::accept` between caching the group and publishing the answer. + let _writing = producer + .state + .write() + .ok() + .unwrap() + .insert_group_request(3, 0, None) + .unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(fill.is_wanted(3), "the answer has not reached the reader yet"); + } + /// An ordered reader taking a filled group leaves the fill at once, so a reader that drops /// the group without reading on stops holding the fill's source. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index f43556ba4d1..cf2b3e0913d 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -389,11 +389,12 @@ impl Misses { // one check judges it whole. Groups arrive out of order, so a fresh late group // keeps the stretch below it owed while the ones around it go stale. let mut from = run.start; - // An aborted group is a hole, so a fill still owes it. + // Only the live feed's arrivals split the run: an aborted group is a hole, and a + // backfill is a fill's answer, reaching the reader once that fill publishes it. let cached = state .lookup .range(run.clone()) - .filter(|(_, slot)| !slot.is_aborted()) + .filter(|(_, slot)| slot.is_walked()) .map(|(&sequence, _)| sequence); for at in cached.chain(std::iter::once(run.end)) { if from < at && state.poll_drifted(from, &drift.edge.servable, drift.budget, waiter) { From 6ef1da4ab85974fb2ff84c6f5f35eabde38bc2d8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 02:38:28 -0700 Subject: [PATCH 45/49] fix(net): pass the new superseded argument when judging owed stretches Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track/miss.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index cf2b3e0913d..673c4ab8d20 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -397,7 +397,7 @@ impl Misses { .filter(|(_, slot)| slot.is_walked()) .map(|(&sequence, _)| sequence); for at in cached.chain(std::iter::once(run.end)) { - if from < at && state.poll_drifted(from, &drift.edge.servable, drift.budget, waiter) { + if from < at && state.poll_drifted(from, &drift.edge.servable, drift.budget, None, waiter) { stale.insert(from..at); } // What arrived live is the feed's to serve, as the reader serves any cached group. From d1ebecafee8225b8d347eb82e62d2f946b5fef3e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 12:39:42 -0700 Subject: [PATCH 46/49] feat(net): refuse a fill's groups without naming them dropped moq-transcode and moq-archive refuse unsupported and transient failures for the readers waiting now, so a later reader asks again; only a source that no longer has the group drops it. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/reader/mod.rs | 14 +++++++-- rs/moq-net/src/model/track.rs | 33 ++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 48 +++++++++++++++++++----------- rs/moq-transcode/src/lib.rs | 4 +-- rs/moq-transcode/src/rung.rs | 31 ++++++++++++------- 5 files changed, 97 insertions(+), 33 deletions(-) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 65032942f64..efc754235ef 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -379,6 +379,16 @@ async fn serve_timeline(shared: Arc>, requests: Timeli .await; } +/// Answer group `sequence`, which the archive failed to load. A store failure may pass, so +/// only the readers waiting now are refused; anything else means the archive can't serve it. +fn unavailable(fill: &track::Fill, sequence: u64, err: &Error) { + let groups = sequence..sequence + 1; + match err { + Error::Store(_) => fill.refuse_groups(groups, moq_net::Error::Stream(moq_net::StreamError::Internal)), + _ => fill.drop_groups(groups), + } +} + async fn serve_timeline_group( shared: &Shared, name: &str, @@ -421,7 +431,7 @@ async fn serve_timeline_group( Ok(object) => object, Err(err) => { tracing::warn!(track = name, sequence, %err, "archived timeline group is unavailable"); - reject(); + unavailable(fill, sequence, &err); return; } }; @@ -555,7 +565,7 @@ async fn serve_group( Ok(object) => Some((spans[0].sequence, object)), Err(err) => { tracing::warn!(track, sequence, %err, "archived group is unavailable"); - fill.drop_groups(sequence..sequence + 1); + unavailable(fill, sequence, &err); return; } }; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 8524eb2ff44..0cd02bac1c6 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16843,6 +16843,39 @@ mod test { assert!(fill.is_wanted(3), "the aborted group never arrived"); } + /// Refusing an evicted group the feed already passed fails the readers waiting on it + /// without naming it dropped, so a later reader of the whole group still gets a fill. + #[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(); + for sequence in 0..5 { + write_group(&producer, sequence); + } + evict(&producer, 3); + let waiter = kio::Waiter::noop(); + let tail = producer + .consume() + .fetch_group(3, group::Fetch::default().with_frame_start(2)); + let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { + panic!("an evicted group is a miss"); + }; + first.refuse_groups(3..4, Error::Unsupported); + assert!(matches!(tail.now_or_never(), Some(Err(Error::Unsupported)))); + drop(first); + + let mut subscriber = producer.subscribe(replay().with_groups(3..4)); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 3 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the source may still have group 3"); + }; + assert!(fill.is_wanted(3)); + } + /// A backfill cached before its fill publishes the answer is not a live arrival: the reader /// keeps it owed, so the answer still reaches it. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index cbed0713388..6213306aae1 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -606,6 +606,15 @@ impl Fill { self.inner.drop_groups(groups, Error::NotFound); } + /// Refuse `groups` with `err` without saying the source lost them, as when serving them + /// failed for now or this request asks what the source can't serve. The readers waiting + /// now stop waiting, and a later reader asks again. Only the groups a reader still reads + /// count, as with [`Self::drop_groups`]. + pub fn refuse_groups(&self, groups: ops::Range, err: Error) { + let owed = self.inner.owed(groups); + self.inner.refuse(&owed, err); + } + /// Whether a reader still waits on this fill. pub fn is_used(&self) -> bool { self.inner.result.is_used() @@ -764,24 +773,7 @@ impl Inner { /// longer has them, which binds every reader. Only what a reader still reads: a handler /// refusing the whole fill never checked the rest. fn drop_groups(&self, groups: ops::Range, err: Error) { - let groups = groups.start.max(self.start.group)..groups.end.min(self.end); - if groups.is_empty() { - return; - } - let wanted = self.wanted(); - let unresolved: RangeSet = { - let outcome = self.result.read(); - let mut unresolved = RangeSet::new(); - for run in wanted.overlapping(&groups) { - for gap in outcome - .resolved - .gaps(&(run.start.max(groups.start)..run.end.min(groups.end))) - { - unresolved.insert(gap); - } - } - unresolved - }; + let unresolved = self.owed(groups); if unresolved.is_empty() { return; } @@ -793,6 +785,26 @@ impl Inner { self.refuse(&unresolved, err); } + /// The part of `groups` inside the fill that a reader still reads and nothing answered yet. + fn owed(&self, groups: ops::Range) -> RangeSet { + let groups = groups.start.max(self.start.group)..groups.end.min(self.end); + let mut owed = RangeSet::new(); + if groups.is_empty() { + return owed; + } + let wanted = self.wanted(); + let outcome = self.result.read(); + for run in wanted.overlapping(&groups) { + for gap in outcome + .resolved + .gaps(&(run.start.max(groups.start)..run.end.min(groups.end))) + { + owed.insert(gap); + } + } + owed + } + /// The part of `groups` nothing answered yet. fn unresolved(&self, groups: ops::Range) -> RangeSet { self.result.read().resolved.gaps(&groups).collect() diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index cc2f88baa45..ce1b5aa96b8 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1452,8 +1452,8 @@ mod tests { .fetch_group(7, moq_net::group::Fetch::default().with_frame_start(2)) .await; match partial { - Err(moq_net::Error::NotFound) => {} - Err(err) => panic!("expected a NotFound refusal, got {err}"), + Err(moq_net::Error::Unsupported) => {} + Err(err) => panic!("expected an Unsupported refusal, got {err}"), Ok(_) => panic!("served a fetch that starts mid-group"), } diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index f4c6c24cde7..9d5c8b853d9 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -515,27 +515,32 @@ fn spawn_fill( /// Transcode group `sequence` of a fill, fetching it from the source. /// -/// A failure while someone still wants the group drops it. Once nobody wants the -/// group, it is left unanswered instead, releasing the permit for the groups the -/// fill still owes: its last reader already left it, and accepting afterwards would -/// cache the group a fresh fill is about to serve. +/// The group is dropped only once the source says it no longer has it. Any other +/// failure while someone still wants the group refuses it for those readers alone, +/// so a later reader asks again. Once nobody wants the group, it is left unanswered +/// instead, releasing the permit for the groups the fill still owes: its last reader +/// already left it, and accepting afterwards would cache the group a fresh fill is +/// about to serve. async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Result<(), Error> { - let reject = || fill.drop_groups(sequence..sequence + 1); + let groups = sequence..sequence + 1; + let refuse = |err: moq_net::Error| fill.refuse_groups(groups.clone(), err); + let failed = || refuse(moq_net::Error::Stream(moq_net::StreamError::Internal)); // A fresh encode of this group need not match the bytes the reader's head // came from (the live encoder, or an earlier fetch), so its tail cannot // continue that head. Refuse it so the reader moves on to the next group - // boundary instead of decoding a splice. + // boundary instead of decoding a splice; a reader of the whole group still + // gets it. let frame = fill.frame_start(sequence); if frame != 0 { tracing::debug!(sequence, frame, "refusing a fetch that starts mid-group"); - reject(); + refuse(moq_net::Error::Unsupported); return Ok(()); } let track = match rung.broadcast.track(&rung.source) { Ok(track) => track, Err(err) => { - reject(); + refuse(err.clone()); return Err(err.into()); } }; @@ -545,8 +550,12 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul _ = fill.unwanted(sequence) => return abandon(sequence), source = track.fetch_group(sequence, options) => match source { Ok(source) => source, + Err(moq_net::Error::NotFound) => { + fill.drop_groups(groups); + return Err(moq_net::Error::NotFound.into()); + } Err(err) => { - reject(); + refuse(err.clone()); return Err(err.into()); } }, @@ -561,7 +570,7 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul let pipeline = match rung.pipeline().await { Ok(pipeline) => pipeline, Err(err) => { - reject(); + failed(); return Err(err); } }; @@ -569,7 +578,7 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul let container = match rung.container() { Ok(container) => container, Err(err) => { - reject(); + failed(); return Err(err); } }; From fc2af59ec3494d86f5378957b4e1b2414193b2ed Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 12:52:11 -0700 Subject: [PATCH 47/49] fix(dart): regenerate the subscription max delay default Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index cc29c203488..1d3f0bebb69 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -772,7 +772,7 @@ class MoqSubscription { final MoqOrder? order; MoqSubscription({ this.priority = 0, - this.maxDelayUs = null, + this.maxDelayUs = 0, this.ranges = null, this.order = null, }); From d61b75e9d2509bcc238fd1e4d3d73540eebdd7a8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 12:55:07 -0700 Subject: [PATCH 48/49] test(py): drop an unneeded type ignore Co-Authored-By: Claude Opus 5.5 --- py/moq-rs/tests/test_local.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index abefeb096d4..d22f95453bc 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -113,7 +113,7 @@ async def reject(): fill.drop_groups(5, 6) task = asyncio.create_task(reject()) - with pytest.raises(moq.Error.NotFound): # type: ignore[attr-defined] + with pytest.raises(moq.Error.NotFound): await consumer.fetch_group("events", 5) await task From f6051fc3882db838775fe84e9731a7a4412a8595 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 13:21:03 -0700 Subject: [PATCH 49/49] fix(net): a source's miss refuses only the request it answers An upstream FETCH answering not found says nothing about a later request from another frame, such as the tail of a group whose head expired, so it no longer records the group dropped for every reader. moq-archive refuses an expired head the same way. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/reader/mod.rs | 19 +++++++++++------- rs/moq-net/src/model/track.rs | 31 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 13 ++++++++----- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index efc754235ef..1b8348fa5b0 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -544,13 +544,18 @@ async fn serve_group( let mut changed = shared.changed.subscribe(); let mut spans = shared.index.lock().unwrap().group(track, sequence); - // The earliest retained record must hold the requested frame; an expired head is gone. - if spans - .first() - .is_none_or(|span| span.start > Position::new(sequence, start)) - { - fill.drop_groups(sequence..sequence + 1); - return; + // The earliest retained record must hold the requested frame. Nothing retained is gone; + // an expired head is refused for this request alone, since a later one may ask the tail. + match spans.first() { + None => { + fill.drop_groups(sequence..sequence + 1); + return; + } + Some(span) if span.start > Position::new(sequence, start) => { + fill.refuse_groups(sequence..sequence + 1, moq_net::Error::NotFound); + return; + } + Some(_) => {} } // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, // so a cache too small to hold it costs no second GET. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index cb5b5547d45..edf24852a50 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16942,6 +16942,37 @@ mod test { assert!(fill.is_wanted(3)); } + /// A source refusing a fetch as not found answers only that request, such as a head that + /// expired while the tail is kept, so a later reader still gets a fill for the group. + #[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(); + for sequence in 0..5 { + write_group(&producer, sequence); + } + evict(&producer, 3); + let waiter = kio::Waiter::noop(); + let fetch = producer.consume().fetch_group(3, None); + let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { + panic!("an evicted group is a miss"); + }; + first.group(3).reject(Error::NotFound); + assert!(matches!(fetch.now_or_never(), Some(Err(Error::NotFound)))); + drop(first); + + let mut subscriber = producer.subscribe(replay().with_groups(3..4)); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 3 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the source may still have part of group 3"); + }; + assert!(fill.is_wanted(3)); + } + /// A backfill cached before its fill publishes the answer is not a live arrival: the reader /// keeps it owed, so the answer still reaches it. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 6213306aae1..392e62d9db2 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -842,8 +842,8 @@ impl Inner { return; }; match err { - // The source no longer has it. - Error::NotFound | Error::NotFetchable => self.drop_groups(sequence..end, err), + // A datagram is never cached, so no request ever gets it. + Error::NotFetchable => self.drop_groups(sequence..end, err), // Stale under the fill's budget, and so is every group older than it: a timed // track's starts never rewind. Only the publisher's max age binds every reader; // under the fill's own budget they are withdrawn, for a reader with a wider one. @@ -857,7 +857,9 @@ impl Inner { } } } - // Anything else says nothing about the group: refused for now, recorded nowhere. + // Anything else, a miss included, answers only this request: one starting at + // another frame may still be served, such as the tail of a group whose head + // expired. Refused for now, recorded nowhere. err => { let unresolved = self.unresolved(sequence..end); self.refuse(&unresolved, err); @@ -1015,8 +1017,9 @@ impl GroupFill { self.inner.accept(self.sequence, info.into()) } - /// Refuse the group with the source's answer. A miss names it dropped, and - /// [`Error::Old`] every older group of the fill too. + /// Refuse the group with the source's answer, for the readers waiting now. Only a + /// datagram, or [`Error::Old`] under the publisher's max age (with every older group of + /// the fill), names it dropped. pub(crate) fn reject(self, err: Error) { self.inner.reject(self.sequence, err); }