From 739082cb2225d0a2a37d40cab622e79f28af39a8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 22:12:11 -0700 Subject: [PATCH 01/48] quest(m1): claim subscribe-ranges/dynamic Co-Authored-By: Claude Opus 5.5 From 40d72f0e3454334d4c7bd897439409d63f38af7b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 23:23:41 -0700 Subject: [PATCH 02/48] feat(moq-net): serve each subscriber its own ranges, with time anchors A subscriber now reads only the groups its own Subscription::ranges cover, resolved where it is served and again whenever they are replaced, instead of reading every group the aggregate brings in. A new Anchor::Time(pts) resolves from the cached groups' starts; an untimed track refuses it. The drop scan names only sequences inside the reader's ranges, so the SUBSCRIBE_DROP accounting can reuse it. moq-c and moq-mux stop re-resolving relative starts on their own. moq-ffi, moq-c, and the bindings carry the time anchor, and the FFI update now reports a refused one. Adds track_range_serving, which found and fixed two per-range walks per hand-out. Co-Authored-By: Claude Opus 5.5 --- dart/moq/MIGRATE.md | 6 + doc/concept/moq-lite.md | 15 +- go/wrapper/MIGRATE.md | 6 + go/wrapper/subscribe.go | 7 +- go/wrapper/types.go | 4 +- kt/moq/MIGRATE.md | 6 + .../kotlin/dev/moq/Aliases.kt | 4 +- py/moq-rs/MIGRATE.md | 6 + py/moq-rs/moq/subscribe.py | 5 +- quest/m1/subscribe-ranges/dynamic.md | 35 +- rs/moq-c/src/api.rs | 12 +- rs/moq-c/src/consume.rs | 41 +- rs/moq-c/src/test.rs | 83 +++ rs/moq-ffi/MIGRATE.md | 6 + rs/moq-ffi/src/consumer.rs | 66 ++- rs/moq-ffi/src/media.rs | 5 +- rs/moq-ffi/src/producer.rs | 2 +- rs/moq-ffi/src/test.rs | 64 +- rs/moq-mux/src/container/consumer.rs | 46 +- rs/moq-net/MIGRATE.md | 7 + rs/moq-net/benches/track.rs | 51 ++ rs/moq-net/src/model/resume.rs | 9 + rs/moq-net/src/model/subscription.rs | 111 ++-- rs/moq-net/src/model/subscription/window.rs | 430 ++++++++++++++ rs/moq-net/src/model/track.rs | 558 ++++++++++++++---- rs/moq-net/src/model/track/demand.rs | 22 +- rs/moq-net/src/model/track/demand/ranges.rs | 5 +- swift/MIGRATE.md | 6 + swift/Sources/Moq/Track.swift | 7 +- 29 files changed, 1353 insertions(+), 272 deletions(-) create mode 100644 rs/moq-net/src/model/subscription/window.rs diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 965ea3b1cd6..bae8c41459b 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time + +A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains a time variant, so exhaustive switches need a case for it. +`TrackConsumer.update` now throws on a time anchor an untimed track can't serve. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 4a207f14975..eb41810de2a 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -216,12 +216,15 @@ 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. | -| **Ranges** | Which groups to ask for: from a group, or a number of groups back from the latest, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | - -Ranges are a request, not a filter. A subscriber reads from where its lowest -range starts, and may also get groups another subscriber of the same track -asked for. A relay merges its subscribers' ranges into one upstream request, -which today's wire versions carry as the single span covering them. +| **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. | + +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. 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. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 8689478554b..6c75b68765f 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time + +A subscription now reads only the groups its own `Ranges` cover. +`AnchorTime` names the group containing a presentation time, and type switches over `Anchor` need a case for it. +`TrackConsumer.Update` also fails on a time anchor an untimed track can't serve. + ### Subscriptions carry group ranges (#5732) `Subscription.GroupStart` and `GroupEnd` are replaced by `Ranges`, a slice of `Range` whose ends are `AnchorAbsolute` or `AnchorRelative` (an absolute group and frame, or a count of groups before the latest), and an optional `Order`. diff --git a/go/wrapper/subscribe.go b/go/wrapper/subscribe.go index ce9764b6b71..74f3d7cdae9 100644 --- a/go/wrapper/subscribe.go +++ b/go/wrapper/subscribe.go @@ -156,15 +156,14 @@ func (t *TrackConsumer) Info() (TrackInfo, error) { return trackInfoFromFFI(info), nil } -// Update changes this subscriber's delivery preferences. It fails only on a -// negative MaxDelay. +// Update changes this subscriber's delivery preferences. It fails on a +// negative MaxDelay, or a time anchor the track can't serve. func (t *TrackConsumer) Update(subscription Subscription) error { sub, err := subscription.ffi() if err != nil { return err } - t.inner.Update(sub) - return nil + return t.inner.Update(sub) } // Groups ranges over groups in sequence order. diff --git a/go/wrapper/types.go b/go/wrapper/types.go index 2139b6ed3b6..53da90eeeaa 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -58,12 +58,14 @@ type ( // A relative start is the first frame of its group, a relative end includes its whole // group, and an absolute end is exclusive. Range = ffi.MoqRange - // Anchor is where one end of a Range falls: AnchorAbsolute or AnchorRelative. + // Anchor is where one end of a Range falls: AnchorAbsolute, AnchorRelative, or AnchorTime. Anchor = ffi.MoqAnchor // AnchorAbsolute is an exact group and frame. AnchorAbsolute = ffi.MoqAnchorAbsolute // AnchorRelative is the group Groups before the latest one, clamped to group 0: 0 is the latest. AnchorRelative = ffi.MoqAnchorRelative + // AnchorTime is the group containing TimestampUs, a presentation time in microseconds. + AnchorTime = ffi.MoqAnchorTime // Order is which of a subscription's pending groups the publisher sends first. Order = ffi.MoqOrder // VideoDecoderOutput configures what a video.Consumer delivers: an optional resize, a max delay, and whether frames keep the decoder's surface (macOS only; refused elsewhere). diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 2552722b9e0..8710934b05d 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time + +A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains `AnchorTime`, so exhaustive `when` expressions need a branch for it. +`TrackConsumer.update` now throws `MoqException` on a time anchor an untimed track can't serve. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Range` whose ends are `AnchorAbsolute` or `AnchorRelative` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 87c02cff362..6430cf04fc7 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -103,12 +103,14 @@ typealias Route = uniffi.moq.MoqRoute typealias Subscription = uniffi.moq.MoqSubscription /** A span of a track to deliver: from `start` up to `end`, or onward when `end` is null. */ typealias Range = uniffi.moq.MoqRange -/** Where one end of a [Range] falls: [AnchorAbsolute] or [AnchorRelative]. */ +/** Where one end of a [Range] falls: [AnchorAbsolute], [AnchorRelative], or [AnchorTime]. */ typealias Anchor = uniffi.moq.MoqAnchor /** An exact group and frame. */ typealias AnchorAbsolute = uniffi.moq.MoqAnchor.Absolute /** The group `groups` before the latest one, clamped to group 0: 0 is the latest. */ typealias AnchorRelative = uniffi.moq.MoqAnchor.Relative +/** The group containing `timestampUs`, a presentation time in microseconds. */ +typealias AnchorTime = uniffi.moq.MoqAnchor.Time /** Which pending group the publisher sends first: `ASCENDING` or `DESCENDING`. */ typealias Order = uniffi.moq.MoqOrder /** Options for fetching one past group by sequence. */ diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 980942e7d1e..ed785df58a3 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time + +A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains a `TIME` variant, the group containing a presentation time. +`TrackConsumer.update` now raises on a time anchor an untimed track can't serve. + ### Subscriptions carry group ranges (#5732) `Subscription.group_start` and `group_end` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/py/moq-rs/moq/subscribe.py b/py/moq-rs/moq/subscribe.py index 58cdd2901b1..c4251494082 100644 --- a/py/moq-rs/moq/subscribe.py +++ b/py/moq-rs/moq/subscribe.py @@ -149,7 +149,10 @@ def info(self) -> TrackInfo: return TrackInfo._from_ffi(self._inner.info()) def update(self, subscription: Subscription) -> None: - """Change this subscriber's delivery preferences.""" + """Change this subscriber's delivery preferences. + + Raises on a time anchor the track can't serve. + """ self._inner.update(subscription._ffi()) def cancel(self) -> None: diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md index ad5fd0d5672..fb2ee1b3fa4 100644 --- a/quest/m1/subscribe-ranges/dynamic.md +++ b/quest/m1/subscribe-ranges/dynamic.md @@ -11,19 +11,13 @@ kept beside it. ## Plan -`track::Subscription` already carries aggregated `ranges`, but only as a wire -preference: locally a subscriber's ranges set where its reads start, and it -still reads every later group the aggregate brings in. Serving a range locally -means filtering a subscriber's reads to its own ranges, and a miss below the -cache becomes a range request on `Dynamic` rather than a group request. +A subscriber already reads only its own ranges, re-resolved when they are +replaced, and a `Time(pts)` anchor resolves from the cached groups' starts, or +is refused on an untimed track. What remains is the miss: one below the cache +becomes a range request on `Dynamic` rather than a group request. -- Add a `Time(pts)` anchor beside `Absolute` and `Relative` (see the - [line](/quest/m1/subscribe-ranges/README.md)), carried through moq-ffi and - every binding like the other anchors. A track resolves it from its cached - groups' timestamps and passes an unresolved one to its `track::Dynamic` - handler; an untimed track refuses it. Test a time start and end resolved - against the cache, an unresolved one passed through to a handler, and an - untimed track refusing one. +- Pass a time anchor the cache can't resolve to the `track::Dynamic` handler + in its range request, and test one passed through. - Replace `requested_group` and `fetch_group` rather than keep both. Update every caller (relay, moq-archive, hls, the ladder) in the same PR, including the archive consumer's time resolution from its key listing if it @@ -34,7 +28,9 @@ cache becomes a range request on `Dynamic` rather than a group request. `moq_relay::fetch_group` (added in the 2026-10-05 audit). - Every sequence in range either arrives or is named as dropped, so a reader never waits on a miss. Share the accounting with - [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) rather than adding a second one. + [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) 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 @@ -43,17 +39,8 @@ cache becomes a range request on `Dynamic` rather than a group request. budget or lets one expired caller cancel another still-valid reader. Reuse FETCH's optional per-reader budget and publisher clamp rather than restoring a historical exemption. -- Move every relative-start resolution into the model, which counts back - from produced groups only. Today two consumers re-resolve relative starts - on their own. moq-c's `apply_raw_subscription` (`rs/moq-c/src/consume.rs`) - counts back from `Ordered::latest()`, the shared group and datagram - sequence, so a datagram past the newest group skews the start. It also - re-resolves on every update, because the model doesn't. moq-mux's - `container::Consumer::new` treats a relative start as an unfloored join - and adopts the first arrival, which under newest-first delivery can skip - the older groups the range asked for. -- Benchmark serving and filling over range count and span as separate axes, - beside `track_subscription_ranges`, which covers aggregation. +- Benchmark filling over range count and span as separate axes, beside + `track_range_serving`, which covers serving. ## Related diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 80890cd5231..401caafc781 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -583,8 +583,8 @@ pub struct moq_subscription { /// A span of a track to deliver: from `start` up to `end`, or onward without one. /// -/// A relative start is the first frame of its group, and a relative end includes its whole -/// group. An absolute end is exclusive. +/// A relative or time start is the first frame of its group, and a relative or time end +/// includes its whole group. An absolute end is exclusive. #[repr(C)] #[allow(non_camel_case_types)] #[derive(Clone, Copy)] @@ -605,7 +605,8 @@ pub struct moq_anchor { /// `moq_anchor_kind` discriminant. pub kind: u32, /// The group, or with `MOQ_ANCHOR_RELATIVE` how many groups before the latest one - /// (0 is the latest, and an offset past the first group clamps to it). + /// (0 is the latest, and an offset past the first group clamps to it), or with + /// `MOQ_ANCHOR_TIME` a presentation time in microseconds. pub group: u64, /// The frame within `group`, read only with `MOQ_ANCHOR_ABSOLUTE`. pub frame: u64, @@ -620,6 +621,8 @@ pub enum moq_anchor_kind { MOQ_ANCHOR_ABSOLUTE = 0, /// A count of groups back from the latest one. MOQ_ANCHOR_RELATIVE = 1, + /// The group containing a presentation time. An untimed track refuses it. + MOQ_ANCHOR_TIME = 2, } /// Which of a subscription's pending groups the publisher sends first. Zero is the default. @@ -642,6 +645,9 @@ fn parse_anchor(anchor: &moq_anchor) -> Result { }) } v if v == moq_anchor_kind::MOQ_ANCHOR_RELATIVE as u32 => moq_net::track::Anchor::Relative(anchor.group), + v if v == moq_anchor_kind::MOQ_ANCHOR_TIME as u32 => { + moq_net::track::Anchor::Time(moq_net::Timestamp::from_micros(anchor.group)?) + } _ => return Err(Error::InvalidCode), }) } diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 2597f60932a..5638cc2083c 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -1,7 +1,6 @@ use std::{ ffi::c_char, future::Future, - ops::Bound, pin::Pin, task::{Poll, ready}, }; @@ -683,37 +682,15 @@ impl Consume { } fn apply_raw_subscription(track: &mut moq_net::track::Ordered, subscription: Option) { - let subscription = subscription.unwrap_or_default(); - // Reads start where the lowest range does. Without a floor of its own (the oldest - // range, the default) a raw reader joins at the latest group. - let latest = match track.poll_largest(&kio::Waiter::noop()) { - std::task::Poll::Ready(Some(position)) => Some(position.group), - _ => None, - }; - let start = subscription - .ranges - .iter() - .map(|range| match range.start { - moq_net::track::Anchor::Absolute(start) => Some(start.group), - moq_net::track::Anchor::Relative(u64::MAX) => latest, - moq_net::track::Anchor::Relative(back) => latest.map(|latest| latest.saturating_sub(back)), - }) - .min() - .flatten(); - // They stop past the last absolute end; an open or relative one follows the live edge. - let end = subscription - .ranges - .iter() - .map(|range| match range.end { - Some(moq_net::track::Anchor::Absolute(end)) => Some(end), - _ => None, - }) - .collect::>>() - .map(|ends| ends.into_iter().max().unwrap_or_default()); - track.set_groups(( - start.map_or(Bound::Unbounded, Bound::Included), - end.map_or(Bound::Unbounded, moq_net::track::Position::group_end), - )); + let mut subscription = subscription.unwrap_or_default(); + // The subscription reads only its own ranges, resolved where it is served. A raw + // reader without a floor of its own (the oldest range, the default) joins at the + // latest group instead. + for range in &mut subscription.ranges { + if range.start == moq_net::track::Range::oldest().start { + range.start = moq_net::track::Range::live().start; + } + } // A closed track makes the update meaningless; the reader already sees the close. let _ = track.update(subscription); } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index ac8de2aea69..4ea412b7b0b 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1804,6 +1804,89 @@ fn raw_track_subscription_options_and_update() { assert_eq!(moq_origin_close(origin), 0); } +/// A time anchor starts at the group containing that presentation time. +#[test] +fn raw_track_time_anchor() { + let origin = id(moq_origin_create()); + let path = b"raw-track-time"; + let broadcast = publish_broadcast(origin, path); + + let track_name = b"data"; + let info = moq_track_info { + priority: 3, + max_age_us: 1_000_000, + max_age_present: true, + timescale: 1_000_000, + timescale_present: true, + }; + let track = + id(unsafe { moq_publish_track(broadcast, track_name.as_ptr() as *const c_char, track_name.len(), &info) }); + + let payloads: [&[u8]; 3] = [b"zero", b"one", b"two"]; + for (i, payload) in payloads.into_iter().enumerate() { + assert_eq!( + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), i as u64 * 20_000) }, + 0 + ); + } + + let consume = request_broadcast(origin, path); + + let frame_cb = Callback::new(); + let ranges = [moq_range { + start: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, + group: 30_000, + frame: 0, + }, + end: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, + group: 0, + frame: 0, + }, + end_present: false, + }]; + let subscription = moq_subscription { + priority: 5, + max_delay_us: 1_000_000, + ranges: ranges.as_ptr(), + ranges_len: ranges.len(), + order: 0, + }; + let consumer = id(unsafe { + moq_consume_track( + consume, + track_name.as_ptr() as *const c_char, + track_name.len(), + &subscription, + Some(channel_callback), + frame_cb.ptr, + ) + }); + + for expected in [&b"one"[..], b"two"] { + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 0, + timestamp_present: false, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; + assert_eq!(received, expected); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + } + + assert_eq!(moq_consume_track_cancel(consumer), 0); + assert_eq!(frame_cb.recv_terminal(), 0); + assert_eq!(moq_publish_track_finish(track), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn json_snapshot_publish_consume() { let origin = id(moq_origin_create()); diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index e75eb72a719..56a0a708a08 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,12 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Ranges filter reads, and anchors can name a time + +A subscription now reads only the groups its own `ranges` cover, rather than starting at the lowest one and taking whatever arrives. +`MoqAnchor` gains `Time { timestamp_us }`, the group containing that presentation time, so exhaustive matches need an arm for it. +`MoqTrackConsumer::update` now returns `Result<(), MoqError>`, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. + ### Subscriptions carry group ranges (#5732) `MoqSubscription::group_start` and `group_end` are replaced by `ranges`, a list of `MoqRange { start, end }` whose ends are `MoqAnchor::Absolute { group, frame }` or `MoqAnchor::Relative { groups }`, and an optional `order`. diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 4eb8e2945be..664e5165c26 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -38,9 +38,8 @@ pub struct MoqSubscription { /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. /// - /// A request to the publisher, aggregated across every subscriber. The one place it - /// touches this subscription's reads is where they start: at the lowest group any - /// range starts at. + /// A request to the publisher, aggregated across every subscriber, and this + /// subscription's read filter: it reads only the groups its own ranges cover. #[uniffi(default = None)] pub ranges: Option>, /// Which pending group the publisher sends first, or null for newest first. @@ -50,8 +49,8 @@ pub struct MoqSubscription { /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is null. /// -/// A relative start is the first frame of its group, and a relative end includes its whole -/// group. An absolute end is exclusive. +/// A relative or time start is the first frame of its group, and a relative or time end +/// includes its whole group. An absolute end is exclusive. #[derive(Clone, Copy, uniffi::Record)] pub struct MoqRange { /// Where delivery starts. @@ -68,6 +67,9 @@ pub enum MoqAnchor { Absolute { group: u64, frame: u64 }, /// The group this many before the latest, clamped to group 0: `0` is the latest group. Relative { groups: u64 }, + /// The group containing this presentation time, in microseconds. An untimed track + /// refuses it. + Time { timestamp_us: u64 }, } /// Which of a subscription's pending groups the publisher sends first, mirroring @@ -80,21 +82,28 @@ pub enum MoqOrder { Descending, } -impl From for moq_net::track::Anchor { - fn from(anchor: MoqAnchor) -> Self { - match anchor { +impl TryFrom for moq_net::track::Anchor { + type Error = MoqError; + + fn try_from(anchor: MoqAnchor) -> Result { + Ok(match anchor { MoqAnchor::Absolute { group, frame } => Self::Absolute(moq_net::track::Position { group, frame }), MoqAnchor::Relative { groups } => Self::Relative(groups), - } + MoqAnchor::Time { timestamp_us } => { + Self::Time(moq_net::Timestamp::from_micros(timestamp_us).map_err(|_| MoqError::TimeOverflow)?) + } + }) } } -impl From for moq_net::track::Range { - fn from(range: MoqRange) -> Self { - Self { - start: range.start.into(), - end: range.end.map(Into::into), - } +impl TryFrom for moq_net::track::Range { + type Error = MoqError; + + fn try_from(range: MoqRange) -> Result { + Ok(Self { + start: range.start.try_into()?, + end: range.end.map(TryInto::try_into).transpose()?, + }) } } @@ -128,18 +137,25 @@ impl From for moq_net::group::Fetch { } } -impl From for moq_net::track::Subscription { - fn from(s: MoqSubscription) -> Self { +impl TryFrom for moq_net::track::Subscription { + type Error = MoqError; + + 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)); if let Some(ranges) = s.ranges { - subscription = subscription.with_ranges(ranges.into_iter().map(Into::into)); + subscription = subscription.with_ranges( + ranges + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?, + ); } if let Some(order) = s.order { subscription = subscription.with_order(order.into()); } - subscription + Ok(subscription) } } @@ -255,7 +271,7 @@ impl MoqBroadcastConsumer { ) -> Result, MoqError> { self.driver .watch(async { - let subscription = subscription.map(moq_net::track::Subscription::from); + let subscription = subscription.map(moq_net::track::Subscription::try_from).transpose()?; let track = self.inner.track(&name)?.subscribe(subscription).await?; Ok(Arc::new(MoqTrackConsumer::new(track, self.driver.clone()))) }) @@ -663,9 +679,13 @@ impl MoqTrackConsumer { /// Change this subscriber's delivery preferences. /// /// Silently ignored if the track already ended; the update is meaningless at - /// that point. - pub fn update(&self, subscription: MoqSubscription) { - let _ = self.control.update(subscription.into()); + /// that point. Errors on a time anchor the track can't serve, leaving the + /// preferences as they were. + pub fn update(&self, subscription: MoqSubscription) -> Result<(), MoqError> { + match self.control.update(subscription.try_into()?) { + Ok(()) | Err(moq_net::Error::Closed) => Ok(()), + Err(err) => Err(err.into()), + } } /// Cancel all current and future reads. diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index d080bf65734..8b75a9da21e 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -1411,7 +1411,10 @@ impl MoqMediaTrackConsumer { // subscription if init parsing fails. let media = media_container(container)?; let resolved = broadcast.resolve_inner(reference.as_deref()).await?; - let subscription = subscription.map(moq_net::track::Subscription::from).unwrap_or_default(); + let subscription = subscription + .map(moq_net::track::Subscription::try_from) + .transpose()? + .unwrap_or_default(); let track = broadcast .driver .watch(async { Ok(resolved.track(&name)?.subscribe(subscription).await?) }) diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 9e30534e81a..a435dc166c1 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -517,7 +517,7 @@ impl MoqTrackProducer { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); let track = guard.as_ref().ok_or(MoqError::Closed)?; - let subscription = subscription.map(moq_net::track::Subscription::from); + let subscription = subscription.map(moq_net::track::Subscription::try_from).transpose()?; Ok(Arc::new(MoqTrackConsumer::new( track.subscribe(subscription), Background::default(), diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 001a0c66161..ef188372286 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -919,7 +919,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { end: None, }]), order: None, - }); + }).unwrap(); let payload = b"updated subscription".to_vec(); track @@ -3911,7 +3911,7 @@ async fn raw_track_update_during_pending_group_still_reads_datagram() { end: None, }]), order: None, - }); + }).unwrap(); let sequence = track.append_datagram(datagram_frame(b"after-update")).unwrap(); let datagram = tokio::time::timeout(TIMEOUT, consumer.recv_datagram()) @@ -6591,7 +6591,7 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { }]), order: Some(MoqOrder::Ascending), }; - let converted = moq_net::track::Subscription::from(subscription.clone()); + let converted = moq_net::track::Subscription::try_from(subscription.clone()).unwrap(); assert_eq!(converted.order, moq_net::track::Order::Ascending); assert_eq!( converted.ranges, @@ -6613,3 +6613,61 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { } assert_eq!(sequences, vec![0, 1, 2]); } + +/// A time anchor starts at the group containing it, and an untimed track refuses one. +#[tokio::test] +async fn time_anchors_start_at_the_containing_group() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("events".into(), Some(timed_info())).unwrap(); + for timestamp_us in [0, 1_000, 2_000] { + let group = track.append_group().unwrap(); + group + .write_frame(MoqFrame { + payload: b"x".to_vec(), + timestamp_us: Some(timestamp_us), + }) + .unwrap(); + group.finish().unwrap(); + } + + let since = |timestamp_us| MoqSubscription { + priority: 0, + max_delay_us: 10_000_000, + ranges: Some(vec![MoqRange { + start: MoqAnchor::Time { timestamp_us }, + end: None, + }]), + order: Some(MoqOrder::Ascending), + }; + let consumer = track.consume(Some(since(1_500))).unwrap(); + let mut sequences = Vec::new(); + for _ in 0..2 { + let group = tokio::time::timeout(TIMEOUT, consumer.recv_group()) + .await + .expect("timed out waiting for a cached group") + .unwrap() + .expect("expected a group"); + sequences.push(group.sequence()); + } + assert_eq!(sequences, vec![1, 2]); + + let untimed = broadcast + .publish_track( + "status".into(), + Some(MoqTrackInfo { + timescale: None, + ..timed_info() + }), + ) + .unwrap(); + let consumer = untimed.consume(None).unwrap(); + assert!(matches!( + consumer.update(since(0)), + Err(MoqError::Protocol { + details: MoqProtocolError { + kind: MoqProtocolKind::TimestampMismatch, + .. + } + }) + )); +} diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index fe26ab0ec30..c6de8b91805 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -161,17 +161,7 @@ impl Consumer { // edge from there. The budget is clamped to the track's retention window, // since history the publisher no longer keeps can't be waited for and would // otherwise stall the catch-up by the excess. - // Only absolute starts name a floor: a relative one counts back from wherever the - // publisher is, which the first arrivals say. - let start = subscription - .ranges - .iter() - .map(|range| match range.start { - moq_net::track::Anchor::Absolute(start) => Some(start.group), - moq_net::track::Anchor::Relative(_) => None, - }) - .collect::>>() - .and_then(|starts| starts.into_iter().min()); + let start = track.floor(); let max_delay = subscription .max_delay .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); @@ -290,6 +280,15 @@ impl Consumer { // (a gap below the served window looks like in-flight groups until the budget // expires, or forever on a quiet track), so the cursor adopts it from the // first arrivals instead of assuming group 0. Skipping stays the budget's job. + // A relative start names its floor once the first group to arrive resolves it. + if self.startup + && !self.newest + && let Some(start) = self.track.floor() + { + self.pending.retain(|group| group.sequence >= start); + self.current = start; + self.startup = false; + } if self.startup { // NOTE: poll_min_timestamp buffers at least one frame per group and // registers the waiter on the ones still empty. Any wire frame counts, so a @@ -2888,6 +2887,31 @@ mod tests { assert_eq!(micros, vec![0, 100_000], "the open group's late frames are delivered"); } + /// A relative start names its floor once resolved: delivery starts that many groups + /// back from the latest, rather than at whichever group arrives first. + #[tokio::test] + async fn startup_at_a_relative_start() { + tokio::time::pause(); + let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + for sequence in 5..=9 { + write_group(&mut track, sequence, &[ts(sequence * 100_000)]); + } + let subscription = moq_net::track::Subscription::default() + .with_max_delay(Duration::from_secs(10)) + .with_ranges([moq_net::track::Range { + start: moq_net::track::Anchor::Relative(2), + end: None, + }]); + let consumer_track = track.subscribe(subscription); + assert_eq!(consumer_track.floor(), Some(7)); + let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); + track.finish().unwrap(); + + let frames = read_all(&mut consumer).await.unwrap(); + let micros: Vec = frames.iter().map(|f| f.timestamp.as_micros()).collect(); + assert_eq!(micros, vec![700_000, 800_000, 900_000]); + } + #[tokio::test] async fn startup_single_group_mid_stream() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index bdcf4edb0b6..1aadbbf5f32 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter each subscriber's reads + +A `track::Subscriber` now reads only the groups its own `Subscription::ranges` cover, resolved again whenever `update` replaces them; before, they only set where its reads started. +A reader that relied on groups another subscriber asked for must widen its own ranges. +`track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. +Subscribing or updating with one on an untimed track fails with `Error::TimestampMismatch`. + ### Track readers follow `largest()` instead of reading `latest()` (#5725) `latest()` on `track::{Producer, Consumer, Subscriber, Ordered}` is gone. diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 73756c0f415..f0887e1e6ac 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -56,6 +56,10 @@ //! disjoint range, swept over how many ranges and how many groups each spans: reading the //! union must cost the same per range whatever it spans. //! +//! `track_range_serving` drains one reader whose own ranges are disjoint, swept over how +//! many ranges and how many cached groups each spans: each group handed out must cost the +//! same, so a walk over the ranges or the groups between them 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. @@ -107,6 +111,10 @@ const RANGES: [u64; 4] = [1, 16, 256, 4096]; /// Groups each aggregated range spans. const SPAN: [u64; 3] = [1, 64, 4096]; +/// Disjoint ranges one reader is served, and the cached groups each spans. +const SERVED_RANGES: [u64; 3] = [1, 16, 256]; +const SERVED_SPAN: [u64; 3] = [1, 8, 64]; + /// Concurrent publishers sharing one relay-style cache pool. const WRITERS: [usize; 4] = [1, 2, 4, 8]; @@ -811,6 +819,48 @@ fn bench_subscription_ranges(c: &mut Criterion) { group.finish(); } +fn bench_range_serving(c: &mut Criterion) { + let mut group = c.benchmark_group("track_range_serving"); + for span in SERVED_SPAN { + for ranges in SERVED_RANGES { + let broadcast = broadcast::Info::default().produce(); + let track = broadcast.create_track("bench", None).unwrap(); + // A gap between neighbors keeps every range distinct, and its group cached. + for _ in 0..ranges * (span + 1) { + let mut group = track.append_group().unwrap(); + group.write_frame(None, Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + } + let subscription = track::Subscription::default() + .with_max_delay(AGED_BUDGET) + .with_ranges((0..ranges).map(|i| { + let start = i * (span + 1); + track::Range::groups(start..start + span) + })); + + group.throughput(Throughput::Elements(ranges * span)); + group.bench_function(BenchmarkId::new(format!("span_{span}"), ranges), |b| { + b.iter_batched( + || track.subscribe(subscription.clone()), + |mut reader| { + let waiter = kio::Waiter::noop(); + let mut served = 0; + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + served += 1; + } + assert_eq!(served, ranges * span); + reader + }, + BatchSize::SmallInput, + ); + }); + drop((track, broadcast)); + } + } + group.finish(); +} + /// Late joins scan a bounded datagram ring; sweep both the backlog and the joining audience. fn bench_datagram_subscriber_join(c: &mut Criterion) { let mut group = c.benchmark_group("track_datagram_subscriber_join"); @@ -1213,6 +1263,7 @@ criterion_group!( bench_subscriber_join, bench_range_demand, bench_subscription_ranges, + bench_range_serving, bench_subscriber_preferences, bench_datagram_subscriber_join, bench_parked_read, diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index c958c4b7641..04a9bbe8863 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -397,6 +397,15 @@ impl Subscriber { } } + /// The floor the first copy answered with; see [`track::Subscriber::floor`]. Later + /// copies start where the reader got to, which names no floor of the reader's own. + pub(super) fn floor(&self) -> Option { + self.reader().copies.iter().find_map(|copy| match ©.sub { + Sub::Ready(sub) if copy.floor.is_none() => Some(sub.floor()), + _ => None, + })? + } + /// Poll for sequences the serving copy gave up; see [`track::Subscriber::poll_drop`]. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(super) fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll> { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index a7f8c6adc82..2cf19e0ce13 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -3,6 +3,11 @@ use std::{ time::Duration, }; +use crate::Timestamp; + +mod window; +pub(super) use window::{Containing, Holdings, Window}; + /// Subscriber-side preferences for receiving a track. /// /// Each subscriber holds its own [`Subscription`]; the publisher observes an @@ -70,11 +75,10 @@ pub struct Subscription { /// for nothing. /// /// A request to the publisher, aggregated across every live subscriber as the union of - /// their ranges, so it says what the publisher sends, not what any one subscriber sees. - /// The one place it touches this subscriber's reads is where its cursor starts: at the - /// lowest group any of its ranges names. [`crate::track::Subscriber::set_groups`] is the - /// local read filter. See [Local cursor vs wire - /// preference](crate::track::Subscriber#local-cursor-vs-wire-preference). + /// their ranges, and this subscriber's read filter: it reads only the groups its own + /// ranges cover, whatever another subscriber widens the union to. + /// [`crate::track::Subscriber::set_groups`] narrows the reads further. See [Local cursor + /// vs wire preference](crate::track::Subscriber#local-cursor-vs-wire-preference). pub ranges: Vec, /// Which pending group the publisher sends first; see [`Order`]. /// @@ -140,28 +144,37 @@ pub enum Order { Descending, } -/// Where one end of a [`Range`] falls: a fixed position, or a count of groups back from the -/// latest one. +/// Where one end of a [`Range`] falls: a fixed position, a count of groups back from the +/// latest one, or a presentation time. /// -/// A relative anchor resolves against the latest group of the hop that serves it, once, -/// when it starts serving. Before any group exists it resolves at the first one, so -/// [`Range::live`] starts there. +/// A relative or time anchor resolves at the hop that serves it, when it starts serving +/// and again whenever the subscriber replaces its ranges. Before any group exists a +/// relative anchor resolves at the first one, so [`Range::live`] starts there. /// -/// No published wire carries one yet: upstream, a relative start is no floor, the same -/// request as [`Range::oldest`], and a relative end is no end. Behind a moq-transport upstream, -/// which joins without a floor at the live group, `Relative(n)..` starts there. +/// No published wire carries either yet: upstream, a relative or time start is no floor, +/// the same request as [`Range::oldest`], and a relative or time end is no end. Behind a +/// moq-transport upstream, which joins without a floor at the live group, `Relative(n)..` +/// starts there. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Anchor { /// This exact position. Absolute(Position), /// The group this many before the latest, clamped to group 0: `Relative(0)` is the latest group. Relative(u64), + /// The group containing this presentation time: the newest one starting at or before it. + /// + /// Resolved from the starts of the groups the serving hop holds. When none starts at + /// or before it, a start reaches back to the oldest group, and while no group has + /// started at all it waits like `Relative(0)`. An end stays open until a group + /// starting after it is known. An untimed track refuses it with + /// [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). + Time(Timestamp), } /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is `None`. /// -/// Relative anchors cover whole groups: a relative start is frame 0 of its group, and a -/// relative end includes its group, so `Absolute(0)..Relative(0)` delivers through the latest +/// Relative and time anchors cover whole groups: such a start is frame 0 of its group, and +/// such an end includes its group, so `Absolute(0)..Relative(0)` delivers through the latest /// group. An absolute end is exclusive, like the end of a [`std::ops::Range`]: see /// [`Position::after`] and [`Position::after_group`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -208,16 +221,6 @@ impl Range { } } - /// The first position this range delivers, or `None` while it waits on a latest group. - fn resolve_start(&self, latest: Option) -> Option { - match self.start { - Anchor::Absolute(start) => Some(start), - // Nothing sits below group 0, so the farthest offset needs no latest group. - Anchor::Relative(u64::MAX) => Some(Position::default()), - Anchor::Relative(back) => Some(Position::group(latest?.saturating_sub(back))), - } - } - /// Whether this range certainly delivers nothing, wherever the latest group lands. pub(super) fn is_empty(&self) -> bool { match (self.start, self.end) { @@ -279,6 +282,8 @@ pub(super) fn union(ranges: impl IntoIterator) -> Vec { (Anchor::Absolute(_), None | Some(Anchor::Absolute(_))) | (Anchor::Relative(_), None) => false, (Anchor::Absolute(start), _) => tail.is_none_or(|tail| start < tail), (Anchor::Relative(back), _) => farthest.is_none_or(|farthest| back > farthest), + // A time can't be placed against the others without the hop's groups. + (Anchor::Time(_), _) => true, })); union.extend(farthest.map(|back| Range { start: Anchor::Relative(back), @@ -396,8 +401,8 @@ impl Subscription { /// These preferences with every range raised to start no earlier than `floor` and /// stop no later than `end`, the exclusive end. /// - /// A relative anchor becomes the bound itself, since which of the two is tighter - /// depends on a latest group this hop may not know. That asks for no less than the + /// A relative or time anchor becomes the bound itself, since which of the two is tighter + /// depends on groups this hop may not know. That asks for no less than the /// range would. pub(super) fn clamped(&self, floor: Option, end: Option) -> Self { let ranges = self @@ -407,7 +412,7 @@ impl Subscription { start: match (range.start, floor) { (start, None) => start, (Anchor::Absolute(start), Some(floor)) => Anchor::Absolute(start.max(floor)), - (Anchor::Relative(_), Some(floor)) => Anchor::Absolute(floor), + (Anchor::Relative(_) | Anchor::Time(_), Some(floor)) => Anchor::Absolute(floor), }, end: match (range.end, end) { (requested, None) => requested, @@ -419,17 +424,11 @@ impl Subscription { Self { ranges, ..self.clone() } } - /// The lowest group this subscription's ranges start at, resolving relative starts - /// against `latest`. `None` while one waits on a latest group that does not exist yet. - /// - /// Empty ranges ask for nothing, and no ranges at all floor nowhere: group 0. - pub(super) fn floor(&self, latest: Option) -> Option { - let mut floor = None; - for range in self.ranges.iter().filter(|range| !range.is_empty()) { - let start = range.resolve_start(latest)?.group; - floor = Some(floor.map_or(start, |floor: u64| floor.min(start))); - } - Some(floor.unwrap_or(0)) + /// Whether any range ends at a presentation time, which an untimed track refuses. + pub(super) fn has_time(&self) -> bool { + self.ranges.iter().any(|range| { + matches!(range.start, Anchor::Time(_)) || matches!(range.end, Some(Anchor::Time(_))) + }) } } @@ -666,26 +665,40 @@ mod tests { /// group exists only the farthest offset resolves, since it is group 0 regardless. #[test] fn relative_starts_resolve_against_the_latest_group() { + struct Untimed; + impl Holdings for Untimed { + fn timed(&self) -> bool { + false + } + fn containing(&self, _: Timestamp) -> Containing { + Containing::Unknown + } + fn after(&self, _: Timestamp, _: u64) -> Option { + None + } + } + let floor = |sub: &Subscription, latest| Window::new(&sub.ranges, latest, &Untimed).unwrap().floor(); + let live = Subscription::default().with_ranges([Range::live()]); - assert_eq!(live.floor(Some(7)), Some(7)); - assert_eq!(live.floor(None), None); + assert_eq!(floor(&live, Some(7)), Some(7)); + assert_eq!(floor(&live, None), None); let buffering = Subscription::default().with_ranges([range(Anchor::Relative(3), None)]); - assert_eq!(buffering.floor(Some(7)), Some(4)); - assert_eq!(buffering.floor(Some(1)), Some(0), "an offset past the start clamps"); + assert_eq!(floor(&buffering, Some(7)), Some(4)); + assert_eq!(floor(&buffering, Some(1)), Some(0), "an offset past the start clamps"); - assert_eq!(Subscription::default().floor(None), Some(0)); + assert_eq!(floor(&Subscription::default(), None), Some(0)); // The lowest range wins, and one unresolved range leaves the whole floor unresolved. let mixed = Subscription::default().with_ranges([range(absolute(4, 0), None), Range::live()]); - assert_eq!(mixed.floor(Some(3)), Some(3)); - assert_eq!(mixed.floor(Some(9)), Some(4)); - assert_eq!(mixed.floor(None), None); + assert_eq!(floor(&mixed, Some(3)), Some(3)); + assert_eq!(floor(&mixed, Some(9)), Some(4)); + assert_eq!(floor(&mixed, None), None); // Nothing asked for floors nowhere, and an empty range asks for nothing. - assert_eq!(Subscription::default().with_ranges([]).floor(None), Some(0)); + assert_eq!(floor(&Subscription::default().with_ranges([]), None), Some(0)); let empty = Subscription::default().with_ranges([Range::groups(5..5), Range::groups(10..)]); - assert_eq!(empty.floor(None), Some(10)); + assert_eq!(floor(&empty, None), Some(10)); } /// The bounds a wire without ranges carries: the lowest absolute start and the highest diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs new file mode 100644 index 00000000000..a149bce1202 --- /dev/null +++ b/rs/moq-net/src/model/subscription/window.rs @@ -0,0 +1,430 @@ +//! A subscriber's own ranges, resolved to the group sequences it reads. +//! +//! The aggregate a publisher sees is the union of every subscriber's ranges, so it says +//! what arrives, not what one subscriber asked for. A [`Window`] is the per-subscriber +//! side: resolved at the hop serving the subscriber, it filters that subscriber's reads. + +use std::ops; + +use rangemap::RangeInclusiveSet; + +use super::{Anchor, Range}; +use crate::{Error, Result, Timestamp}; + +/// When a serving hop's groups start, for resolving time anchors. +pub(in crate::model) trait Holdings { + /// Whether the track carries timestamps at all. + fn timed(&self) -> bool; + + /// Where `pts` falls among the groups with a known start. + fn containing(&self, pts: Timestamp) -> Containing; + + /// The lowest group at or above `from` known to start after `pts`. + fn after(&self, pts: Timestamp, from: u64) -> Option; +} + +/// Where a presentation time falls among the groups a hop holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::model) enum Containing { + /// The newest group starting at or before it. + Group(u64), + /// Every group with a known start begins after it. + Before, + /// No group has a known start. + Unknown, +} + +/// A subscriber's [`Range`]s resolved to the group sequences it reads. +#[derive(Debug)] +pub(in crate::model) struct Window { + /// What these were resolved from, so only replacing the ranges resolves again. + ranges: Vec, + spans: Vec, + /// The groups the spans cover, an unresolved time end counting as open. + groups: 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 + /// per group, not once per range. + settled: bool, +} + +/// One non-empty range, resolved. +#[derive(Clone, Copy, Debug)] +struct Span { + start: u64, + end: End, + /// Whether the start names a group, rather than reaching back to the oldest one. + floored: bool, +} + +#[derive(Clone, Copy, Debug)] +enum End { + /// Before this group. + Before(u64), + /// Past every group. + Open, + /// Before the first group starting after this time, once one is known. + Time(Timestamp), +} + +impl Window { + /// Resolve `ranges`, counting relative anchors back from `latest`. + /// + /// Refuses a time anchor on an untimed track with [`Error::TimestampMismatch`]. + pub fn new(ranges: &[Range], latest: Option, holdings: &impl Holdings) -> Result { + let mut window = Self { + ranges: ranges.to_vec(), + spans: Vec::new(), + groups: RangeInclusiveSet::new(), + pending: false, + settled: false, + }; + window.place(latest, holdings)?; + Ok(window) + } + + /// Whether nothing is left to resolve as groups arrive. + pub fn is_settled(&self) -> bool { + self.settled + } + + /// Whether a relative anchor still waits on a first group. + pub fn is_pending(&self) -> bool { + self.pending + } + + /// Resolve what the groups now held settle: relative anchors once `latest` exists, and + /// time ends once a group starting after them is known. Returns whether anything moved. + pub fn settle(&mut self, latest: Option, holdings: &impl Holdings) -> bool { + if self.pending { + if latest.is_none() { + return false; + } + // Checked when the ranges were first resolved, against the same track. + let _ = self.place(latest, holdings); + return !self.pending; + } + let mut moved = false; + for span in &mut self.spans { + if let End::Time(pts) = span.end + && let Some(end) = holdings.after(pts, span.start) + { + span.end = End::Before(end); + moved = true; + } + } + if moved { + self.rebuild(); + } + moved + } + + /// Whether group `sequence` is in range. Everything is while a relative anchor waits. + pub fn contains(&self, sequence: u64) -> bool { + self.pending || self.groups.contains(&sequence) + } + + /// The lowest group in range, `None` while a relative anchor waits. Nothing in range + /// floors nowhere: group 0. + pub fn floor(&self) -> Option { + match self.pending { + true => None, + false => Some(self.groups.iter().next().map_or(0, |run| *run.start())), + } + } + + /// The lowest group the ranges name, `None` while one reaches back to the oldest group + /// or a relative anchor waits on a first group. + pub fn start(&self) -> Option { + if self.pending || self.spans.iter().any(|span| !span.floored) { + return None; + } + self.spans.iter().map(|span| span.start).min() + } + + /// The exclusive end past every group in range, `None` when one stays open. + pub fn end(&self) -> Option { + if self.pending { + return None; + } + match self.groups.iter().next_back() { + Some(run) => run.end().checked_add(1), + None => Some(0), + } + } + + /// The groups in range from `from` on, lowest first. + pub fn runs(&self, from: u64) -> impl Iterator> + '_ { + let all = self.pending.then(|| from..=u64::MAX); + let runs = (!self.pending).then(|| { + self.groups + .overlapping(from..=u64::MAX) + .map(move |run| (*run.start()).max(from)..=*run.end()) + }); + all.into_iter().chain(runs.into_iter().flatten()) + } + + /// 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)?; + if self.pending { + return Some(range); + } + let run = self.groups.overlapping(range.start..=last).next()?; + let end = match run.end().checked_add(1) { + Some(end) => end.min(range.end), + None => range.end, + }; + Some((*run.start()).max(range.start)..end) + } + + fn place(&mut self, latest: Option, holdings: &impl Holdings) -> Result<()> { + let time = |anchor: Option| matches!(anchor, Some(Anchor::Time(_))); + if !holdings.timed() && self.ranges.iter().any(|range| time(Some(range.start)) || time(range.end)) { + return Err(Error::TimestampMismatch); + } + self.spans.clear(); + self.pending = false; + for range in self.ranges.iter().filter(|range| !range.is_empty()) { + let (Some((start, floored)), Some(end)) = (start(range.start, latest, holdings), end(range.end, latest)) + else { + self.pending = true; + continue; + }; + let end = match end { + End::Time(pts) => holdings.after(pts, start).map_or(end, End::Before), + end => end, + }; + self.spans.push(Span { start, end, floored }); + } + self.rebuild(); + Ok(()) + } + + fn rebuild(&mut self) { + self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); + self.groups = RangeInclusiveSet::new(); + for span in &self.spans { + let last = match span.end { + End::Before(end) => match end.checked_sub(1) { + Some(last) if last >= span.start => last, + _ => continue, + }, + End::Open | End::Time(_) => u64::MAX, + }; + self.groups.insert(span.start..=last); + } + } +} + +/// The first group a range starts at, and whether it names that group rather than +/// reaching back to the oldest one. `None` while it waits on a latest group. +fn start(anchor: Anchor, latest: Option, holdings: &impl Holdings) -> Option<(u64, bool)> { + match anchor { + Anchor::Absolute(start) => Some((start.group, true)), + // Nothing sits below group 0, so the farthest offset needs no latest group. + Anchor::Relative(u64::MAX) => Some((0, false)), + Anchor::Relative(back) => Some((latest?.saturating_sub(back), true)), + Anchor::Time(pts) => match holdings.containing(pts) { + Containing::Group(group) => Some((group, true)), + // The group holding it is older than any held, so reach back to the oldest. + Containing::Before => Some((0, false)), + // Nothing has started yet: like the live edge, the latest group. + Containing::Unknown => Some((latest?, true)), + }, + } +} + +/// Where a range stops, or `None` while it waits on a latest group. +fn end(anchor: Option, latest: Option) -> Option { + Some(match anchor { + None => End::Open, + Some(Anchor::Absolute(end)) => end.final_sequence().map_or(End::Open, End::Before), + Some(Anchor::Relative(back)) => latest? + .saturating_sub(back) + .checked_add(1) + .map_or(End::Open, End::Before), + Some(Anchor::Time(pts)) => End::Time(pts), + }) +} + +#[cfg(test)] +mod tests { + use super::super::Position; + use super::*; + + /// Groups by sequence with their start in milliseconds, `None` for one not started. + struct Starts(Vec<(u64, Option)>); + + impl Holdings for Starts { + fn timed(&self) -> bool { + true + } + + fn containing(&self, pts: Timestamp) -> Containing { + let mut stamped = false; + for (sequence, start) in self.0.iter().rev() { + let Some(start) = start else { continue }; + stamped = true; + if Timestamp::from_millis(*start).unwrap() <= pts { + return Containing::Group(*sequence); + } + } + match stamped { + true => Containing::Before, + false => Containing::Unknown, + } + } + + fn after(&self, pts: Timestamp, from: u64) -> Option { + self.0 + .iter() + .filter(|(sequence, _)| *sequence >= from) + .find_map(|(sequence, start)| { + start + .filter(|start| Timestamp::from_millis(*start).unwrap() > pts) + .map(|_| *sequence) + }) + } + } + + struct Untimed; + + impl Holdings for Untimed { + fn timed(&self) -> bool { + false + } + fn containing(&self, _: Timestamp) -> Containing { + Containing::Unknown + } + fn after(&self, _: Timestamp, _: u64) -> Option { + None + } + } + + fn time(millis: u64) -> Anchor { + Anchor::Time(Timestamp::from_millis(millis).unwrap()) + } + + fn range(start: Anchor, end: impl Into>) -> Range { + Range { start, end: end.into() } + } + + fn covered(window: &Window, upto: u64) -> Vec { + (0..upto).filter(|sequence| window.contains(*sequence)).collect() + } + + /// Disjoint ranges filter to exactly their groups; the gap between them is out. + #[test] + fn ranges_cover_only_their_groups() { + let window = Window::new(&[Range::groups(2..4), Range::groups(7..)], Some(9), &Untimed).unwrap(); + assert_eq!(covered(&window, 10), vec![2, 3, 7, 8, 9]); + assert_eq!(window.floor(), Some(2)); + assert_eq!(window.end(), None); + assert_eq!(window.runs(3).collect::>(), vec![3..=3, 7..=u64::MAX]); + assert_eq!(window.clip(0..10), Some(2..4)); + assert_eq!(window.clip(4..10), Some(7..10)); + assert_eq!(window.clip(4..7), None); + + // A mid-group end includes its group, a head-of-group end excludes it. + let window = Window::new( + &[range( + Anchor::Absolute(Position { group: 1, frame: 2 }), + Anchor::Absolute(Position { group: 3, frame: 1 }), + )], + None, + &Untimed, + ) + .unwrap(); + assert_eq!(covered(&window, 6), vec![1, 2, 3]); + assert_eq!(window.end(), Some(4)); + + // Nothing asked for is nothing in range, floored nowhere. + let window = Window::new(&[], None, &Untimed).unwrap(); + assert_eq!(covered(&window, 4), Vec::::new()); + assert_eq!((window.floor(), window.end()), (Some(0), Some(0))); + } + + /// Relative anchors count back from the latest group, both ends whole groups, and wait + /// for a first group when there is none. + #[test] + fn relative_anchors_count_back_from_the_latest_group() { + let window = Window::new(&[range(Anchor::Relative(3), Anchor::Relative(1))], Some(7), &Untimed).unwrap(); + assert_eq!(covered(&window, 10), vec![4, 5, 6]); + + let mut window = Window::new(&[Range::live()], None, &Untimed).unwrap(); + assert!(window.is_pending() && window.contains(3) && window.floor().is_none()); + assert!(!window.settle(None, &Untimed)); + assert!(window.settle(Some(5), &Untimed)); + assert_eq!(covered(&window, 8), vec![5, 6, 7]); + assert!(window.is_settled()); + + // The farthest offset needs no latest group, and names no floor. + let window = Window::new(&[Range::oldest()], None, &Untimed).unwrap(); + assert_eq!((window.floor(), window.start()), (Some(0), None)); + } + + /// The named floor is the lowest start, unless a range reaches back to the oldest + /// group or still waits on a first one; an explicit group 0 names one. + #[test] + fn the_named_start_is_the_lowest_unless_one_reaches_back() { + let start = |ranges: &[Range], latest| Window::new(ranges, latest, &Untimed).unwrap().start(); + assert_eq!(start(&[Range::groups(4..), Range::live()], Some(9)), Some(4)); + assert_eq!(start(&[Range::groups(4..), Range::live()], Some(2)), Some(2)); + assert_eq!(start(&[Range::groups(0..)], None), Some(0)); + assert_eq!(start(&[Range::groups(4..), Range::live()], None), None); + assert_eq!(start(&[Range::groups(4..), Range::oldest()], Some(9)), None); + assert_eq!(start(&[Range::groups(4..), Range::groups(..2)], Some(9)), None); + } + + /// A time start is the group containing it, an end stops just past that group, and an + /// end nothing has started after yet stays open until something does. + #[test] + fn time_anchors_resolve_from_group_starts() { + let starts = Starts(vec![(2, Some(2000)), (3, Some(3000)), (5, Some(5000)), (6, None)]); + let window = Window::new(&[range(time(3500), time(4500))], Some(6), &starts).unwrap(); + assert_eq!(covered(&window, 8), vec![3, 4]); + assert!(window.is_settled()); + + // Group 6 may yet start at 5000 too, so an end there waits on it. + let window = Window::new(&[range(time(3500), time(5000))], Some(6), &starts).unwrap(); + assert!(!window.is_settled()); + assert_eq!(covered(&window, 8), vec![3, 4, 5, 6, 7]); + + // Before every start held: reach back to the oldest. + let window = Window::new(&[range(time(1000), time(2500))], Some(6), &starts).unwrap(); + assert_eq!(covered(&window, 8), vec![0, 1, 2]); + + // Past the newest start: open until a later group is known to start after it. + let mut starts = Starts(vec![(2, Some(2000)), (3, Some(3000))]); + let mut window = Window::new(&[range(time(3500), time(3600))], Some(3), &starts).unwrap(); + assert!(!window.is_settled()); + assert!(window.contains(4)); + starts.0.push((4, None)); + assert!(!window.settle(Some(4), &starts), "an unstarted group proves nothing"); + starts.0.push((5, Some(5000))); + assert!(window.settle(Some(5), &starts)); + assert_eq!(covered(&window, 8), vec![3, 4]); + + // Nothing started yet: a time start waits like the live edge. + let starts = Starts(Vec::new()); + let mut window = Window::new(&[range(time(1000), None)], None, &starts).unwrap(); + assert!(window.is_pending()); + let starts = Starts(vec![(4, None)]); + assert!(window.settle(Some(4), &starts)); + assert_eq!(window.floor(), Some(4)); + } + + #[test] + fn an_untimed_track_refuses_time_anchors() { + assert!(matches!( + Window::new(&[range(time(0), None)], None, &Untimed), + Err(Error::TimestampMismatch) + )); + assert!(matches!( + Window::new(&[range(Anchor::Relative(0), time(0))], None, &Untimed), + Err(Error::TimestampMismatch) + )); + } +} diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 75dfa3981ed..766c328e7a2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -21,7 +21,7 @@ use super::ledger::{self, Ledger}; use super::{Datagram, Requests}; use super::Cap; -use super::subscription::NewGroup; +use super::subscription::{Containing, Holdings, NewGroup, Window}; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; use rangemap::RangeSet; @@ -564,6 +564,15 @@ impl Slot { } } + /// Where the copy readers get starts in presentation time, if it has started. + fn start(&self) -> Option { + let group = self.serving(); + match group.is_aborted() { + true => None, + false => group.timestamp(), + } + } + /// The checked start of whichever copy began at frame 0. fn bound(&self) -> Option { self.copies().find_map(group::Producer::bound) @@ -626,6 +635,49 @@ pub(crate) struct FetchOutcome { accepted: bool, } +/// Whether `time` is strictly after `other`, as instants: exact across scales, unlike +/// [`Timestamp`]'s `Ord`, which breaks a tie between scales. +fn later(time: Timestamp, other: Timestamp) -> bool { + u128::from(time.value()) * u128::from(other.scale().as_u64()) + > u128::from(other.value()) * u128::from(time.scale().as_u64()) +} + +/// Where the groups this hop holds start, for resolving time anchors. Walks down from the +/// newest group, so a time near the live edge costs a few groups whatever the cache holds. +impl Holdings for TrackState { + fn timed(&self) -> bool { + self.info.as_ref().is_none_or(|info| info.timescale.is_some()) + } + + fn containing(&self, pts: Timestamp) -> Containing { + let mut started = false; + for (&sequence, slot) in self.lookup.iter().rev() { + let Some(start) = slot.start() else { continue }; + if !later(start, pts) { + return Containing::Group(sequence); + } + started = true; + } + match started { + true => Containing::Before, + false => Containing::Unknown, + } + } + + fn after(&self, pts: Timestamp, from: u64) -> Option { + let mut after = None; + for (&sequence, slot) in self.lookup.range(from..).rev() { + match slot.start() { + Some(start) if later(start, pts) => after = Some(sequence), + Some(_) => break, + // Not started, so it proves nothing either way. + None => {} + } + } + after + } +} + impl TrackState { /// The starts a first frame of group `sequence` must fall between: the nearest checked /// starts cached on either side, since groups land out of sequence order, and the @@ -810,7 +862,7 @@ impl TrackState { self.shown(sequence); } - /// Find the smallest-sequence cached group satisfying + /// Find the smallest-sequence cached group in `window` satisfying /// `next_sequence <= seq < end_sequence (if set)`. Used by /// [`Subscriber::next_group`] so the range can be widened (or unset) /// after the fact and previously-skipped cached groups become available @@ -823,6 +875,7 @@ impl TrackState { &self, next_sequence: u64, end_sequence: Option, + window: &Window, ) -> Poll>> { // Nothing more can arrive once the track was sealed (dropped with its end // declared) or aborted. Either closed the channel, so parking is not an option: @@ -836,10 +889,14 @@ impl TrackState { }; // If the exclusive end is already at or below where we'd resume, no - // group can ever satisfy this call until the cap rises. Pending (not - // None) so the consumer is parked rather than told the stream is over. - // An empty range (`end == 0`) parks even at the first sequence. - if let Some(cap) = end_sequence + // group can ever satisfy this call until the cap rises or the ranges widen. + // Pending (not None) so the consumer is parked rather than told the stream + // is over. An empty range (`end == 0`) parks even at the first sequence. + let cap = match (end_sequence, window.end()) { + (Some(cap), Some(end)) => Some(cap.min(end)), + (cap, end) => cap.or(end), + }; + if let Some(cap) = cap && cap <= next_sequence { if closed { @@ -848,13 +905,16 @@ impl TrackState { return Poll::Pending; } - let best = self - .lookup - .range(next_sequence.max(self.live_floor.unwrap_or(0))..) + let best = window + .runs(next_sequence.max(self.live_floor.unwrap_or(0))) .filter(|_| self.readable()) - .map(|(_, slot)| slot.serving()) - .take_while(|group| super::subscription::before_end(group.sequence, end_sequence)) - .find(|group| !group.is_aborted()); + .find_map(|run| { + self.lookup + .range(run) + .map(|(_, slot)| slot.serving()) + .take_while(|group| super::subscription::before_end(group.sequence, end_sequence)) + .find(|group| !group.is_aborted()) + }); if let Some(group) = best { // Deliberately no cache refresh here: this is a pure seek, and the caller may @@ -2759,10 +2819,10 @@ impl Producer { /// The info is fixed at creation, so there's nothing to wait for (no /// SUBSCRIBE_OK round trip). Pass `None` for [`Subscription::default`]. /// - /// The read cursor starts at the group the subscription named (its floor), or 0. - /// [`Subscription::max_delay`] is what asks for data: delivery skips everything above - /// the floor that the budget convicts, so the default budget of zero delivers only - /// the latest group and a larger one reaches back over what it can still use. + /// Reads only the groups the subscription's ranges cover. + /// [`Subscription::max_delay`] is what asks for data: delivery skips everything in range + /// that the budget convicts, so the default budget of zero delivers only the latest + /// group and a larger one reaches back over what it can still use. pub fn subscribe(&self, subscription: impl Into>) -> Subscriber { let preferences = subscription.into().unwrap_or_default(); @@ -3548,10 +3608,10 @@ impl Consumer { /// [`Subscriber`] once the track info is available, or the track's abort error (or /// [`Error::Dropped`]) if it is already closed. /// - /// The read cursor starts at the group the subscription named (its floor), or 0. - /// [`Subscription::max_delay`] is what asks for data: delivery skips everything above - /// the floor that the budget convicts, so the default budget of zero delivers only - /// the latest group and a larger one reaches back over what it can still use. + /// Reads only the groups the subscription's ranges cover. + /// [`Subscription::max_delay`] is what asks for data: delivery skips everything in range + /// that the budget convicts, so the default budget of zero delivers only the latest + /// group and a larger one reaches back over what it can still use. pub fn subscribe(&self, subscription: impl Into>) -> kio::Pending { let state = self.resolve(); let subscription = Preferences::register(state.read(), subscription.into().unwrap_or_default()); @@ -3936,6 +3996,7 @@ impl Subscribing { // Wait until the track info is available let info = ready!(self.state.poll(waiter, |state| state.poll_info())) .map_err(|e| e.abort.clone().unwrap_or(Error::Dropped))??; + refuse_untimed(&info, &self.subscription.read())?; let resume = self.state.read().routes.clone(); let inner = match resume { @@ -4359,25 +4420,20 @@ impl kio::Task for Fetching { /// /// # Local cursor vs wire preference /// -/// Group bounds exist at two levels, and setting one does not imply the other: -/// -/// - [`Self::set_groups`] limits **this subscriber's reads**, filtering exactly what -/// this handle returns without changing the publisher's demand. -/// - [`Subscription::ranges`], set via [`Self::update`], are a **request to the -/// publisher**. They're aggregated across every live subscriber (their union), so they -/// say what the publisher should send, not what this subscriber sees. +/// Group bounds exist at two levels: /// -/// They stay separate because their scopes differ: a subscriber can't filter by the -/// aggregate, since another subscriber can widen it, and the publisher can't honor a -/// cursor it's never told about. So setting only the cursor still transfers the skipped -/// groups, and setting only the preference still returns groups another subscriber asked -/// for. Set both to skip them *and* avoid the transfer. +/// - [`Subscription::ranges`], set via [`Self::update`], are both a **request to the +/// publisher** and **this subscriber's read filter**. The publisher sees the union across +/// every live subscriber, but each subscriber reads only the groups its own ranges cover, +/// resolved where it is served: a relative anchor counted back from the latest group (or +/// the first to arrive, before any exists) and a time anchor from the cached groups' +/// starts, again whenever the ranges are replaced. +/// - [`Self::set_groups`] narrows **this subscriber's reads** further, without changing the +/// publisher's demand. /// -/// The one place they meet is where the cursor comes from. A new subscriber's cursor is -/// floored at the lowest group its own ranges start at, a relative start counted back from -/// the latest group (or the first to arrive, before any exists), and its -/// [`Subscription::max_delay`] decides what above the floor is worth delivering. Every later -/// move is the caller's. +/// A group outside either is held rather than skipped past, so widening either one later +/// makes it available again. Within both, [`Subscription::max_delay`] decides what is +/// still worth delivering, measured against the track's live edge. pub struct Subscriber { name: Arc, // The broadcast this track belongs to; see [`Self::broadcast`]. @@ -4549,9 +4605,18 @@ struct Cursor { datagram_index: usize, /// Minimum sequence to return from any `recv` method. Set by `start_at`. min_sequence: u64, - /// Whether the subscription's floor still waits on the track's first group: a range - /// counting back from the latest group cannot resolve before one exists. - floor_pending: bool, + /// This subscriber's own ranges, resolved: only the groups in it are read. + window: Window, + /// The [`Preferences::ranges_revision`] the window was resolved from. + ranges_revision: u64, + /// Bumped whenever [`Self::window`] moves, so the drop scan knows to look again. + window_revision: u64, + /// Why the ranges can't be served here: a time anchor on an untimed track. + refused: Option, + /// The window revision and cap at which no parked group was in reach. A group is only + /// parked out of reach, so the walk waits for either to move rather than running once + /// per hand-out over every range. + reoffer_idle: Option<(u64, Option)>, /// One past the highest sequence returned by `next_group`, or `None` once it returned /// `u64::MAX` and no group can follow. /// Used only by that method to skip late arrivals; does not affect `recv_group`. @@ -4589,6 +4654,13 @@ struct Cursor { drops: Option>, } +/// The sequences a [`DropCursor`] reports from: a cursor's span, narrowed to its window. +struct Within<'a> { + span: ops::Range, + window: &'a Window, + revision: u64, +} + /// What a wire publisher's cursor gave up, found as it changes rather than by rescanning /// the cache on every poll. struct DropCursor { @@ -4597,9 +4669,10 @@ struct DropCursor { cuts: super::aborts::Watch, /// Sequences given up and not reported yet: stale skips, cuts, and upstream drops. pending: RangeSet, - /// The [`TrackState::drops_revision`] and window last folded into `pending`. + /// The [`TrackState::drops_revision`], span, and window revision last folded into + /// `pending`. #[cfg_attr(not(feature = "async"), allow(dead_code))] - scanned: Option<(u64, ops::Range)>, + scanned: Option<(u64, ops::Range, u64)>, /// Every sequence this cursor already reported. #[cfg_attr(not(feature = "async"), allow(dead_code))] reported: RangeSet, @@ -4618,7 +4691,8 @@ impl DropCursor { } /// The lowest unreported run of `within` given up, now marked reported. - fn next(&mut self, state: &TrackState, cut: BTreeSet, within: &ops::Range) -> Option> { + fn next(&mut self, state: &TrackState, cut: BTreeSet, within: &Within<'_>) -> Option> { + let (span, window) = (&within.span, within.window); for sequence in cut { // A cut already reclaimed from the cache is in the track's drops instead. if state.is_cut(sequence) @@ -4629,14 +4703,14 @@ impl DropCursor { } // Rescan only after the drops change or the window moves; out-of-window runs were // skipped and may be owed now. - let scan = (state.drops_revision, within.clone()); + let scan = (state.drops_revision, span.clone(), within.revision); if self.scanned.as_ref() != Some(&scan) { self.scanned = Some(scan); - for dropped in state.drops.overlapping(within) { + for dropped in state.drops.overlapping(span) { self.pending - .insert(dropped.start.max(within.start)..dropped.end.min(within.end)); + .insert(dropped.start.max(span.start)..dropped.end.min(span.end)); } - for (&sequence, slot) in state.lookup.range(within.clone()) { + for (&sequence, slot) in state.lookup.range(span.clone()) { if slot.visible && slot.group.is_cut() && let Some(end) = sequence.checked_add(1) @@ -4647,9 +4721,13 @@ impl DropCursor { } while let Some(range) = self.pending.iter().next().cloned() { self.pending.remove(range.clone()); - let clipped = range.start.max(within.start)..range.end.min(within.end); - if clipped.is_empty() { + // Only what this subscriber asked for is owed: the first run of its ranges in it, + // the rest of the range waiting its turn. + let Some(clipped) = window.clip(range.start.max(span.start)..range.end.min(span.end)) else { continue; + }; + if clipped.end < range.end { + self.pending.insert(clipped.end..range.end); } if let Some(gap) = self.reported.gaps(&clipped).next() { if gap.end < range.end { @@ -4668,10 +4746,14 @@ impl Cursor { // The ranges floor the cursor, and the budget picks where above it to start, timed // or not: delivery skips the cached groups it already judges stale, on whichever // clock convicts them. - let (floor, datagram_index) = { + let (window, ranges_revision, refused, datagram_index) = { let preferences = subscription.read(); + let ranges_revision = subscription.ranges_revision(); let state = state.read(); - let floor = preferences.floor(state.newest_produced); + let (window, refused) = match Window::new(&preferences.ranges, state.newest_produced, &*state) { + Ok(window) => (window, None), + Err(err) => (Window::new(&[], None, &*state).expect("nothing to refuse"), Some(err)), + }; // Arrival order need not be timestamp order. Only skip the initial stale prefix; // subsequent delivery stays in arrival order, including late datagrams. let newest = state.datagrams.iter().filter_map(|datagram| datagram.timestamp).max(); @@ -4687,13 +4769,17 @@ impl Cursor { }) .expect("the newest datagram is within max delay") }); - (floor, state.datagram_offset + start) + (window, ranges_revision, refused, state.datagram_offset + start) }; Self { state, subscription, - min_sequence: floor.unwrap_or(0), - floor_pending: floor.is_none(), + min_sequence: 0, + window, + ranges_revision, + window_revision: 0, + reoffer_idle: None, + refused, index: 0, datagram_index, next_sequence: Some(0), @@ -4709,7 +4795,12 @@ impl Cursor { #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll> { - let within = self.min_sequence..self.end_sequence.unwrap_or(u64::MAX); + self.sync(); + let within = Within { + span: self.floor()..self.end_sequence.unwrap_or(u64::MAX), + window: &self.window, + revision: self.window_revision, + }; let drops = match &mut self.drops { Some(drops) => drops, None => self.drops.insert(Box::new(DropCursor::new(&self.state.read()))), @@ -4738,19 +4829,66 @@ impl Cursor { } } - /// Raise the floor to where the subscription's ranges start, once a group exists to - /// count a relative start back from. Returns whether the floor is settled. - fn resolve_floor(&mut self) -> bool { - if self.floor_pending { - // The track had no produced group at subscribe, so the newest one then was the - // first to arrive, however many have arrived or left since. - let latest = self.state.read().first_produced; - if let Some(floor) = self.subscription.read().floor(latest) { - self.min_sequence = self.min_sequence.max(floor); - self.floor_pending = false; + /// Bring [`Self::window`] up to date: resolved again once the subscriber replaces its + /// ranges, counting relative anchors back from the latest group then, and settled as + /// the groups it waits on arrive. + fn sync(&mut self) { + let replaced = { + let preferences = self.subscription.read(); + let revision = self.subscription.ranges_revision(); + (revision != self.ranges_revision).then(|| (revision, preferences.ranges.clone())) + }; + let state = self.state.read(); + let moved = match replaced { + Some((revision, ranges)) => { + self.ranges_revision = revision; + match Window::new(&ranges, state.newest_produced, &*state) { + Ok(window) => { + self.window = window; + self.refused = None; + } + Err(err) => self.refused = Some(err), + } + true + } + None if self.window.is_settled() => false, + // The track had no produced group when the ranges resolved, so the newest one + // then was the first to arrive, however many have arrived or left since. + None if self.window.is_pending() => self.window.settle(state.first_produced, &*state), + None => self.window.settle(state.newest_produced, &*state), + }; + drop(state); + if moved { + self.window_revision += 1; + } + } + + /// The lowest group this cursor reads: its own floor, raised to its window's. + fn floor(&self) -> u64 { + self.min_sequence.max(self.window.floor().unwrap_or(0)) + } + + /// Whether group `sequence`, at or above [`Self::floor`], is one this cursor reads now: + /// in its window and below its cap. One that isn't may be later. + fn admits(&self, sequence: u64) -> bool { + super::subscription::before_end(sequence, self.end_sequence) && self.window.contains(sequence) + } + + /// The lowest parked group this cursor reads now. + fn reoffer(&mut self) -> Option { + let at = (self.window_revision, self.end_sequence); + if self.parked.is_empty() || self.reoffer_idle == Some(at) { + return None; + } + for run in self.window.runs(self.floor()) { + if let Some((&sequence, _)) = self.parked.range(run).next() + && super::subscription::before_end(sequence, self.end_sequence) + { + return Some(sequence); } } - !self.floor_pending + self.reoffer_idle = Some(at); + None } // A helper to automatically apply Dropped if the state is closed without an error. @@ -4871,7 +5009,11 @@ impl Cursor { } fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { - self.resolve_floor(); + self.sync(); + if let Some(err) = &self.refused { + return Poll::Ready(Err(err.clone())); + } + let floor = self.floor(); if let Some(aborts) = &self.aborts { // A sequence may name both a live group and its fetched head. Only the // consumer we parked decides whether this notification invalidates it. @@ -4884,7 +5026,7 @@ impl Cursor { // Each overtaken entry is removed once, rather than scanning every entry // again for every delivered group. while let Some((&sequence, _)) = self.parked.first_key_value() - && sequence < self.min_sequence + && sequence < floor { self.parked.pop_first(); aborts.remove(sequence); @@ -4898,10 +5040,10 @@ impl Cursor { let drift = ready!(self.poll_drift(self.end_sequence, waiter))?; loop { - // Re-offer the lowest parked group back inside the cap once it rises, - // ahead of the arrival cursor: it is the oldest thing still owed. - let consumer = match self.parked.keys().next().copied() { - Some(sequence) if super::subscription::before_end(sequence, self.end_sequence) => { + // Re-offer the lowest parked group back in range once the cap rises or the ranges + // widen, ahead of the arrival cursor: it is the oldest thing still owed. + let consumer = match self.reoffer() { + Some(sequence) => { let group = self.parked.remove(&sequence).expect("just looked it up"); self.aborts .as_ref() @@ -4912,8 +5054,9 @@ impl Cursor { group } _ => { + let floor = self.floor(); let Some((producer, found_index)) = - ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, self.min_sequence))?) + ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, floor))?) else { // Parked groups survive a finished track: they become deliverable // again if the cap rises, so the stream isn't over while any are held. @@ -4929,14 +5072,17 @@ impl Cursor { self.index = found_index + 1; // The first group to arrive resolves a relative floor, which may land - // above it. - if self.floor_pending && self.resolve_floor() && consumer.sequence < self.min_sequence { - continue; + // above it, and a later one may settle where a time end falls. + if !self.window.is_settled() { + self.sync(); + if consumer.sequence < self.floor() { + continue; + } } - // Park a group beyond the cap instead of dropping it, and keep scanning - // so an in-range group that arrived behind it still flows. - if !super::subscription::before_end(consumer.sequence, self.end_sequence) { + // Park a group beyond the cap or outside the ranges instead of dropping it, + // and keep scanning so an in-range group that arrived behind it still flows. + if !self.admits(consumer.sequence) { self.park(consumer, waiter); continue; @@ -4974,17 +5120,14 @@ impl Cursor { return Poll::Ready(Ok(None)); }; self.datagram_index = found_index + 1; - self.resolve_floor(); + self.sync(); let sequence = datagram.sequence; let mid_group = self.subscription.read().start().is_some_and(|start| { start.group == sequence && start.frame > datagram.wire.as_ref().and_then(|wire| wire.object_id).unwrap_or(0) }); - if sequence >= self.min_sequence - && super::subscription::before_end(sequence, self.end_sequence) - && !mid_group - { + if sequence >= self.floor() && self.admits(sequence) && !mid_group { return Poll::Ready(Ok(Some(datagram))); } } @@ -4996,14 +5139,19 @@ impl Cursor { let Some(next) = self.next_sequence else { return Poll::Ready(Ok(None)); }; - self.resolve_floor(); - let mut floor = next.max(self.min_sequence); + self.sync(); + if let Some(err) = &self.refused { + return Poll::Ready(Err(err.clone())); + } + let mut floor = next.max(self.floor()); let end = self.end_sequence; // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(end, waiter))?; let group = loop { - let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end))?) else { + let window = &self.window; + let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end, window))?) + else { // 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 @@ -5012,10 +5160,14 @@ impl Cursor { }; let group = producer.consume(); - // The first group to arrive resolves a relative floor, which may land above it. - if self.floor_pending && self.resolve_floor() && group.sequence < self.min_sequence { - floor = floor.max(self.min_sequence); - continue; + // The first group to arrive resolves a relative floor, which may land above it, and + // a later one may settle where a time end falls. + if !self.window.is_settled() { + self.sync(); + if group.sequence < self.floor() || !self.window.contains(group.sequence) { + floor = floor.max(self.floor()).max(group.sequence.saturating_add(1)); + continue; + } } // Skip a group the budget has given up on and keep scanning, so one poll @@ -5069,6 +5221,8 @@ impl Cursor { #[derive(Clone)] pub struct Control { subscription: Preferences, + // Whether the track is timed, so a time anchor on an untimed one is refused. + timed: bool, } impl Control { @@ -5080,12 +5234,24 @@ impl Control { /// Replace this subscriber's preferences, updating the producer's aggregate. /// /// Returns [`Error::Closed`] if the track already ended; the update is - /// meaningless at that point and can usually be ignored. + /// meaningless at that point and can usually be ignored. Refuses a time anchor on + /// an untimed track with [`Error::TimestampMismatch`], leaving the preferences as they were. pub fn update(&self, subscription: Subscription) -> Result<()> { + if !self.timed && subscription.has_time() { + return Err(Error::TimestampMismatch); + } self.subscription.update(subscription) } } +/// Refuse `subscription` when it asks an untimed track for a presentation time. +fn refuse_untimed(info: &Info, subscription: &Subscription) -> Result<()> { + match info.timescale.is_none() && subscription.has_time() { + true => Err(Error::TimestampMismatch), + false => Ok(()), + } +} + impl Subscriber { /// The track's [`Info`], resolved when the subscription was established. /// @@ -5130,6 +5296,7 @@ impl Subscriber { Inner::Plain(cursor) => cursor.subscription.clone(), Inner::Resume(resume, _) => resume.subscription().clone(), }, + timed: self.info.timescale.is_some(), } } @@ -5246,10 +5413,11 @@ impl Subscriber { Inner::Resume(resume, _) => return resume.has_unread_group(unread), }; let state = cursor.state.read(); - let floor = cursor.min_sequence.max(state.live_floor.unwrap_or(0)).max(unread.start); + let floor = cursor.floor().max(state.live_floor.unwrap_or(0)).max(unread.start); let eligible = |sequence: u64| { sequence >= floor && super::subscription::before_end(sequence, unread.end) + && cursor.window.contains(sequence) && !unread.delivered.contains(sequence) }; cursor @@ -5377,8 +5545,21 @@ impl Subscriber { Inner::Resume(resume, _) => return resume.poll_start(waiter), }; let start = ready!(Consumer::poll_state_start(&cursor.state, waiter)); - cursor.resolve_floor(); - Poll::Ready(start.map(|start| start.max(cursor.min_sequence))) + cursor.sync(); + Poll::Ready(start.map(|start| start.max(cursor.floor()))) + } + + /// The lowest group this subscriber's ranges name, as resolved here when it last read: + /// `None` while one reaches back to the oldest group still around, or a relative + /// start still waits on the first group to arrive. + /// + /// Where a reader that waits on each group in sequence starts, rather than at whatever + /// arrives first, which newest-first delivery makes the latest. + pub fn floor(&self) -> Option { + match &self.inner { + Inner::Plain(cursor) => cursor.window.start(), + Inner::Resume(resume, _) => resume.floor(), + } } /// Poll for sequences this subscription gave up without serving. @@ -5441,7 +5622,7 @@ impl Subscriber { /// `2..=5` includes groups 2 through 5; `2..5` excludes group 5. An omitted /// start preserves the current floor, and an omitted end removes the cap. /// Groups above the end remain buffered and can be read after raising the cap. - /// This changes only local delivery; use [`Subscription::ranges`] and + /// This narrows only local delivery, within [`Subscription::ranges`]; use those and /// [`Self::update`] to change the requested groups. pub fn set_groups(&mut self, groups: impl RangeBounds) { let (start, end) = super::subscription::sequence_bounds(groups); @@ -5453,7 +5634,7 @@ impl Subscriber { /// /// A local filter, not a request: it doesn't tell the publisher anything, so the /// skipped groups are still delivered and simply not returned. To ask the publisher - /// to start there instead, set [`Subscription::ranges`] via [`Self::update`]. + /// to start there too, set [`Subscription::ranges`] via [`Self::update`]. /// See [Local cursor vs wire preference](Self#local-cursor-vs-wire-preference). #[cfg_attr(not(feature = "async"), allow(dead_code))] // pending: /quest/m1/public-model/serve.md @@ -5508,14 +5689,22 @@ impl Subscriber { /// Replace this subscriber's delivery preferences. /// /// Stored verbatim; the publisher's max age window is applied to the aggregate, not - /// here (see [`Producer::subscription`]). Returns [`Error::Closed`] if the track - /// already ended; the update is meaningless at that point and can usually be ignored. + /// here (see [`Producer::subscription`]). Replaced ranges resolve again here, a + /// relative anchor counting back from the latest group now. Returns [`Error::Closed`] + /// if the track already ended; the update is meaningless at that point and can usually + /// be ignored. Refuses a time anchor on an untimed track with + /// [`Error::TimestampMismatch`], leaving the preferences as they were. pub fn update(&mut self, subscription: Subscription) -> Result<()> { + refuse_untimed(&self.info, &subscription)?; let channel = match &self.inner { Inner::Plain(cursor) => &cursor.subscription, Inner::Resume(resume, _) => resume.subscription(), }; - channel.update(subscription) + channel.update(subscription)?; + if let Inner::Plain(cursor) = &mut self.inner { + cursor.sync(); + } + Ok(()) } /// Poll for the track's largest location: the current one, then each newer one, and `None` once the track ends. @@ -9166,12 +9355,14 @@ mod test { append_at(&mut producer, second * 1000); } - // A range's end is a request to the publisher, folded in with every other - // subscriber's, and it does not filter this handle: the groups above it arrive - // anyway. Capping the live edge with it would pin the edge below them, leaving - // everything past it with nothing newer to be late against. + // The budget caps every range: a range ending below the live edge is judged against + // that edge, not its own end, so with no budget nothing in it is fresh. let mut subscriber = producer.subscribe(Subscription::default().with_end(Position::after_group(1))); - assert_eq!(drain(&mut subscriber), vec![3]); + assert_eq!(drain(&mut subscriber), Vec::::new()); + + // A budget that covers it delivers the range, and nothing above it. + let mut subscriber = producer.subscribe(replay().with_end(Position::after_group(1))); + assert_eq!(drain(&mut subscriber), vec![0, 1]); } #[test] @@ -9183,7 +9374,7 @@ mod test { // Capped at group 0: the route running on past the cap is data this subscriber // can never be served, so it isn't a live edge to be late against. - let mut subscriber = producer.subscribe(Subscription::default().with_end(Position::after_group(0))); + let mut subscriber = producer.subscribe(Subscription::default()); subscriber.set_groups(..1); assert_eq!(drain(&mut subscriber), vec![0]); @@ -10779,7 +10970,7 @@ mod test { fn empty_local_cap_holds_while_another_subscriber_requests_everything() { let producer = track_producer("test", Info::timed()); let mut everything = producer.subscribe(replay()); - let mut empty = producer.subscribe(replay().with_end(Position::group(0))); + let mut empty = producer.subscribe(replay()); empty.set_groups((Bound::Unbounded, Position::group(0).group_end())); for s in 0..3 { @@ -13337,4 +13528,157 @@ mod test { group.write_frame(None, &b"x"[..]).unwrap(); } } + + /// A subscriber reads only its own ranges, whatever another subscriber's widen the + /// aggregate to, in arrival order and in sequence order alike. + #[test] + #[cfg(feature = "async")] + fn a_subscriber_reads_only_its_own_ranges() { + let mut producer = track_producer("test", Info::timed()); + let mut everything = producer.subscribe(replay()); + let ranges = [Range::groups(1..3), Range::groups(5..)]; + let mut mine = producer.subscribe(replay().with_ranges(ranges)); + let mut ordered = producer.subscribe(replay().with_ranges(ranges)).ordered(); + for second in 0..7 { + append_at(&mut producer, second * 1000); + } + assert_eq!(drain(&mut everything), vec![0, 1, 2, 3, 4, 5, 6]); + assert_eq!(drain(&mut mine), vec![1, 2, 5, 6]); + assert_eq!(drain_ordered(&mut ordered), vec![1, 2, 5, 6]); + } + + /// Replacing the ranges re-offers what the old ones held back, and a relative anchor + /// counts back from the latest group as of the update, not the subscribe. + #[test] + #[cfg(feature = "async")] + fn replaced_ranges_resolve_again() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_groups(0..2)); + for second in 0..4 { + append_at(&mut producer, second * 1000); + } + assert_eq!(drain(&mut subscriber), vec![0, 1]); + + subscriber.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut subscriber), vec![2, 3], "held back, now in range"); + + // The latest group is 3 now, so one back is 2, already read: only new groups follow. + let mut late = producer.subscribe(replay().with_groups(..1)); + assert_eq!(drain(&mut late), vec![0]); + late.update(replay().with_ranges([Range { + start: Anchor::Relative(1), + end: None, + }])) + .unwrap(); + assert_eq!(drain(&mut late), vec![2, 3]); + append_at(&mut producer, 4000); + assert_eq!(drain(&mut late), vec![4]); + } + + /// Datagrams outside a subscriber's ranges are dropped like the groups. + #[test] + #[cfg(feature = "async")] + fn datagrams_outside_the_ranges_are_skipped() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range::groups(1..2), Range::groups(3..)])); + for millis in 0..5 { + producer + .append_datagram(Timestamp::from_millis(millis).unwrap(), &b"x"[..]) + .unwrap(); + } + let mut sequences = Vec::new(); + while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { + sequences.push(datagram.sequence); + } + assert_eq!(sequences, vec![1, 3, 4]); + } + + /// Only the sequences a subscriber asked for are named dropped to it. + #[test] + #[cfg(feature = "async")] + fn drops_are_named_only_within_the_ranges() { + let producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range::groups(2..4), Range::groups(6..8)])); + producer.drop_groups(0..10); + let waiter = kio::Waiter::noop(); + let mut dropped = Vec::new(); + while let Poll::Ready(range) = subscriber.poll_drop(&waiter) { + dropped.push(range); + } + assert_eq!(dropped, vec![2..4, 6..8]); + } + + /// A time start is the cached group containing it and a time end stops just past that + /// group; one past every start held stays open until a group starts after it. + #[test] + #[cfg(feature = "async")] + fn time_anchors_resolve_against_the_cache() { + let mut producer = track_producer("test", Info::timed()); + for second in 0..4 { + append_at(&mut producer, second * 1000); + } + let time = |millis| Anchor::Time(Timestamp::from_millis(millis).unwrap()); + + let mut seek = producer.subscribe(replay().with_ranges([Range { + start: time(1500), + end: Some(time(2000)), + }])); + assert_eq!(drain(&mut seek), vec![1, 2]); + + // A start before every group reaches back to the oldest one. + let mut early = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Time(Timestamp::from_micros(0).unwrap()), + end: Some(time(500)), + }])); + assert_eq!(drain(&mut early), vec![0]); + + // Past the newest start: open until a group is known to start after the end. + let mut live = producer.subscribe(replay().with_ranges([Range { + start: time(3000), + end: Some(time(4500)), + }])); + assert_eq!(drain(&mut live), vec![3]); + append_at(&mut producer, 4000); + append_at(&mut producer, 5000); + append_at(&mut producer, 6000); + assert_eq!(drain(&mut live), vec![4]); + } + + /// An untimed track refuses a time anchor wherever the ranges reach it. + #[test] + #[cfg(feature = "async")] + fn an_untimed_track_refuses_time_anchors() { + let producer = untimed_producer(); + let timed = replay().with_ranges([Range { + start: Anchor::Time(Timestamp::from_millis(0).unwrap()), + end: None, + }]); + + let pending = producer.consume().subscribe(timed.clone()); + assert!(matches!( + pending.poll_ok(&kio::Waiter::noop()), + Poll::Ready(Err(Error::TimestampMismatch)) + )); + + let mut subscriber = producer.subscribe(replay()); + assert!(matches!(subscriber.update(timed.clone()), Err(Error::TimestampMismatch))); + assert!(matches!( + subscriber.control().update(timed.clone()), + Err(Error::TimestampMismatch) + )); + assert_eq!(subscriber.subscription(), replay(), "a refused update changes nothing"); + + let mut refused = producer.subscribe(timed); + assert!(matches!( + refused.recv_group().now_or_never(), + Some(Err(Error::TimestampMismatch)) + )); + + // Ranges it can serve lift the refusal. + refused.update(replay()).unwrap(); + let mut group = producer.append_group().unwrap(); + group.finish().unwrap(); + let read = refused.recv_group().now_or_never().expect("the group is cached"); + assert_eq!(read.unwrap().map(|group| group.sequence), Some(0)); + } } diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index 071b50838d2..db85879e006 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -1,5 +1,13 @@ //! Incremental demand, separate from the track's cached content. -use std::{collections::BTreeMap, sync::Arc, task::Poll, time::Duration}; +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{self, AtomicU64}, + }, + task::Poll, + time::Duration, +}; use super::{NewGroup, Order, Subscription, TrackState, clamp_combined}; @@ -138,6 +146,8 @@ pub(in crate::model) struct Preferences(Arc); struct Preference { value: kio::Producer, subscriptions: Option>, + /// Bumped under the write whenever the ranges change. + ranges_revision: AtomicU64, } impl Preferences { @@ -151,6 +161,7 @@ impl Preferences { Self(Arc::new(Preference { value: kio::Producer::new(value), subscriptions, + ranges_revision: AtomicU64::new(0), })) } @@ -186,6 +197,9 @@ impl Preferences { .subscriptions .as_ref() .map(|subs| subs.lock().replace(Some(&value), Some(&next))); + if value.ranges != next.ranges { + self.0.ranges_revision.fetch_add(1, atomic::Ordering::Relaxed); + } *value = next; drop(value); if let Some(wakes) = &mut wakes { @@ -202,6 +216,11 @@ impl Preferences { pub(in crate::model) fn read(&self) -> kio::Ref<'_, Subscription> { self.0.value.read() } + /// Counts changes to the ranges, so a reader can tell them apart without comparing every + /// range. Read it while holding [`Self::read`] to get the count matching those ranges. + pub(in crate::model) fn ranges_revision(&self) -> u64 { + self.0.ranges_revision.load(atomic::Ordering::Relaxed) + } pub(in crate::model) fn consume(&self) -> kio::Consumer { self.0.value.consume() } @@ -232,6 +251,7 @@ impl From> for Preferences { Self(Arc::new(Preference { value, subscriptions: None, + ranges_revision: AtomicU64::new(0), })) } } diff --git a/rs/moq-net/src/model/track/demand/ranges.rs b/rs/moq-net/src/model/track/demand/ranges.rs index 120723920d5..88bae86d296 100644 --- a/rs/moq-net/src/model/track/demand/ranges.rs +++ b/rs/moq-net/src/model/track/demand/ranges.rs @@ -4,7 +4,8 @@ //! where it ends, so a position is covered while the running sum stays above zero. A treap //! keeps each subtree's sum and lowest running sum, so a change touches two boundaries and //! asks one range-minimum question, whatever else the audience holds. Ranges anchored to -//! the latest group can't be placed on that line and are counted by value instead. +//! the latest group or to a time can't be placed on that line and are counted by value +//! instead. use std::cell::OnceCell; use std::collections::BTreeMap; @@ -139,6 +140,8 @@ impl Ranges { match range.start { Anchor::Absolute(start) => self.absolute.tail().is_some_and(|tail| tail <= start), Anchor::Relative(back) => self.farthest().is_some_and(|farthest| back <= farthest), + // Where a time falls among the groups is the serving hop's to say. + Anchor::Time(_) => false, } } } diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 9227b0b2d5e..42b00baf518 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time + +A subscription now reads only the groups its own `ranges` cover. +`Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. +`TrackConsumer.update(subscription:)` now `throws`, on a time anchor an untimed track can't serve; call it with `try`. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Subscription.Range` whose ends are a `Subscription.Anchor` (`.absolute(group:frame:)` or `.relative(groups:)`), and an optional `order`. diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index f5883967f14..440c91d6c95 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -44,9 +44,10 @@ public final class TrackConsumer: AsyncSequence, Sendable { try ffi.info() } - /// Change this subscriber's delivery preferences. - public func update(subscription: Subscription) { - ffi.update(subscription: subscription) + /// Change this subscriber's delivery preferences. Throws on a time anchor the track + /// can't serve. + public func update(subscription: Subscription) throws { + try ffi.update(subscription: subscription) } /// Cancel all current and future reads. From d68f823cfdce1ff100d5c58fe4d20e623fbbf7e3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 23:51:46 -0700 Subject: [PATCH 03/48] chore(moq-net): box the cursor's range filter, regenerate Dart bindings The resolved window and its bookkeeping live behind one box so every reader that embeds a cursor stays its old size. Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 48 ++++++++- rs/moq-ffi/src/test.rs | 40 ++++---- rs/moq-net/src/model/subscription.rs | 6 +- rs/moq-net/src/model/subscription/window.rs | 9 +- rs/moq-net/src/model/track.rs | 106 +++++++++++--------- 5 files changed, 137 insertions(+), 72 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 3f990238d98..0762e1aba2e 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -4238,6 +4238,12 @@ class FfiConverterMoqAnchor { lifted.value, lifted.bytesRead - subview.offsetInBytes + 4, ); + case 3: + final lifted = TimeMoqAnchor.read(subview); + return LiftRetVal( + lifted.value, + lifted.bytesRead - subview.offsetInBytes + 4, + ); default: throw UniffiInternalError( UniffiInternalError.unexpectedEnumCase, @@ -4347,6 +4353,44 @@ class RelativeMoqAnchor extends MoqAnchor { } } +class TimeMoqAnchor extends MoqAnchor { + final int timestampUs; + TimeMoqAnchor(int this.timestampUs); + TimeMoqAnchor._(int this.timestampUs); + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final timestampUs_lifted = FfiConverterUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final timestampUs = timestampUs_lifted.value; + new_offset += timestampUs_lifted.bytesRead; + return LiftRetVal(TimeMoqAnchor._(timestampUs), new_offset); + } + + @override + RustBuffer lower() { + final buf = Uint8List(allocationSize()); + write(buf); + return toRustBuffer(buf); + } + + @override + int allocationSize() { + return FfiConverterUInt64.allocationSize(timestampUs) + 4; + } + + @override + int write(Uint8List buf) { + buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 3); + int new_offset = buf.offsetInBytes + 4; + new_offset += FfiConverterUInt64.write( + timestampUs, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset; + } +} + enum MoqOrder { ascending, descending } class FfiConverterMoqOrder { @@ -8322,7 +8366,7 @@ class MoqTrackConsumer implements MoqTrackConsumerInterface { FfiConverterMoqSubscription.lower(subscription), status, ); - }, null); + }, moqExceptionErrorHandler); } } @@ -18637,7 +18681,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group() != 28712) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_update() != 24851) { + 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) { diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index ef188372286..0a34eca2bb8 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -911,15 +911,17 @@ async fn raw_track_update_does_not_wait_for_pending_read() { tokio::spawn(async move { consumer.read_frame().await }) }; - consumer.update(MoqSubscription { - priority: 10, - max_delay_us: 25_000, - ranges: Some(vec![MoqRange { - start: MoqAnchor::Absolute { group: 0, frame: 0 }, - end: None, - }]), - order: None, - }).unwrap(); + consumer + .update(MoqSubscription { + priority: 10, + max_delay_us: 25_000, + ranges: Some(vec![MoqRange { + start: MoqAnchor::Absolute { group: 0, frame: 0 }, + end: None, + }]), + order: None, + }) + .unwrap(); let payload = b"updated subscription".to_vec(); track @@ -3903,15 +3905,17 @@ async fn raw_track_update_during_pending_group_still_reads_datagram() { spawn_parked(async move { consumer.next_group().await }).await }; - consumer.update(MoqSubscription { - priority: 10, - max_delay_us: 25_000, - ranges: Some(vec![MoqRange { - start: MoqAnchor::Absolute { group: 0, frame: 0 }, - end: None, - }]), - order: None, - }).unwrap(); + consumer + .update(MoqSubscription { + priority: 10, + max_delay_us: 25_000, + ranges: Some(vec![MoqRange { + start: MoqAnchor::Absolute { group: 0, frame: 0 }, + end: None, + }]), + order: None, + }) + .unwrap(); let sequence = track.append_datagram(datagram_frame(b"after-update")).unwrap(); let datagram = tokio::time::timeout(TIMEOUT, consumer.recv_datagram()) diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 2cf19e0ce13..692953a992e 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -426,9 +426,9 @@ impl Subscription { /// Whether any range ends at a presentation time, which an untimed track refuses. pub(super) fn has_time(&self) -> bool { - self.ranges.iter().any(|range| { - matches!(range.start, Anchor::Time(_)) || matches!(range.end, Some(Anchor::Time(_))) - }) + self.ranges + .iter() + .any(|range| matches!(range.start, Anchor::Time(_)) || matches!(range.end, Some(Anchor::Time(_)))) } } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index a149bce1202..aa9052323c1 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -156,7 +156,7 @@ impl Window { /// The groups in range from `from` on, lowest first. pub fn runs(&self, from: u64) -> impl Iterator> + '_ { - let all = self.pending.then(|| from..=u64::MAX); + let all = self.pending.then_some(from..=u64::MAX); let runs = (!self.pending).then(|| { self.groups .overlapping(from..=u64::MAX) @@ -181,7 +181,12 @@ impl Window { fn place(&mut self, latest: Option, holdings: &impl Holdings) -> Result<()> { let time = |anchor: Option| matches!(anchor, Some(Anchor::Time(_))); - if !holdings.timed() && self.ranges.iter().any(|range| time(Some(range.start)) || time(range.end)) { + if !holdings.timed() + && self + .ranges + .iter() + .any(|range| time(Some(range.start)) || time(range.end)) + { return Err(Error::TimestampMismatch); } self.spans.clear(); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 766c328e7a2..ba0ab98d94e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -21,8 +21,8 @@ use super::ledger::{self, Ledger}; use super::{Datagram, Requests}; use super::Cap; -use super::subscription::{Containing, Holdings, NewGroup, Window}; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; +use super::subscription::{Containing, Holdings, NewGroup, Window}; use rangemap::RangeSet; mod demand; @@ -4605,18 +4605,8 @@ struct Cursor { datagram_index: usize, /// Minimum sequence to return from any `recv` method. Set by `start_at`. min_sequence: u64, - /// This subscriber's own ranges, resolved: only the groups in it are read. - window: Window, - /// The [`Preferences::ranges_revision`] the window was resolved from. - ranges_revision: u64, - /// Bumped whenever [`Self::window`] moves, so the drop scan knows to look again. - window_revision: u64, - /// Why the ranges can't be served here: a time anchor on an untimed track. - refused: Option, - /// The window revision and cap at which no parked group was in reach. A group is only - /// parked out of reach, so the walk waits for either to move rather than running once - /// per hand-out over every range. - reoffer_idle: Option<(u64, Option)>, + /// This subscriber's own ranges, resolved. Boxed: every reader embeds a cursor. + filter: Box, /// One past the highest sequence returned by `next_group`, or `None` once it returned /// `u64::MAX` and no group can follow. /// Used only by that method to skip late arrivals; does not affect `recv_group`. @@ -4741,12 +4731,28 @@ impl DropCursor { } } +/// A cursor's own ranges, resolved, and what keeps them current. +struct Filter { + /// Only the groups in it are read. + window: Window, + /// The [`Preferences::ranges_revision`] the window was resolved from. + resolved: u64, + /// Bumped whenever the window moves, so the drop scan knows to look again. + revision: u64, + /// Why the ranges can't be served here: a time anchor on an untimed track. + refused: Option, + /// The revision and cap at which no parked group was in reach. A group is only parked + /// out of reach, so the walk waits for either to move rather than running once per + /// hand-out over every range. + reoffer_idle: Option<(u64, Option)>, +} + impl Cursor { fn new(state: kio::Consumer, subscription: Preferences) -> Self { // The ranges floor the cursor, and the budget picks where above it to start, timed // or not: delivery skips the cached groups it already judges stale, on whichever // clock convicts them. - let (window, ranges_revision, refused, datagram_index) = { + let (filter, datagram_index) = { let preferences = subscription.read(); let ranges_revision = subscription.ranges_revision(); let state = state.read(); @@ -4769,17 +4775,20 @@ impl Cursor { }) .expect("the newest datagram is within max delay") }); - (window, ranges_revision, refused, state.datagram_offset + start) + let filter = Filter { + window, + resolved: ranges_revision, + revision: 0, + refused, + reoffer_idle: None, + }; + (filter, state.datagram_offset + start) }; Self { state, subscription, min_sequence: 0, - window, - ranges_revision, - window_revision: 0, - reoffer_idle: None, - refused, + filter: Box::new(filter), index: 0, datagram_index, next_sequence: Some(0), @@ -4798,8 +4807,8 @@ impl Cursor { self.sync(); let within = Within { span: self.floor()..self.end_sequence.unwrap_or(u64::MAX), - window: &self.window, - revision: self.window_revision, + window: &self.filter.window, + revision: self.filter.revision, }; let drops = match &mut self.drops { Some(drops) => drops, @@ -4836,58 +4845,58 @@ impl Cursor { let replaced = { let preferences = self.subscription.read(); let revision = self.subscription.ranges_revision(); - (revision != self.ranges_revision).then(|| (revision, preferences.ranges.clone())) + (revision != self.filter.resolved).then(|| (revision, preferences.ranges.clone())) }; let state = self.state.read(); let moved = match replaced { Some((revision, ranges)) => { - self.ranges_revision = revision; + self.filter.resolved = revision; match Window::new(&ranges, state.newest_produced, &*state) { Ok(window) => { - self.window = window; - self.refused = None; + self.filter.window = window; + self.filter.refused = None; } - Err(err) => self.refused = Some(err), + Err(err) => self.filter.refused = Some(err), } true } - None if self.window.is_settled() => false, + None if self.filter.window.is_settled() => false, // The track had no produced group when the ranges resolved, so the newest one // then was the first to arrive, however many have arrived or left since. - None if self.window.is_pending() => self.window.settle(state.first_produced, &*state), - None => self.window.settle(state.newest_produced, &*state), + None if self.filter.window.is_pending() => self.filter.window.settle(state.first_produced, &*state), + None => self.filter.window.settle(state.newest_produced, &*state), }; drop(state); if moved { - self.window_revision += 1; + self.filter.revision += 1; } } /// The lowest group this cursor reads: its own floor, raised to its window's. fn floor(&self) -> u64 { - self.min_sequence.max(self.window.floor().unwrap_or(0)) + self.min_sequence.max(self.filter.window.floor().unwrap_or(0)) } /// Whether group `sequence`, at or above [`Self::floor`], is one this cursor reads now: /// in its window and below its cap. One that isn't may be later. fn admits(&self, sequence: u64) -> bool { - super::subscription::before_end(sequence, self.end_sequence) && self.window.contains(sequence) + super::subscription::before_end(sequence, self.end_sequence) && self.filter.window.contains(sequence) } /// The lowest parked group this cursor reads now. fn reoffer(&mut self) -> Option { - let at = (self.window_revision, self.end_sequence); - if self.parked.is_empty() || self.reoffer_idle == Some(at) { + let at = (self.filter.revision, self.end_sequence); + if self.parked.is_empty() || self.filter.reoffer_idle == Some(at) { return None; } - for run in self.window.runs(self.floor()) { + for run in self.filter.window.runs(self.floor()) { if let Some((&sequence, _)) = self.parked.range(run).next() && super::subscription::before_end(sequence, self.end_sequence) { return Some(sequence); } } - self.reoffer_idle = Some(at); + self.filter.reoffer_idle = Some(at); None } @@ -5010,7 +5019,7 @@ impl Cursor { fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.sync(); - if let Some(err) = &self.refused { + if let Some(err) = &self.filter.refused { return Poll::Ready(Err(err.clone())); } let floor = self.floor(); @@ -5073,7 +5082,7 @@ impl Cursor { // The first group to arrive resolves a relative floor, which may land // above it, and a later one may settle where a time end falls. - if !self.window.is_settled() { + if !self.filter.window.is_settled() { self.sync(); if consumer.sequence < self.floor() { continue; @@ -5140,7 +5149,7 @@ impl Cursor { return Poll::Ready(Ok(None)); }; self.sync(); - if let Some(err) = &self.refused { + if let Some(err) = &self.filter.refused { return Poll::Ready(Err(err.clone())); } let mut floor = next.max(self.floor()); @@ -5149,7 +5158,7 @@ impl Cursor { let drift = ready!(self.poll_drift(end, waiter))?; let group = loop { - let window = &self.window; + let window = &self.filter.window; let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end, window))?) else { // Deliberately no flush of `seek_pending` here: only a delivery commit @@ -5162,9 +5171,9 @@ impl Cursor { // The first group to arrive resolves a relative floor, which may land above it, and // a later one may settle where a time end falls. - if !self.window.is_settled() { + if !self.filter.window.is_settled() { self.sync(); - if group.sequence < self.floor() || !self.window.contains(group.sequence) { + if group.sequence < self.floor() || !self.filter.window.contains(group.sequence) { floor = floor.max(self.floor()).max(group.sequence.saturating_add(1)); continue; } @@ -5417,7 +5426,7 @@ impl Subscriber { let eligible = |sequence: u64| { sequence >= floor && super::subscription::before_end(sequence, unread.end) - && cursor.window.contains(sequence) + && cursor.filter.window.contains(sequence) && !unread.delivered.contains(sequence) }; cursor @@ -5557,7 +5566,7 @@ impl Subscriber { /// arrives first, which newest-first delivery makes the latest. pub fn floor(&self) -> Option { match &self.inner { - Inner::Plain(cursor) => cursor.window.start(), + Inner::Plain(cursor) => cursor.filter.window.start(), Inner::Resume(resume, _) => resume.floor(), } } @@ -13661,7 +13670,10 @@ mod test { )); let mut subscriber = producer.subscribe(replay()); - assert!(matches!(subscriber.update(timed.clone()), Err(Error::TimestampMismatch))); + assert!(matches!( + subscriber.update(timed.clone()), + Err(Error::TimestampMismatch) + )); assert!(matches!( subscriber.control().update(timed.clone()), Err(Error::TimestampMismatch) @@ -13676,7 +13688,7 @@ mod test { // Ranges it can serve lift the refusal. refused.update(replay()).unwrap(); - let mut group = producer.append_group().unwrap(); + let group = producer.append_group().unwrap(); group.finish().unwrap(); let read = refused.recv_group().now_or_never().expect("the group is cached"); assert_eq!(read.unwrap().map(|group| group.sequence), Some(0)); From 81f8a87e3c6e4e8824e6dd246dd1d2b4af5ea63d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 23:57:12 -0700 Subject: [PATCH 04/48] docs: link the MIGRATE entries to #5929 Co-Authored-By: Claude Opus 5.5 --- dart/moq/MIGRATE.md | 2 +- go/wrapper/MIGRATE.md | 2 +- kt/moq/MIGRATE.md | 2 +- py/moq-rs/MIGRATE.md | 2 +- rs/moq-ffi/MIGRATE.md | 2 +- rs/moq-net/MIGRATE.md | 2 +- swift/MIGRATE.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index bae8c41459b..6f2865ee07d 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. `Anchor` gains a time variant, so exhaustive switches need a case for it. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 6c75b68765f..59a807e1bba 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `Ranges` cover. `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 8710934b05d..0b152c1cadc 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. `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 ed785df58a3..98f5c02a2d9 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. `Anchor` gains a `TIME` variant, the group containing a presentation time. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 56a0a708a08..2928644260e 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,7 +5,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased -### Ranges filter reads, and anchors can name a time +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover, rather than starting at the lowest one and taking whatever arrives. `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 1aadbbf5f32..e55ba5a50e2 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter each subscriber's reads +### Ranges filter each subscriber's reads (#5929) A `track::Subscriber` now reads only the groups its own `Subscription::ranges` cover, resolved again whenever `update` replaces them; before, they only set where its reads started. A reader that relied on groups another subscriber asked for must widen its own ranges. diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 42b00baf518..1609ea9a46e 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. `Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. From feb8a72023a51921914677394376210e50305a9e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 09:15:03 -0700 Subject: [PATCH 05/48] feat(moq-c): give the time anchor its own timestamp_us field Also document the extra group a time end may let through at the live edge. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 4 +++- rs/moq-c/MIGRATE.md | 5 +++++ rs/moq-c/src/api.rs | 7 ++++--- rs/moq-c/src/test.rs | 6 +++++- rs/moq-net/src/model/subscription.rs | 5 +++-- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index eb41810de2a..69770ec5336 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -224,7 +224,9 @@ subscriber of the same track asked for more. 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. +untimed track refuses it. At the live edge, a time end may let through one extra +group that turns out to start past it, since holding each group until its first +frame arrives would cost latency. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 83b105d149e..74e5dab5b26 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time (#5929) + +A raw track consumer now reads only the groups its `ranges` cover, rather than every group from the lowest start to the last absolute end. +`moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. + ### OpenH264 is a build feature (#5771) The release archives and the CMake build still carry the software H.264 fallback. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 401caafc781..8c6c0e9c019 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -605,11 +605,12 @@ pub struct moq_anchor { /// `moq_anchor_kind` discriminant. pub kind: u32, /// The group, or with `MOQ_ANCHOR_RELATIVE` how many groups before the latest one - /// (0 is the latest, and an offset past the first group clamps to it), or with - /// `MOQ_ANCHOR_TIME` a presentation time in microseconds. + /// (0 is the latest, and an offset past the first group clamps to it). pub group: u64, /// The frame within `group`, read only with `MOQ_ANCHOR_ABSOLUTE`. pub frame: u64, + /// The presentation time in microseconds, read only with `MOQ_ANCHOR_TIME`. + pub timestamp_us: u64, } /// How a [moq_anchor] names its position. @@ -646,7 +647,7 @@ fn parse_anchor(anchor: &moq_anchor) -> Result { } v if v == moq_anchor_kind::MOQ_ANCHOR_RELATIVE as u32 => moq_net::track::Anchor::Relative(anchor.group), v if v == moq_anchor_kind::MOQ_ANCHOR_TIME as u32 => { - moq_net::track::Anchor::Time(moq_net::Timestamp::from_micros(anchor.group)?) + moq_net::track::Anchor::Time(moq_net::Timestamp::from_micros(anchor.timestamp_us)?) } _ => return Err(Error::InvalidCode), }) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 4ea412b7b0b..40febbd3e09 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1729,11 +1729,13 @@ fn raw_track_subscription_options_and_update() { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 1, frame: 0, + timestamp_us: 0, }, end: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 2, frame: 0, + timestamp_us: 0, }, end_present: true, }]; @@ -1836,13 +1838,15 @@ fn raw_track_time_anchor() { let ranges = [moq_range { start: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, - group: 30_000, + group: 0, frame: 0, + timestamp_us: 30_000, }, end: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 0, frame: 0, + timestamp_us: 0, }, end_present: false, }]; diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 692953a992e..47fffdc0aa6 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -166,8 +166,9 @@ pub enum Anchor { /// Resolved from the starts of the groups the serving hop holds. When none starts at /// or before it, a start reaches back to the oldest group, and while no group has /// started at all it waits like `Relative(0)`. An end stays open until a group - /// starting after it is known. An untimed track refuses it with - /// [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). + /// starting after it is known, so at the live edge a reader may get one extra group, + /// handed out before its first frame showed it starts past the end. An untimed track + /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } From 13a2b33526e250dca7dd042de8feb449a5dd5f2c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 09:59:21 -0700 Subject: [PATCH 06/48] fix(moq-c): refuse a raw track update at the call moq_consume_track_update queued updates for the reader task, which dropped a TimestampMismatch, so a time anchor on an untimed track silently kept the old ranges. Once subscribed, updates now go through the track's Control and fail at the call; one made before SUBSCRIBE_OK applies once it arrives and a refusal then ends the consumer. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/MIGRATE.md | 1 + rs/moq-c/src/api.rs | 5 ++- rs/moq-c/src/consume.rs | 92 +++++++++++++++++++++-------------------- rs/moq-c/src/test.rs | 90 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 46 deletions(-) diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 74e5dab5b26..1373e7de1fa 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. A raw track consumer now reads only the groups its `ranges` cover, rather than every group from the lowest start to the last absolute end. `moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. +`moq_consume_track_update` fails with a negative code when the track refuses an update, such as a time anchor on an untimed track, leaving the subscription as it was. ### OpenH264 is a build feature (#5771) diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 8c6c0e9c019..20b9f4825d9 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -3687,7 +3687,10 @@ pub unsafe extern "C" fn moq_consume_track( /// /// Pass NULL for `subscription` to reset to moq-net defaults. /// -/// Returns a zero on success, or a negative code on failure. +/// Returns a zero on success, or a negative code on failure, such as a time anchor on an +/// untimed track, which leaves the subscription as it was. An update made before the +/// subscription is established applies once it is, so a refusal then ends the consumer +/// through its callback instead. /// /// # Safety /// - The caller must ensure that subscription is either NULL or a valid pointer to a [moq_subscription] struct, diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 5638cc2083c..ab941b6e6c8 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -4,7 +4,7 @@ use std::{ pin::Pin, task::{Poll, ready}, }; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::oneshot; use crate::ffi::OnStatus; use crate::{ @@ -106,11 +106,19 @@ struct TaskEntry { /// A raw track task also accepts subscription updates while it is running. struct RawTaskEntry { close: Option>, - update: mpsc::UnboundedSender>, + control: RawControl, callback: OnStatus, } -/// Outcome of polling a raw track source alongside its control channels. +/// Where a raw track's subscription updates go. +enum RawControl { + /// Waiting on SUBSCRIBE_OK, holding the latest update to apply once it arrives. + Pending(Option), + /// Subscribed: updates apply at once, so a refusal reaches the caller. + Live(moq_net::track::Control), +} + +/// Outcome of polling a raw track source alongside its close signal. enum RawStep { /// A delivered value: the next group, or the next frame within a group. Item(T), @@ -653,10 +661,9 @@ impl Consume { let name = name.to_string(); let channel = oneshot::channel(); - let (update, updates) = mpsc::unbounded_channel(); let entry = RawTaskEntry { close: Some(channel.0), - update, + control: RawControl::Pending(None), callback: on_frame, }; let id = self.raw_task.insert(Some(entry))?; @@ -664,9 +671,22 @@ impl Consume { // `subscribe` blocks on SUBSCRIBE_OK, so run it inside the task. tokio::spawn(async move { let res = crate::ffi::run(driver.watch(async move { - let mut track = broadcast.track(&name)?.subscribe(subscription.clone()).await?.ordered(); - Self::apply_raw_subscription(&mut track, subscription); - Self::run_raw(on_frame, track, channel.1, updates).await + let track = broadcast.track(&name)?.subscribe(subscription.clone()).await?.ordered(); + let control = track.control(); + Self::update_raw(&control, Self::raw_subscription(subscription))?; + // Go live under the lock, so no later update lands before the pending one. + if let Some(entry) = State::lock() + .consume + .raw_task + .get_mut(id) + .and_then(|entry| entry.as_mut()) + { + let pending = std::mem::replace(&mut entry.control, RawControl::Live(control.clone())); + if let RawControl::Pending(Some(subscription)) = pending { + Self::update_raw(&control, subscription)?; + } + } + Self::run_raw(on_frame, track, channel.1).await })) .await; @@ -681,25 +701,30 @@ impl Consume { Ok(id) } - fn apply_raw_subscription(track: &mut moq_net::track::Ordered, subscription: Option) { + /// A raw reader's preferences, which join at the latest group unless a range names + /// a floor of its own. + fn raw_subscription(subscription: Option) -> moq_net::track::Subscription { let mut subscription = subscription.unwrap_or_default(); - // The subscription reads only its own ranges, resolved where it is served. A raw - // reader without a floor of its own (the oldest range, the default) joins at the - // latest group instead. for range in &mut subscription.ranges { if range.start == moq_net::track::Range::oldest().start { range.start = moq_net::track::Range::live().start; } } - // A closed track makes the update meaningless; the reader already sees the close. - let _ = track.update(subscription); + subscription + } + + fn update_raw(control: &moq_net::track::Control, subscription: moq_net::track::Subscription) -> Result<(), Error> { + match control.update(subscription) { + // A closed track makes the update meaningless; the reader already sees the close. + Ok(()) | Err(moq_net::Error::Closed) => Ok(()), + Err(err) => Err(err.into()), + } } async fn run_raw( callback: OnStatus, mut track: moq_net::track::Ordered, mut close: oneshot::Receiver<()>, - mut updates: mpsc::UnboundedReceiver>, ) -> Result<(), Error> { // Deliver every frame in sequence order, reading all frames within each // group rather than the one-frame-per-group convenience. This is the @@ -711,7 +736,7 @@ impl Consume { loop { let mut group = match moq_net::kio::wait(|waiter| -> Poll, Error>> { - if Self::poll_raw_control(&mut close, &mut updates, &mut track, waiter) { + if Pin::new(&mut close).poll(&mut waiter.context()).is_ready() { return Poll::Ready(Ok(RawStep::Stop)); } match ready!(track.poll_next_group(waiter))? { @@ -728,7 +753,7 @@ impl Consume { loop { let frame = match moq_net::kio::wait(|waiter| -> Poll, Error>> { - if Self::poll_raw_control(&mut close, &mut updates, &mut track, waiter) { + if Pin::new(&mut close).poll(&mut waiter.context()).is_ready() { return Poll::Ready(Ok(RawStep::Stop)); } match ready!(group.poll_read_frame(waiter))? { @@ -752,33 +777,6 @@ impl Consume { } } - /// Poll the close and update channels, applying any subscription updates inline. - /// - /// Returns `true` when the task must stop: either the consumer was closed - /// (`close` fired) or the update channel was dropped. The caller must then - /// unwind rather than poll `close` again, since a completed oneshot panics if - /// re-polled. Borrows `track` only for the duration of the call so the caller - /// can poll a track/group source afterwards. - fn poll_raw_control( - close: &mut oneshot::Receiver<()>, - updates: &mut mpsc::UnboundedReceiver>, - track: &mut moq_net::track::Ordered, - waiter: &moq_net::kio::Waiter, - ) -> bool { - let mut cx = waiter.context(); - if Pin::new(close).poll(&mut cx).is_ready() { - return true; - } - - loop { - match updates.poll_recv(&mut cx) { - Poll::Ready(Some(subscription)) => Self::apply_raw_subscription(track, subscription), - Poll::Ready(None) => return true, - Poll::Pending => return false, - } - } - } - pub fn raw_track_update( &mut self, track: Id, @@ -789,7 +787,11 @@ impl Consume { .get_mut(track) .and_then(|entry| entry.as_mut()) .ok_or(Error::TrackNotFound)?; - entry.update.send(subscription).map_err(|_| Error::TrackNotFound)?; + let subscription = Self::raw_subscription(subscription); + match &mut entry.control { + RawControl::Pending(pending) => *pending = Some(subscription), + RawControl::Live(control) => Self::update_raw(control, subscription)?, + } Ok(()) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 40febbd3e09..2ad2d5bf1f6 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1891,6 +1891,96 @@ fn raw_track_time_anchor() { assert_eq!(moq_origin_close(origin), 0); } +/// An untimed track refuses a time anchor update at the call, keeping its ranges. The C +/// API only publishes timed tracks, so the track comes from moq-net. +#[test] +fn raw_track_update_refuses_time_on_untimed() { + let origin = id(moq_origin_create()); + let path = b"raw-track-untimed"; + let broadcast = publish_broadcast(origin, path); + + let track_name = b"data"; + let track = State::lock() + .publish + .track( + Id::try_from(broadcast).unwrap(), + "data", + Some(moq_net::track::Info::default().with_timescale(None)), + ) + .unwrap(); + let write = |payload: &'static [u8]| { + State::lock() + .publish + .track_producer(track) + .unwrap() + .write_frame(None, payload) + .unwrap() + }; + + let consume = request_broadcast(origin, path); + let frame_cb = Callback::new(); + let consumer = id(unsafe { + moq_consume_track( + consume, + track_name.as_ptr() as *const c_char, + track_name.len(), + std::ptr::null(), + Some(channel_callback), + frame_cb.ptr, + ) + }); + + // The first frame proves the subscription is established. + write(b"zero"); + let frame_id = id(frame_cb.recv()); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + + let ranges = [moq_range { + start: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, + group: 0, + frame: 0, + timestamp_us: 0, + }, + end: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, + group: 0, + frame: 0, + timestamp_us: 0, + }, + end_present: false, + }]; + let update = moq_subscription { + priority: 3, + max_delay_us: 1_000_000, + ranges: ranges.as_ptr(), + ranges_len: ranges.len(), + order: 0, + }; + assert!(unsafe { moq_consume_track_update(consumer, &update) } < 0); + + // The consumer keeps reading under its old ranges. + write(b"one"); + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 0, + timestamp_present: false, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; + assert_eq!(received, b"one"); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + + assert_eq!(moq_consume_track_cancel(consumer), 0); + assert_eq!(frame_cb.recv_terminal(), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn json_snapshot_publish_consume() { let origin = id(moq_origin_create()); From 9dd0ab4ed38b6f6246ef99f5f94ea892856c5ada Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 10:30:03 -0700 Subject: [PATCH 07/48] fix(moq-net): resolve time anchors only from frame-0 starts, refuse datagrams too A copy joined mid-group stamps a later frame than the group's start, so it could convince a time end that the group started past it. A refused cursor also read and discarded datagrams instead of returning its refusal. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 52 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index a916d7308bc..cba0a84661f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -564,13 +564,12 @@ impl Slot { } } - /// Where the copy readers get starts in presentation time, if it has started. + /// Where the group starts in presentation time, from a live copy holding frame 0. A + /// copy joined mid-group stamps a later frame, which proves nothing about the start. fn start(&self) -> Option { - let group = self.serving(); - match group.is_aborted() { - true => None, - false => group.timestamp(), - } + self.copies() + .filter(|group| group.live_first_frame() == Some(0)) + .find_map(group::Producer::timestamp) } /// The checked start of whichever copy began at frame 0. @@ -5123,6 +5122,11 @@ impl Cursor { /// group past the cap, nothing holds it for a later raise. fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll>> { loop { + // Refuse before reading, so a refused subscription consumes no datagram. + self.sync(); + if let Some(err) = &self.filter.refused { + return Poll::Ready(Err(err.clone())); + } let Some((datagram, found_index)) = ready!(self.poll(waiter, |state| state.poll_recv_datagram(self.datagram_index))?) else { @@ -13661,11 +13665,34 @@ mod test { assert_eq!(drain(&mut live), vec![4]); } + /// A group joined mid-group stamps a later frame than its start, so it never proves a + /// time end falls before it. + #[test] + #[cfg(feature = "async")] + fn a_partial_group_proves_no_start() { + let mut producer = track_producer("test", Info::timed()); + append_at(&mut producer, 0); + append_at(&mut producer, 1000); + let mut partial = producer.append_group().unwrap(); + partial.start_at(1).unwrap(); + partial + .write_frame(Timestamp::from_millis(2500).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + partial.finish().unwrap(); + + let time = |millis| Anchor::Time(Timestamp::from_millis(millis).unwrap()); + let mut seek = producer.subscribe(replay().with_ranges([Range { + start: time(1500), + end: Some(time(2200)), + }])); + assert_eq!(drain(&mut seek), vec![1, 2]); + } + /// An untimed track refuses a time anchor wherever the ranges reach it. #[test] #[cfg(feature = "async")] fn an_untimed_track_refuses_time_anchors() { - let producer = untimed_producer(); + let mut producer = untimed_producer(); let timed = replay().with_ranges([Range { start: Anchor::Time(Timestamp::from_millis(0).unwrap()), end: None, @@ -13693,12 +13720,21 @@ mod test { refused.recv_group().now_or_never(), Some(Err(Error::TimestampMismatch)) )); + producer.append_datagram(None, &b"x"[..]).unwrap(); + assert!(matches!( + refused.recv_datagram().now_or_never(), + Some(Err(Error::TimestampMismatch)) + )); // Ranges it can serve lift the refusal. refused.update(replay()).unwrap(); let group = producer.append_group().unwrap(); group.finish().unwrap(); let read = refused.recv_group().now_or_never().expect("the group is cached"); - assert_eq!(read.unwrap().map(|group| group.sequence), Some(0)); + assert_eq!( + read.unwrap().map(|group| group.sequence), + Some(1), + "the datagram took sequence 0" + ); } } From c0156905b7d51bc318f68f8ef2b1a52d58cc3dc6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 10:54:16 -0700 Subject: [PATCH 08/48] docs: name the time anchor in the Swift and Dart aliases and the C++ guide Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 6 ++++++ dart/moq/lib/src/aliases.dart | 2 +- swift/Sources/Moq/Aliases.swift | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 63ce5456a5b..7c476fa23dc 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads, and anchors can name a time (#5929) + +A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains a `Time` case holding `timestamp_us`, the group containing that presentation time, so exhaustive visits need a case for it. +`TrackConsumer::update` now returns a result, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. + ### Subscriptions carry group ranges (#5732) `Subscription::group_start` and `group_end` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index a72a77f225e..ee9d97750c1 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -120,7 +120,7 @@ typedef Subscription = MoqSubscription; /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is null. typedef Range = MoqRange; -/// Where one end of a [Range] falls: absolute or relative to the latest group. +/// Where one end of a [Range] falls: absolute, relative to the latest group, or the group containing a time. typedef Anchor = MoqAnchor; /// Which pending group the publisher sends first. diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index 97c8cce74e2..9b9fd71b54c 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -115,8 +115,8 @@ public typealias ProtocolError = MoqFFI.MoqProtocolError extension Subscription { /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is `nil`. public typealias Range = MoqFFI.MoqRange - /// Where one end of a `Range` falls: `.absolute(group:frame:)` or - /// `.relative(groups:)` back from the latest group. + /// Where one end of a `Range` falls: `.absolute(group:frame:)`, `.relative(groups:)` + /// back from the latest group, or `.time(timestampUs:)` for the group containing it. public typealias Anchor = MoqFFI.MoqAnchor /// Which pending group the publisher sends first: `.ascending` or `.descending`. public typealias Order = MoqFFI.MoqOrder From 01cec52b9fe4acb6e10abf6ffd1cf7c0f8c59dad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 11:05:35 -0700 Subject: [PATCH 09/48] quest(m1): the time anchor moves to exact timestamps with the bindings Co-Authored-By: Claude Opus 5.5 --- quest/m1/binding-timestamps.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quest/m1/binding-timestamps.md b/quest/m1/binding-timestamps.md index 20403c6446c..1bd34d50a51 100644 --- a/quest/m1/binding-timestamps.md +++ b/quest/m1/binding-timestamps.md @@ -16,8 +16,10 @@ Public API: breaking in every binding. Today every binding speaks microseconds: moq-ffi `timestamp_us: Option` (about 160 sites in 9 files: `MoqFrame`, `MoqDatagram`, `MoqMediaFrame`, audio -frames, json/flate producer `update`/`append`, media `flush`), moq-c -`timestamp_us` plus `timestamp_present` pairs (about 140 sites in 6 files), and +frames, json/flate producer `update`/`append`, media `flush`, the +subscription start `MoqAnchor::Time`), moq-c `timestamp_us` plus +`timestamp_present` pairs (about 140 sites in 6 files, plus +`moq_anchor.timestamp_us` read as `MOQ_ANCHOR_TIME`), and each wrapper's native type (Python `timedelta`, Go `*time.Duration`, Swift `UInt64`, Kotlin `Duration`, Dart `int`, C++ `std::optional`, OBS microseconds). Reads floor through `as_micros()`, so any scale that does not From 52d2c4ffdaff9392760f6a1965fc6eddef43c870 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:23:51 -0700 Subject: [PATCH 10/48] fix(moq-net): a lowered floor re-offers the cached groups it newly covers The arrival walk passed groups below the floor without parking them, so widening the ranges (or a SUBSCRIBE_UPDATE lowering Group Start) could never serve a cached group that arrived before the ones already read. A relay could not fold a second subscription into one by lowering the start. The cursor now remembers the highest floor its walk applied. When the floor drops below it, the cached groups in between are looked up by sequence and parked, so each update costs only the groups it re-offers, and the subscriber's delivered set keeps any from going out twice. This reverses #4940's no-rewind rule; its two publisher tests now pin the new contract. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 16 ++++--- drafts/draft-lcurley-moq-lite.md | 1 + rs/moq-net/src/lite/publisher.rs | 14 +++--- rs/moq-net/src/model/track.rs | 78 +++++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 69770ec5336..f6fb4a7c599 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -220,13 +220,15 @@ Each subscription carries the knobs that decide behavior under congestion: 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. 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 may let through one extra -group that turns out to start past it, since holding each group until its first -frame arrives would cost latency. +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 +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 +lets through every group handed out before its first frame showed it starts past +the end, since holding each group until its first frame arrives would cost +latency. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 941072fbbe6..0246921a411 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1187,6 +1187,7 @@ MUST be 0 when `Group End` is 0, since an unbounded subscription has no end grou A subscriber can modify a subscription with a SUBSCRIBE_UPDATE message. A subscriber MAY send multiple SUBSCRIBE_UPDATE messages to update the subscription. The start and end group can be changed in either direction (growing or shrinking). +Lowering `Group Start` covers the groups below the old start that the publisher still holds, not only those that arrive later; a group already sent is not sent again. ~~~ SUBSCRIBE_UPDATE Message { diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 7a524912caa..21981d380de 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -5253,9 +5253,9 @@ mod serve_group_test { drop(track); run.await.unwrap(); } - // Group 2 arrived before the subscription and the cursor has passed it, so it was - // never served either. - assert_eq!(drops(&mut writer, &log, version).await, [(0, 4), (6, 6)]); + // Group 2 arrived before the subscription, but it is cached, so the lowered start + // serves it and the drops name only what is missing around it. + assert_eq!(drops(&mut writer, &log, version).await, [(0, 1), (3, 4), (6, 6)]); } /// A lowered start restarts the age of the gaps it newly asks for, as the subscriber does, @@ -5597,10 +5597,10 @@ mod serve_group_test { assert_eq!(relay.opened(), 1, "the finished group is the live edge"); } - /// Widening the floor never rewinds past a group already served: group 0 - /// arrived before the served groups, so the cursor has moved past it. + /// Widening the floor serves a cached group below the old one, even though it arrived + /// before the groups already served, and serves those only once. #[moq_clock::test] - async fn a_widening_update_does_not_rewind_past_served_groups() { + async fn a_widening_update_serves_a_cached_group_below_the_old_floor() { let mut track = track::Producer::new( Arc::new(broadcast::Info::default()), "test", @@ -5625,7 +5625,7 @@ mod serve_group_test { end_frame: None, }); relay.settle(); - assert_eq!(relay.opened(), 2, "group 0 was passed, not skipped"); + assert_eq!(relay.opened(), 3, "group 0 is cached and now in range"); } /// A SUBSCRIBE_UPDATE landing while the first group waits on the source's start keeps diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 0b36a398f3e..7fd29261ee0 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4630,6 +4630,9 @@ struct Cursor { /// are parked here instead of dropped). Keyed by sequence so the lowest is /// re-offered first. parked: BTreeMap, + /// The highest floor `recv_group` has applied. Its walk passes the groups below the + /// floor without parking them, so lowering it parks those still cached. + passed: u64, aborts: Option>, /// [`Self::end_sequence`], shared with the groups this cursor hands out so their /// expiry measures drift against the same servable window after a cap moves. @@ -4802,6 +4805,7 @@ impl Cursor { next_sequence: Some(0), end_sequence: None, parked: BTreeMap::new(), + passed: 0, aborts: None, drift_cap: kio::Producer::new(None), stale: stats::Content::default(), @@ -5025,12 +5029,47 @@ impl Cursor { aborts.register(waiter); } + /// Park the cached groups in range from `floor` up to [`Self::passed`], which the walk + /// passed under a higher floor, so they are re-offered like any widened range. + fn backfill(&mut self, floor: u64, waiter: &kio::Waiter) { + let groups: Vec = { + let state = self.state.read(); + // Nothing is read from the cache now, so leave the floor to lower once it is. + if !state.readable() { + return; + } + self.filter + .window + .runs(floor.max(state.live_floor.unwrap_or(0))) + .take_while(|run| *run.start() < self.passed) + .flat_map(|run| state.lookup.range(run).map(|(_, slot)| slot.serving())) + .take_while(|group| group.sequence < self.passed) + .filter(|group| !group.is_aborted()) + .cloned() + .collect() + }; + self.passed = floor; + if groups.is_empty() { + return; + } + // Consumed with the track guard released, as the walk does. + for group in groups { + self.park(group.consume(), waiter); + } + // In reach at the revision `reoffer` last found nothing at, so look again. + self.filter.reoffer_idle = None; + } + fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.sync(); if let Some(err) = &self.filter.refused { return Poll::Ready(Err(err.clone())); } let floor = self.floor(); + match floor < self.passed { + true => self.backfill(floor, waiter), + false => self.passed = floor, + } if let Some(aborts) = &self.aborts { // A sequence may name both a live group and its fetched head. Only the // consumer we parked decides whether this notification invalidates it. @@ -5072,6 +5111,7 @@ impl Cursor { } _ => { let floor = self.floor(); + self.passed = self.passed.max(floor); let Some((producer, found_index)) = ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, floor))?) else { @@ -5093,6 +5133,7 @@ impl Cursor { if !self.filter.window.is_settled() { self.sync(); if consumer.sequence < self.floor() { + self.passed = self.passed.max(self.floor()); continue; } } @@ -5457,7 +5498,11 @@ impl Subscriber { .lookup .get(sequence) .is_some_and(|slot| slot.stamp == *stamp && !slot.is_aborted()) - })) + }) || state + // Passed under a higher floor, so the walk re-parks them; see `Cursor::backfill`. + .lookup + .range(floor..cursor.passed.max(floor)) + .any(|(sequence, slot)| eligible(*sequence) && !slot.is_aborted())) } /// Receive the next group in arrival order. @@ -5712,7 +5757,9 @@ impl Subscriber { /// /// Stored verbatim; the publisher's max age window is applied to the aggregate, not /// here (see [`Producer::subscription`]). Replaced ranges resolve again here, a - /// relative anchor counting back from the latest group now. Returns [`Error::Closed`] + /// relative anchor counting back from the latest group now, and widening them offers + /// the cached groups they newly cover, even ones that arrived before the groups + /// already read. Returns [`Error::Closed`] /// if the track already ended; the update is meaningless at that point and can usually /// be ignored. Refuses a time anchor on an untimed track with /// [`Error::TimestampMismatch`], leaving the preferences as they were. @@ -13620,6 +13667,33 @@ mod test { assert_eq!(drain(&mut late), vec![4]); } + /// Lowering the floor re-offers the cached groups the arrival walk passed below it, + /// as widening any range does. + #[test] + #[cfg(feature = "async")] + fn a_lowered_floor_reoffers_what_arrived_below_it() { + let mut producer = track_producer("test", Info::timed()); + for second in 0..6 { + append_at(&mut producer, second * 1000); + } + let mut subscriber = producer.subscribe(replay().with_groups(5..)); + assert_eq!(drain(&mut subscriber), vec![5]); + + subscriber.update(replay().with_groups(2..)).unwrap(); + assert_eq!(drain(&mut subscriber), vec![2, 3, 4], "passed below the floor, now in range"); + subscriber.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut subscriber), vec![0, 1]); + + // Raising and lowering it again re-offers only what is cached in between, and + // nothing already served goes out twice. + subscriber.update(replay().with_groups(4..)).unwrap(); + assert_eq!(drain(&mut subscriber), Vec::::new()); + subscriber.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut subscriber), Vec::::new()); + append_at(&mut producer, 6000); + assert_eq!(drain(&mut subscriber), vec![6]); + } + /// Datagrams outside a subscriber's ranges are dropped like the groups. #[test] #[cfg(feature = "async")] From e4f31c20bd72bc20a110c2f93c29bea28fafd6ce Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:23:51 -0700 Subject: [PATCH 11/48] docs(moq-net): state the real bound on a time end; quest the one-group cap An unresolved time end covers every group handed out before its first frame, not just one. Say so on Anchor::Time and in the concept doc, and track capping it at one speculative group in quest/m1/subscribe-ranges/time-end-cap.md. Co-Authored-By: Claude Opus 5.5 --- quest/m1/subscribe-ranges/README.md | 1 + quest/m1/subscribe-ranges/time-end-cap.md | 34 +++++++++++++++++++++++ rs/moq-net/src/model/subscription.rs | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 quest/m1/subscribe-ranges/time-end-cap.md diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 6897d975607..c30143f95df 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -91,6 +91,7 @@ every sequence delivered or dropped. - [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 - [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` model and lite-07 parity +- [Time end cap](/quest/m1/subscribe-ranges/time-end-cap.md) - at most one group past a time end at the live edge ## Related diff --git a/quest/m1/subscribe-ranges/time-end-cap.md b/quest/m1/subscribe-ranges/time-end-cap.md new file mode 100644 index 00000000000..717e1e359fa --- /dev/null +++ b/quest/m1/subscribe-ranges/time-end-cap.md @@ -0,0 +1,34 @@ +# [M] One speculative group past a time end + +## Goal + +A subscriber whose range ends at `Time(pts)` receives at most one group past that +end at the live edge, however many groups the publisher opens before their first +frames land. Today an unresolved time end covers every sequence through `u64::MAX`, +so each group handed out before its first frame can leak past the end; the docs +state that real bound (`track::Anchor::Time`, `doc/concept/moq-lite.md`). + +## Plan + +Found by Codex on #5929 and reproduced there: range `time(3000)..time(3500)` with +three groups opened before any frame delivers all three, not one. + +- `Window` caps an unresolved time end at the newest held group known to start at or + before pts, plus the first unstarted group above it, instead of `u64::MAX`. + `settle` recomputes the cap as groups start. +- Raising the cap must wake readers: register on the speculative group's start + (`group::Producer::poll_started`) from both `Cursor::poll_recv_group` and + `poll_next_group`, so a parked group past the cap is re-offered once the group + before it starts, without waiting on the next arrival. +- A held group that never shows a start (a copy joined mid-group) must not pin the + cap: a later group starting at or before pts lifts it past. +- Datagram-only timed tracks: datagrams are not in `lookup`, so a time end on them + never resolves and the cap would stay at the start group, dropping every later + datagram. Resolve datagram starts too, or leave datagram reads uncapped. + +Regression test: the #5929 repro, plus the wake (a parked group re-offered when its +predecessor's first frame lands) under mock time. + +## Related + +- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - where time anchors are resolved for a `Dynamic` handler diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 47fffdc0aa6..b44a1f8b76e 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -166,7 +166,7 @@ pub enum Anchor { /// Resolved from the starts of the groups the serving hop holds. When none starts at /// or before it, a start reaches back to the oldest group, and while no group has /// started at all it waits like `Relative(0)`. An end stays open until a group - /// starting after it is known, so at the live edge a reader may get one extra group, + /// starting after it is known, so at the live edge a reader may get every group /// handed out before its first frame showed it starts past the end. An untimed track /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), From d79f671bfe9f863ecc6c99f421f11f0a2f2245bd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:47:04 -0700 Subject: [PATCH 12/48] style(moq-net): name the passed-floor check in has_unread_group Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7fd29261ee0..558851b4c71 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5483,12 +5483,19 @@ impl Subscriber { && cursor.filter.window.contains(sequence) && !unread.delivered.contains(sequence) }; + // Passed under a higher floor, so the walk parks them again; see `Cursor::backfill`. + let passed = || { + state + .lookup + .range(floor..cursor.passed.max(floor)) + .any(|(sequence, slot)| eligible(*sequence) && !slot.is_aborted()) + }; cursor .parked .iter() .any(|(sequence, group)| eligible(*sequence) && !group.is_aborted()) || (state.readable() - && state + && (state .arrival .iter() .skip(cursor.index.saturating_sub(state.offset)) @@ -5498,11 +5505,7 @@ impl Subscriber { .lookup .get(sequence) .is_some_and(|slot| slot.stamp == *stamp && !slot.is_aborted()) - }) || state - // Passed under a higher floor, so the walk re-parks them; see `Cursor::backfill`. - .lookup - .range(floor..cursor.passed.max(floor)) - .any(|(sequence, slot)| eligible(*sequence) && !slot.is_aborted())) + }) || passed())) } /// Receive the next group in arrival order. @@ -13680,7 +13683,11 @@ mod test { assert_eq!(drain(&mut subscriber), vec![5]); subscriber.update(replay().with_groups(2..)).unwrap(); - assert_eq!(drain(&mut subscriber), vec![2, 3, 4], "passed below the floor, now in range"); + assert_eq!( + drain(&mut subscriber), + vec![2, 3, 4], + "passed below the floor, now in range" + ); subscriber.update(replay().with_groups(0..)).unwrap(); assert_eq!(drain(&mut subscriber), vec![0, 1]); From 9b680ece993d0089f14847c96e467f8df9165f2f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:26:22 -0700 Subject: [PATCH 13/48] docs(moq-net): quest local frame bounds; fix the stale drift cap comment A local track::Subscriber hands out whole groups even when its range starts or ends mid-group. That predates this branch (reproduced on main 824179a3a), and the wire publishers already position each group per subscription, so it is tracked in quest/m1/subscribe-ranges/frame-bounds.md. The drift_cap comment still said ranges don't filter the subscriber; say why the edge stays uncapped by them instead. Co-Authored-By: Claude Opus 5.5 --- quest/m1/subscribe-ranges/README.md | 1 + quest/m1/subscribe-ranges/frame-bounds.md | 37 +++++++++++++++++++++++ rs/moq-net/src/model/track.rs | 10 +++--- 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 quest/m1/subscribe-ranges/frame-bounds.md diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index c30143f95df..299fb915102 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -91,6 +91,7 @@ every sequence delivered or dropped. - [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 - [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` model and lite-07 parity +- [Frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) - a local subscriber reads only the frames its ranges cover - [Time end cap](/quest/m1/subscribe-ranges/time-end-cap.md) - at most one group past a time end at the live edge ## Related diff --git a/quest/m1/subscribe-ranges/frame-bounds.md b/quest/m1/subscribe-ranges/frame-bounds.md new file mode 100644 index 00000000000..23c940af714 --- /dev/null +++ b/quest/m1/subscribe-ranges/frame-bounds.md @@ -0,0 +1,37 @@ +# [M] A local subscriber reads only its frame bounds + +## Goal + +A `track::Subscriber` whose ranges start or end partway through a group reads only the +frames they cover: a start at group 4 frame 5 hands out group 4 from frame 5, and an +end mid-group stops there, whatever another subscriber widened the aggregate to. Today +the model filters whole groups and hands out every cached frame, so local readers +(moq-ffi, moq-mux, in-process apps) see frames they never asked for. + +## Plan + +Found by Codex on #5929 and reproduced on `main` (824179a3a) as well as on that PR: a +subscription starting at frame 5 of group 0 is handed the group from frame 0. #5929's +`Window` collapses each range to group sequences, but the leak predates it: `main`'s +cursor never positioned a delivered group either. Only the datagram path checks the +start frame (`mid_group` in `Cursor::poll_recv_datagram`). + +The wire is not affected: the lite publisher positions each served group per +subscription (`position_group` in `rs/moq-net/src/lite/publisher.rs`, and the same +shape in `js/net/src/lite/publisher.ts`). + +- Keep each range's absolute frame bounds in the `Window` beside its group runs. +- Position every group the cursor hands out (`group::Consumer::start_at`/`end_at`), on + the arrival, ordered, and re-offer paths, using the bounds of the range that covers it. + A group whose head is gone and that was asked for from frame 0 stays a miss, as + `position_group` does today. +- Then let the lite publisher rely on the model's positioning instead of its own, if + that is simpler. +- Check `js/net`'s `track` subscriber for the same shape and mirror the fix. + +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/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 558851b4c71..ebac7edcc76 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4637,12 +4637,10 @@ struct Cursor { /// [`Self::end_sequence`], shared with the groups this cursor hands out so their /// expiry measures drift against the same servable window after a cap moves. /// - /// Deliberately not where [`Subscription::ranges`] end. That is a *request to the publisher*, - /// folded in with every other subscriber's, and it does not filter this handle (see - /// [`Subscriber`]): another unbounded subscriber widens the aggregate and the groups - /// arrive here anyway. Capping the drift edge with it would pin the live edge at the - /// requested end while delivery ran past it, and everything above would then have - /// nothing newer to be late against. + /// Deliberately not where [`Subscription::ranges`] end. They filter what this handle + /// reads, but they are filters, not exemptions: max delay caps every range against the + /// track's live edge. Capping the edge at a range's end would judge a range in the past + /// only against itself, so none of it would ever be late however far the track moved on. drift_cap: kio::Producer>, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the stats scope lives on the owning From bb03fca8c80b35e75dfc0f154986d34d3076d560 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:12:33 -0700 Subject: [PATCH 14/48] fix(moq-net): a lowered floor re-offers only groups readers can see The backfill and has_unread_group read the cache by sequence, so they also picked up fetched backfill (never replayed in arrival order) and groups a route still withholds until reveal_group. Filter both to shown slots, as the arrival walk does. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 61 +++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index ebac7edcc76..97b86d638bd 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5040,7 +5040,14 @@ impl Cursor { .window .runs(floor.max(state.live_floor.unwrap_or(0))) .take_while(|run| *run.start() < self.passed) - .flat_map(|run| state.lookup.range(run).map(|(_, slot)| slot.serving())) + // Only what the walk could have read: never a fetched or withheld group. + .flat_map(|run| { + state + .lookup + .range(run) + .filter(|(_, slot)| slot.is_shown()) + .map(|(_, slot)| slot.serving()) + }) .take_while(|group| group.sequence < self.passed) .filter(|group| !group.is_aborted()) .cloned() @@ -5486,7 +5493,7 @@ impl Subscriber { state .lookup .range(floor..cursor.passed.max(floor)) - .any(|(sequence, slot)| eligible(*sequence) && !slot.is_aborted()) + .any(|(sequence, slot)| eligible(*sequence) && slot.is_shown() && !slot.is_aborted()) }; cursor .parked @@ -12840,6 +12847,56 @@ mod test { .unwrap(); } + /// Lowering the floor re-offers only what the arrival walk could have read: a fetched + /// group stays out of arrival order, and a withheld one waits until it is revealed. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_lowered_floor_reoffers_neither_fetched_nor_withheld_groups() { + let (producer, _pool) = pooled_producer(1 << 40); + let dynamic = producer.dynamic(); + let consumer = producer.consume(); + producer.create_group(5u64.into()).unwrap().finish().unwrap(); + let mut subscriber = producer.subscribe(replay().with_groups(5..)); + assert_eq!(subscriber.assert_group().sequence, 5); + + let pending = consumer.fetch_group(2, None); + let req = dynamic + .requested_group() + .now_or_never() + .expect("should not block") + .unwrap(); + let mut fetched = req.accept(Info::timed()).unwrap(); + fetched + .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"backfill")) + .unwrap(); + fetched.finish().unwrap(); + pending.await.unwrap(); + let mut withheld = producer.receive_group(group::Info { sequence: 3 }).unwrap(); + + subscriber.update(replay().with_groups(0..)).unwrap(); + subscriber.assert_no_group(); + + withheld + .write_frame(Timestamp::from_millis(3).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + producer.reveal_group(&withheld); + assert_eq!(subscriber.assert_group().sequence, 3, "shown once revealed"); + subscriber.assert_no_group(); + + // A cursor floor lowered before the next read counts only shown groups as unread. + let mut lowered = producer.subscribe(replay()); + lowered.start_at(5); + assert_eq!(lowered.assert_group().sequence, 5); + lowered.start_at(0); + let delivered = super::super::delivered::Delivered::default(); + let unread = Unread { + start: 0, + end: Some(3), + delivered: &delivered, + }; + assert!(!lowered.has_unread_group(&unread), "the fetched group 2 is not unread"); + } + /// A fetched (backfill) group is served by sequence but never replayed to /// arrival-order subscribers. #[moq_clock::test] From dee0478a48ff2858e00310e4309904c92b4d057a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:12:43 -0700 Subject: [PATCH 15/48] docs(drafts): log the lowered Group Start behavior in the lite-07 changelog Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-lite.md | 1 + 1 file changed, 1 insertion(+) diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 0246921a411..62443152916 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1597,6 +1597,7 @@ The `Message Length` describes the payload size on the wire. - SUBSCRIBE_OK `Group` names where delivery starts when the subscription resolves, which need not be the first group sent. It is not a new floor. A subscriber does not wait for a group between the requested floor and it, but a publisher still delivers one that arrives later within Subscriber Max Age. - Corrected the SUBSCRIBE note that offset `Group Start` by 1. Only `Group End` and `Frame End` are offset so 0 can mean absent. `Group Start` is an absolute sequence, and 0 is group 0. - A subscription's range bounds datagrams like groups, and FETCH never returns a datagram. +- A SUBSCRIBE_UPDATE that lowers `Group Start` covers the groups below the old start that the publisher still holds, not only those that arrive later. A group already sent is not sent again. - A publisher SHOULD order a datagram among the subscription's streams as if it were its Group's stream, ahead of that stream when both exist. It SHOULD drop a datagram rather than block when the transport has no room for it, where it previously sent one only if the congestion controller could transmit it immediately. - Assigned 0x3A NOT_FETCHABLE in the stream error table: a FETCH for a group delivered only as a datagram. - The subscriber FINs its Subscribe Stream after settling its tail; graceful session close waits for that FIN or reset. From cee4bc0c3ea573bc242f6752b741c35f70e0df6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:18:31 -0700 Subject: [PATCH 16/48] fix(moq-net): a failover clamp never widens a range A replacement copy clamped every relative or time start to the reader's newest delivered group, so a later disjoint time range (Time(10s), say) was asked from group 0 and its own filter let through everything in between. Capping a replaced copy did the same to a time end. The Window now reports each range resolved, by index. A replacement copy is clamped from where the serving copy resolved the reader's ranges, read before that copy is capped, and a copy recomputing its own wire uses its own resolution, or resolves replaced ranges against its cache now. Only a range not resolved yet still becomes the bound itself. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 89 ++++++++++++++++++++- rs/moq-net/src/model/subscription.rs | 55 +++++++++---- rs/moq-net/src/model/subscription/window.rs | 42 +++++++++- rs/moq-net/src/model/track.rs | 27 ++++++- 4 files changed, 190 insertions(+), 23 deletions(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 1746945c844..e87af7a5589 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -24,7 +24,7 @@ use std::task::{Poll, ready}; use crate::{Error, Result, StreamError, group, stats, track}; use super::delivered::Delivered; -use super::subscription::{Cap, Position, Subscription}; +use super::subscription::{Cap, Position, Resolved, Subscription}; /// What the front says about a logical track: the copy to read now, and how it ended. #[derive(Default)] @@ -269,6 +269,11 @@ struct Copy { sub: Sub, /// Where the subscription was asked to start, so demand updates never ask for less. floor: Option, + /// The reader's ranges [`Self::resolved`] are for. + from: Vec, + /// Each of those ranges resolved, by index, so clamping never widens one: as the + /// serving copy had them when this one was subscribed, until this one answers. + resolved: Vec>, /// Replaced by a newer route: nothing past this group, the newest it had then, is /// taken from it. until: Option>, @@ -293,8 +298,19 @@ enum Sub { impl Copy { /// What this copy is asked for: the reader's own preferences, from no earlier than /// where it was asked to start, and through no later than where it was replaced. - fn wire(&self, reader: &Subscription) -> Subscription { - let mut wire = reader.clamped(self.floor, self.end().map(Position::group)); + fn wire(&mut self, reader: &Subscription) -> Subscription { + match &mut self.sub { + // Its own resolution, which capping its end must not move. + Sub::Ready(sub) if reader.ranges == self.from => self.resolved = sub.resolved_ranges(), + // Replaced ranges resolve against this copy's cache now, as any subscriber's do. + Sub::Ready(sub) => { + self.resolved = sub.resolve_ranges(&reader.ranges); + self.from = reader.ranges.clone(); + } + Sub::Pending(_) if reader.ranges == self.from => {} + Sub::Pending(_) => self.resolved.clear(), + } + let mut wire = reader.clamped(self.floor, self.end().map(Position::group), &self.resolved); // A replaced copy only finishes what it had, so a new group from it never arrives. if self.until.is_some() { wire.new_group = None; @@ -537,6 +553,17 @@ impl Reader { if self.copies.last().is_some_and(|copy| copy.generation == generation) { return; } + // Where the serving copy resolved the reader's ranges: the floor raises each start + // there, never lowering a later range to the floor. Read before capping that copy + // below, which replaces its ranges and so resolves them again as of now. + let resolved = match self.copies.last_mut() { + Some(Copy { + sub: Sub::Ready(sub), + from, + .. + }) if *from == self.mirrored.ranges => sub.resolved_ranges(), + _ => Vec::new(), + }; // The replaced copies finish the groups they had when replaced, and nothing newer. for copy in &mut self.copies { if copy.until.is_none() { @@ -547,12 +574,14 @@ impl Reader { let floor = self.newest.map(Position::group); // The wire request carries the floor from the first message: some sessions read // the demand only once. - let wire = self.mirrored.clamped(floor, None); + let wire = self.mirrored.clamped(floor, None, &resolved); self.copies.push(Copy { generation, sub: Sub::Pending(track.subscribe(wire)), track, floor, + from: self.mirrored.ranges.clone(), + resolved, until: None, done: None, datagrams_done: None, @@ -1713,6 +1742,58 @@ mod test { assert_eq!(recv(&mut sub).sequence, 6); } + /// A replacement copy keeps each range where the serving copy resolved it: the floor + /// raises a later time range's start rather than lowering it to the floor, and capping + /// the replaced copy never widens a time end. + #[test] + #[cfg(feature = "async")] + fn a_replacement_keeps_a_later_time_range_disjoint() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let ranges = [ + track::Range::groups(0..1), + track::Range { + start: track::Anchor::Time(ts(10_000)), + end: Some(track::Anchor::Time(ts(11_000))), + }, + ]; + let subscription = Subscription::default() + .with_max_delay(Duration::from_secs(30)) + .with_ranges(ranges); + let mut sub = logical + .consume() + .subscribe(subscription) + .now_or_never() + .unwrap() + .unwrap(); + for sequence in 0..13 { + let mut group = a.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + assert_eq!(recv(&mut sub).sequence, 0); + + let b = copy(); + routes.serve(b.consume()); + // The read subscribes the replacement while the replaced copy still serves. + let mut rest = Vec::new(); + while let Some(Ok(Some(group))) = sub.recv_group().now_or_never() { + rest.push(group.sequence); + } + for sequence in 0..13 { + let mut group = b.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + while let Some(Ok(Some(group))) = sub.recv_group().now_or_never() { + rest.push(group.sequence); + } + rest.sort(); + assert_eq!(rest, vec![10, 11]); + } + #[test] fn a_replaced_copy_at_the_last_sequence_has_no_overflow_cap() { for (last, end) in [(Some(u64::MAX), None), (None, Some(Position::default()))] { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index b44a1f8b76e..57ac9a122e6 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -6,7 +6,7 @@ use std::{ use crate::Timestamp; mod window; -pub(super) use window::{Containing, Holdings, Window}; +pub(super) use window::{Containing, Holdings, Resolved, Window}; /// Subscriber-side preferences for receiving a track. /// @@ -402,24 +402,47 @@ impl Subscription { /// These preferences with every range raised to start no earlier than `floor` and /// stop no later than `end`, the exclusive end. /// - /// A relative or time anchor becomes the bound itself, since which of the two is tighter - /// depends on groups this hop may not know. That asks for no less than the - /// range would. - pub(super) fn clamped(&self, floor: Option, end: Option) -> Self { + /// `resolved` names where each range resolved, by index, so a relative or time anchor + /// becomes its own group, raised to `floor` or capped at `end`, rather than the bound + /// itself: clamping never widens a range. One not resolved becomes the bound, which + /// asks for no less than the range would. + pub(super) fn clamped( + &self, + floor: Option, + end: Option, + resolved: &[Option], + ) -> Self { + let clamping = floor.is_some() || end.is_some(); let ranges = self .ranges .iter() - .map(|range| Range { - start: match (range.start, floor) { - (start, None) => start, - (Anchor::Absolute(start), Some(floor)) => Anchor::Absolute(start.max(floor)), - (Anchor::Relative(_) | Anchor::Time(_), Some(floor)) => Anchor::Absolute(floor), - }, - end: match (range.end, end) { - (requested, None) => requested, - (Some(Anchor::Absolute(requested)), Some(end)) => Some(Anchor::Absolute(requested.min(end))), - (_, Some(end)) => Some(Anchor::Absolute(end)), - }, + .enumerate() + .map(|(index, range)| { + let resolved = resolved.get(index).copied().flatten().filter(|_| clamping); + let start = match (range.start, resolved) { + (Anchor::Relative(_) | Anchor::Time(_), Some(resolved)) => { + Anchor::Absolute(Position::group(resolved.start)) + } + (start, _) => start, + }; + let requested = match (range.end, resolved.and_then(|resolved| resolved.end)) { + (Some(Anchor::Relative(_) | Anchor::Time(_)), Some(resolved)) => { + Some(Anchor::Absolute(Position::group(resolved))) + } + (requested, _) => requested, + }; + Range { + start: match (start, floor) { + (start, None) => start, + (Anchor::Absolute(start), Some(floor)) => Anchor::Absolute(start.max(floor)), + (Anchor::Relative(_) | Anchor::Time(_), Some(floor)) => Anchor::Absolute(floor), + }, + end: match (requested, end) { + (requested, None) => requested, + (Some(Anchor::Absolute(requested)), Some(end)) => Some(Anchor::Absolute(requested.min(end))), + (_, Some(end)) => Some(Anchor::Absolute(end)), + }, + } }) .collect(); Self { ranges, ..self.clone() } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index aa9052323c1..4540e427012 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -11,6 +11,14 @@ use rangemap::RangeInclusiveSet; use super::{Anchor, Range}; use crate::{Error, Result, Timestamp}; +/// One range resolved to groups: from `start` up to the exclusive `end`, `None` while it +/// stays open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::model) struct Resolved { + pub start: u64, + pub end: Option, +} + /// When a serving hop's groups start, for resolving time anchors. pub(in crate::model) trait Holdings { /// Whether the track carries timestamps at all. @@ -52,6 +60,8 @@ pub(in crate::model) struct Window { /// One non-empty range, resolved. #[derive(Clone, Copy, Debug)] struct Span { + /// Which of the ranges it came from. + index: usize, start: u64, end: End, /// Whether the start names a group, rather than reaching back to the oldest one. @@ -143,6 +153,22 @@ impl Window { self.spans.iter().map(|span| span.start).min() } + /// Each range resolved, in the order given: `None` for one that is empty or still + /// waits on a first group. + pub fn resolved(&self) -> Vec> { + let mut resolved = vec![None; self.ranges.len()]; + for span in &self.spans { + resolved[span.index] = Some(Resolved { + start: span.start, + end: match span.end { + End::Before(end) => Some(end), + End::Open | End::Time(_) => None, + }, + }); + } + resolved + } + /// The exclusive end past every group in range, `None` when one stays open. pub fn end(&self) -> Option { if self.pending { @@ -191,7 +217,7 @@ impl Window { } self.spans.clear(); self.pending = false; - for range in self.ranges.iter().filter(|range| !range.is_empty()) { + for (index, range) in self.ranges.iter().enumerate().filter(|(_, range)| !range.is_empty()) { let (Some((start, floored)), Some(end)) = (start(range.start, latest, holdings), end(range.end, latest)) else { self.pending = true; @@ -201,7 +227,12 @@ impl Window { End::Time(pts) => holdings.after(pts, start).map_or(end, End::Before), end => end, }; - self.spans.push(Span { start, end, floored }); + self.spans.push(Span { + index, + start, + end, + floored, + }); } self.rebuild(); Ok(()) @@ -331,6 +362,13 @@ mod tests { assert_eq!(window.clip(0..10), Some(2..4)); assert_eq!(window.clip(4..10), Some(7..10)); assert_eq!(window.clip(4..7), None); + assert_eq!( + window.resolved(), + vec![ + Some(Resolved { start: 2, end: Some(4) }), + Some(Resolved { start: 7, end: None }), + ] + ); // A mid-group end includes its group, a head-of-group end excludes it. let window = Window::new( diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 97b86d638bd..d7e7575dd5a 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -22,7 +22,7 @@ use super::{Datagram, Requests}; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; -use super::subscription::{Containing, Holdings, NewGroup, Window}; +use super::subscription::{Containing, Holdings, NewGroup, Resolved, Window}; use rangemap::RangeSet; mod demand; @@ -5637,6 +5637,31 @@ impl Subscriber { } } + /// This subscriber's own ranges, by index, as resolved here: `None` for one empty or + /// still waiting, and for every range of a front's reader. + pub(super) fn resolved_ranges(&mut self) -> Vec> { + match &mut self.inner { + Inner::Plain(cursor) => { + cursor.sync(); + cursor.filter.window.resolved() + } + Inner::Resume(..) => Vec::new(), + } + } + + /// `ranges` resolved against this track's cache now, by index. + pub(super) fn resolve_ranges(&self, ranges: &[Range]) -> Vec> { + match &self.inner { + Inner::Plain(cursor) => { + let state = cursor.state.read(); + Window::new(ranges, state.newest_produced, &*state) + .map(|window| window.resolved()) + .unwrap_or_default() + } + Inner::Resume(..) => Vec::new(), + } + } + /// Poll for sequences this subscription gave up without serving. /// /// Stale skips are recorded only once this has been polled, so a cursor no wire From a24bff1e1d55687f45ab5c2c52d627b96ba73463 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:59:23 -0700 Subject: [PATCH 17/48] fix(moq-net): an unresolved start never widens to the failover floor A relative or time start a replacement copy cannot resolve yet, such as one updated while the copy is still pending, keeps its anchor instead of becoming the floor, which would read every group from there. A pending copy replaced in turn passes on the resolution it was handed. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 30 ++++++++++++++++++++++++++++ rs/moq-net/src/model/subscription.rs | 11 +++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index e0e21667beb..4c20b037b7b 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -562,6 +562,13 @@ impl Reader { from, .. }) if *from == self.mirrored.ranges => sub.resolved_ranges(), + // One still unanswered passes on what it was handed. + Some(Copy { + sub: Sub::Pending(_), + from, + resolved, + .. + }) if *from == self.mirrored.ranges => resolved.clone(), _ => Vec::new(), }; // The replaced copies finish the groups they had when replaced, and nothing newer. @@ -1952,6 +1959,29 @@ mod test { assert_eq!(rest, vec![10, 11]); } + /// A replacement updated to a time range it cannot resolve yet asks for that range, + /// not everything from the floor: before it answers, and once it answers with nothing + /// to resolve it against. + #[test] + fn a_replacement_keeps_an_updated_time_start() { + let routes = Producer::new(); + let a = copy(); + routes.serve(a.consume()); + let sub = routes.consume().subscribe(kio::Producer::new(Subscription::default()).into()); + let mut reader = sub.reader.lock().unwrap(); + let copy = &mut reader.copies[0]; + copy.floor = Some(Position::group(0)); + let ranges = vec![track::Range { + start: track::Anchor::Time(ts(10_000)), + end: None, + }]; + let updated = Subscription::default().with_ranges(ranges.clone()); + assert!(matches!(copy.sub, Sub::Pending(_))); + assert_eq!(copy.wire(&updated).ranges, ranges); + assert!(copy.poll_ready((0, Cap::from(..)), &kio::Waiter::noop()).is_ready()); + assert_eq!(copy.wire(&updated).ranges, ranges); + } + #[test] fn a_replaced_copy_at_the_last_sequence_has_no_overflow_cap() { for (last, end) in [(Some(u64::MAX), None), (None, Some(Position::default()))] { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 57ac9a122e6..289328679e0 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -400,12 +400,12 @@ impl Subscription { } /// These preferences with every range raised to start no earlier than `floor` and - /// stop no later than `end`, the exclusive end. + /// stop no later than `end`, the exclusive end, without ever widening one. /// /// `resolved` names where each range resolved, by index, so a relative or time anchor - /// becomes its own group, raised to `floor` or capped at `end`, rather than the bound - /// itself: clamping never widens a range. One not resolved becomes the bound, which - /// asks for no less than the range would. + /// becomes its own group, raised to `floor` or capped at `end`. A start not resolved + /// keeps its anchor, since the floor may lie before it; an end not resolved becomes + /// the bound. pub(super) fn clamped( &self, floor: Option, @@ -433,9 +433,8 @@ impl Subscription { }; Range { start: match (start, floor) { - (start, None) => start, (Anchor::Absolute(start), Some(floor)) => Anchor::Absolute(start.max(floor)), - (Anchor::Relative(_) | Anchor::Time(_), Some(floor)) => Anchor::Absolute(floor), + (start, _) => start, }, end: match (requested, end) { (requested, None) => requested, From d788a309123ca36d28e1892bdac25104cead7121 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:02:04 -0700 Subject: [PATCH 18/48] style(moq-net): format the pending-copy regression test Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 4c20b037b7b..f4e5a17cafd 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1967,7 +1967,9 @@ mod test { let routes = Producer::new(); let a = copy(); routes.serve(a.consume()); - let sub = routes.consume().subscribe(kio::Producer::new(Subscription::default()).into()); + let sub = routes + .consume() + .subscribe(kio::Producer::new(Subscription::default()).into()); let mut reader = sub.reader.lock().unwrap(); let copy = &mut reader.copies[0]; copy.floor = Some(Position::group(0)); From 7060b2673728c014e1f3c7de1220ec871d7e96b1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:40:10 -0700 Subject: [PATCH 19/48] fix(moq-net): a replacement copy never reads below where the reader got to A start the wire kept as an anchor can resolve below the copy's floor once it answers, and the reader never delivered the groups it skipped there, so the delivered set did not stop them. Raise the copy's local start to its floor when it answers, as start_at already does. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 41 +++++++++++++++++++++++++++- rs/moq-net/src/model/subscription.rs | 4 +-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index f4e5a17cafd..02dd7204193 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -337,7 +337,8 @@ impl Copy { fn poll_ready(&mut self, groups: (u64, Cap), waiter: &kio::Waiter) -> Poll> { if let Sub::Pending(pending) = &self.sub { let mut sub = ready!(pending.poll_ok(waiter))?; - sub.raise_start_to(groups.0); + // An anchor the wire kept may resolve below where the reader got to. + sub.raise_start_to(self.floor.map_or(groups.0, |floor| groups.0.max(floor.group))); sub.end_at(groups.1); self.sub = Sub::Ready(Box::new(sub)); } @@ -1984,6 +1985,44 @@ mod test { assert_eq!(copy.wire(&updated).ranges, ranges); } + /// A replacement asked for a range reaching back past where the reader got to, while + /// still unanswered, never hands out a group the reader skipped below that point. + #[test] + #[cfg(feature = "async")] + fn a_pending_replacement_never_reads_below_the_floor() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, Duration::from_secs(30)); + let mut five = a.create_group(group::Info { sequence: 5 }).unwrap(); + five.write_frame(ts(5000), b"x".as_ref()).unwrap(); + assert_eq!(recv(&mut sub).sequence, 5); + + let b = track::Request::new(Arc::new(broadcast::Info::default()), "video"); + routes.serve(b.consume()); + // The read subscribes the replacement, which has not answered yet. + assert!(sub.recv_group().now_or_never().is_none()); + let back = sub.subscription().with_ranges([track::Range { + start: track::Anchor::Relative(100), + end: None, + }]); + sub.update(back).unwrap(); + assert!(sub.recv_group().now_or_never().is_none()); + + let b = b.accept(crate::track::Info::timed()); + for sequence in 0..7 { + let mut group = b.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + let mut rest = Vec::new(); + while let Some(Ok(Some(group))) = sub.recv_group().now_or_never() { + rest.push(group.sequence); + } + assert_eq!(rest, vec![6]); + } + #[test] fn a_replaced_copy_at_the_last_sequence_has_no_overflow_cap() { for (last, end) in [(Some(u64::MAX), None), (None, Some(Position::default()))] { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 289328679e0..13d849835c7 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -404,8 +404,8 @@ impl Subscription { /// /// `resolved` names where each range resolved, by index, so a relative or time anchor /// becomes its own group, raised to `floor` or capped at `end`. A start not resolved - /// keeps its anchor, since the floor may lie before it; an end not resolved becomes - /// the bound. + /// keeps its anchor, since the floor may lie before it, so the caller drops what it + /// resolves to below `floor`; an end not resolved becomes the bound. pub(super) fn clamped( &self, floor: Option, From a4eea28099f0c2a16e2e42ef7f32106e1fe2b74b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:55:28 -0700 Subject: [PATCH 20/48] fix(moq-mux): read the floor through the optional subscription main introduced Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/container/consumer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 31fb4b52961..85ee5cc90fb 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -311,7 +311,7 @@ impl Consumer { // A relative start names its floor once the first group to arrive resolves it. if self.startup && !self.newest - && let Some(start) = self.track.floor() + && let Some(start) = self.subscriber().floor() { self.pending.retain(|group| group.sequence >= start); self.current = start; From 80363284668b80c488416daa59364474466f2b02 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 17:09:39 -0700 Subject: [PATCH 21/48] fix(moq-net): a cursor holds only its range, and widening re-scans the cache A reader no longer parks a consumer and an abort watch for every group outside its ranges or past its cap, which a narrow reader under an open aggregate accumulated per produced group. The walk passes such a group holding nothing. A move of the floor, ranges, or cap owes the spans it newly admits, and the walk re-offers what the cache still holds there through an ordered lookup, once per widening: O(log n + k) for the k groups in the span. This subsumes the lowered-floor backfill. A finished track now ends a reader with nothing left in range instead of holding it open for a cap that may rise. Drift is measured under where the ranges end as well as the local cap, so a range behind the live edge keeps its own newest group. track_range_widening replaces track_parked_abort. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/track.rs | 96 ++++--- rs/moq-net/src/model/aborts.rs | 85 +----- rs/moq-net/src/model/cache.rs | 10 +- rs/moq-net/src/model/group.rs | 6 - rs/moq-net/src/model/track.rs | 505 +++++++++++++++------------------ 5 files changed, 292 insertions(+), 410 deletions(-) diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index f0887e1e6ac..5d34818d408 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -60,6 +60,11 @@ //! many ranges and how many cached groups each spans: each group handed out must cost the //! same, so a walk over the ranges or the groups between them shows up as a slope. //! +//! `track_range_widening` widens one reader's ranges by a fixed span of cached groups and +//! back, swept over the groups cached and the readers beside it: a reader holds nothing +//! for the groups outside its ranges, so a widening scans only the span it newly covers, +//! and a scan of the cache or the audience 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. @@ -115,6 +120,13 @@ const SPAN: [u64; 3] = [1, 64, 4096]; const SERVED_RANGES: [u64; 3] = [1, 16, 256]; const SERVED_SPAN: [u64; 3] = [1, 8, 64]; +/// Readers beside the one widening, and the groups cached around the span it widens by. +const WIDENING_READERS: [usize; 3] = [1, 64, 1024]; +const WIDENING_CACHED: [u64; 3] = [64, 1024, 16_384]; + +/// Cached groups one widening newly covers. +const WIDENED: u64 = 8; + /// Concurrent publishers sharing one relay-style cache pool. const WRITERS: [usize; 4] = [1, 2, 4, 8]; @@ -1202,47 +1214,47 @@ fn bench_empty_run(c: &mut Criterion) { group.finish(); } -/// Abort one non-prefix parked group, swept over backlog and capped subscribers. -fn bench_parked_abort(c: &mut Criterion) { - let mut bench = c.benchmark_group("track_parked_abort"); - bench.sample_size(10); - for readers in [1, 8, 64] { - for retained in [1u64, 64, 1024] { - bench.bench_function(BenchmarkId::new(readers.to_string(), retained), |b| { - b.iter_batched_ref( - || { - let broadcast = broadcast::Producer::new(Default::default()); - let track = broadcast.create_track("parked", timed()).unwrap(); - let mut subscribers: Vec<_> = (0..readers) - .map(|_| { - let mut subscriber = - track.subscribe(track::Subscription::default().with_max_delay(AGED_BUDGET)); - subscriber.set_groups(..0); - subscriber - }) - .collect(); - let groups: Vec<_> = (0..retained) - .map(|sequence| { - let mut group = track.create_group(moq_net::group::Info { sequence }).unwrap(); - group.write_frame(Timestamp::ZERO, b"f".as_ref()).unwrap(); - group.clone().finish().unwrap(); - group - }) - .collect(); - for subscriber in &mut subscribers { - assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); - } - (broadcast, track, subscribers, groups) - }, - |(_, _, subscribers, groups)| { - groups[retained as usize / 2].clone().abort(Error::Old).unwrap(); - for subscriber in subscribers { - let res = subscriber.poll_recv_group(&kio::Waiter::noop()); - assert!(res.is_pending()); - } - }, - BatchSize::PerIteration, - ); +/// One reader widening its ranges by [`WIDENED`] cached groups mid-cache and back, beside +/// idle narrow readers. Every widening after the first finds groups already handed out, +/// which the reader skips, so each one costs the same scan of the span. +fn bench_range_widening(c: &mut Criterion) { + let mut bench = c.benchmark_group("track_range_widening"); + bench.throughput(Throughput::Elements(1)); + for readers in WIDENING_READERS { + for cached in WIDENING_CACHED { + bench.bench_function(BenchmarkId::new(format!("{readers}r"), cached), |b| { + let broadcast = broadcast::Info::default().produce(); + let track = broadcast.create_track("bench", None).unwrap(); + for _ in 0..cached { + let mut group = track.append_group().unwrap(); + group.write_frame(None, Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + } + let narrow = track::Subscription::default() + .with_max_delay(AGED_BUDGET) + .with_groups(0..1); + let mid = cached / 2; + let wide = narrow + .clone() + .with_ranges([track::Range::groups(0..1), track::Range::groups(mid..mid + WIDENED)]); + let waiter = kio::Waiter::noop(); + let mut readers: Vec<_> = (0..readers) + .map(|_| { + let mut reader = track.subscribe(narrow.clone()); + while let Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + reader + }) + .collect(); + let reader = &mut readers[0]; + b.iter(|| { + reader.update(wide.clone()).unwrap(); + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + } + reader.update(narrow.clone()).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + }); + drop((readers, track, broadcast)); }); } } @@ -1251,7 +1263,7 @@ fn bench_parked_abort(c: &mut Criterion) { criterion_group!( benches, - bench_parked_abort, + bench_range_widening, bench_fanout, bench_fanout_frame, bench_fetch_expiry, diff --git a/rs/moq-net/src/model/aborts.rs b/rs/moq-net/src/model/aborts.rs index 162ddedb3ed..aa9995f7e2f 100644 --- a/rs/moq-net/src/model/aborts.rs +++ b/rs/moq-net/src/model/aborts.rs @@ -1,40 +1,25 @@ -//! Abort notifications for the groups each capped subscriber parks, and for every -//! producer cut a wire publisher forwards as a drop. +//! Notifications of every producer cut a wire publisher forwards as a drop. use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; -/// One registration per subscriber, with pending sequences bounded by its parked set, -/// or by the cuts between two polls. +/// One registration per wire publisher, with pending sequences bounded by the cuts +/// between two polls. #[derive(Default)] pub(super) struct Aborts { - // Visit only active cursors, even after a larger fanout has disconnected. + // Visit only active watchers, even after a larger fanout has disconnected. readers: kio::Lock>>, } #[derive(Default)] struct State { - // Notified of every cut, whatever `watched` holds. - cuts: bool, - watched: BTreeSet, pending: BTreeSet, waiters: kio::WaiterList, } impl Aborts { - pub(super) fn watch(self: &Arc) -> Watch { - self.register(State::default()) - } - pub(super) fn watch_cuts(self: &Arc) -> Watch { - self.register(State { - cuts: true, - ..State::default() - }) - } - - fn register(self: &Arc, state: State) -> Watch { - let state = kio::Lock::new(state); + let state = kio::Lock::new(State::default()); let mut readers = self.readers.lock(); let key = readers .last_key_value() @@ -47,13 +32,14 @@ impl Aborts { } } - pub(super) fn notify(&self, sequence: u64, cut: bool) { + /// Tell every watcher that the publisher cut group `sequence`. + pub(super) fn notify(&self, sequence: u64) { let mut wakes = Vec::new(); { let readers = self.readers.lock(); for reader in readers.values() { let mut state = reader.lock(); - if ((cut && state.cuts) || state.watched.contains(&sequence)) && state.pending.insert(sequence) { + if state.pending.insert(sequence) { wakes.push(state.waiters.take()); } } @@ -72,26 +58,6 @@ pub(super) struct Watch { } impl Watch { - pub(super) fn insert(&self, sequence: u64) { - self.state.lock().watched.insert(sequence); - } - - pub(super) fn remove(&self, sequence: u64) { - let mut state = self.state.lock(); - state.watched.remove(&sequence); - state.pending.remove(&sequence); - } - - pub(super) fn register(&self, waiter: &kio::Waiter) { - let mut state = self.state.lock(); - state.waiters.register(waiter); - let pending = !state.pending.is_empty(); - drop(state); - if pending { - waiter.waker().wake_by_ref(); - } - } - pub(super) fn poll(&self, waiter: &kio::Waiter) -> BTreeSet { let mut state = self.state.lock(); state.waiters.register(waiter); @@ -120,39 +86,16 @@ mod tests { } #[test] - fn registering_after_an_abort_still_wakes() { + fn a_cut_wakes_the_watcher_once_per_poll() { let registry = Arc::new(Aborts::default()); - let watch = registry.watch(); - watch.insert(7); - registry.notify(7, false); + let watch = registry.watch_cuts(); let counter = Arc::new(Counter::default()); let waiter = kio::Waiter::new(Waker::from(counter.clone())); - watch.register(&waiter); + assert!(watch.poll(&waiter).is_empty()); + registry.notify(2); + registry.notify(3); assert_eq!(counter.0.load(Ordering::SeqCst), 1); - assert_eq!(watch.poll(&waiter), BTreeSet::from([7])); - } - - #[test] - fn a_cuts_watch_sees_every_cut_and_nothing_else() { - let registry = Arc::new(Aborts::default()); - let watch = registry.watch_cuts(); - registry.notify(1, false); - registry.notify(2, true); - assert_eq!(watch.poll(&kio::Waiter::noop()), BTreeSet::from([2])); - } - - #[test] - fn notifications_remain_bounded_by_watched_groups() { - let registry = Arc::new(Aborts::default()); - let watch = registry.watch(); - watch.insert(3); - for sequence in 0..1024 { - registry.notify(sequence, false); - } - assert_eq!(watch.poll(&kio::Waiter::noop()), BTreeSet::from([3])); - registry.notify(3, false); - watch.remove(3); - assert!(watch.poll(&kio::Waiter::noop()).is_empty()); + assert_eq!(watch.poll(&waiter), BTreeSet::from([2, 3])); drop(watch); assert!(registry.readers.lock().is_empty()); } diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 640fedfa7bc..de7a2a20f60 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -814,19 +814,15 @@ impl Track { &self.wakes } - pub(super) fn watch_aborts(&self) -> aborts::Watch { - self.aborts.get_or_init(Arc::default).watch() - } - /// Watch every group the publisher cuts, rather than chosen sequences. pub(super) fn watch_cuts(&self) -> aborts::Watch { self.aborts.get_or_init(Arc::default).watch_cuts() } - /// Notify watchers that group `sequence` aborted, and whether the publisher cut it. + /// Notify watchers that group `sequence` aborted, if the publisher cut it. pub(super) fn aborted(&self, sequence: u64, cut: bool) { - if let Some(aborts) = self.aborts.get() { - aborts.notify(sequence, cut); + if cut && let Some(aborts) = self.aborts.get() { + aborts.notify(sequence); } } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 0090be97737..f145cb9ee64 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2139,12 +2139,6 @@ impl Consumer { self.cursor.expiry_pending() } - /// Whether the group has been aborted (including pool eviction); the abort - /// dropped the cached frames, so a held consumer has nothing left to read. - pub(super) fn is_aborted(&self) -> bool { - self.cursor.state.read().abort.is_some() - } - /// Mark the group as still being read, so a slow batch drain does not expire it. pub fn keep_alive(&self) { self.cursor.state.read().charge.refresh(); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index dd052d54c70..778093597af 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -23,7 +23,7 @@ use super::{Datagram, Requests}; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; use super::subscription::{Containing, Holdings, NewGroup, Resolved, Window}; -use rangemap::RangeSet; +use rangemap::{RangeInclusiveSet, RangeSet}; mod demand; pub(super) use demand::Preferences; @@ -516,6 +516,12 @@ impl Slot { self.is_live() && !self.pending } + /// Whether the arrival walk hands this slot out: the live feed's, not withheld, and + /// still serving, a fetched head counting for an aborted live group. + fn is_walked(&self) -> bool { + self.visible && !self.pending && !self.is_aborted() + } + /// The copy to hand readers: the fetched head while it lasts, else the live group. fn serving(&self) -> &group::Producer { match &self.head { @@ -4647,22 +4653,8 @@ struct Cursor { /// the producer's cache and become eligible again when the cap rises (or is removed). /// `Some(0)` is the empty range. end_sequence: Option, - /// Groups received beyond the [`Self::end_sequence`] cap, held for `recv_group` - /// until the cap rises (arrival-order reads consume the shared cursor, so they - /// are parked here instead of dropped). Keyed by sequence so the lowest is - /// re-offered first. - parked: BTreeMap, - /// The highest floor `recv_group` has applied. Its walk passes the groups below the - /// floor without parking them, so lowering it parks those still cached. - passed: u64, - aborts: Option>, - /// [`Self::end_sequence`], shared with the groups this cursor hands out so their - /// expiry measures drift against the same servable window after a cap moves. - /// - /// Deliberately not where [`Subscription::ranges`] end. They filter what this handle - /// reads, but they are filters, not exemptions: max delay caps every range against the - /// track's live edge. Capping the edge at a range's end would judge a range in the past - /// only against itself, so none of it would ever be late however far the track moved on. + /// [`Self::edge_cap`], shared with the groups this cursor hands out so their expiry + /// measures drift against the same servable window after the cap or ranges move. drift_cap: kio::Producer>, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the stats scope lives on the owning @@ -4772,10 +4764,14 @@ struct Filter { revision: u64, /// Why the ranges can't be served here: a time anchor on an untimed track. refused: Option, - /// The revision and cap at which no parked group was in reach. A group is only parked - /// out of reach, so the walk waits for either to move rather than running once per - /// hand-out over every range. - reoffer_idle: Option<(u64, Option)>, + /// What the arrival walk reads: the window from the floor, below the cap. The walk + /// passes a group outside it holding nothing, so widening it re-scans the cache. + admitted: RangeInclusiveSet, + /// The floor, [`Self::revision`], and cap [`Self::admitted`] was taken at. + admitted_at: Option<(u64, u64, Option)>, + /// The spans a widening newly admitted, scanned in the cache lowest first and handed + /// out ahead of the walk: each cached group in them is visited once. + owed: RangeInclusiveSet, } impl Cursor { @@ -4811,7 +4807,9 @@ impl Cursor { resolved: ranges_revision, revision: 0, refused, - reoffer_idle: None, + admitted: RangeInclusiveSet::new(), + admitted_at: None, + owed: RangeInclusiveSet::new(), }; (filter, state.datagram_offset + start) }; @@ -4824,9 +4822,6 @@ impl Cursor { datagram_index, next_sequence: Some(0), end_sequence: None, - parked: BTreeMap::new(), - passed: 0, - aborts: None, drift_cap: kio::Producer::new(None), stale: stats::Content::default(), seek_pending: BTreeMap::new(), @@ -4860,13 +4855,24 @@ impl Cursor { } } - /// Publish [`Self::end_sequence`] to the groups already handed out. + /// The exclusive end drift is measured under: the cap, lowered to where the ranges + /// end, so a range behind the live edge is judged against its own newest group rather + /// than skipped for groups it never reads. + fn edge_cap(&self) -> Option { + match (self.end_sequence, self.filter.window.end()) { + (Some(cap), Some(end)) => Some(cap.min(end)), + (cap, end) => cap.or(end), + } + } + + /// Publish [`Self::edge_cap`] to the groups already handed out. fn update_drift_cap(&mut self) { + let cap = self.edge_cap(); // Skip a no-op write: every handed-out group's expiry watches this channel. - if *self.drift_cap.read() != self.end_sequence + if *self.drift_cap.read() != cap && let Ok(mut current) = self.drift_cap.write() { - *current = self.end_sequence; + *current = cap; } } @@ -4901,6 +4907,7 @@ impl Cursor { drop(state); if moved { self.filter.revision += 1; + self.update_drift_cap(); } } @@ -4915,20 +4922,90 @@ impl Cursor { super::subscription::before_end(sequence, self.end_sequence) && self.filter.window.contains(sequence) } - /// The lowest parked group this cursor reads now. - fn reoffer(&mut self) -> Option { - let at = (self.filter.revision, self.end_sequence); - if self.parked.is_empty() || self.filter.reoffer_idle == Some(at) { + /// The groups the arrival walk reads under the exclusive `cap`: the window from the + /// floor, below it. + fn admitted(&self, cap: Option) -> RangeInclusiveSet { + let mut admitted = RangeInclusiveSet::new(); + let last = match cap { + Some(end) => match end.checked_sub(1) { + Some(last) => last, + None => return admitted, + }, + None => u64::MAX, + }; + for run in self.filter.window.runs(self.floor()) { + if *run.start() > last { + break; + } + admitted.insert(*run.start()..=(*run.end()).min(last)); + } + admitted + } + + /// [`Filter::owed`] as of now: what was owed and what the walk newly reads since + /// [`Filter::admitted`] was taken, both cut to `admitted`. + fn owed(&self, admitted: &RangeInclusiveSet) -> RangeInclusiveSet { + // Nothing is owed before the walk first reads: it starts at the oldest arrival. + if self.filter.admitted_at.is_none() { + return RangeInclusiveSet::new(); + } + let mut owed = RangeInclusiveSet::new(); + for run in admitted.iter() { + for gap in self.filter.admitted.gaps(run) { + owed.insert(gap); + } + for span in self.filter.owed.overlapping(run) { + owed.insert((*span.start()).max(*run.start())..=(*span.end()).min(*run.end())); + } + } + owed + } + + /// Take in a move of the floor, the ranges, or the cap: whatever it newly admits is + /// owed. Costs the ranges, never the cache, and only when one moved. + fn admit(&mut self) { + let at = (self.floor(), self.filter.revision, self.end_sequence); + if self.filter.admitted_at == Some(at) { + return; + } + let admitted = self.admitted(self.end_sequence); + self.filter.owed = self.owed(&admitted); + self.filter.admitted = admitted; + self.filter.admitted_at = Some(at); + } + + /// The lowest cached group still owed, consuming the spans scanned to find it: only + /// one the walk could have read, never fetched backfill or a withheld group. + fn next_owed(&mut self) -> Option { + if self.filter.owed.is_empty() { return None; } - for run in self.filter.window.runs(self.floor()) { - if let Some((&sequence, _)) = self.parked.range(run).next() - && super::subscription::before_end(sequence, self.end_sequence) - { - return Some(sequence); + let state = self.state.read(); + // Nothing is read from the cache now, so leave what is owed until it is. + if !state.readable() { + return None; + } + let low = state.live_floor.unwrap_or(0); + while let Some(run) = self.filter.owed.iter().next().cloned() { + let from = (*run.start()).max(low); + let found = (from <= *run.end()) + .then(|| { + state + .lookup + .range(from..=*run.end()) + .map(|(_, slot)| slot) + .find(|slot| slot.is_walked()) + .map(Slot::serving) + }) + .flatten(); + match found { + Some(group) => { + self.filter.owed.remove(*run.start()..=group.sequence); + return Some(group.clone()); + } + None => self.filter.owed.remove(run), } } - self.filter.reoffer_idle = Some(at); None } @@ -4992,7 +5069,7 @@ impl Cursor { let max_delay = self.subscription.read().max_delay; let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); - let edge = state.drift_edge(self.end_sequence); + let edge = state.drift_edge(self.edge_cap()); // Every group below the last successor found shares it, so a run of cached groups // that cannot serve is walked once per pass, not once per gap. let mut arrival: Option<&Slot> = None; @@ -5031,123 +5108,33 @@ impl Cursor { })) } - /// Hold a capped group without losing another copy's abort notification. - fn park(&mut self, consumer: group::Consumer, waiter: &kio::Waiter) { - let aborts = self - .aborts - .get_or_insert_with(|| Box::new(self.state.read().cache.watch_aborts())); - // Register before checking: an abort on either side of the check is - // observed now or queued for the next poll. - aborts.insert(consumer.sequence); - if consumer.is_aborted() { - if !self.parked.contains_key(&consumer.sequence) { - aborts.remove(consumer.sequence); - } - } else { - self.parked.insert(consumer.sequence, consumer); - } - aborts.register(waiter); - } - - /// Park the cached groups in range from `floor` up to [`Self::passed`], which the walk - /// passed under a higher floor, so they are re-offered like any widened range. - fn backfill(&mut self, floor: u64, waiter: &kio::Waiter) { - let groups: Vec = { - let state = self.state.read(); - // Nothing is read from the cache now, so leave the floor to lower once it is. - if !state.readable() { - return; - } - self.filter - .window - .runs(floor.max(state.live_floor.unwrap_or(0))) - .take_while(|run| *run.start() < self.passed) - // Only what the walk could have read: never a fetched or withheld group. - .flat_map(|run| { - state - .lookup - .range(run) - .filter(|(_, slot)| slot.is_shown()) - .map(|(_, slot)| slot.serving()) - }) - .take_while(|group| group.sequence < self.passed) - .filter(|group| !group.is_aborted()) - .cloned() - .collect() - }; - self.passed = floor; - if groups.is_empty() { - return; - } - // Consumed with the track guard released, as the walk does. - for group in groups { - self.park(group.consume(), waiter); - } - // In reach at the revision `reoffer` last found nothing at, so look again. - self.filter.reoffer_idle = None; - } - fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.sync(); if let Some(err) = &self.filter.refused { return Poll::Ready(Err(err.clone())); } - let floor = self.floor(); - match floor < self.passed { - true => self.backfill(floor, waiter), - false => self.passed = floor, - } - if let Some(aborts) = &self.aborts { - // A sequence may name both a live group and its fetched head. Only the - // consumer we parked decides whether this notification invalidates it. - for sequence in aborts.poll(waiter) { - if self.parked.get(&sequence).is_some_and(group::Consumer::is_aborted) { - self.parked.remove(&sequence); - aborts.remove(sequence); - } - } - // Each overtaken entry is removed once, rather than scanning every entry - // again for every delivered group. - while let Some((&sequence, _)) = self.parked.first_key_value() - && sequence < floor - { - self.parked.pop_first(); - aborts.remove(sequence); - } - if self.parked.is_empty() { - self.aborts = None; - } - } + self.admit(); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(self.end_sequence, waiter))?; + let drift = ready!(self.poll_drift(self.edge_cap(), waiter))?; loop { - // Re-offer the lowest parked group back in range once the cap rises or the ranges - // widen, ahead of the arrival cursor: it is the oldest thing still owed. - let consumer = match self.reoffer() { - Some(sequence) => { - let group = self.parked.remove(&sequence).expect("just looked it up"); - self.aborts - .as_ref() - .expect("parked groups are watched") - .remove(sequence); + // 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 = match self.next_owed() { + Some(producer) => { + let consumer = producer.consume(); // A re-offer is a delivery: stamp it like a fresh hand-out. - group.cache_refresh(); - group + consumer.cache_refresh(); + consumer } - _ => { + None => { let floor = self.floor(); - self.passed = self.passed.max(floor); let Some((producer, found_index)) = ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, floor))?) else { - // Parked groups survive a finished track: they become deliverable - // again if the cap rises, so the stream isn't over while any are held. - if self.parked.is_empty() { - return Poll::Ready(Ok(None)); - } - return Poll::Pending; + return Poll::Ready(Ok(None)); }; let consumer = producer.consume(); // Stamp with the track guard released, so delivery never nests the @@ -5159,17 +5146,16 @@ impl Cursor { // above it, and a later one may settle where a time end falls. if !self.filter.window.is_settled() { self.sync(); + self.admit(); if consumer.sequence < self.floor() { - self.passed = self.passed.max(self.floor()); continue; } } - // Park a group beyond the cap or outside the ranges instead of dropping it, - // and keep scanning so an in-range group that arrived behind it still flows. + // Pass a group beyond the cap or outside the ranges, holding nothing: a + // widening finds it in the cache again. Keep scanning so an in-range group + // that arrived behind it still flows. if !self.admits(consumer.sequence) { - self.park(consumer, waiter); - continue; } consumer @@ -5236,7 +5222,7 @@ impl Cursor { let mut floor = next.max(self.floor()); let end = self.end_sequence; // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(end, waiter))?; + let drift = ready!(self.poll_drift(self.edge_cap(), waiter))?; let group = loop { let window = &self.filter.window; @@ -5414,11 +5400,11 @@ impl Subscriber { /// The budget remains attached to a returned group: if it stalls while newer data /// advances, its pending frame read ends with [`Error::Old`]. /// - /// Honors the group range set by [`Self::set_groups`]: - /// a group beyond the cap is parked (not dropped) and re-offered once the cap rises - /// (lowest sequence first), without blocking in-range groups that arrive behind it. - /// A parked group that the producer evicts or expires in the meantime is dropped, - /// so parking never outlives the track's cache policy. + /// Honors the group range set by [`Self::set_groups`]: a group beyond the cap is + /// passed without blocking in-range groups that arrive behind it, and holds nothing + /// here. Raising the cap, or widening the ranges, offers the groups it newly covers + /// that the cache still holds, lowest sequence first. Once the track finishes, a + /// reader with nothing left in range ends. /// /// Returns `Poll::Ready(Ok(Some(group)))` when a group is available, /// `Poll::Ready(Ok(None))` when the track is finished, @@ -5510,29 +5496,36 @@ impl Subscriber { && cursor.filter.window.contains(sequence) && !unread.delivered.contains(sequence) }; - // Passed under a higher floor, so the walk parks them again; see `Cursor::backfill`. - let passed = || { - state - .lookup - .range(floor..cursor.passed.max(floor)) - .any(|(sequence, slot)| eligible(*sequence) && slot.is_shown() && !slot.is_aborted()) + // What a widening owes, the walk has yet to take in, or lifting the local cap would: + // a group past it is unread, not gone. + let owed = cursor.owed(&cursor.admitted(None)); + let last = unread.end.map(|end| end.checked_sub(1)); + let owed = || { + owed.iter().any(|run| { + let end = match last { + Some(Some(last)) => (*run.end()).min(last), + Some(None) => return false, + None => *run.end(), + }; + *run.start() <= end + && state + .lookup + .range(*run.start()..=end) + .any(|(sequence, slot)| eligible(*sequence) && slot.is_walked()) + }) }; - cursor - .parked - .iter() - .any(|(sequence, group)| eligible(*sequence) && !group.is_aborted()) - || (state.readable() - && (state - .arrival - .iter() - .skip(cursor.index.saturating_sub(state.offset)) - .any(|(sequence, stamp)| { - eligible(*sequence) - && state - .lookup - .get(sequence) - .is_some_and(|slot| slot.stamp == *stamp && !slot.is_aborted()) - }) || passed())) + state.readable() + && (state + .arrival + .iter() + .skip(cursor.index.saturating_sub(state.offset)) + .any(|(sequence, stamp)| { + eligible(*sequence) + && state + .lookup + .get(sequence) + .is_some_and(|slot| slot.stamp == *stamp && !slot.is_aborted()) + }) || owed()) } /// Receive the next group in arrival order. @@ -5743,7 +5736,7 @@ impl Subscriber { /// /// `2..=5` includes groups 2 through 5; `2..5` excludes group 5. An omitted /// start preserves the current floor, and an omitted end removes the cap. - /// Groups above the end remain buffered and can be read after raising the cap. + /// Groups above the end stay cached and can be read after raising the cap. /// This narrows only local delivery, within [`Subscription::ranges`]; use those and /// [`Self::update`] to change the requested groups. pub fn set_groups(&mut self, groups: impl RangeBounds) { @@ -5788,10 +5781,10 @@ impl Subscriber { /// A local filter, not a request; [`Subscription::ranges`] are the wire-level /// counterpart. See [Local cursor vs wire preference](Self#local-cursor-vs-wire-preference). /// - /// Groups beyond the cap are held rather than skipped past, so a later call to - /// [`Self::set_groups`] with a higher bound (or unbounded) makes them available again. - /// Lowering the cap below the consumer's current cursor parks the consumer until the - /// cap is raised. + /// Groups beyond the cap stay in the cache, so a later call to [`Self::set_groups`] + /// with a higher bound (or unbounded) offers the ones still cached. Lowering the cap + /// below the consumer's current cursor waits until the cap is raised, or the track + /// finishes. // pending: /quest/m1/public-model/serve.md pub(crate) fn end_at(&mut self, end: impl Into) { match &mut self.inner { @@ -9496,16 +9489,19 @@ mod test { #[test] #[cfg(feature = "async")] - fn a_requested_end_does_not_cap_the_live_edge() { + fn a_range_behind_the_live_edge_keeps_its_newest_group() { let mut producer = track_producer("test", Info::timed()); - for second in 0..4 { + for second in 0..=10 { append_at(&mut producer, second * 1000); } - // The budget caps every range: a range ending below the live edge is judged against - // that edge, not its own end, so with no budget nothing in it is fresh. - let mut subscriber = producer.subscribe(Subscription::default().with_end(Position::after_group(1))); - assert_eq!(drain(&mut subscriber), Vec::::new()); + // Drift is measured under where the range ends, so with no budget its own newest + // group is still fresh: the groups past it are not ones it reads. + let mut subscriber = producer.subscribe(Subscription::default().with_groups(0..2)); + assert_eq!(drain(&mut subscriber), vec![1]); + let mut subscriber = producer.subscribe(Subscription::default().with_groups(0..2)).ordered(); + let next = subscriber.next_group().now_or_never().unwrap().unwrap().unwrap(); + assert_eq!(next.sequence, 1); // A budget that covers it delivers the range, and nothing above it. let mut subscriber = producer.subscribe(replay().with_end(Position::after_group(1))); @@ -10891,12 +10887,12 @@ mod test { ); } - /// `recv_group` (arrival order) honors the `end_at` cap by parking, like - /// `next_group`: beyond-cap groups are held, not dropped, and a raised cap - /// re-offers them, even after the track finishes. + /// `recv_group` (arrival order) honors the `end_at` cap like `next_group`: a group + /// beyond it is passed, holding nothing, and a raised cap finds it in the cache again. + /// Once the track finishes, a capped reader ends with what it could read. #[test] #[cfg(feature = "async")] - fn end_at_parks_recv_group() { + fn end_at_caps_recv_group() { let producer = track_producer("test", Info::timed()); let mut consumer = producer.subscribe(replay()); @@ -10915,21 +10911,19 @@ mod test { ); assert!(consumer.recv_group().now_or_never().is_none(), "capped at 2"); - // A finished track keeps the parked group claimable: the cap may rise. - producer.finish().unwrap(); - assert!( - consumer.recv_group().now_or_never().is_none(), - "still parked after finish" - ); - consumer.set_groups(..); assert_eq!( consumer.recv_group().now_or_never().unwrap().unwrap().unwrap().sequence, 2 ); + + consumer.set_groups(..3); + producer.create_group(group::Info { sequence: 3 }).unwrap(); + assert!(consumer.recv_group().now_or_never().is_none(), "capped at 3"); + producer.finish().unwrap(); assert!( matches!(consumer.recv_group().now_or_never(), Some(Ok(None))), - "finished once the parked group drains" + "a finished track ends a capped reader" ); } @@ -11041,52 +11035,6 @@ mod test { ); } - /// Eviction aborts a parked group behind a sleeping subscriber's back. Nothing - /// else will poll it (the track already finished), so the entry has to carry a - /// waiter or the subscription sleeps forever holding its stream open. - #[test] - #[cfg(feature = "async")] - fn evicted_parked_group_wakes_the_clean_end() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::task::{Context, Wake}; - - /// A waker that counts its wakes, for asserting a pending poll left a live - /// registration behind. - struct CountWaker(AtomicUsize); - impl Wake for CountWaker { - fn wake(self: std::sync::Arc) { - self.0.fetch_add(1, Ordering::SeqCst); - } - } - - let producer = track_producer("test", Info::timed()); - let mut consumer = producer.subscribe(None); - - producer.create_group(group::Info { sequence: 0 }).unwrap(); - assert_eq!( - consumer.recv_group().now_or_never().unwrap().unwrap().unwrap().sequence, - 0 - ); - - consumer.set_groups(..1); - let straggler = producer.create_group(group::Info { sequence: 1 }).unwrap(); - assert!(consumer.recv_group().now_or_never().is_none(), "parked at the cap"); - producer.finish().unwrap(); - - let counter = std::sync::Arc::new(CountWaker(AtomicUsize::new(0))); - let waker = std::task::Waker::from(counter.clone()); - let mut cx = Context::from_waker(&waker); - let mut fut = std::pin::pin!(consumer.recv_group()); - assert!( - fut.as_mut().poll(&mut cx).is_pending(), - "the parked group holds it open" - ); - - straggler.abort(Error::Old).unwrap(); - assert!(counter.0.load(Ordering::SeqCst) > 0, "the eviction wakeup was lost"); - assert!(matches!(fut.as_mut().poll(&mut cx), Poll::Ready(Ok(None)))); - } - /// An exclusive cap at 0 is the empty range: no group is delivered, even group 0. #[test] #[cfg(feature = "async")] @@ -11783,47 +11731,6 @@ mod test { ); } - #[test] - fn an_aborted_candidate_keeps_the_parked_copy_watched() { - let producer = track_producer("test", Info::timed()); - let live = producer.create_group(group::Info { sequence: 0 }).unwrap(); - let mut subscriber = producer.subscribe(replay()); - subscriber.set_groups(..0); - let waiter = kio::Waiter::noop(); - assert!(subscriber.poll_recv_group(&waiter).is_pending()); - - // A second copy can abort after lookup but before the cursor parks it. - let candidate = group::Info { sequence: 0 }.produce(); - let aborted = candidate.consume(); - candidate.abort(Error::Old).unwrap(); - let Inner::Plain(cursor) = &mut subscriber.inner else { - unreachable!() - }; - cursor.park(aborted, &waiter); - - live.abort(Error::Old).unwrap(); - producer.finish().unwrap(); - assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); - } - - #[test] - fn an_abort_removes_a_non_prefix_parked_entry() { - let producer = track_producer("test", Info::timed()); - let groups: Vec<_> = (0..3) - .map(|sequence| producer.create_group(group::Info { sequence }).unwrap()) - .collect(); - let mut subscriber = producer.subscribe(replay()); - subscriber.set_groups(..0); - let waiter = kio::Waiter::noop(); - assert!(subscriber.poll_recv_group(&waiter).is_pending()); - groups[1].clone().abort(Error::Old).unwrap(); - assert!(subscriber.poll_recv_group(&waiter).is_pending()); - let Inner::Plain(cursor) = &subscriber.inner else { - unreachable!() - }; - assert_eq!(cursor.parked.keys().copied().collect::>(), vec![0, 2]); - } - /// The live copy ending early (its subscription went away) leaves a fetched head /// that still holds the whole group servable, instead of reclaiming the slot. #[moq_clock::test] @@ -13804,6 +13711,36 @@ mod test { assert_eq!(drain(&mut late), vec![4]); } + /// A narrow reader under an open aggregate passes the groups outside its range holding + /// nothing for them, however many arrive, and widening its ranges still serves the + /// ones the cache holds in the new span, lowest first and once each. + #[test] + #[cfg(feature = "async")] + fn a_narrow_reader_holds_nothing_for_excluded_groups() { + let producer = track_producer("test", Info::timed()); + let _wide = producer.subscribe(replay()); + let mut narrow = producer.subscribe(replay().with_groups(0..2)); + for sequence in 0..1000 { + producer.create_group(group::Info { sequence }).unwrap(); + } + assert_eq!(drain(&mut narrow), vec![0, 1]); + let Inner::Plain(cursor) = &narrow.inner else { + unreachable!() + }; + assert!(cursor.filter.owed.is_empty(), "nothing owed for excluded groups"); + assert_eq!(cursor.filter.admitted.iter().count(), 1, "only the range is kept"); + + narrow + .update(replay().with_ranges([Range::groups(0..2), Range::groups(5..8)])) + .unwrap(); + assert_eq!(drain(&mut narrow), vec![5, 6, 7]); + narrow.set_groups(..9); + narrow.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut narrow), vec![2, 3, 4, 8]); + narrow.set_groups(..); + assert_eq!(drain(&mut narrow), (9..1000).collect::>()); + } + /// Lowering the floor re-offers the cached groups the arrival walk passed below it, /// as widening any range does. #[test] From 5f53bdd6168a455b14949efa087e9c766cd6eda3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 17:09:39 -0700 Subject: [PATCH 22/48] fix(moq-net): a copy updated while pending names the ranges it will resolve Clearing a pending copy's resolution on a range update left `from` naming the old ranges, so once it answered and was replaced, the replacement could not take its resolution and resolved anchors from its own cache instead. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 50 +++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 29244930571..1bf4443bb8f 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -314,7 +314,11 @@ impl Copy { self.from = reader.ranges.clone(); } Sub::Pending(_) if reader.ranges == self.from => {} - Sub::Pending(_) => self.resolved.clear(), + // Its own resolution, once it answers, is for these. + Sub::Pending(_) => { + self.resolved.clear(); + self.from = reader.ranges.clone(); + } } let mut wire = reader.clamped(self.floor, self.end().map(Position::group), &self.resolved); // A bounded copy only finishes what it had, so a new group from it never arrives. @@ -2283,6 +2287,50 @@ mod test { assert_eq!(rest, vec![6]); } + /// A copy updated while pending, then answering, hands its own resolution of the + /// updated ranges to the copy replacing it, rather than leaving the replacement to + /// resolve them from whatever it caches. + #[test] + #[cfg(feature = "async")] + fn a_copy_updated_while_pending_passes_on_its_resolution() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, Duration::from_secs(30)); + let mut zero = a.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(ts(0), b"x".as_ref()).unwrap(); + assert_eq!(recv(&mut sub).sequence, 0); + + let b = track::Request::new(Arc::new(broadcast::Info::default()), "video"); + routes.serve(b.consume()); + // The read subscribes the replacement, which has not answered yet. + assert!(sub.recv_group().now_or_never().is_none()); + let ranges = [track::Range { + start: track::Anchor::Time(ts(10_000)), + end: None, + }]; + sub.update(sub.subscription().with_ranges(ranges)).unwrap(); + assert!(sub.recv_group().now_or_never().is_none()); + + let b = b.accept(crate::track::Info::timed()); + for sequence in 0..13 { + let mut group = b.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + while let Some(Ok(Some(_))) = sub.recv_group().now_or_never() {} + + let c = copy(); + routes.serve(c.consume()); + assert!(sub.recv_group().now_or_never().is_none()); + let start = c.subscription().expect("subscribed").ranges[0].start; + assert!( + matches!(start, track::Anchor::Absolute(position) if position.group >= 10), + "the replacement asks from where b resolved the time: {start:?}" + ); + } + #[test] fn a_replaced_copy_at_the_last_sequence_has_no_overflow_cap() { for (last, end) in [(Some(u64::MAX), None), (None, Some(Position::default()))] { From 6c77716113d2a661fe65d941b5f80090b7866431 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 17:10:15 -0700 Subject: [PATCH 23/48] docs(moq-net): a finished track ends a capped subscriber Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/MIGRATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 0f109e1f004..72d340397b2 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -10,6 +10,7 @@ A `track::Subscriber` now reads only the groups its own `Subscription::ranges` c A reader that relied on groups another subscriber asked for must widen its own ranges. `track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. Subscribing or updating with one on an untimed track fails with `Error::TimestampMismatch`. +Once a track finishes, a subscriber with nothing left under its `set_groups` cap ends rather than waiting for the cap to rise; raise it before the track finishes to read past it. ### An expired group fails with `Old` even when drained (#5944) From 6db1f342bfa5e7c7e4e2f9cd780f0c336c44ff2e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 17:17:19 -0700 Subject: [PATCH 24/48] test(moq-net): apply the pending copy's update as the reader does Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 1bf4443bb8f..02b5512622b 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -2244,6 +2244,7 @@ mod test { }]; let updated = Subscription::default().with_ranges(ranges.clone()); assert!(matches!(copy.sub, Sub::Pending(_))); + copy.update(&updated); assert_eq!(copy.wire(&updated).ranges, ranges); assert!(copy.poll_ready((0, Cap::from(..)), &kio::Waiter::noop()).is_ready()); assert_eq!(copy.wire(&updated).ranges, ranges); From 30cb4f16cf6b3927ac615613ee7ee8c74cbf80c9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 18:01:54 -0700 Subject: [PATCH 25/48] fix(moq-net): a time end stays open across a group yet to start A later group starting past a time end settled it even with a cached group below that had not presented frame 0, so once that group started inside the end it was still delivered. A group that may still start now keeps the end unresolved, and the window settles it on a later poll as before; one that never will (finished empty, aborted, or joined mid-group) proves nothing. No per-group state is kept. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/group.rs | 7 +++++ rs/moq-net/src/model/subscription.rs | 5 ++-- rs/moq-net/src/model/subscription/window.rs | 6 ++-- rs/moq-net/src/model/track.rs | 31 ++++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index f145cb9ee64..2ebe058c27a 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1478,6 +1478,13 @@ impl Producer { self.state.read().started() } + /// Whether this copy may yet present the group's frame 0: it holds it, and nothing + /// opened or ended it. + pub(super) fn may_start(&self) -> bool { + let state = self.state.read(); + state.offset == 0 && !state.started() + } + /// Where the group starts in presentation time: its first frame's timestamp, or /// `None` while no frame has been opened or when that frame is untimed. /// diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 13d849835c7..ed443d27c16 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -166,8 +166,9 @@ pub enum Anchor { /// Resolved from the starts of the groups the serving hop holds. When none starts at /// or before it, a start reaches back to the oldest group, and while no group has /// started at all it waits like `Relative(0)`. An end stays open until a group - /// starting after it is known, so at the live edge a reader may get every group - /// handed out before its first frame showed it starts past the end. An untimed track + /// starting after it is known, with every group below that one started or never to + /// start, so at the live edge a reader may get every group handed out before its + /// first frame showed it starts past the end. An untimed track /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 4540e427012..96f401c1e04 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -27,7 +27,8 @@ pub(in crate::model) trait Holdings { /// Where `pts` falls among the groups with a known start. fn containing(&self, pts: Timestamp) -> Containing; - /// The lowest group at or above `from` known to start after `pts`. + /// The lowest group at or above `from` known to start after `pts`, `None` while a + /// group below it may yet start at or before `pts`. fn after(&self, pts: Timestamp, from: u64) -> Option; } @@ -74,7 +75,8 @@ enum End { Before(u64), /// Past every group. Open, - /// Before the first group starting after this time, once one is known. + /// Before the first group starting after this time, once one is known and no group + /// below it may yet start before the time. Time(Timestamp), } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 778093597af..19fe2a61e47 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -587,6 +587,12 @@ impl Slot { .find_map(group::Producer::timestamp) } + /// Whether the group has no start yet but may still get one: a copy holding frame 0 + /// has not presented it. + fn may_start(&self) -> bool { + self.start().is_none() && self.copies().any(group::Producer::may_start) + } + /// The checked start of whichever copy began at frame 0. fn bound(&self) -> Option { self.copies().find_map(group::Producer::bound) @@ -684,7 +690,9 @@ impl Holdings for TrackState { match slot.start() { Some(start) if later(start, pts) => after = Some(sequence), Some(_) => break, - // Not started, so it proves nothing either way. + // It may still start before `pts`, so nothing above it settles the end yet. + None if slot.may_start() => after = None, + // It never will, so it proves nothing either way. None => {} } } @@ -13864,6 +13872,27 @@ mod test { assert_eq!(drain(&mut seek), vec![1, 2]); } + /// A time end stays open across a group that has yet to start: it may start before the + /// end, so a later group starting after it settles nothing. + #[test] + #[cfg(feature = "async")] + fn a_time_end_waits_on_an_unstarted_group_below_it() { + let producer = track_producer("test", Info::timed()); + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(at(0), bytes::Bytes::from_static(b"x")).unwrap(); + let mut hole = producer.create_group(group::Info { sequence: 1 }).unwrap(); + let mut two = producer.create_group(group::Info { sequence: 2 }).unwrap(); + two.write_frame(at(2000), bytes::Bytes::from_static(b"x")).unwrap(); + + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(500))), + }])); + hole.write_frame(at(1000), bytes::Bytes::from_static(b"x")).unwrap(); + assert_eq!(drain(&mut subscriber), vec![0]); + } + /// An untimed track refuses a time anchor wherever the ranges reach it. #[test] #[cfg(feature = "async")] From 03611bc579297b94a9f899c9bef3ae1452c8d4b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 18:23:37 -0700 Subject: [PATCH 26/48] fix(moq-net): a time end settles only once every sequence below it is accounted for A later group starting past a time end settled it across a sequence that had not arrived, so when that group then arrived starting inside the end it was still delivered. Timestamps never rewind, so the end now settles before the lowest group starting past it only once every sequence between it and a group starting at or before the time is accounted for: arrived and started, unable to start, dropped, below where the feed starts, or given up by the arrivals' grace. Checked as ranges against the drops and arrivals the track already keeps, so nothing is held per group and a wide gap costs no more than a narrow one. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription.rs | 8 ++- rs/moq-net/src/model/subscription/window.rs | 4 +- rs/moq-net/src/model/track.rs | 74 +++++++++++++++++++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index ed443d27c16..97c7b46f62e 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -166,9 +166,11 @@ pub enum Anchor { /// Resolved from the starts of the groups the serving hop holds. When none starts at /// or before it, a start reaches back to the oldest group, and while no group has /// started at all it waits like `Relative(0)`. An end stays open until a group - /// starting after it is known, with every group below that one started or never to - /// start, so at the live edge a reader may get every group handed out before its - /// first frame showed it starts past the end. An untimed track + /// starting after it is known and every sequence below that one is accounted for + /// (started, dropped, or never to start), so at the live edge a reader may get every + /// group handed out before its first frame showed it starts past the end, and a feed + /// that reports no drops leaves it open across a missing group until the wait for that + /// group runs out. An untimed track /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 96f401c1e04..d7360c6afac 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -28,7 +28,7 @@ pub(in crate::model) trait Holdings { fn containing(&self, pts: Timestamp) -> Containing; /// The lowest group at or above `from` known to start after `pts`, `None` while a - /// group below it may yet start at or before `pts`. + /// sequence below it, cached or not, may yet start at or before `pts`. fn after(&self, pts: Timestamp, from: u64) -> Option; } @@ -75,7 +75,7 @@ enum End { Before(u64), /// Past every group. Open, - /// Before the first group starting after this time, once one is known and no group + /// Before the first group starting after this time, once one is known and no sequence /// below it may yet start before the time. Time(Timestamp), } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 19fe2a61e47..de578e5bec6 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -685,22 +685,42 @@ impl Holdings for TrackState { } fn after(&self, pts: Timestamp, from: u64) -> Option { + // Timestamps never rewind, so the end falls before the lowest group starting past + // `pts`, once every sequence below it, down to one starting at or before `pts`, is + // accounted for: none may still start before `pts`. let mut after = None; + // The lowest sequence walked, and whether one below `after` may still start. + let mut low = None; + let mut open = false; for (&sequence, slot) in self.lookup.range(from..).rev() { + let gap = sequence.saturating_add(1)..low.unwrap_or(sequence.saturating_add(1)); + low = Some(sequence); match slot.start() { - Some(start) if later(start, pts) => after = Some(sequence), - Some(_) => break, - // It may still start before `pts`, so nothing above it settles the end yet. - None if slot.may_start() => after = None, - // It never will, so it proves nothing either way. - None => {} + Some(start) if later(start, pts) => { + after = Some(sequence); + open = false; + } + Some(_) => return after.filter(|_| !open && self.accounted(gap)), + // Not started: one that still may blocks the end, one that never will + // proves nothing either way. + None => open |= after.is_some() && (slot.may_start() || !self.accounted(gap)), } } - after + after.filter(|_| !open && self.accounted(from..low.unwrap_or(from))) } } impl TrackState { + /// Whether no sequence in `groups` can still arrive: each arrived, was dropped, lies + /// below where the live feed starts, or was given up once the arrivals' grace ran out. + /// Ranges throughout, so a wide gap costs no more than a narrow one. + fn accounted(&self, groups: ops::Range) -> bool { + // Without a declared start, the first arrival stands in for it, as the tail's does. + let floor = self.start_sequence.or(self.arrivals.first()).unwrap_or(0); + let groups = groups.start.max(floor)..groups.end; + groups.is_empty() || self.drops.gaps(&groups).all(|gap| self.arrivals.covers(gap)) + } + /// The starts a first frame of group `sequence` must fall between: the nearest checked /// starts cached on either side, since groups land out of sequence order, and the /// eviction watermark. A group at or below the watermark is late: its floor may be gone, @@ -13893,6 +13913,46 @@ mod test { assert_eq!(drain(&mut subscriber), vec![0]); } + /// A time end stays open across a sequence yet to arrive: it may start before the end, + /// so a later group starting after it settles nothing. Once that sequence is reported + /// dropped, nothing below the later group can start before the end, so it settles. + #[test] + #[cfg(feature = "async")] + fn a_time_end_waits_on_an_absent_group_below_it() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let ranges = [Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(500))), + }]; + for dropped in [false, true] { + let producer = track_producer("test", Info::timed()); + let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(at(0), bytes::Bytes::from_static(b"x")).unwrap(); + let mut two = producer.create_group(group::Info { sequence: 2 }).unwrap(); + two.write_frame(at(2000), bytes::Bytes::from_static(b"x")).unwrap(); + + let mut subscriber = producer.subscribe(replay().with_ranges(ranges)); + assert_eq!( + subscriber.resolved_ranges(), + vec![Some(Resolved { start: 0, end: None })], + "group 1 may still start before the end" + ); + if dropped { + producer.drop_groups(1..2); + assert_eq!(drain(&mut subscriber), vec![0]); + assert_eq!( + subscriber.resolved_ranges(), + vec![Some(Resolved { start: 0, end: Some(2) })], + "the drop settles the end" + ); + continue; + } + let mut one = producer.create_group(group::Info { sequence: 1 }).unwrap(); + one.write_frame(at(1000), bytes::Bytes::from_static(b"x")).unwrap(); + assert_eq!(drain(&mut subscriber), vec![0]); + } + } + /// An untimed track refuses a time anchor wherever the ranges reach it. #[test] #[cfg(feature = "async")] From 330ae11ef52e94fc6d54756c8c9641a2ce9649e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 18:48:38 -0700 Subject: [PATCH 27/48] test(moq-c): set refresh in the time anchor test's track info Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 97c934bedb6..c958ea50bd6 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1826,6 +1826,8 @@ fn raw_track_time_anchor() { max_age_present: true, timescale: 1_000_000, timescale_present: true, + refresh: 0, + refresh_present: false, }; let track = id(unsafe { moq_publish_track(broadcast, track_name.as_ptr() as *const c_char, track_name.len(), &info) }); From 9f0ec25d8a139daeee213f0b05477a2da3d4631c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 19:29:24 -0700 Subject: [PATCH 28/48] fix(moq-net): measure drift only within the groups a subscriber's ranges cover A group in the gap between two ranges is never read, so it neither sets the drift edge nor supersedes a group below it. The cursor's scalar drift cap becomes the servable window below its cap, shared with handed-out groups. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 252 ++++++++++++++++++++-------------- 1 file changed, 149 insertions(+), 103 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 4fa504529eb..25ff86c29d4 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1044,8 +1044,8 @@ impl TrackState { } } - /// The live edge a subscription bounded at `cap` measures drift against: the - /// highest-sequence group that has presented a frame, or `None` while nothing + /// The live edge a subscription served `servable` measures drift against: the + /// highest-sequence group in it that has presented a frame, or `None` while nothing /// stamped is servable (an unstamped track drives no media-time expiry; the wall /// clock alone judges it). /// @@ -1053,42 +1053,49 @@ impl TrackState { /// every candidate below it and above none at or past it. Recomputing per candidate /// would make walking a backlog of N groups off in one poll cost O(N^2). /// - /// Capped because a group can only be late relative to data that would actually be + /// Bounded because a group can only be late relative to data that would actually be /// served in its place: a reader capped by [`Subscriber::set_groups`] can't jump past - /// the cap, so the groups it still wants aren't stale just because the track ran on. + /// the cap, and one reading disjoint ranges never reads the gap between them, so the + /// groups it still wants aren't stale just because the track ran on. /// Fetched backfill is absent from `arrival`, so it cannot age subscription content /// as though it were a live replacement. - fn live_edge(&self, cap: Option) -> Option { - self.lookup - .range((Bound::Unbounded, cap.map_or(Bound::Unbounded, Bound::Excluded))) - .rev() - .find_map(|(_, slot)| { - if !slot.is_shown() { - return None; - } - // The map is ordered by sequence, so the first stamped group from the - // back is the newest content that exists. - let timestamp = slot.group.timestamp()?; - Some(PresentationEdge { - sequence: slot.group.sequence, - stamp: slot.stamp, - timestamp: slot.group.latest().unwrap_or(timestamp), - }) + fn live_edge(&self, servable: &Servable) -> Option { + self.servable(servable, 0).rev().find_map(|slot| { + if !slot.is_shown() { + return None; + } + // The map is ordered by sequence, so the first stamped group from the + // back is the newest content that exists. + let timestamp = slot.group.timestamp()?; + Some(PresentationEdge { + sequence: slot.group.sequence, + stamp: slot.stamp, + timestamp: slot.group.latest().unwrap_or(timestamp), }) + }) } - /// This track's own edge under the exclusive `cap`, for measuring drift. + /// This track's own edge within `servable`, for measuring drift. /// /// None on an untimed track, where nothing is ever stale: its groups carry no /// timestamp, so looking for one would scan the whole cache on every poll. - fn drift_edge(&self, cap: Option) -> Edge { + fn drift_edge(&self, servable: &Servable) -> Edge { let untimed = self.info.as_ref().is_some_and(|info| info.timescale.is_none()); Edge { - presentation: if untimed { None } else { self.live_edge(cap) }, - cap, + presentation: if untimed { None } else { self.live_edge(servable) }, + servable: servable.clone(), } } + /// The cached groups at or above `from` that `servable` covers, lowest first: one + /// lookup per span. + fn servable<'a>(&'a self, servable: &'a Servable, from: u64) -> impl DoubleEndedIterator + 'a { + servable + .spans(from) + .flat_map(|span| self.lookup.range(span)) + .map(|(_, slot)| slot) + } + /// Whether `sequence` still holds the servable incarnation `stamp`. fn holds(&self, sequence: u64, stamp: u32) -> bool { self.lookup @@ -1103,30 +1110,24 @@ impl TrackState { /// own last timestamp says where it *starts* presenting, not where it ends; only its /// successor's start proves it cannot run past it. The bound is the *immediate* /// servable successor's start, so an unstamped successor leaves the reach unbounded - /// until it presents its first frame, and so does having no servable successor below - /// `cap` at all. - fn reach(&self, sequence: u64, cap: Option) -> Option { - self.successor(sequence, cap)?.group.timestamp() + /// until it presents its first frame, and so does having no successor in `servable` + /// at all. + fn reach(&self, sequence: u64, servable: &Servable) -> Option { + self.successor(sequence, servable)?.group.timestamp() } - /// The group after `sequence` that bounds it: the first servable one below `cap`, + /// The group after `sequence` that bounds it: the first servable one in `servable`, /// stamped or not, empty or not. - fn successor(&self, sequence: u64, cap: Option) -> Option<&Slot> { - self.lookup - .range(sequence.saturating_add(1)..) - .map(|(_, slot)| slot) - .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) + fn successor<'a>(&'a self, sequence: u64, servable: &'a Servable) -> Option<&'a Slot> { + self.servable(servable, sequence.checked_add(1)?) .find(|slot| slot.is_shown()) } /// The group after `sequence` that starts its wall clock: like [`Self::successor`], but /// counting a group a route is still withholding. Its header arrived, so a relay ages the /// group before it as the origin does, even if its first frame never comes. - fn arrival(&self, sequence: u64, cap: Option) -> Option<&Slot> { - self.lookup - .range(sequence.saturating_add(1)..) - .map(|(_, slot)| slot) - .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) + fn arrival<'a>(&'a self, sequence: u64, servable: &'a Servable) -> Option<&'a Slot> { + self.servable(servable, sequence.checked_add(1)?) .find(|slot| slot.is_live()) } @@ -1153,8 +1154,8 @@ impl TrackState { /// a group that lands between the two restarts the clock, and an aborted successor /// hands it to the next one. The clock is read only once there is a successor, /// so judging the newest group, the common case, costs no clock read. - fn superseded(&self, sequence: u64, cap: Option, budget: Duration) -> bool { - self.arrival(sequence, cap) + fn superseded(&self, sequence: u64, servable: &Servable, budget: Duration) -> bool { + self.arrival(sequence, servable) .is_some_and(|successor| self.aged(successor, budget)) } @@ -1199,11 +1200,11 @@ impl TrackState { /// [`Self::is_stale`] for group `sequence`, whether this track holds it or not: its /// successor here starts a full `budget` behind the edge, or arrived a full `budget` ago. fn drifted(&self, sequence: u64, edge: &Edge, budget: Duration) -> bool { - if self.superseded(sequence, edge.cap, budget) { + if self.superseded(sequence, &edge.servable, budget) { return true; } self.live_above(sequence, edge) - .is_some_and(|live| live.behind(self.reach(sequence, edge.cap), budget)) + .is_some_and(|live| live.behind(self.reach(sequence, &edge.servable), budget)) } /// The edge that could convict group `sequence`: above it, and still holding the @@ -1214,18 +1215,18 @@ impl TrackState { .filter(|live| live.sequence > sequence && self.holds(live.sequence, live.stamp)) } - /// [`Self::drifted`] under `cap`, parking `waiter` on exactly what else can move the + /// [`Self::drifted`] within `servable`, parking `waiter` on exactly what else can move the /// verdict: the successor's first frame or abort, the edge's abort, the edge crossing /// the media deadline, and the wall clock crossing the successor's arrival plus /// `budget`. Parking on every track change instead wakes each of N parked reads per append. /// /// The caller parks on a group landing above `sequence` first, in the way that fits /// whether this track holds it. - fn poll_drifted(&self, sequence: u64, cap: Option, budget: Duration, waiter: &kio::Waiter) -> bool { + fn poll_drifted(&self, sequence: u64, servable: &Servable, budget: Duration, waiter: &kio::Waiter) -> bool { // Unbounded until a group lands above. Registered before judging, so a racing abort // is observed or wakes us, and armed for whichever successor this pass selected, so // re-selecting re-arms it. - let Some(arrival) = self.watch_abort(waiter, |state| state.arrival(sequence, cap)) else { + let Some(arrival) = self.watch_abort(waiter, |state| state.arrival(sequence, servable)) else { return false; }; if let Some(deadline) = arrival.arrived.checked_add(budget) { @@ -1237,7 +1238,7 @@ impl TrackState { let reach = loop { // An abort can change reach even after the successor is stamped. Unbounded // until a group shows above, or the successor presents its first frame. - let Some(successor) = self.watch_abort(waiter, |state| state.successor(sequence, cap)) else { + let Some(successor) = self.watch_abort(waiter, |state| state.successor(sequence, servable)) else { return false; }; let successor = &successor.group; @@ -1252,7 +1253,7 @@ impl TrackState { // either seen here or wakes us. self.cache.wakes().watch_deadline(reach, budget, waiter); loop { - let edge = self.drift_edge(cap); + let edge = self.drift_edge(servable); // The edge's abort presents nothing and never touches the track, yet hands the // edge to a lower group that may already sit past the deadline. Watch it, and // re-resolve if it already landed. Terminates: an aborted group is never the @@ -3879,7 +3880,7 @@ impl Consumer { let state = state.read(); let budget = clamp_max_delay(budget, state.max_age_bound()); state.cache.wakes().watch_held(sequence, waiter); - match state.poll_drifted(sequence, None, budget, waiter) { + match state.poll_drifted(sequence, &Servable::ALL, budget, waiter) { true => Poll::Ready(()), false => Poll::Pending, } @@ -4464,7 +4465,7 @@ impl kio::Task for Fetching { let state = state.read(); let budget = clamp_max_delay(self.max_delay.unwrap_or(Duration::MAX), state.max_age_bound()); state.cache.wakes().watch_landing(sequence, waiter); - if state.poll_drifted(sequence, None, budget, waiter) { + if state.poll_drifted(sequence, &Servable::ALL, budget, waiter) { return Poll::Ready(Err(Error::Old)); } } @@ -4613,7 +4614,7 @@ enum Inner { /// One poll's view of how far this subscription may drift: the clamped budget and the /// live edge to measure a candidate group against. Resolved once, then applied to every /// group that poll considers. -#[derive(Clone, Copy)] +#[derive(Clone)] struct Drift { budget: Duration, edge: Edge, @@ -4628,8 +4629,8 @@ struct GroupExpiry { /// the last real subscriber. state: kio::ConsumerWeak, subscription: kio::Consumer, - /// The cursor's drift cap; see [`Cursor::drift_cap`]. - cap: kio::Consumer>, + /// What the cursor can be served; see [`Cursor::servable`]. + servable: kio::Consumer, sequence: u64, } @@ -4661,7 +4662,7 @@ impl group::Expiry for FetchExpiry { }; let budget = clamp_max_delay(budget, state.max_age_bound()); state.cache.wakes().watch_landing(self.sequence, waiter); - state.poll_drifted(self.sequence, None, budget, waiter) && state.lookup.contains_key(&self.sequence) + state.poll_drifted(self.sequence, &Servable::ALL, budget, waiter) && state.lookup.contains_key(&self.sequence) } } @@ -4672,7 +4673,7 @@ impl group::Expiry for GroupExpiry { Arc::new(Self { state: state.unwrap_or_else(|| self.state.clone()), subscription: self.subscription.clone(), - cap: self.cap.clone(), + servable: self.servable.clone(), sequence: self.sequence, }) } @@ -4687,28 +4688,47 @@ impl group::Expiry for GroupExpiry { max_delay }); - let mut cap = None; - let _ = self.cap.poll(waiter, |current| { - cap = **current; + let mut servable = Servable::ALL; + let _ = self.servable.poll(waiter, |current| { + servable = (**current).clone(); Poll::<()>::Pending }); let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); state.cache.wakes().watch_landing(self.sequence, waiter); - state.poll_drifted(self.sequence, cap, budget, waiter) && state.lookup.contains_key(&self.sequence) + state.poll_drifted(self.sequence, &servable, budget, waiter) && state.lookup.contains_key(&self.sequence) } } /// The group a poll's drift is measured against, identified well enough to tell it apart /// from whatever may occupy its sequence by the time a candidate is judged. -#[derive(Clone, Copy)] +#[derive(Clone)] struct Edge { /// This track's own edge, revalidated before it convicts anything. presentation: Option, - /// The cap the edge was resolved under, so per-candidate reach lookups measure + /// What the edge was resolved within, so per-candidate reach lookups measure /// against the same servable window. - cap: Option, + servable: Servable, +} + +/// 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)] +struct Servable(Option>>); + +impl Servable { + /// Every group, for a reader no window narrows. + const ALL: Self = Self(None); + + /// The spans at or above `from`, lowest first. + fn spans(&self, from: u64) -> impl DoubleEndedIterator> + '_ { + let set = self.0.as_deref(); + set.into_iter() + .flat_map(move |set| set.overlapping(from..=u64::MAX)) + .map(move |span| (*span.start()).max(from)..=*span.end()) + .chain(set.is_none().then_some(from..=u64::MAX)) + } } /// The newest servable group that has presented at least one frame. @@ -4755,9 +4775,10 @@ struct Cursor { /// the producer's cache and become eligible again when the cap rises (or is removed). /// `Some(0)` is the empty range. end_sequence: Option, - /// [`Self::edge_cap`], shared with the groups this cursor hands out so their expiry - /// measures drift against the same servable window after the cap or ranges move. - drift_cap: kio::Producer>, + /// What this cursor can be served: its window below its cap. Shared with the groups it + /// hands out so their expiry measures drift against the same groups after the cap or + /// ranges move. + servable: kio::Producer, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the stats scope lives on the owning /// [`Subscriber`], which drains this after every read. @@ -4918,7 +4939,7 @@ impl Cursor { }; (filter, state.datagram_offset + start) }; - Self { + let mut cursor = Self { state, subscription, min_sequence: 0, @@ -4927,11 +4948,13 @@ impl Cursor { datagram_index, next_sequence: Some(0), end_sequence: None, - drift_cap: kio::Producer::new(None), + servable: kio::Producer::new(Servable::ALL), stale: stats::Content::default(), seek_pending: BTreeMap::new(), drops: None, - } + }; + cursor.update_servable(); + cursor } #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -4960,24 +4983,22 @@ impl Cursor { } } - /// The exclusive end drift is measured under: the cap, lowered to where the ranges - /// end, so a range behind the live edge is judged against its own newest group rather - /// than skipped for groups it never reads. - fn edge_cap(&self) -> Option { - match (self.end_sequence, self.filter.window.end()) { - (Some(cap), Some(end)) => Some(cap.min(end)), - (cap, end) => cap.or(end), - } + /// The groups drift is measured within: the window below the cap, so a range behind + /// the live edge is judged against its own newest group, and a group in a gap between + /// ranges against nothing, rather than skipped for groups it never reads. + fn servable(&self) -> Servable { + self.servable.read().clone() } - /// Publish [`Self::edge_cap`] to the groups already handed out. - fn update_drift_cap(&mut self) { - let cap = self.edge_cap(); + /// Resolve [`Self::servable`] again once the window or cap moved, and publish it to the + /// groups already handed out. Costs the ranges, never the cache. + fn update_servable(&mut self) { + let servable = Servable(Some(Arc::new(self.admitted(0, self.end_sequence)))); // Skip a no-op write: every handed-out group's expiry watches this channel. - if *self.drift_cap.read() != cap - && let Ok(mut current) = self.drift_cap.write() + if *self.servable.read() != servable + && let Ok(mut current) = self.servable.write() { - *current = cap; + *current = servable; } } @@ -5012,7 +5033,7 @@ impl Cursor { drop(state); if moved { self.filter.revision += 1; - self.update_drift_cap(); + self.update_servable(); } } @@ -5027,9 +5048,8 @@ impl Cursor { super::subscription::before_end(sequence, self.end_sequence) && self.filter.window.contains(sequence) } - /// The groups the arrival walk reads under the exclusive `cap`: the window from the - /// floor, below it. - fn admitted(&self, cap: Option) -> RangeInclusiveSet { + /// The window from `from`, below the exclusive `cap`. + fn admitted(&self, from: u64, cap: Option) -> RangeInclusiveSet { let mut admitted = RangeInclusiveSet::new(); let last = match cap { Some(end) => match end.checked_sub(1) { @@ -5038,7 +5058,7 @@ impl Cursor { }, None => u64::MAX, }; - for run in self.filter.window.runs(self.floor()) { + for run in self.filter.window.runs(from) { if *run.start() > last { break; } @@ -5073,7 +5093,8 @@ impl Cursor { if self.filter.admitted_at == Some(at) { return; } - let admitted = self.admitted(self.end_sequence); + // The arrival walk reads the window from the floor. + let admitted = self.admitted(self.floor(), self.end_sequence); self.filter.owed = self.owed(&admitted); self.filter.admitted = admitted; self.filter.admitted_at = Some(at); @@ -5134,14 +5155,14 @@ impl Cursor { /// This subscriber's clamped drift budget and the live edge to measure against, /// resolved once per poll. /// - /// `cap` bounds the edge: the caller passes the same window it reads from, so a + /// `servable` bounds the edge: the caller passes the same window it reads from, so a /// group is only ever judged against content that could actually be served in its /// place. Read fresh each poll, so a mid-stream [`Control::update`] applies /// to the very next group, and shared across every candidate that poll walks off, so /// discarding a backlog of N groups costs one scan rather than N. Only ever /// [`Poll::Ready`]; the track ending surfaces as the error the caller was going to /// get anyway. - fn poll_drift(&self, cap: Option, waiter: &kio::Waiter) -> Poll> { + fn poll_drift(&self, servable: &Servable, waiter: &kio::Waiter) -> Poll> { let mut max_delay = Duration::default(); let _ = self.subscription.poll_ref(waiter, |subscription| { max_delay = subscription.max_delay; @@ -5150,14 +5171,14 @@ impl Cursor { self.poll(waiter, |state| { Poll::Ready(Ok(Drift { budget: clamp_max_delay(max_delay, state.max_age_bound()), - edge: state.drift_edge(cap), + edge: state.drift_edge(servable), })) }) } /// Whether the drift budget says to skip `group`, against a [`Drift`] already resolved /// for this poll. - fn poll_stale(&self, group: &group::Consumer, drift: Drift, waiter: &kio::Waiter) -> Poll> { + fn poll_stale(&self, group: &group::Consumer, drift: &Drift, waiter: &kio::Waiter) -> Poll> { self.poll(waiter, |state| { Poll::Ready(Ok(state.is_stale(group.sequence, &drift.edge, drift.budget))) }) @@ -5174,21 +5195,21 @@ impl Cursor { let max_delay = self.subscription.read().max_delay; let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); - let edge = state.drift_edge(self.edge_cap()); + let edge = state.drift_edge(&self.servable()); // Every group below the last successor found shares it, so a run of cached groups // that cannot serve is walked once per pass, not once per gap. let mut arrival: Option<&Slot> = None; let mut successor: Option<&Slot> = None; while let Some(oldest) = delivered.above_gap() { if arrival.is_none_or(|slot| slot.group.sequence <= oldest) { - arrival = state.arrival(oldest, edge.cap); + arrival = state.arrival(oldest, &edge.servable); } // Either clock, as `drifted` judges a group: without a successor, neither. let Some(first) = arrival else { break; }; if successor.is_none_or(|slot| slot.group.sequence <= oldest) { - successor = state.successor(oldest, edge.cap); + successor = state.successor(oldest, &edge.servable); } let past = state.aged(first, budget) || successor.is_some_and(|slot| { @@ -5208,7 +5229,7 @@ impl Cursor { group.with_expiry(Arc::new(GroupExpiry { state: self.state.weak(), subscription: self.subscription.consume(), - cap: self.drift_cap.consume(), + servable: self.servable.consume(), sequence, })) } @@ -5221,7 +5242,7 @@ impl Cursor { self.admit(); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(self.edge_cap(), waiter))?; + let drift = ready!(self.poll_drift(&self.servable(), waiter))?; loop { // Re-offer what a widening newly covers ahead of the arrival walk, lowest first: @@ -5269,7 +5290,7 @@ impl Cursor { // Drop a group the drift budget has given up on and keep scanning, so one // poll walks a whole backlog off rather than handing it out group by group. - if ready!(self.poll_stale(&consumer, drift, waiter))? { + if ready!(self.poll_stale(&consumer, &drift, waiter))? { self.stale.add(consumer.content()); if let Some(drops) = &mut self.drops && let Some(end) = consumer.sequence.checked_add(1) @@ -5327,7 +5348,7 @@ impl Cursor { let mut floor = next.max(self.floor()); let end = self.end_sequence; // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(self.edge_cap(), waiter))?; + let drift = ready!(self.poll_drift(&self.servable(), waiter))?; let group = loop { let window = &self.filter.window; @@ -5353,7 +5374,7 @@ impl Cursor { // Skip a group the budget has given up on and keep scanning, so one poll // walks a whole backlog off rather than handing it out group by group. - if ready!(self.poll_stale(&group, drift, waiter))? { + if ready!(self.poll_stale(&group, &drift, waiter))? { // Not counted yet: a conviction is not permanent (the budget can widen, // the edge can be evicted), so the group may still be delivered. // Re-snapshot on every re-examination so the eventual count reflects the @@ -5586,7 +5607,7 @@ impl Subscriber { }; // What a widening owes, the walk has yet to take in, or lifting the local cap would: // a group past it is unread, not gone. - let owed = cursor.owed(&cursor.admitted(None)); + let owed = cursor.owed(&cursor.admitted(cursor.floor(), None)); let last = unread.end.map(|end| end.checked_sub(1)); let owed = || { owed.iter().any(|run| { @@ -5877,7 +5898,7 @@ impl Subscriber { match &mut self.inner { Inner::Plain(cursor) => { cursor.end_sequence = end.into().exclusive(); - cursor.update_drift_cap(); + cursor.update_servable(); } Inner::Resume(resume, _) => resume.end_at(end.into()), } @@ -9508,7 +9529,7 @@ mod test { let state = producer.state.read(); let drift = Drift { budget: Duration::ZERO, - edge: state.drift_edge(None), + edge: state.drift_edge(&Servable::ALL), }; assert!( state.is_stale(0, &drift.edge, drift.budget), @@ -9572,6 +9593,31 @@ mod test { assert_eq!(drain(&mut subscriber), vec![0, 1]); } + /// Drift is measured only against groups the ranges cover: a group in the gap between + /// two spans is never read, so it neither sets the edge nor replaces the newest group + /// below it. A group in the later span is read, so it does. + #[test] + #[cfg(feature = "async")] + fn a_gap_between_ranges_is_not_a_drift_edge() { + let ranges = || Subscription::default().with_ranges([Range::groups(0..2), Range::groups(10..12)]); + let producer = track_producer("test", Info::timed()); + for (sequence, millis) in [(0, 0), (1, 1000), (9, 9000)] { + let mut group = producer.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(Timestamp::from_millis(millis).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + group.finish().unwrap(); + } + // With no budget, the newest group the ranges cover is the edge. + assert_eq!(drain(&mut producer.subscribe(ranges())), vec![1]); + + let mut ten = producer.create_group(group::Info { sequence: 10 }).unwrap(); + ten.write_frame(Timestamp::from_millis(10_000).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + ten.finish().unwrap(); + assert_eq!(drain(&mut producer.subscribe(ranges())), vec![10]); + } + #[test] #[cfg(feature = "async")] fn a_capped_subscriber_measures_drift_against_its_cap() { From 472a40f9cbf8746370083fb58c2f004a569b5936 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 20:09:40 -0700 Subject: [PATCH 29/48] perf(moq-net): keep a single servable run as cheap as a cap One run is held as an exclusive end, and the cursor keeps its own copy rather than reading the shared channel per poll, so the single-range drift scan is one lookup with no allocation or atomic. Disjoint runs scan one lookup each. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 125 ++++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 45 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 25ff86c29d4..79515f736f9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1060,7 +1060,7 @@ impl TrackState { /// Fetched backfill is absent from `arrival`, so it cannot age subscription content /// as though it were a live replacement. fn live_edge(&self, servable: &Servable) -> Option { - self.servable(servable, 0).rev().find_map(|slot| { + self.find_servable(servable, 0, true, |slot| { if !slot.is_shown() { return None; } @@ -1087,13 +1087,39 @@ impl TrackState { } } - /// The cached groups at or above `from` that `servable` covers, lowest first: one - /// lookup per span. - fn servable<'a>(&'a self, servable: &'a Servable, from: u64) -> impl DoubleEndedIterator + 'a { - servable - .spans(from) - .flat_map(|span| self.lookup.range(span)) - .map(|(_, slot)| slot) + /// 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. + fn find_servable<'a, R>( + &'a self, + servable: &Servable, + from: u64, + rev: bool, + mut f: impl FnMut(&'a Slot) -> Option, + ) -> Option { + let mut scan = |range: std::collections::btree_map::Range<'a, u64, Slot>| match rev { + true => range.rev().find_map(|(_, slot)| f(slot)), + false => range.map(|(_, slot)| slot).find_map(&mut f), + }; + match servable { + Servable::Below(end) => { + // A range ending before it starts would panic, and holds nothing anyway. + if end.is_some_and(|end| end < from) { + return None; + } + let end = end.map_or(Bound::Unbounded, Bound::Excluded); + 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), + } + } + } } /// Whether `sequence` still holds the servable incarnation `stamp`. @@ -1118,17 +1144,19 @@ impl TrackState { /// The group after `sequence` that bounds it: the first servable one in `servable`, /// stamped or not, empty or not. - fn successor<'a>(&'a self, sequence: u64, servable: &'a Servable) -> Option<&'a Slot> { - self.servable(servable, sequence.checked_add(1)?) - .find(|slot| slot.is_shown()) + fn successor(&self, sequence: u64, servable: &Servable) -> Option<&Slot> { + self.find_servable(servable, sequence.checked_add(1)?, false, |slot| { + slot.is_shown().then_some(slot) + }) } /// The group after `sequence` that starts its wall clock: like [`Self::successor`], but /// counting a group a route is still withholding. Its header arrived, so a relay ages the /// group before it as the origin does, even if its first frame never comes. - fn arrival<'a>(&'a self, sequence: u64, servable: &'a Servable) -> Option<&'a Slot> { - self.servable(servable, sequence.checked_add(1)?) - .find(|slot| slot.is_live()) + fn arrival(&self, sequence: u64, servable: &Servable) -> Option<&Slot> { + self.find_servable(servable, sequence.checked_add(1)?, false, |slot| { + slot.is_live().then_some(slot) + }) } /// The slot `select` picks, with `waiter` parked on its abort. @@ -4629,7 +4657,7 @@ struct GroupExpiry { /// the last real subscriber. state: kio::ConsumerWeak, subscription: kio::Consumer, - /// What the cursor can be served; see [`Cursor::servable`]. + /// What the cursor can be served; see [`Cursor::published`]. servable: kio::Consumer, sequence: u64, } @@ -4715,19 +4743,27 @@ struct Edge { /// 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)] -struct Servable(Option>>); +enum Servable { + /// Everything below the exclusive end, or everything: one run, the common case, kept + /// as cheap to copy and look up as a cap. Its start is dropped, since every group a + /// reader is judged on lies at or above it, and so does anything measured against one. + Below(Option), + /// Disjoint runs, lowest first. + Runs(Arc>), +} impl Servable { /// Every group, for a reader no window narrows. - const ALL: Self = Self(None); + const ALL: Self = Self::Below(None); - /// The spans at or above `from`, lowest first. - fn spans(&self, from: u64) -> impl DoubleEndedIterator> + '_ { - let set = self.0.as_deref(); - set.into_iter() - .flat_map(move |set| set.overlapping(from..=u64::MAX)) - .map(move |span| (*span.start()).max(from)..=*span.end()) - .chain(set.is_none().then_some(from..=u64::MAX)) + /// The groups in `runs`. + fn new(runs: RangeInclusiveSet) -> Self { + let mut iter = runs.iter(); + match (iter.next(), iter.next()) { + (None, _) => Self::Below(Some(0)), + (Some(run), None) => Self::Below(run.end().checked_add(1)), + _ => Self::Runs(Arc::new(runs)), + } } } @@ -4775,10 +4811,13 @@ struct Cursor { /// the producer's cache and become eligible again when the cap rises (or is removed). /// `Some(0)` is the empty range. end_sequence: Option, - /// What this cursor can be served: its window below its cap. Shared with the groups it - /// hands out so their expiry measures drift against the same groups after the cap or - /// ranges move. - servable: kio::Producer, + /// What this cursor can be served, the only groups its drift is measured within: its + /// window below its cap, so a range behind the live edge is judged against its own + /// newest group, and a group in a gap between ranges against nothing. + servable: Servable, + /// [`Self::servable`], shared with the groups this cursor hands out so their expiry + /// measures drift against the same groups after the cap or ranges move. + published: kio::Producer, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the stats scope lives on the owning /// [`Subscriber`], which drains this after every read. @@ -4948,7 +4987,8 @@ impl Cursor { datagram_index, next_sequence: Some(0), end_sequence: None, - servable: kio::Producer::new(Servable::ALL), + servable: Servable::ALL, + published: kio::Producer::new(Servable::ALL), stale: stats::Content::default(), seek_pending: BTreeMap::new(), drops: None, @@ -4983,22 +5023,17 @@ impl Cursor { } } - /// The groups drift is measured within: the window below the cap, so a range behind - /// the live edge is judged against its own newest group, and a group in a gap between - /// ranges against nothing, rather than skipped for groups it never reads. - fn servable(&self) -> Servable { - self.servable.read().clone() - } - /// Resolve [`Self::servable`] again once the window or cap moved, and publish it to the /// groups already handed out. Costs the ranges, never the cache. fn update_servable(&mut self) { - let servable = Servable(Some(Arc::new(self.admitted(0, self.end_sequence)))); + let servable = Servable::new(self.admitted(0, self.end_sequence)); // Skip a no-op write: every handed-out group's expiry watches this channel. - if *self.servable.read() != servable - && let Ok(mut current) = self.servable.write() - { - *current = servable; + if self.servable == servable { + return; + } + self.servable = servable; + if let Ok(mut current) = self.published.write() { + *current = self.servable.clone(); } } @@ -5195,7 +5230,7 @@ impl Cursor { let max_delay = self.subscription.read().max_delay; let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); - let edge = state.drift_edge(&self.servable()); + let edge = state.drift_edge(&self.servable); // Every group below the last successor found shares it, so a run of cached groups // that cannot serve is walked once per pass, not once per gap. let mut arrival: Option<&Slot> = None; @@ -5229,7 +5264,7 @@ impl Cursor { group.with_expiry(Arc::new(GroupExpiry { state: self.state.weak(), subscription: self.subscription.consume(), - servable: self.servable.consume(), + servable: self.published.consume(), sequence, })) } @@ -5242,7 +5277,7 @@ impl Cursor { self.admit(); // 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))?; + let drift = ready!(self.poll_drift(&self.servable, waiter))?; loop { // Re-offer what a widening newly covers ahead of the arrival walk, lowest first: @@ -5348,7 +5383,7 @@ impl Cursor { let mut floor = next.max(self.floor()); let end = self.end_sequence; // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(&self.servable(), waiter))?; + let drift = ready!(self.poll_drift(&self.servable, waiter))?; let group = loop { let window = &self.filter.window; From 00390f2aae00785b1cdfcdb77467cac0b850801f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 09:21:37 -0700 Subject: [PATCH 30/48] feat(moq-net): restore time anchors on main's per-subscriber ranges Re-ports the time anchors #5929 split out onto main's shape, through every binding. An unsettled time end now walks the groups forward from where it stopped: a starts index carries the newest group known to start inside it past a stalled one, the first group that may still start is the only one read on speculation, and settling resumes at the first sequence not yet accounted for. A failover cap never replaces an unsettled time end, and a datagram is judged by its own timestamp. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 4 +- dart/moq/MIGRATE.md | 4 +- dart/moq/lib/src/aliases.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 48 +- doc/concept/moq-lite.md | 9 +- go/wrapper/MIGRATE.md | 4 +- go/wrapper/subscribe.go | 7 +- go/wrapper/types.go | 4 +- kt/moq/MIGRATE.md | 4 +- .../kotlin/dev/moq/Aliases.kt | 4 +- py/moq-rs/MIGRATE.md | 4 +- py/moq-rs/moq/subscribe.py | 5 +- quest/m1/binding-timestamps.md | 6 +- rs/moq-c/MIGRATE.md | 4 +- rs/moq-c/src/api.rs | 16 +- rs/moq-c/src/consume.rs | 90 +-- rs/moq-c/src/test.rs | 180 ++++++ rs/moq-ffi/MIGRATE.md | 4 +- rs/moq-ffi/src/consumer.rs | 61 +- rs/moq-ffi/src/media.rs | 5 +- rs/moq-ffi/src/producer.rs | 2 +- rs/moq-ffi/src/test.rs | 100 ++- rs/moq-net/MIGRATE.md | 2 + rs/moq-net/src/model/expiry.rs | 42 +- rs/moq-net/src/model/group.rs | 25 +- rs/moq-net/src/model/ledger.rs | 8 + rs/moq-net/src/model/resume.rs | 41 +- rs/moq-net/src/model/subscription.rs | 64 +- rs/moq-net/src/model/subscription/window.rs | 611 +++++++++++++++++- rs/moq-net/src/model/track.rs | 414 +++++++++++- rs/moq-net/src/model/track/demand/ranges.rs | 5 +- swift/MIGRATE.md | 4 +- swift/Sources/Moq/Aliases.swift | 4 +- swift/Sources/Moq/Track.swift | 7 +- 34 files changed, 1585 insertions(+), 209 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index b95a4e97690..5b470aceed7 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains a `Time` case holding `timestamp_us`, the group containing that presentation time, so exhaustive visits need a case for it. +`TrackConsumer::update` now returns a result, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. ### Track demand reports the wanted group (#5930) diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 3e45fc90d91..a52afe354e7 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains a time variant, so exhaustive switches need a case for it. +`TrackConsumer.update` now throws on a time anchor an untimed track can't serve. ### Track demand reports the wanted group (#5930) diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index a72a77f225e..ee9d97750c1 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -120,7 +120,7 @@ typedef Subscription = MoqSubscription; /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is null. typedef Range = MoqRange; -/// Where one end of a [Range] falls: absolute or relative to the latest group. +/// Where one end of a [Range] falls: absolute, relative to the latest group, or the group containing a time. typedef Anchor = MoqAnchor; /// Which pending group the publisher sends first. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 2947697cf54..e9c78a3a31d 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -4209,6 +4209,12 @@ class FfiConverterMoqAnchor { lifted.value, lifted.bytesRead - subview.offsetInBytes + 4, ); + case 3: + final lifted = TimeMoqAnchor.read(subview); + return LiftRetVal( + lifted.value, + lifted.bytesRead - subview.offsetInBytes + 4, + ); default: throw UniffiInternalError( UniffiInternalError.unexpectedEnumCase, @@ -4318,6 +4324,44 @@ class RelativeMoqAnchor extends MoqAnchor { } } +class TimeMoqAnchor extends MoqAnchor { + final int timestampUs; + TimeMoqAnchor(int this.timestampUs); + TimeMoqAnchor._(int this.timestampUs); + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final timestampUs_lifted = FfiConverterUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final timestampUs = timestampUs_lifted.value; + new_offset += timestampUs_lifted.bytesRead; + return LiftRetVal(TimeMoqAnchor._(timestampUs), new_offset); + } + + @override + RustBuffer lower() { + final buf = Uint8List(allocationSize()); + write(buf); + return toRustBuffer(buf); + } + + @override + int allocationSize() { + return FfiConverterUInt64.allocationSize(timestampUs) + 4; + } + + @override + int write(Uint8List buf) { + buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 3); + int new_offset = buf.offsetInBytes + 4; + new_offset += FfiConverterUInt64.write( + timestampUs, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset; + } +} + enum MoqOrder { ascending, descending } class FfiConverterMoqOrder { @@ -8295,7 +8339,7 @@ class MoqTrackConsumer implements MoqTrackConsumerInterface { FfiConverterMoqSubscription.lower(subscription), status, ); - }, null); + }, moqExceptionErrorHandler); } } @@ -18430,7 +18474,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group() != 28712) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_update() != 24851) { + 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) { diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index edda6e031b0..15be2807448 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -229,7 +229,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. | -| **Ranges** | Which groups to ask for: from a group, or a number of groups back from the latest, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | +| **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. This lets a finished subscription settle without waiting for missing groups. @@ -240,7 +240,12 @@ 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 -today's wire versions carry as the single span covering them. +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 +lets through every group handed out before its first frame showed it starts past +the end, since holding each group until its first frame arrives would cost +latency. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 5dbc9ca53ea..a5eb1419222 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `Ranges` cover. +`AnchorTime` names the group containing a presentation time, and type switches over `Anchor` need a case for it. +`TrackConsumer.Update` also fails on a time anchor an untimed track can't serve. ### Track demand reports the wanted group (#5930) diff --git a/go/wrapper/subscribe.go b/go/wrapper/subscribe.go index 5d6efcda25f..f9172cf133f 100644 --- a/go/wrapper/subscribe.go +++ b/go/wrapper/subscribe.go @@ -150,15 +150,14 @@ func (t *TrackConsumer) Info() (TrackInfo, error) { return trackInfoFromFFI(info), nil } -// Update changes this subscriber's delivery preferences. It fails only on a -// negative MaxDelay. +// Update changes this subscriber's delivery preferences. It fails on a +// negative MaxDelay, or a time anchor the track can't serve. func (t *TrackConsumer) Update(subscription Subscription) error { sub, err := subscription.ffi() if err != nil { return err } - t.inner.Update(sub) - return nil + return t.inner.Update(sub) } // Groups ranges over groups in sequence order. diff --git a/go/wrapper/types.go b/go/wrapper/types.go index 0ce689b03de..c55269463c2 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -58,12 +58,14 @@ type ( // A relative start is the first frame of its group, a relative end includes its whole // group, and an absolute end is exclusive. Range = ffi.MoqRange - // Anchor is where one end of a Range falls: AnchorAbsolute or AnchorRelative. + // Anchor is where one end of a Range falls: AnchorAbsolute, AnchorRelative, or AnchorTime. Anchor = ffi.MoqAnchor // AnchorAbsolute is an exact group and frame. AnchorAbsolute = ffi.MoqAnchorAbsolute // AnchorRelative is the group Groups before the latest one, clamped to group 0: 0 is the latest. AnchorRelative = ffi.MoqAnchorRelative + // AnchorTime is the group containing TimestampUs, a presentation time in microseconds. + AnchorTime = ffi.MoqAnchorTime // Order is which of a subscription's pending groups the publisher sends first. Order = ffi.MoqOrder // VideoDecoderOutput configures what a video.Consumer delivers: an optional resize, a max delay, and whether frames keep the decoder's surface (macOS only; refused elsewhere). diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 880c0c67e5b..21287bb9c09 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains `AnchorTime`, so exhaustive `when` expressions need a branch for it. +`TrackConsumer.update` now throws `MoqException` on a time anchor an untimed track can't serve. ### Track demand reports the wanted group (#5930) diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 87c02cff362..6430cf04fc7 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -103,12 +103,14 @@ typealias Route = uniffi.moq.MoqRoute typealias Subscription = uniffi.moq.MoqSubscription /** A span of a track to deliver: from `start` up to `end`, or onward when `end` is null. */ typealias Range = uniffi.moq.MoqRange -/** Where one end of a [Range] falls: [AnchorAbsolute] or [AnchorRelative]. */ +/** Where one end of a [Range] falls: [AnchorAbsolute], [AnchorRelative], or [AnchorTime]. */ typealias Anchor = uniffi.moq.MoqAnchor /** An exact group and frame. */ typealias AnchorAbsolute = uniffi.moq.MoqAnchor.Absolute /** The group `groups` before the latest one, clamped to group 0: 0 is the latest. */ typealias AnchorRelative = uniffi.moq.MoqAnchor.Relative +/** The group containing `timestampUs`, a presentation time in microseconds. */ +typealias AnchorTime = uniffi.moq.MoqAnchor.Time /** Which pending group the publisher sends first: `ASCENDING` or `DESCENDING`. */ typealias Order = uniffi.moq.MoqOrder /** Options for fetching one past group by sequence. */ diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 3a49615cdf0..695c7c7654c 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. +`Anchor` gains a `TIME` variant, the group containing a presentation time. +`TrackConsumer.update` now raises on a time anchor an untimed track can't serve. ### Track demand reports the wanted group (#5930) diff --git a/py/moq-rs/moq/subscribe.py b/py/moq-rs/moq/subscribe.py index 5935b657de7..4648d683d6a 100644 --- a/py/moq-rs/moq/subscribe.py +++ b/py/moq-rs/moq/subscribe.py @@ -126,7 +126,10 @@ def info(self) -> TrackInfo: return TrackInfo._from_ffi(self._inner.info()) def update(self, subscription: Subscription) -> None: - """Change this subscriber's delivery preferences.""" + """Change this subscriber's delivery preferences. + + Raises on a time anchor the track can't serve. + """ self._inner.update(subscription._ffi()) def cancel(self) -> None: diff --git a/quest/m1/binding-timestamps.md b/quest/m1/binding-timestamps.md index fd8058c6a24..9e85c153dd6 100644 --- a/quest/m1/binding-timestamps.md +++ b/quest/m1/binding-timestamps.md @@ -16,8 +16,10 @@ Public API: breaking in every binding. Today every binding speaks microseconds: moq-ffi `timestamp_us: Option` (about 160 sites in 9 files: `MoqFrame`, `MoqDatagram`, `MoqMediaFrame`, audio -frames, json/flate producer `update`/`append`, media `flush`), moq-c -`timestamp_us` plus `timestamp_present` pairs (about 140 sites in 6 files), and +frames, json/flate producer `update`/`append`, media `flush`, the +subscription start `MoqAnchor::Time`), moq-c `timestamp_us` plus +`timestamp_present` pairs (about 140 sites in 6 files, plus +`moq_anchor.timestamp_us` read as `MOQ_ANCHOR_TIME`), and each wrapper's native type (Python `timedelta`, Go `*time.Duration`, Swift `UInt64`, Kotlin `Duration`, Dart `int`, C++ `std::optional`, OBS microseconds). Reads floor through `as_micros()`, so any scale that does not diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 6d7a0db5163..93f78e4b047 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A raw track consumer now reads only the groups its `ranges` cover, rather than every group from the lowest start to the last absolute end. +`moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. +`moq_consume_track_update` fails with a negative code when the track refuses an update, such as a time anchor on an untimed track, leaving the subscription as it was. ### Demand reports the wanted group (#5930) diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index fd318e25216..5e7c5c5501c 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -593,8 +593,8 @@ pub struct moq_subscription { /// A span of a track to deliver: from `start` up to `end`, or onward without one. /// -/// A relative start is the first frame of its group, and a relative end includes its whole -/// group. An absolute end is exclusive. +/// A relative or time start is the first frame of its group, and a relative or time end +/// includes its whole group. An absolute end is exclusive. #[repr(C)] #[allow(non_camel_case_types)] #[derive(Clone, Copy)] @@ -619,6 +619,8 @@ pub struct moq_anchor { pub group: u64, /// The frame within `group`, read only with `MOQ_ANCHOR_ABSOLUTE`. pub frame: u64, + /// The presentation time in microseconds, read only with `MOQ_ANCHOR_TIME`. + pub timestamp_us: u64, } /// How a [moq_anchor] names its position. @@ -630,6 +632,8 @@ pub enum moq_anchor_kind { MOQ_ANCHOR_ABSOLUTE = 0, /// A count of groups back from the latest one. MOQ_ANCHOR_RELATIVE = 1, + /// The group containing a presentation time. An untimed track refuses it. + MOQ_ANCHOR_TIME = 2, } /// Which of a subscription's pending groups the publisher sends first. Zero is the default. @@ -652,6 +656,9 @@ fn parse_anchor(anchor: &moq_anchor) -> Result { }) } v if v == moq_anchor_kind::MOQ_ANCHOR_RELATIVE as u32 => moq_net::track::Anchor::Relative(anchor.group), + v if v == moq_anchor_kind::MOQ_ANCHOR_TIME as u32 => { + moq_net::track::Anchor::Time(moq_net::Timestamp::from_micros(anchor.timestamp_us)?) + } _ => return Err(Error::InvalidCode), }) } @@ -3726,7 +3733,10 @@ pub unsafe extern "C" fn moq_consume_track( /// /// Pass NULL for `subscription` to reset to moq-net defaults. /// -/// Returns a zero on success, or a negative code on failure. +/// Returns a zero on success, or a negative code on failure, such as a time anchor on an +/// untimed track, which leaves the subscription as it was. An update made before the +/// subscription is established applies once it is, so a refusal then ends the consumer +/// through its callback instead. /// /// # Safety /// - The caller must ensure that subscription is either NULL or a valid pointer to a [moq_subscription] struct, diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 4c2871f0cef..6f476f52540 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -5,7 +5,7 @@ use std::{ pin::Pin, task::{Poll, ready}, }; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::oneshot; use crate::ffi::OnStatus; use crate::task::Task; @@ -133,7 +133,15 @@ async fn resolve( /// A raw track task also accepts subscription updates while it is running. struct RawTaskEntry { task: Task, - update: mpsc::UnboundedSender>, + control: RawControl, +} + +/// Where a raw track's subscription updates go. +enum RawControl { + /// Waiting on SUBSCRIBE_OK, holding the latest update to apply once it arrives. + Pending(Option), + /// Subscribed: updates apply at once, so a refusal reaches the caller. + Live(moq_net::track::Control), } /// Outcome of polling a raw track source alongside its control channels. @@ -577,15 +585,28 @@ impl Consume { let name = name.to_string(); let (task, close) = Task::new(on_frame); - let (update, updates) = mpsc::unbounded_channel(); - let id = self.raw_task.insert(Some(RawTaskEntry { task, update }))?; + let control = RawControl::Pending(None); + let id = self.raw_task.insert(Some(RawTaskEntry { task, control }))?; // `subscribe` blocks on SUBSCRIBE_OK, so run it inside the task. tokio::spawn(async move { let res = crate::ffi::run(driver.watch(async move { - let mut track = broadcast.track(&name)?.subscribe(subscription.clone()).await?.ordered(); - Self::apply_raw_subscription(&mut track, subscription); - Self::run_raw(on_frame, track, close, updates).await + let track = broadcast.track(&name)?.subscribe(subscription.clone()).await?.ordered(); + let control = track.control(); + Self::update_raw(&control, Self::raw_subscription(subscription))?; + // Go live under the lock, so no later update lands before the pending one. + if let Some(entry) = State::lock() + .consume + .raw_task + .get_mut(id) + .and_then(|entry| entry.as_mut()) + { + let pending = std::mem::replace(&mut entry.control, RawControl::Live(control.clone())); + if let RawControl::Pending(Some(subscription)) = pending { + Self::update_raw(&control, subscription)?; + } + } + Self::run_raw(on_frame, track, close).await })) .await; @@ -600,24 +621,30 @@ impl Consume { Ok(id) } - /// Apply a raw reader's preferences, which join at the latest group unless a range - /// names a floor of its own. - fn apply_raw_subscription(track: &mut moq_net::track::Ordered, subscription: Option) { + /// A raw reader's preferences, which join at the latest group unless a range names + /// a floor of its own. + fn raw_subscription(subscription: Option) -> moq_net::track::Subscription { let mut subscription = subscription.unwrap_or_default(); for range in &mut subscription.ranges { if range.start == moq_net::track::Range::oldest().start { range.start = moq_net::track::Range::live().start; } } - // A closed track makes the update meaningless; the reader already sees the close. - let _ = track.update(subscription); + subscription + } + + fn update_raw(control: &moq_net::track::Control, subscription: moq_net::track::Subscription) -> Result<(), Error> { + match control.update(subscription) { + // A closed track makes the update meaningless; the reader already sees the close. + Ok(()) | Err(moq_net::Error::Closed) => Ok(()), + Err(err) => Err(err.into()), + } } async fn run_raw( callback: OnStatus, mut track: moq_net::track::Ordered, mut close: oneshot::Receiver<()>, - mut updates: mpsc::UnboundedReceiver>, ) -> Result<(), Error> { // Deliver every frame in sequence order, reading all frames within each // group rather than the one-frame-per-group convenience. This is the @@ -629,7 +656,7 @@ impl Consume { loop { let mut group = match moq_net::kio::wait(|waiter| -> Poll, Error>> { - if Self::poll_raw_control(&mut close, &mut updates, &mut track, waiter) { + if Pin::new(&mut close).poll(&mut waiter.context()).is_ready() { return Poll::Ready(Ok(RawStep::Stop)); } match ready!(track.poll_next_group(waiter))? { @@ -646,7 +673,7 @@ impl Consume { loop { let frame = match moq_net::kio::wait(|waiter| -> Poll, Error>> { - if Self::poll_raw_control(&mut close, &mut updates, &mut track, waiter) { + if Pin::new(&mut close).poll(&mut waiter.context()).is_ready() { return Poll::Ready(Ok(RawStep::Stop)); } match ready!(group.poll_read_frame(waiter))? { @@ -670,33 +697,6 @@ impl Consume { } } - /// Poll the close and update channels, applying any subscription updates inline. - /// - /// Returns `true` when the task must stop: either the consumer was closed - /// (`close` fired) or the update channel was dropped. The caller must then - /// unwind rather than poll `close` again, since a completed oneshot panics if - /// re-polled. Borrows `track` only for the duration of the call so the caller - /// can poll a track/group source afterwards. - fn poll_raw_control( - close: &mut oneshot::Receiver<()>, - updates: &mut mpsc::UnboundedReceiver>, - track: &mut moq_net::track::Ordered, - waiter: &moq_net::kio::Waiter, - ) -> bool { - let mut cx = waiter.context(); - if Pin::new(close).poll(&mut cx).is_ready() { - return true; - } - - loop { - match updates.poll_recv(&mut cx) { - Poll::Ready(Some(subscription)) => Self::apply_raw_subscription(track, subscription), - Poll::Ready(None) => return true, - Poll::Pending => return false, - } - } - } - pub fn raw_track_update( &mut self, track: Id, @@ -707,7 +707,11 @@ impl Consume { .get_mut(track) .and_then(|entry| entry.as_mut()) .ok_or(Error::TrackNotFound)?; - entry.update.send(subscription).map_err(|_| Error::TrackNotFound)?; + let subscription = Self::raw_subscription(subscription); + match &mut entry.control { + RawControl::Pending(pending) => *pending = Some(subscription), + RawControl::Live(control) => Self::update_raw(control, subscription)?, + } Ok(()) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 3dec15a63fc..03aee96d97d 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1735,11 +1735,13 @@ fn raw_track_subscription_options_and_update() { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 1, frame: 0, + timestamp_us: 0, }, end: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 2, frame: 0, + timestamp_us: 0, }, end_present: true, }]; @@ -1810,6 +1812,183 @@ fn raw_track_subscription_options_and_update() { assert_eq!(moq_origin_close(origin), 0); } +/// A time anchor starts at the group containing that presentation time. +#[test] +fn raw_track_time_anchor() { + let origin = id(moq_origin_create()); + let path = b"raw-track-time"; + let broadcast = publish_broadcast(origin, path); + + let track_name = b"data"; + let info = moq_track_info { + priority: 3, + max_age_us: 1_000_000, + max_age_present: true, + timescale: 1_000_000, + timescale_present: true, + refresh: 0, + refresh_present: false, + }; + let track = + id(unsafe { moq_publish_track(broadcast, track_name.as_ptr() as *const c_char, track_name.len(), &info) }); + + let payloads: [&[u8]; 3] = [b"zero", b"one", b"two"]; + for (i, payload) in payloads.into_iter().enumerate() { + assert_eq!( + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), i as u64 * 20_000) }, + 0 + ); + } + + let consume = request_broadcast(origin, path); + + let frame_cb = Callback::new(); + let ranges = [moq_range { + start: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, + group: 0, + frame: 0, + timestamp_us: 30_000, + }, + end: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, + group: 0, + frame: 0, + timestamp_us: 0, + }, + end_present: false, + }]; + let subscription = moq_subscription { + priority: 5, + max_delay_us: 1_000_000, + ranges: ranges.as_ptr(), + ranges_len: ranges.len(), + order: 0, + }; + let consumer = id(unsafe { + moq_consume_track( + consume, + track_name.as_ptr() as *const c_char, + track_name.len(), + &subscription, + Some(channel_callback), + frame_cb.ptr, + ) + }); + + for expected in [&b"one"[..], b"two"] { + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 0, + timestamp_present: false, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; + assert_eq!(received, expected); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + } + + assert_eq!(moq_consume_track_cancel(consumer), 0); + assert_eq!(frame_cb.recv_terminal(), 0); + assert_eq!(moq_publish_track_finish(track), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// An untimed track refuses a time anchor update at the call, keeping its ranges. The C +/// API only publishes timed tracks, so the track comes from moq-net. +#[test] +fn raw_track_update_refuses_time_on_untimed() { + let origin = id(moq_origin_create()); + let path = b"raw-track-untimed"; + let broadcast = publish_broadcast(origin, path); + + let track_name = b"data"; + let track = State::lock() + .publish + .track( + Id::try_from(broadcast).unwrap(), + "data", + Some(moq_net::track::Info::default().with_timescale(None)), + ) + .unwrap(); + let write = |payload: &'static [u8]| { + State::lock() + .publish + .track_producer(track) + .unwrap() + .write_frame(None, payload) + .unwrap() + }; + + let consume = request_broadcast(origin, path); + let frame_cb = Callback::new(); + let consumer = id(unsafe { + moq_consume_track( + consume, + track_name.as_ptr() as *const c_char, + track_name.len(), + std::ptr::null(), + Some(channel_callback), + frame_cb.ptr, + ) + }); + + // The first frame proves the subscription is established. + write(b"zero"); + let frame_id = id(frame_cb.recv()); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + + let ranges = [moq_range { + start: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, + group: 0, + frame: 0, + timestamp_us: 0, + }, + end: moq_anchor { + kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, + group: 0, + frame: 0, + timestamp_us: 0, + }, + end_present: false, + }]; + let update = moq_subscription { + priority: 3, + max_delay_us: 1_000_000, + ranges: ranges.as_ptr(), + ranges_len: ranges.len(), + order: 0, + }; + assert!(unsafe { moq_consume_track_update(consumer, &update) } < 0); + + // The consumer keeps reading under its old ranges. + write(b"one"); + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 0, + timestamp_present: false, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; + assert_eq!(received, b"one"); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + + assert_eq!(moq_consume_track_cancel(consumer), 0); + assert_eq!(frame_cb.recv_terminal(), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn raw_track_relative_update_counts_from_the_track_edge() { let origin = id(moq_origin_create()); @@ -1835,6 +2014,7 @@ fn raw_track_relative_update_counts_from_the_track_edge() { kind: kind as u32, group, frame: 0, + timestamp_us: 0, }; let subscribe = |ranges: &[moq_range]| moq_subscription { priority: 0, diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index c71da0c5e19..d89e202a65f 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,9 +5,11 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover, rather than starting at the lowest one and taking whatever arrives. +`MoqAnchor` gains `Time { timestamp_us }`, the group containing that presentation time, so exhaustive matches need an arm for it. +`MoqTrackConsumer::update` now returns `Result<(), MoqError>`, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. ### Track demand reports the wanted group (#5930) diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 728f08e1999..40be2cadba4 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -49,8 +49,8 @@ pub struct MoqSubscription { /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is null. /// -/// A relative start is the first frame of its group, and a relative end includes its whole -/// group. An absolute end is exclusive. +/// A relative or time start is the first frame of its group, and a relative or time end +/// includes its whole group. An absolute end is exclusive. #[derive(Clone, Copy, uniffi::Record)] pub struct MoqRange { /// Where delivery starts. @@ -67,6 +67,9 @@ pub enum MoqAnchor { Absolute { group: u64, frame: u64 }, /// The group this many before the latest, clamped to group 0: `0` is the latest group. Relative { groups: u64 }, + /// The group containing this presentation time, in microseconds. An untimed track + /// refuses it. + Time { timestamp_us: u64 }, } /// Which of a subscription's pending groups the publisher sends first, mirroring @@ -79,21 +82,28 @@ pub enum MoqOrder { Descending, } -impl From for moq_net::track::Anchor { - fn from(anchor: MoqAnchor) -> Self { - match anchor { +impl TryFrom for moq_net::track::Anchor { + type Error = MoqError; + + fn try_from(anchor: MoqAnchor) -> Result { + Ok(match anchor { MoqAnchor::Absolute { group, frame } => Self::Absolute(moq_net::track::Position { group, frame }), MoqAnchor::Relative { groups } => Self::Relative(groups), - } + MoqAnchor::Time { timestamp_us } => { + Self::Time(moq_net::Timestamp::from_micros(timestamp_us).map_err(|_| MoqError::TimeOverflow)?) + } + }) } } -impl From for moq_net::track::Range { - fn from(range: MoqRange) -> Self { - Self { - start: range.start.into(), - end: range.end.map(Into::into), - } +impl TryFrom for moq_net::track::Range { + type Error = MoqError; + + fn try_from(range: MoqRange) -> Result { + Ok(Self { + start: range.start.try_into()?, + end: range.end.map(TryInto::try_into).transpose()?, + }) } } @@ -127,18 +137,25 @@ impl From for moq_net::group::Fetch { } } -impl From for moq_net::track::Subscription { - fn from(s: MoqSubscription) -> Self { +impl TryFrom for moq_net::track::Subscription { + type Error = MoqError; + + 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)); if let Some(ranges) = s.ranges { - subscription = subscription.with_ranges(ranges.into_iter().map(Into::into)); + subscription = subscription.with_ranges( + ranges + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?, + ); } if let Some(order) = s.order { subscription = subscription.with_order(order.into()); } - subscription + Ok(subscription) } } @@ -254,7 +271,7 @@ impl MoqBroadcastConsumer { ) -> Result, MoqError> { self.driver .watch(async { - let subscription = subscription.map(moq_net::track::Subscription::from); + let subscription = subscription.map(moq_net::track::Subscription::try_from).transpose()?; let track = self.inner.track(&name)?.subscribe(subscription).await?; Ok(Arc::new(MoqTrackConsumer::new(track, self.driver.clone()))) }) @@ -664,9 +681,13 @@ impl MoqTrackConsumer { /// Change this subscriber's delivery preferences. /// /// Silently ignored if the track already ended; the update is meaningless at - /// that point. - pub fn update(&self, subscription: MoqSubscription) { - let _ = self.control.update(subscription.into()); + /// that point. Errors on a time anchor the track can't serve, leaving the + /// preferences as they were. + pub fn update(&self, subscription: MoqSubscription) -> Result<(), MoqError> { + match self.control.update(subscription.try_into()?) { + Ok(()) | Err(moq_net::Error::Closed) => Ok(()), + Err(err) => Err(err.into()), + } } /// Cancel all current and future reads. diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 23e05fc6534..2ada4028234 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -1330,7 +1330,10 @@ impl MoqMediaTrackConsumer { // subscription if init parsing fails. let media = media_container(container)?; let resolved = broadcast.resolve_inner(reference.as_deref()).await?; - let subscription = subscription.map(moq_net::track::Subscription::from).unwrap_or_default(); + let subscription = subscription + .map(moq_net::track::Subscription::try_from) + .transpose()? + .unwrap_or_default(); let track = broadcast .driver .watch(async { Ok(resolved.track(&name)?.subscribe(subscription).await?) }) diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index e0798506645..168131edd72 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -525,7 +525,7 @@ impl MoqTrackProducer { let _guard = crate::ffi::enter(); let guard = self.inner.lock().unwrap(); let track = guard.as_ref().ok_or(MoqError::Closed)?; - let subscription = subscription.map(moq_net::track::Subscription::from); + let subscription = subscription.map(moq_net::track::Subscription::try_from).transpose()?; Ok(Arc::new(MoqTrackConsumer::new( track.subscribe(subscription), Background::default(), diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 58ea35e056d..a24faffb922 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -915,15 +915,17 @@ async fn raw_track_update_does_not_wait_for_pending_read() { tokio::spawn(async move { consumer.read_frame().await }) }; - consumer.update(MoqSubscription { - priority: 10, - max_delay_us: 25_000, - ranges: Some(vec![MoqRange { - start: MoqAnchor::Absolute { group: 0, frame: 0 }, - end: None, - }]), - order: None, - }); + consumer + .update(MoqSubscription { + priority: 10, + max_delay_us: 25_000, + ranges: Some(vec![MoqRange { + start: MoqAnchor::Absolute { group: 0, frame: 0 }, + end: None, + }]), + order: None, + }) + .unwrap(); let payload = b"updated subscription".to_vec(); track @@ -3908,15 +3910,17 @@ async fn raw_track_update_during_pending_group_still_reads_datagram() { spawn_parked(async move { consumer.next_group().await }).await }; - consumer.update(MoqSubscription { - priority: 10, - max_delay_us: 25_000, - ranges: Some(vec![MoqRange { - start: MoqAnchor::Absolute { group: 0, frame: 0 }, - end: None, - }]), - order: None, - }); + consumer + .update(MoqSubscription { + priority: 10, + max_delay_us: 25_000, + ranges: Some(vec![MoqRange { + start: MoqAnchor::Absolute { group: 0, frame: 0 }, + end: None, + }]), + order: None, + }) + .unwrap(); let sequence = track.append_datagram(datagram_frame(b"after-update")).unwrap(); let datagram = tokio::time::timeout(TIMEOUT, consumer.recv_datagram()) @@ -6711,7 +6715,7 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { }]), order: Some(MoqOrder::Ascending), }; - let converted = moq_net::track::Subscription::from(subscription.clone()); + let converted = moq_net::track::Subscription::try_from(subscription.clone()).unwrap(); assert_eq!(converted.order, moq_net::track::Order::Ascending); assert_eq!( converted.ranges, @@ -6733,3 +6737,61 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { } assert_eq!(sequences, vec![0, 1, 2]); } + +/// A time anchor starts at the group containing it, and an untimed track refuses one. +#[tokio::test] +async fn time_anchors_start_at_the_containing_group() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("events".into(), Some(timed_info())).unwrap(); + for timestamp_us in [0, 1_000, 2_000] { + let group = track.append_group().unwrap(); + group + .write_frame(MoqFrame { + payload: b"x".to_vec(), + timestamp_us: Some(timestamp_us), + }) + .unwrap(); + group.finish().unwrap(); + } + + let since = |timestamp_us| MoqSubscription { + priority: 0, + max_delay_us: 10_000_000, + ranges: Some(vec![MoqRange { + start: MoqAnchor::Time { timestamp_us }, + end: None, + }]), + order: Some(MoqOrder::Ascending), + }; + let consumer = track.consume(Some(since(1_500))).unwrap(); + let mut sequences = Vec::new(); + for _ in 0..2 { + let group = tokio::time::timeout(TIMEOUT, consumer.recv_group()) + .await + .expect("timed out waiting for a cached group") + .unwrap() + .expect("expected a group"); + sequences.push(group.sequence()); + } + assert_eq!(sequences, vec![1, 2]); + + let untimed = broadcast + .publish_track( + "status".into(), + Some(MoqTrackInfo { + timescale: None, + ..timed_info() + }), + ) + .unwrap(); + let consumer = untimed.consume(None).unwrap(); + assert!(matches!( + consumer.update(since(0)), + Err(MoqError::Protocol { + details: MoqProtocolError { + kind: MoqProtocolKind::TimestampMismatch, + .. + } + }) + )); +} diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index dead14e4b55..55615b1dac6 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -8,6 +8,8 @@ How to update your code for each breaking change, newest first. A `track::Subscriber` now reads only the groups its own `Subscription::ranges` cover, resolved again whenever `update` replaces them; before, they only set where its reads started. A reader that relied on groups another subscriber asked for must widen its own ranges. +`track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. +Subscribing or updating with one on an untimed track fails with `Error::TimestampMismatch`. Once a track finishes, a subscriber with nothing left under its `set_groups` cap ends rather than waiting for the cap to rise; raise it before the track finishes to read past it. ### `largest()` reports only upstream state, within a reader's `set_groups` bounds diff --git a/rs/moq-net/src/model/expiry.rs b/rs/moq-net/src/model/expiry.rs index 23e4602979b..9f66b5a0e0e 100644 --- a/rs/moq-net/src/model/expiry.rs +++ b/rs/moq-net/src/model/expiry.rs @@ -34,6 +34,10 @@ pub(super) struct Wakes { /// Relaxed: a read registers before resolving the edge, under the group locks a /// write takes before it presents, so a write the read missed sees its deadline. earliest: AtomicU64, + /// The lowest sequence a parked time end watches for a start, or `u64::MAX`, so a first + /// frame below every one skips the lock. Ordered by the starts index's lock, which a + /// read takes after registering and a first frame before checking this. + starts_from: AtomicU64, parked: kio::Lock, } @@ -53,6 +57,9 @@ struct Parked { /// Exact cache arrivals, independent of the live edge and neighbouring groups. arrivals: BTreeMap, arrival_sweep: usize, + /// Reads parked on a time end, by the lowest sequence whose start could move it. + starts: BTreeMap, + starts_sweep: usize, /// Reads by wall-clock deadline, released by the pool's driver. wall: BTreeMap, /// The earliest `wall` deadline the pool was asked to release, so arming a later one @@ -67,6 +74,7 @@ impl Default for Wakes { fn default() -> Self { Self { earliest: AtomicU64::new(u64::MAX), + starts_from: AtomicU64::new(u64::MAX), parked: kio::Lock::default(), } } @@ -179,6 +187,36 @@ impl Wakes { parked.arrivals.entry(sequence).or_default().register(waiter); } + /// Park `waiter` until a group at or above `from` starts. + pub(super) fn watch_start(&self, from: u64, waiter: &kio::Waiter) { + let mut parked = self.parked.lock(); + if parked.starts.len() >= parked.starts_sweep { + parked.starts.retain(|_, list| !list.is_empty()); + parked.starts_sweep = (parked.starts.len() * 2).max(HELD_SWEEP_MIN); + } + parked.starts.entry(from).or_default().register(waiter); + self.starts_from.fetch_min(from, Ordering::Relaxed); + } + + /// Group `sequence` started: wake the time ends it could move. + pub(super) fn started(&self, sequence: u64) { + if sequence < self.starts_from.load(Ordering::Relaxed) { + return; + } + let mut woken = Vec::new(); + { + let mut parked = self.parked.lock(); + while let Some(entry) = parked.starts.first_entry() + && *entry.key() <= sequence + { + woken.push(entry.remove()); + } + let from = parked.starts.first_key_value().map_or(u64::MAX, |(from, _)| *from); + self.starts_from.store(from, Ordering::Relaxed); + } + woken.iter_mut().for_each(kio::WaiterList::wake); + } + /// Wake only cache arrivals whose outcome this insertion or boundary changes. pub(super) fn arrived(&self, groups: impl std::ops::RangeBounds) { let groups = (groups.start_bound().cloned(), groups.end_bound().cloned()); @@ -222,13 +260,15 @@ impl Wakes { let parked = { let mut parked = self.parked.lock(); self.earliest.store(u64::MAX, Ordering::Relaxed); + self.starts_from.store(u64::MAX, Ordering::Relaxed); std::mem::take(&mut *parked) }; let lists = parked.deadlines.into_values().chain(parked.landings.into_values()); let lists = lists .chain(parked.held.into_values()) .chain(parked.wall.into_values()) - .chain(parked.arrivals.into_values()); + .chain(parked.arrivals.into_values()) + .chain(parked.starts.into_values()); for mut list in lists { list.wake(); } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 8fc2bb600dc..5b08acee373 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1032,9 +1032,9 @@ impl Producer { self.alive.checked.store(true, Ordering::Relaxed); self.advance_largest(&state); drop(state); - // The group's lock is released first: sharing reads it back through its slot. - if let (Some(track), Some(start)) = (&track, bound) { - track.share_bound(self.info.sequence, start); + // The group's lock is released first: indexing reads it back through its slot. + if let Some(track) = &track { + track.first_frame(self.info.sequence, bound); } drop(track); @@ -1120,9 +1120,9 @@ impl Producer { self.advance_largest(&state); } drop(state); - // The group's lock is released first: sharing reads it back through its slot. - if let (Some(track), Some(start)) = (&track, bound) { - track.share_bound(self.info.sequence, start); + // The group's lock is released first: indexing reads it back through its slot. + if let Some(track) = &track { + track.first_frame(self.info.sequence, bound); } drop(track); @@ -1237,9 +1237,9 @@ impl Producer { let bound = state.settle(origin); self.alive.checked.store(true, Ordering::Relaxed); drop(state); - // The group's lock is released first: sharing reads it back through its slot. - if let (Some(track), Some(start)) = (&track, bound) { - track.share_bound(self.info.sequence, start); + // The group's lock is released first: indexing reads it back through its slot. + if let Some(track) = &track { + track.first_frame(self.info.sequence, bound); } drop(track); @@ -1478,6 +1478,13 @@ impl Producer { self.state.read().started() } + /// Whether this copy may yet present the group's frame 0: it holds it, and nothing + /// opened or ended it. + pub(super) fn may_start(&self) -> bool { + let state = self.state.read(); + state.offset == 0 && !state.started() + } + /// Where the group starts in presentation time: its first frame's timestamp, or /// `None` while no frame has been opened or when that frame is untimed. /// diff --git a/rs/moq-net/src/model/ledger.rs b/rs/moq-net/src/model/ledger.rs index f23a89106c7..7d206c57c0a 100644 --- a/rs/moq-net/src/model/ledger.rs +++ b/rs/moq-net/src/model/ledger.rs @@ -114,6 +114,14 @@ impl Ledger { self.runs.first().map(|run| run.groups.start) } + /// The exclusive end of the accounted run holding `sequence`, `None` while it is not. + pub fn run_end(&self, sequence: u64) -> Option { + let run = self + .runs + .get(self.runs.partition_point(|run| run.groups.end <= sequence))?; + (run.groups.start <= sequence).then_some(run.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 e82a3a24126..3001848ca43 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -27,7 +27,7 @@ use std::task::{Poll, ready}; use crate::{Error, Result, StreamError, group, stats, track}; use super::delivered::Delivered; -use super::subscription::{Anchor, Cap, Position, Resolved, Subscription, Window}; +use super::subscription::{Anchor, Cap, Position, Resolved, Subscription, Unheld, Window}; /// What the front says about a logical track: the copy to read now, and how it ended. #[derive(Default)] @@ -173,7 +173,7 @@ impl Consumer { /// Subscribe to the logical track with the reader's own preferences. pub(super) fn subscribe(&self, subscription: track::Preferences) -> Subscriber { let mirrored = subscription.read().clone(); - let window = Window::new(&mirrored.ranges, None); + let window = Window::new(&mirrored.ranges, None, &Unheld).expect("an unheld hop refuses nothing"); // An absolute floor holds from the start, as on a plain cursor; a relative one // counts back from an edge, so the reported largest is never below it. let floor = window.floor().unwrap_or(0); @@ -627,16 +627,20 @@ impl Reader { start: match range.start { // The farthest offset reaches back to the oldest group, which names no floor. Anchor::Relative(back) if back != u64::MAX => Anchor::Absolute(Position::group(resolved.start)), + Anchor::Time(_) => Anchor::Absolute(Position::group(resolved.start)), start => start, }, + // A time end the copy has not settled stays one: its cap is not the end. end: match (range.end, resolved.end) { - (Some(Anchor::Relative(_)), Some(end)) => Some(Anchor::Absolute(Position::group(end))), + (Some(Anchor::Relative(_) | Anchor::Time(_)), Some(end)) => { + Some(Anchor::Absolute(Position::group(end))) + } (end, _) => end, }, } }) .collect(); - let window = Window::new(&ranges, None); + let window = Window::new(&ranges, None, &Unheld).expect("an unheld hop refuses nothing"); if !window.is_pending() { self.window = window; } @@ -657,7 +661,7 @@ impl Reader { if let Some(sub) = changed { let replaced = sub.ranges != self.mirrored.ranges; if replaced { - self.window = Window::new(&sub.ranges, None); + self.window = Window::new(&sub.ranges, None, &Unheld).expect("an unheld hop refuses nothing"); } self.mirrored = sub; for copy in &mut self.copies { @@ -2475,16 +2479,23 @@ mod test { assert_eq!(recv(&mut sub).sequence, 6); } - /// A replacement copy keeps a later range disjoint: the floor raises nothing above it, - /// and capping the replaced copy never widens its end. + /// A replacement copy keeps each range where the serving copy resolved it: the floor + /// raises a later time range's start rather than lowering it to the floor, and capping + /// the replaced copy never widens a time end. #[test] #[cfg(feature = "async")] - fn a_replacement_keeps_a_later_range_disjoint() { + fn a_replacement_keeps_a_later_time_range_disjoint() { let routes = Producer::new(); let logical = logical(&routes); let a = copy(); routes.serve(a.consume()); - let ranges = [track::Range::groups(0..1), track::Range::groups(10..12)]; + let ranges = [ + track::Range::groups(0..1), + track::Range { + start: track::Anchor::Time(ts(10_000)), + end: Some(track::Anchor::Time(ts(11_000))), + }, + ]; let subscription = Subscription::default() .with_max_delay(Duration::from_secs(30)) .with_ranges(ranges); @@ -2520,11 +2531,11 @@ mod test { assert_eq!(rest, vec![10, 11]); } - /// A replacement updated to a relative range it cannot resolve yet asks for that range, + /// A replacement updated to a time range it cannot resolve yet asks for that range, /// not everything from the floor: before it answers, and once it answers with nothing /// to resolve it against. #[test] - fn a_replacement_keeps_an_updated_relative_start() { + fn a_replacement_keeps_an_updated_time_start() { let routes = Producer::new(); let a = copy(); routes.serve(a.consume()); @@ -2535,7 +2546,7 @@ mod test { let copy = &mut reader.copies[0]; copy.floor = Some(Position::group(0)); let ranges = vec![track::Range { - start: track::Anchor::Relative(3), + start: track::Anchor::Time(ts(10_000)), end: None, }]; let updated = Subscription::default().with_ranges(ranges.clone()); @@ -2604,7 +2615,7 @@ mod test { // The read subscribes the replacement, which has not answered yet. assert!(sub.recv_group().now_or_never().is_none()); let ranges = [track::Range { - start: track::Anchor::Relative(2), + start: track::Anchor::Time(ts(10_000)), end: None, }]; sub.update(sub.subscription().with_ranges(ranges)).unwrap(); @@ -2623,8 +2634,8 @@ mod test { assert!(sub.recv_group().now_or_never().is_none()); let start = c.subscription().expect("subscribed").ranges[0].start; assert!( - matches!(start, track::Anchor::Absolute(_)), - "the replacement asks from where b resolved the relative start: {start:?}" + matches!(start, track::Anchor::Absolute(position) if position.group >= 10), + "the replacement asks from where b resolved the time: {start:?}" ); } diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 81f53a38461..7d38e9e3a85 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -3,8 +3,10 @@ use std::{ time::Duration, }; +use crate::Timestamp; + mod window; -pub(super) use window::{Resolved, Window}; +pub(super) use window::{Containing, Held, Holdings, Resolved, Unheld, Window, later}; /// Subscriber-side preferences for receiving a track. /// @@ -101,28 +103,41 @@ pub enum Order { Descending, } -/// Where one end of a [`Range`] falls: a fixed position, or a count of groups back from the -/// latest one. +/// Where one end of a [`Range`] falls: a fixed position, a count of groups back from the +/// latest one, or a presentation time. /// -/// A relative anchor resolves against the latest group of the hop that serves it, when it -/// starts serving and again whenever the subscriber replaces its ranges. Before any group -/// exists it resolves at the first one, so [`Range::live`] starts there. +/// A relative or time anchor resolves at the hop that serves it, when it starts serving +/// and again whenever the subscriber replaces its ranges. Before any group exists a +/// relative anchor resolves at the first one, so [`Range::live`] starts there. /// -/// No published wire carries one yet: upstream, a relative start is no floor, the same -/// request as [`Range::oldest`], and a relative end is no end. Behind a moq-transport -/// upstream, which joins without a floor at the live group, `Relative(n)..` starts there. +/// No published wire carries either yet: upstream, a relative or time start is no floor, +/// the same request as [`Range::oldest`], and a relative or time end is no end. Behind a +/// moq-transport upstream, which joins without a floor at the live group, `Relative(n)..` +/// starts there. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Anchor { /// This exact position. Absolute(Position), /// The group this many before the latest, clamped to group 0: `Relative(0)` is the latest group. Relative(u64), + /// The group containing this presentation time: the newest one starting at or before it. + /// + /// Resolved from the starts of the groups the serving hop holds. When none starts at + /// or before it, a start reaches back to the oldest group, and while no group has + /// started at all it waits like `Relative(0)`. An end stays open until a group + /// starting after it is known and every sequence below that one is accounted for + /// (started, dropped, or never to start), so at the live edge a reader may get every + /// group handed out before its first frame showed it starts past the end, and a feed + /// that reports no drops leaves it open across a missing group until the wait for that + /// group runs out. An untimed track + /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). + Time(Timestamp), } /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is `None`. /// -/// Relative anchors cover whole groups: a relative start is frame 0 of its group, and a -/// relative end includes its group, so `Absolute(0)..Relative(0)` delivers through the latest +/// Relative and time anchors cover whole groups: such a start is frame 0 of its group, and +/// such an end includes its group, so `Absolute(0)..Relative(0)` delivers through the latest /// group. An absolute end is exclusive, like the end of a [`std::ops::Range`]: see /// [`Position::after`] and [`Position::after_group`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -230,6 +245,8 @@ pub(super) fn union(ranges: impl IntoIterator) -> Vec { (Anchor::Absolute(_), None | Some(Anchor::Absolute(_))) | (Anchor::Relative(_), None) => false, (Anchor::Absolute(start), _) => tail.is_none_or(|tail| start < tail), (Anchor::Relative(back), _) => farthest.is_none_or(|farthest| back > farthest), + // A time can't be placed against the others without the hop's groups. + (Anchor::Time(_), _) => true, })); union.extend(farthest.map(|back| Range { start: Anchor::Relative(back), @@ -346,10 +363,11 @@ impl Subscription { /// These preferences with every range raised to start no earlier than `floor` and /// stop no later than `end`, the exclusive end, without ever widening one. /// - /// `resolved` names where each range resolved, by index, so a relative anchor + /// `resolved` names where each range resolved, by index, so a relative or time anchor /// becomes its own group, raised to `floor` or capped at `end`. A start not resolved /// keeps its anchor, since the floor may lie before it, so the caller drops what it - /// resolves to below `floor`; an end not resolved becomes the bound. + /// resolves to below `floor`; an end not resolved becomes the bound, except a time end, + /// which stays so the caller enforces `end` apart from it. pub(super) fn clamped( &self, floor: Option, @@ -364,11 +382,15 @@ impl Subscription { .map(|(index, range)| { let resolved = resolved.get(index).copied().flatten().filter(|_| clamping); let start = match (range.start, resolved) { - (Anchor::Relative(_), Some(resolved)) => Anchor::Absolute(Position::group(resolved.start)), + (Anchor::Relative(_) | Anchor::Time(_), Some(resolved)) => { + Anchor::Absolute(Position::group(resolved.start)) + } (start, _) => start, }; let requested = match (range.end, resolved.and_then(|resolved| resolved.end)) { - (Some(Anchor::Relative(_)), Some(resolved)) => Some(Anchor::Absolute(Position::group(resolved))), + (Some(Anchor::Relative(_) | Anchor::Time(_)), Some(resolved)) => { + Some(Anchor::Absolute(Position::group(resolved))) + } (requested, _) => requested, }; Range { @@ -379,6 +401,9 @@ impl Subscription { end: match (requested, end) { (requested, None) => requested, (Some(Anchor::Absolute(requested)), Some(end)) => Some(Anchor::Absolute(requested.min(end))), + // Folding the cap in would end the copy's time filter for good, so the + // caller enforces the cap itself and the copy keeps the time. + (Some(Anchor::Time(pts)), Some(_)) => Some(Anchor::Time(pts)), (_, Some(end)) => Some(Anchor::Absolute(end)), }, } @@ -387,6 +412,13 @@ impl Subscription { Self { ranges, ..self.clone() } } + /// Whether any range ends at a presentation time, which an untimed track refuses. + pub(super) fn has_time(&self) -> bool { + self.ranges + .iter() + .any(|range| matches!(range.start, Anchor::Time(_)) || matches!(range.end, Some(Anchor::Time(_)))) + } + /// Whether a subscriber joins at the live edge: a range counting back from the latest /// group and following it. A fixed start resumes a known group instead, such as a route /// switch picking up where the reader stopped. @@ -629,7 +661,7 @@ mod tests { /// group exists only the farthest offset resolves, since it is group 0 regardless. #[test] fn relative_starts_resolve_against_the_latest_group() { - let floor = |sub: &Subscription, latest| Window::new(&sub.ranges, latest).floor(); + let floor = |sub: &Subscription, latest| Window::new(&sub.ranges, latest, &Unheld).unwrap().floor(); let live = Subscription::default().with_ranges([Range::live()]); assert_eq!(floor(&live, Some(7)), Some(7)); diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 8fd46645bf8..bab42da9e69 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -9,6 +9,7 @@ use std::ops; use rangemap::RangeInclusiveSet; use super::{Anchor, Range}; +use crate::{Error, Result, Timestamp}; /// One range resolved to groups: from `start` up to the exclusive `end`, `None` while it /// stays open. @@ -18,16 +19,104 @@ pub(in crate::model) struct Resolved { pub end: Option, } +/// When a serving hop's groups start, for resolving time anchors. +/// +/// Each query answers from ordered indexes, so a time end that walks them forward a step +/// at a time pays for each group or run it passes once, however long it stays unsettled. +pub(in crate::model) trait Holdings { + /// Whether the track carries timestamps at all. + fn timed(&self) -> bool; + + /// Where `pts` falls among the groups with a known start. + fn containing(&self, pts: Timestamp) -> Containing; + + /// The lowest group at or above `from` known to start, with where it starts. + fn next_start(&self, from: u64) -> Option<(u64, Timestamp)>; + + /// The lowest held group at or above `from`, with what it says about its start. + fn next_held(&self, from: u64) -> Option<(u64, Held)>; + + /// The exclusive end of the run of sequences from `sequence` that can no longer arrive + /// (dropped, given up, or below where the feed starts), `None` while `sequence` may. + fn gone(&self, sequence: u64) -> Option; + + /// Whether nothing more can arrive or start here, so a time end has nothing to wait + /// on and is left uncapped. + fn complete(&self) -> bool; +} + +/// Where a presentation time falls among the groups a hop holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::model) enum Containing { + /// The newest group starting at or before it. + Group(u64), + /// Every group with a known start begins after it. + Before, + /// No group has a known start. + Unknown, +} + +/// What a held group says about where it starts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::model) enum Held { + /// Its frame 0 presented at this time. + Started(Timestamp), + /// It holds frame 0 and has not presented it yet. + Pending, + /// It never will: it ended empty, was aborted, or is held from past frame 0. + Never, +} + +/// A hop that holds no groups of its own, such as a reader following route copies that +/// resolve its ranges for it: a time end there filters nothing. +pub(in crate::model) struct Unheld; + +impl Holdings for Unheld { + fn timed(&self) -> bool { + true + } + + fn containing(&self, _: Timestamp) -> Containing { + Containing::Unknown + } + + fn next_start(&self, _: u64) -> Option<(u64, Timestamp)> { + None + } + + fn next_held(&self, _: u64) -> Option<(u64, Held)> { + None + } + + fn gone(&self, _: u64) -> Option { + None + } + + fn complete(&self) -> bool { + true + } +} + +/// Whether `time` is strictly after `other`, as instants: exact across scales, unlike +/// [`Timestamp`]'s `Ord`, which breaks a tie between scales. +pub(in crate::model) fn later(time: Timestamp, other: Timestamp) -> bool { + u128::from(time.value()) * u128::from(other.scale().as_u64()) + > u128::from(other.value()) * u128::from(time.scale().as_u64()) +} + /// A subscriber's [`Range`]s resolved to the group sequences it reads. #[derive(Debug)] pub(in crate::model) struct Window { /// What these were resolved from, so only replacing the ranges resolves again. ranges: Vec, spans: Vec, - /// The groups the spans cover. + /// The groups the spans cover, an unsettled time end through its cap. groups: 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 + /// per group, not once per range. + settled: bool, } /// One non-empty range, resolved. @@ -47,19 +136,137 @@ enum End { Before(u64), /// Past every group. Open, + /// Before the first group starting after a time, once that is known. + Time(TimeEnd), +} + +/// A time end not settled yet, and how far the walk to settle it got. +/// +/// Timestamps never rewind, so the end falls before the lowest group starting past `pts`, +/// once every sequence between it and the newest one starting at or before `pts` is +/// accounted for. Every position below only moves forward, so each group or run of +/// sequences is passed once, whatever a peer leaves missing. +#[derive(Clone, Copy, Debug)] +struct TimeEnd { + pts: Timestamp, + /// The newest group known to start at or before `pts`: it and everything below it in + /// the span is in range. + known: Option, + /// The lowest group known to start after `pts`: where the end falls once settled. + after: Option, + /// Where the walk over held groups resumes: those below it started, or never will. + held: u64, + /// Where settling resumes: every sequence from above `known` up to it is accounted for. + accounted: u64, + /// The exclusive cap on what is read meanwhile, `None` for none. + limit: Option, + /// The group the cap admits on speculation: the first above `known` that may still + /// start, which may start past `pts`. + speculative: Option, +} + +impl TimeEnd { + fn new(pts: Timestamp, start: u64) -> Self { + Self { + pts, + known: None, + after: None, + held: start, + accounted: start, + limit: Some(start), + speculative: None, + } + } + + /// The lowest sequence not known to be in range. + fn unknown(&self, start: u64) -> u64 { + self.known.map_or(start, |known| known.saturating_add(1)).max(start) + } + + /// Walk the held groups on from where the last call stopped. Returns where the end + /// falls once it settles. + fn advance(&mut self, start: u64, holdings: &impl Holdings) -> Option { + // The starts index passes a group that may still start, so a stalled one never + // keeps a later group known to start inside the end from being read. + while let Some((sequence, at)) = holdings.next_start(self.unknown(start)) + && self.after.is_none_or(|after| sequence < after) + { + match later(at, self.pts) { + true => self.after = Some(sequence), + false => self.known = Some(sequence), + } + } + + // The first held group that may still start is the one read on speculation. + self.held = self.held.max(self.unknown(start)); + self.speculative = None; + while let Some((sequence, held)) = holdings.next_held(self.held) + && self.after.is_none_or(|after| sequence < after) + { + match held { + Held::Started(at) if later(at, self.pts) => self.after = Some(sequence), + Held::Started(_) => self.known = Some(sequence), + Held::Never => {} + Held::Pending => { + self.held = sequence; + self.speculative = Some(sequence); + break; + } + } + self.held = sequence.saturating_add(1).max(self.unknown(start)); + } + self.limit = match (self.speculative, self.after) { + _ if holdings.complete() => None, + (Some(speculative), _) => speculative.checked_add(1), + (None, Some(after)) => Some(after), + // Every held group is accounted for: the next to arrive is the speculative one. + (None, None) => None, + }; + + // Settle once every sequence up to the first group starting past `pts` can no + // longer start inside it. + let after = self.after?; + self.accounted = self.accounted.max(self.unknown(start)); + while self.accounted < after { + let next = holdings.next_held(self.accounted); + match next { + Some((sequence, held)) if sequence == self.accounted => match held { + Held::Pending => return None, + Held::Started(at) if later(at, self.pts) => self.after = Some(sequence), + _ => self.accounted += 1, + }, + _ => { + let below = next.map_or(after, |(sequence, _)| sequence.min(after)); + self.accounted = match holdings.complete() { + true => below, + false => holdings.gone(self.accounted)?.min(below), + }; + } + } + } + self.after + } } impl Window { /// Resolve `ranges`, counting relative anchors back from `latest`. - pub fn new(ranges: &[Range], latest: Option) -> Self { + /// + /// Refuses a time anchor on an untimed track with [`Error::TimestampMismatch`]. + pub fn new(ranges: &[Range], latest: Option, holdings: &impl Holdings) -> Result { let mut window = Self { ranges: ranges.to_vec(), spans: Vec::new(), groups: RangeInclusiveSet::new(), pending: false, + settled: false, }; - window.place(latest); - window + window.place(latest, holdings)?; + Ok(window) + } + + /// Whether nothing is left to resolve as groups arrive and start. + pub fn is_settled(&self) -> bool { + self.settled } /// Whether a relative anchor still waits on a first group. @@ -67,14 +274,53 @@ impl Window { self.pending } - /// Resolve the relative anchors waiting on a first group once `latest` exists. - /// Returns whether anything moved. - pub fn settle(&mut self, latest: Option) -> bool { - if !self.pending || latest.is_none() { - return false; + /// Resolve what the groups now held settle: relative anchors once `latest` exists, and + /// time ends as groups start. Returns whether the groups in range moved. + pub fn settle(&mut self, latest: Option, holdings: &impl Holdings) -> bool { + if self.pending { + if latest.is_none() { + return false; + } + // Checked when the ranges were first resolved, against the same track. + let _ = self.place(latest, holdings); + return !self.pending; + } + let mut moved = false; + for span in &mut self.spans { + let End::Time(time) = &mut span.end else { continue }; + let limit = time.limit; + if let Some(end) = time.advance(span.start, holdings) { + span.end = End::Before(end); + moved = true; + } else { + moved |= time.limit != limit; + } } - self.place(latest); - !self.pending + if moved { + self.rebuild(); + } + moved + } + + /// The groups an unsettled time end reads on speculation, which may start past it: a + /// read parked on the cap wakes when one of them starts. + pub fn speculative(&self) -> impl Iterator + '_ { + self.spans.iter().filter_map(|span| match span.end { + End::Time(time) => time.speculative, + _ => None, + }) + } + + /// The lowest sequence whose start could still move an unsettled time end, `None` + /// without one. + pub fn watch_starts(&self) -> Option { + self.spans + .iter() + .filter_map(|span| match span.end { + End::Time(time) => Some(time.unknown(span.start)), + _ => None, + }) + .min() } /// Whether group `sequence` is in range. Everything is while a relative anchor waits. @@ -82,6 +328,20 @@ impl Window { self.pending || self.groups.contains(&sequence) } + /// Whether a datagram is in range: a whole group of one frame, so its own `timestamp` + /// says exactly whether it falls inside a time end. + pub fn admits_datagram(&self, sequence: u64, timestamp: Option) -> bool { + self.pending + || self.spans.iter().any(|span| { + span.start <= sequence + && match span.end { + End::Before(end) => sequence < end, + End::Open => true, + End::Time(time) => timestamp.is_none_or(|timestamp| !later(timestamp, time.pts)), + } + }) + } + /// The lowest group in range, `None` while a relative anchor waits. Nothing in range /// floors nowhere: group 0. pub fn floor(&self) -> Option { @@ -101,7 +361,7 @@ impl Window { } /// Each range resolved, in the order given: `None` for one that is empty or still - /// waits on a first group. + /// waits on a first group. An unsettled time end is reported open, never at its cap. pub fn resolved(&self) -> Vec> { let mut resolved = vec![None; self.ranges.len()]; for span in &self.spans { @@ -109,16 +369,17 @@ impl Window { start: span.start, end: match span.end { End::Before(end) => Some(end), - End::Open => None, + End::Open | End::Time(_) => None, }, }); } resolved } - /// The exclusive end past every group in range, `None` when one stays open. + /// The exclusive end past every group in range, `None` when one stays open. An + /// unsettled time end counts as open: its cap only holds reads back meanwhile. pub fn end(&self) -> Option { - if self.pending { + if self.pending || self.spans.iter().any(|span| matches!(span.end, End::Time(_))) { return None; } match self.groups.iter().next_back() { @@ -152,14 +413,31 @@ impl Window { Some((*run.start()).max(range.start)..end) } - fn place(&mut self, latest: Option) { + fn place(&mut self, latest: Option, holdings: &impl Holdings) -> Result<()> { + let time = |anchor: Option| matches!(anchor, Some(Anchor::Time(_))); + if !holdings.timed() + && self + .ranges + .iter() + .any(|range| time(Some(range.start)) || time(range.end)) + { + return Err(Error::TimestampMismatch); + } self.spans.clear(); self.pending = false; for (index, range) in self.ranges.iter().enumerate().filter(|(_, range)| !range.is_empty()) { - let (Some((start, floored)), Some(end)) = (start(range.start, latest), end(range.end, latest)) else { + let (Some((start, floored)), Some(end)) = (start(range.start, latest, holdings), end(range.end, latest)) + else { self.pending = true; continue; }; + let end = match end { + End::Time(mut time) => { + time = TimeEnd::new(time.pts, start); + time.advance(start, holdings).map_or(End::Time(time), End::Before) + } + end => end, + }; self.spans.push(Span { index, start, @@ -167,14 +445,25 @@ impl Window { floored, }); } + self.rebuild(); + Ok(()) + } + + fn rebuild(&mut self) { + self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); self.groups = RangeInclusiveSet::new(); for span in &self.spans { - let last = match span.end { - End::Before(end) => match end.checked_sub(1) { + let end = match span.end { + End::Before(end) => Some(end), + End::Open => None, + End::Time(time) => time.limit, + }; + let last = match end { + Some(end) => match end.checked_sub(1) { Some(last) if last >= span.start => last, _ => continue, }, - End::Open => u64::MAX, + None => u64::MAX, }; self.groups.insert(span.start..=last); } @@ -183,16 +472,24 @@ impl Window { /// The first group a range starts at, and whether it names that group rather than /// reaching back to the oldest one. `None` while it waits on a latest group. -fn start(anchor: Anchor, latest: Option) -> Option<(u64, bool)> { +fn start(anchor: Anchor, latest: Option, holdings: &impl Holdings) -> Option<(u64, bool)> { match anchor { Anchor::Absolute(start) => Some((start.group, true)), // Nothing sits below group 0, so the farthest offset needs no latest group. Anchor::Relative(u64::MAX) => Some((0, false)), Anchor::Relative(back) => Some((latest?.saturating_sub(back), true)), + Anchor::Time(pts) => match holdings.containing(pts) { + Containing::Group(group) => Some((group, true)), + // The group holding it is older than any held, so reach back to the oldest. + Containing::Before => Some((0, false)), + // Nothing has started yet: like the live edge, the latest group. + Containing::Unknown => Some((latest?, true)), + }, } } -/// Where a range stops, or `None` while it waits on a latest group. +/// Where a range stops, or `None` while it waits on a latest group. A time end's walk +/// starts once its start is known. fn end(anchor: Option, latest: Option) -> Option { Some(match anchor { None => End::Open, @@ -201,6 +498,7 @@ fn end(anchor: Option, latest: Option) -> Option { .saturating_sub(back) .checked_add(1) .map_or(End::Open, End::Before), + Some(Anchor::Time(pts)) => End::Time(TimeEnd::new(pts, 0)), }) } @@ -209,6 +507,109 @@ mod tests { use super::super::Position; use super::*; + use std::cell::Cell; + + fn at(millis: u64) -> Timestamp { + Timestamp::from_millis(millis).unwrap() + } + + /// A hop's groups by sequence, the runs that can no longer arrive, and how many held + /// groups and starts a walk asked about. + #[derive(Default)] + struct Groups { + held: std::collections::BTreeMap, + gone: Vec>, + complete: bool, + asked: Cell, + } + + impl Groups { + fn new(held: impl IntoIterator) -> Self { + Self { + held: held.into_iter().collect(), + ..Self::default() + } + } + + fn started(sequence: u64, millis: u64) -> (u64, Held) { + (sequence, Held::Started(at(millis))) + } + } + + impl Holdings for Groups { + fn timed(&self) -> bool { + true + } + + fn containing(&self, pts: Timestamp) -> Containing { + let mut stamped = false; + for (&sequence, held) in self.held.iter().rev() { + let Held::Started(start) = held else { continue }; + stamped = true; + if !later(*start, pts) { + return Containing::Group(sequence); + } + } + match stamped { + true => Containing::Before, + false => Containing::Unknown, + } + } + + fn next_start(&self, from: u64) -> Option<(u64, Timestamp)> { + self.asked.set(self.asked.get() + 1); + self.held.range(from..).find_map(|(&sequence, held)| match held { + Held::Started(start) => Some((sequence, *start)), + _ => None, + }) + } + + fn next_held(&self, from: u64) -> Option<(u64, Held)> { + self.asked.set(self.asked.get() + 1); + self.held.range(from..).next().map(|(&sequence, held)| (sequence, *held)) + } + + fn gone(&self, sequence: u64) -> Option { + self.gone.iter().find(|gone| gone.contains(&sequence)).map(|gone| gone.end) + } + + fn complete(&self) -> bool { + self.complete + } + } + + struct Untimed; + + impl Holdings for Untimed { + fn timed(&self) -> bool { + false + } + + fn containing(&self, _: Timestamp) -> Containing { + Containing::Unknown + } + + fn next_start(&self, _: u64) -> Option<(u64, Timestamp)> { + None + } + + fn next_held(&self, _: u64) -> Option<(u64, Held)> { + None + } + + fn gone(&self, _: u64) -> Option { + None + } + + fn complete(&self) -> bool { + false + } + } + + fn time(millis: u64) -> Anchor { + Anchor::Time(Timestamp::from_millis(millis).unwrap()) + } + fn range(start: Anchor, end: impl Into>) -> Range { Range { start, end: end.into() } } @@ -220,7 +621,7 @@ mod tests { /// Disjoint ranges filter to exactly their groups; the gap between them is out. #[test] fn ranges_cover_only_their_groups() { - let window = Window::new(&[Range::groups(2..4), Range::groups(7..)], Some(9)); + let window = Window::new(&[Range::groups(2..4), Range::groups(7..)], Some(9), &Untimed).unwrap(); assert_eq!(covered(&window, 10), vec![2, 3, 7, 8, 9]); assert_eq!(window.floor(), Some(2)); assert_eq!(window.end(), None); @@ -243,12 +644,14 @@ mod tests { Anchor::Absolute(Position { group: 3, frame: 1 }), )], None, - ); + &Untimed, + ) + .unwrap(); assert_eq!(covered(&window, 6), vec![1, 2, 3]); assert_eq!(window.end(), Some(4)); // Nothing asked for is nothing in range, floored nowhere. - let window = Window::new(&[], None); + let window = Window::new(&[], None, &Untimed).unwrap(); assert_eq!(covered(&window, 4), Vec::::new()); assert_eq!((window.floor(), window.end()), (Some(0), Some(0))); } @@ -257,18 +660,18 @@ mod tests { /// for a first group when there is none. #[test] fn relative_anchors_count_back_from_the_latest_group() { - let window = Window::new(&[range(Anchor::Relative(3), Anchor::Relative(1))], Some(7)); + let window = Window::new(&[range(Anchor::Relative(3), Anchor::Relative(1))], Some(7), &Untimed).unwrap(); assert_eq!(covered(&window, 10), vec![4, 5, 6]); - let mut window = Window::new(&[Range::live()], None); + let mut window = Window::new(&[Range::live()], None, &Untimed).unwrap(); assert!(window.is_pending() && window.contains(3) && window.floor().is_none()); - assert!(!window.settle(None)); - assert!(window.settle(Some(5))); + assert!(!window.settle(None, &Untimed)); + assert!(window.settle(Some(5), &Untimed)); assert_eq!(covered(&window, 8), vec![5, 6, 7]); - assert!(!window.is_pending()); + assert!(window.is_settled()); // The farthest offset needs no latest group, and names no floor. - let window = Window::new(&[Range::oldest()], None); + let window = Window::new(&[Range::oldest()], None, &Untimed).unwrap(); assert_eq!((window.floor(), window.start()), (Some(0), None)); } @@ -276,7 +679,7 @@ mod tests { /// group or still waits on a first one; an explicit group 0 names one. #[test] fn the_named_start_is_the_lowest_unless_one_reaches_back() { - let start = |ranges: &[Range], latest| Window::new(ranges, latest).start(); + let start = |ranges: &[Range], latest| Window::new(ranges, latest, &Untimed).unwrap().start(); assert_eq!(start(&[Range::groups(4..), Range::live()], Some(9)), Some(4)); assert_eq!(start(&[Range::groups(4..), Range::live()], Some(2)), Some(2)); assert_eq!(start(&[Range::groups(0..)], None), Some(0)); @@ -284,4 +687,148 @@ mod tests { assert_eq!(start(&[Range::groups(4..), Range::oldest()], Some(9)), None); assert_eq!(start(&[Range::groups(4..), Range::groups(..2)], Some(9)), None); } + + /// A time start is the group containing it, and an end stops just past that group once + /// a later group is known to start after it with nothing between left to arrive. + #[test] + fn time_anchors_resolve_from_group_starts() { + let groups = Groups::new([ + Groups::started(2, 2000), + Groups::started(3, 3000), + Groups::started(4, 4000), + Groups::started(5, 5000), + (6, Held::Pending), + ]); + let window = Window::new(&[range(time(3500), time(4500))], Some(6), &groups).unwrap(); + assert_eq!(covered(&window, 8), vec![3, 4]); + assert!(window.is_settled()); + assert_eq!(window.resolved(), vec![Some(Resolved { start: 3, end: Some(5) })]); + + // Group 6 may yet start at 5000 too: read on speculation, the end left open. + let window = Window::new(&[range(time(3500), time(5000))], Some(6), &groups).unwrap(); + assert!(!window.is_settled()); + assert_eq!(covered(&window, 9), vec![3, 4, 5, 6]); + assert_eq!(window.resolved(), vec![Some(Resolved { start: 3, end: None })]); + assert_eq!(window.end(), None, "the cap is not the end"); + assert_eq!(window.speculative().collect::>(), vec![6]); + + // Before every start held: reach back to the oldest. + let window = Window::new(&[range(time(1000), time(2500))], Some(6), &groups).unwrap(); + assert_eq!(covered(&window, 8), vec![0, 1, 2]); + + // Nothing started yet: a time start waits like the live edge. + let mut window = Window::new(&[range(time(1000), None)], None, &Groups::default()).unwrap(); + assert!(window.is_pending()); + assert!(window.settle(Some(4), &Groups::new([(4, Held::Pending)]))); + assert_eq!(window.floor(), Some(4)); + } + + /// An end settles only once every sequence between the newest group starting inside it + /// and the first starting past it is accounted for. + #[test] + fn a_time_end_settles_once_nothing_below_its_end_can_arrive() { + let mut groups = Groups::new([Groups::started(3, 3000), Groups::started(6, 6000)]); + let mut window = Window::new(&[range(time(3000), time(4000))], Some(6), &groups).unwrap(); + assert!(!window.is_settled(), "4 and 5 may still arrive inside the end"); + assert_eq!(covered(&window, 8), vec![3, 4, 5], "but never 6, known to start past it"); + + // 4 arrives yet to start: the one read on speculation, so 5 waits. + groups.held.insert(4, Held::Pending); + assert!(window.settle(Some(6), &groups)); + assert_eq!(covered(&window, 8), vec![3, 4]); + groups.held.insert(4, Held::Started(at(3500))); + assert!(window.settle(Some(6), &groups)); + assert!(!window.is_settled(), "5 may still arrive"); + groups.gone.push(5..6); + assert!(window.settle(Some(6), &groups)); + assert!(window.is_settled()); + assert_eq!(covered(&window, 8), vec![3, 4, 5]); + + // A group that never starts proves nothing, so it never holds the end open. + let groups = Groups::new([Groups::started(3, 3000), (4, Held::Never), Groups::started(5, 5000)]); + let window = Window::new(&[range(time(3000), time(4000))], Some(5), &groups).unwrap(); + assert!(window.is_settled()); + assert_eq!(covered(&window, 8), vec![3, 4]); + } + + /// An unsettled time end reads at most one group on speculation: the first above the + /// newest known to start inside it that may still start. A group that never starts + /// does not take that place, and a stalled one does not hold back a later group known + /// to start inside the end. + #[test] + fn an_unsettled_time_end_reads_one_group_on_speculation() { + let mut groups = Groups::new([(3, Held::Pending), (4, Held::Pending), (5, Held::Pending)]); + let mut window = Window::new(&[range(Anchor::Absolute(Position::group(3)), time(3500))], Some(5), &groups).unwrap(); + assert_eq!(covered(&window, 8), vec![3]); + + groups.held.insert(3, Held::Started(at(3000))); + assert!(window.settle(Some(5), &groups)); + assert_eq!(covered(&window, 8), vec![3, 4]); + + // 4 never starts, so 5 is the one read on speculation. + groups.held.insert(4, Held::Never); + assert!(window.settle(Some(5), &groups)); + assert_eq!(covered(&window, 8), vec![3, 4, 5]); + + // 5 stalls, 6 starts inside the end: 5 is in range then, and 7 is speculative. + groups.held.insert(6, Held::Started(at(3400))); + groups.held.insert(7, Held::Pending); + assert!(window.settle(Some(7), &groups)); + assert_eq!(covered(&window, 9), vec![3, 4, 5, 6, 7]); + + // 7 starts past the end, which settles with 5 still stalled below it. + groups.held.insert(7, Held::Started(at(3600))); + assert!(window.settle(Some(7), &groups)); + assert!(window.is_settled()); + assert_eq!(covered(&window, 9), vec![3, 4, 5, 6]); + + // Every held group accounted for: the next to arrive is the speculative one. + let groups = Groups::new([Groups::started(3, 3000)]); + let window = Window::new(&[range(time(3000), time(3500))], Some(3), &groups).unwrap(); + assert!(window.contains(4)); + + // Nothing more can arrive or start: uncapped. + let mut groups = Groups::new([Groups::started(3, 3000), (4, Held::Pending), (5, Held::Pending)]); + groups.complete = true; + let window = Window::new(&[range(time(3000), time(3500))], Some(5), &groups).unwrap(); + assert_eq!(covered(&window, 7), vec![3, 4, 5, 6]); + } + + /// A peer that leaves an early sequence missing and keeps sending later groups costs a + /// time end each group once, not a rescan of everything since the gap on every settle. + #[test] + fn a_time_end_walks_each_group_once_behind_a_missing_sequence() { + let mut groups = Groups::new([Groups::started(0, 0)]); + let mut window = Window::new(&[range(time(0), time(1_000_000))], Some(0), &groups).unwrap(); + let count = 2_000; + for sequence in 2..count { + groups.held.insert(sequence, Held::Started(at(sequence * 10))); + window.settle(Some(sequence), &groups); + } + assert!(!window.is_settled(), "group 1 never arrived"); + assert!(window.contains(count - 1)); + let asked = groups.asked.get(); + assert!(asked < 8 * count as usize, "{asked} queries for {count} groups"); + } + + /// A datagram is one frame, so its own timestamp places it against a time end. + #[test] + fn a_datagram_is_judged_by_its_own_timestamp() { + let window = Window::new(&[range(Anchor::Absolute(Position::group(0)), time(3500))], None, &Groups::default()) + .unwrap(); + assert!(window.admits_datagram(9, Some(at(3500)))); + assert!(!window.admits_datagram(9, Some(at(3600)))); + } + + #[test] + fn an_untimed_track_refuses_time_anchors() { + assert!(matches!( + Window::new(&[range(time(0), None)], None, &Untimed), + Err(Error::TimestampMismatch) + )); + assert!(matches!( + Window::new(&[range(Anchor::Relative(0), time(0))], None, &Untimed), + Err(Error::TimestampMismatch) + )); + } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index c91cfc6a5a8..b602a8a8d5a 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -22,7 +22,7 @@ use super::{Datagram, Requests}; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; -use super::subscription::{Resolved, Window}; +use super::subscription::{Containing, Held, Holdings, Resolved, Window, later}; use rangemap::{RangeInclusiveSet, RangeSet}; mod demand; @@ -351,6 +351,11 @@ pub(crate) struct TrackState { // the read guard a first frame holds, so it needs its own lock, never contended. bounds: Mutex>, + // Where each cached group's frame 0 presented, by sequence, received groups too: what + // a time anchor resolves against without walking groups that have not started. Filled + // through the read guard a first frame holds, like `bounds`. + starts: Mutex>, + // The sequence and start of the highest-sequence group that left the cache holding a // checked start (see [`group::Origin::Bound`]). Eviction is not a sequence prefix, so a // group above it is checked against it as well as its cached neighbors; one at or below @@ -589,6 +594,20 @@ impl Slot { } } + /// Where the group starts in presentation time, from a live copy holding frame 0. A + /// copy joined mid-group stamps a later frame, which proves nothing about the start. + fn start(&self) -> Option { + self.copies() + .filter(|group| group.live_first_frame() == Some(0)) + .find_map(group::Producer::timestamp) + } + + /// Whether the group has no start yet but may still get one: a copy holding frame 0 + /// has not presented it. + fn may_start(&self) -> bool { + self.start().is_none() && self.copies().any(group::Producer::may_start) + } + /// The checked start of whichever copy began at frame 0. fn bound(&self) -> Option { self.copies().find_map(group::Producer::bound) @@ -651,6 +670,71 @@ pub(crate) struct FetchOutcome { 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 { + fn timed(&self) -> bool { + self.info.as_ref().is_none_or(|info| info.timescale.is_some()) + } + + fn containing(&self, pts: Timestamp) -> Containing { + let starts = self.starts.lock().unwrap_or_else(PoisonError::into_inner); + let (Some((&first, &first_at)), Some((&last, &last_at))) = (starts.first_key_value(), starts.last_key_value()) + else { + return Containing::Unknown; + }; + if later(first_at, pts) { + return Containing::Before; + } + if !later(last_at, pts) { + return Containing::Group(last); + } + // Timestamps never rewind, so the starts ascend with the sequence: bisect the + // sequences between the newest start known at or before `pts` and one known after. + let (mut best, mut low, mut high) = (first, first, last); + while high - low > 1 { + let mid = low + (high - low) / 2; + match starts.range(low + 1..=mid).next_back() { + Some((&sequence, &at)) if later(at, pts) => high = sequence, + Some((&sequence, _)) => (best, low) = (sequence, mid), + None => low = mid, + } + } + Containing::Group(best) + } + + fn next_start(&self, from: u64) -> Option<(u64, Timestamp)> { + let starts = self.starts.lock().unwrap_or_else(PoisonError::into_inner); + starts.range(from..).next().map(|(&sequence, &at)| (sequence, at)) + } + + fn next_held(&self, from: u64) -> Option<(u64, Held)> { + let (&sequence, slot) = self.lookup.range(from..).next()?; + let held = match slot.start() { + Some(at) => Held::Started(at), + None if slot.may_start() => Held::Pending, + None => Held::Never, + }; + Some((sequence, held)) + } + + fn gone(&self, sequence: u64) -> Option { + // Without a declared start, the first arrival stands in for it, as the tail's does. + let floor = self.start_sequence.or(self.arrivals.first()).unwrap_or(0); + if sequence < floor { + return Some(floor); + } + self.drops + .get(&sequence) + .map(|dropped| dropped.end) + .or_else(|| self.arrivals.run_end(sequence)) + } + + fn complete(&self) -> bool { + self.sealed || self.abort.is_some() + } +} + impl TrackState { /// A consumer joined at the live edge: ask for a group past every sequence written when /// the newest group's bytes after its first frame exceed [`Info::refresh`] times that @@ -719,9 +803,31 @@ impl TrackState { }) } + /// Group `sequence` wrote its first frame: index where it starts, and share a + /// just-checked `bound` with its neighbors. + pub(super) fn first_frame(&self, sequence: u64, bound: Option) { + if let Some(bound) = bound { + self.share_bound(sequence, bound); + } + self.index_start(sequence); + } + + /// Index where cached group `sequence` starts, read back through its slot so only the + /// cached copy holding frame 0 counts, and wake the reads a time end parks on it. + fn index_start(&self, sequence: u64) { + let Some(start) = self.lookup.get(&sequence).and_then(Slot::start) else { + return; + }; + self.starts + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(sequence, start); + self.cache.wakes().started(sequence); + } + /// Record group `sequence`'s just-checked `start` as a bound for its neighbors and the /// name's later producers. A group stamped after it left the cache bounds nothing. - pub(super) fn share_bound(&self, sequence: u64, start: Timestamp) { + fn share_bound(&self, sequence: u64, start: Timestamp) { if self.lookup.get(&sequence).and_then(Slot::bound) != Some(start) { return; } @@ -1461,6 +1567,10 @@ impl TrackState { .get_mut() .unwrap_or_else(PoisonError::into_inner) .retain(|sequence, _| lookup.contains_key(sequence)); + self.starts + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .retain(|sequence, _| lookup.contains_key(sequence)); } /// Take `sequence` out of the cache, keeping the withheld count in step. @@ -1471,6 +1581,10 @@ impl TrackState { .get_mut() .unwrap_or_else(PoisonError::into_inner) .remove(&sequence); + self.starts + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .remove(&sequence); Some(slot) } @@ -1632,6 +1746,8 @@ impl TrackState { .unwrap_or_else(PoisonError::into_inner) .remove(&sequence); } + // A group inserted with its first frame already written started before it was here. + self.index_start(sequence); self.cache.wakes().arrived(sequence..=sequence); if visible && !pending { self.arrival.push_back((sequence, stamp)); @@ -4147,6 +4263,7 @@ impl Subscribing { // Wait until the track info is available let info = ready!(self.state.poll(waiter, |state| state.poll_info())) .map_err(|e| e.abort.clone().unwrap_or(Error::Dropped))??; + refuse_untimed(&info, &self.subscription.read())?; let resume = self.state.read().routes.clone(); let inner = match resume { @@ -4987,6 +5104,8 @@ struct Filter { resolved: u64, /// Bumped whenever the window moves, so the drop scan knows to look again. revision: u64, + /// Why the ranges can't be served here: a time anchor on an untimed track. + refused: Option, /// What the arrival walk reads: the window from the floor, below the cap. The walk /// passes a group outside it holding nothing, so widening it re-scans the cache. admitted: RangeInclusiveSet, @@ -5009,7 +5128,10 @@ impl Cursor { let preferences = subscription.read(); let ranges_revision = subscription.ranges_revision(); let state = state.read(); - let window = Window::new(&preferences.ranges, state.newest_produced); + let (window, refused) = match Window::new(&preferences.ranges, state.newest_produced, &*state) { + Ok(window) => (window, None), + Err(err) => (Window::new(&[], None, &*state).expect("nothing to refuse"), Some(err)), + }; // Arrival order need not be timestamp order. Only skip the initial stale prefix; // subsequent delivery stays in arrival order, including late datagrams. let newest = state.datagrams.iter().filter_map(|datagram| datagram.timestamp).max(); @@ -5029,6 +5151,7 @@ impl Cursor { window, resolved: ranges_revision, revision: 0, + refused, admitted: RangeInclusiveSet::new(), admitted_at: None, owed: RangeInclusiveSet::new(), @@ -5110,8 +5233,8 @@ impl Cursor { /// Bring [`Self::window`] up to date: resolved again once the subscriber replaces its /// ranges, counting relative anchors back from the latest group then, and settled as - /// the groups it waits on arrive. - fn sync(&mut self) { + /// the groups it waits on arrive. Returns whether the window moved. + fn sync(&mut self) -> bool { let replaced = { let preferences = self.subscription.read(); let revision = self.subscription.ranges_revision(); @@ -5121,18 +5244,50 @@ impl Cursor { let moved = match replaced { Some((revision, ranges)) => { self.filter.resolved = revision; - self.filter.window = Window::new(&ranges, state.newest_produced); + match Window::new(&ranges, state.newest_produced, &*state) { + Ok(window) => { + self.filter.window = window; + self.filter.refused = None; + } + Err(err) => self.filter.refused = Some(err), + } true } + None if self.filter.window.is_settled() => false, // The track had no produced group when the ranges resolved, so the newest one // then was the first to arrive, however many have arrived or left since. - None => self.filter.window.settle(state.first_produced), + None if self.filter.window.is_pending() => self.filter.window.settle(state.first_produced, &*state), + None => self.filter.window.settle(state.newest_produced, &*state), }; drop(state); if moved { self.filter.revision += 1; self.update_servable(); } + moved + } + + /// Park a read the cap of an unsettled time end holds back on what raises it: the + /// speculative group starting or ending, or any group it waits on starting. Returns + /// whether the cap already moved, so the read goes again rather than parking. + fn watch_time(&mut self, waiter: &kio::Waiter) -> bool { + if self.filter.window.is_settled() || self.filter.refused.is_some() { + return false; + } + { + let state = self.state.read(); + if let Some(from) = self.filter.window.watch_starts() { + state.cache.wakes().watch_start(from, waiter); + } + for sequence in self.filter.window.speculative() { + if let Some(slot) = state.lookup.get(&sequence) { + let _ = slot.serving().poll_started(waiter); + } + } + } + // Registered first, so a start landing since the last sync is either seen here or + // wakes the read. + self.sync() } /// The lowest group this cursor reads: its own floor, raised to its window's. @@ -5330,6 +5485,9 @@ impl Cursor { return Poll::Ready(Ok(Some(self.with_expiry(peeked.group)))); } self.sync(); + if let Some(err) = &self.filter.refused { + return Poll::Ready(Err(err.clone())); + } self.admit(); // One scan for the whole poll, so walking a backlog off stays linear in its size. @@ -5361,9 +5519,12 @@ impl Cursor { } None => { let floor = self.floor(); - let Some((producer, found_index)) = - ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, floor))?) - else { + let polled = self.poll(waiter, |state| state.poll_recv_group(self.index, floor)); + if polled.is_pending() && self.watch_time(waiter) { + self.admit(); + continue; + } + let Some((producer, found_index)) = ready!(polled)? else { return Poll::Ready(Ok(None)); }; let consumer = producer.consume(); @@ -5374,8 +5535,8 @@ impl Cursor { } // The first group to arrive resolves a relative floor, which may land - // above it. - if self.filter.window.is_pending() { + // above it, and a later one may settle where a time end falls. + if !self.filter.window.is_settled() { self.sync(); self.admit(); // The snapshot saw the pending window, which serves everything. @@ -5446,6 +5607,11 @@ impl Cursor { /// group past the cap, nothing holds it for a later raise. fn poll_recv_datagram(&mut self, waiter: &kio::Waiter) -> Poll>> { loop { + // Refuse before reading, so a refused subscription consumes no datagram. + self.sync(); + if let Some(err) = &self.filter.refused { + return Poll::Ready(Err(err.clone())); + } let Some((datagram, found_index)) = ready!(self.poll(waiter, |state| state.poll_recv_datagram(self.datagram_index))?) else { @@ -5459,7 +5625,10 @@ impl Cursor { start.group == sequence && start.frame > datagram.wire.as_ref().and_then(|wire| wire.object_id).unwrap_or(0) }); - if sequence >= self.floor() && self.admits(sequence) && !mid_group { + // A datagram is a whole group, so its own timestamp places it against a time end. + let admits = super::subscription::before_end(sequence, self.end_sequence) + && self.filter.window.admits_datagram(sequence, datagram.timestamp); + if sequence >= self.floor() && admits && !mid_group { return Poll::Ready(Ok(Some(datagram))); } } @@ -5475,6 +5644,9 @@ impl Cursor { return Poll::Ready(Ok(None)); }; self.sync(); + if let Some(err) = &self.filter.refused { + return Poll::Ready(Err(err.clone())); + } 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))?; @@ -5493,8 +5665,11 @@ impl Cursor { let end = self.end_sequence; let group = loop { let window = &self.filter.window; - let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end, window))?) - else { + let polled = self.poll(waiter, |state| state.poll_next_in_range(floor, end, window)); + if polled.is_pending() && self.watch_time(waiter) { + continue; + } + let Some(producer) = ready!(polled)? else { // 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 @@ -5503,8 +5678,9 @@ impl Cursor { }; let group = producer.consume(); - // The first group to arrive resolves a relative floor, which may land above it. - if self.filter.window.is_pending() { + // The first group to arrive resolves a relative floor, which may land above it, and + // a later one may settle where a time end falls. + if !self.filter.window.is_settled() { self.sync(); // The snapshot saw the pending window, which serves everything. if !self.filter.window.is_pending() { @@ -5583,6 +5759,8 @@ pub struct Control { /// group it handed out is judged against them before the cursor next reads. `None` /// for a reader that follows a route, whose copies take them on its next read. republish: Option, + // Whether the track is timed, so a time anchor on an untimed one is refused. + timed: bool, } /// What a [`Control`] needs to republish a plain cursor's [`Published`] window. @@ -5606,7 +5784,12 @@ impl Republish { if published.read().revision >= revision { return; } - let window = Window::new(&ranges, self.state.read().newest_produced); + let state = self.state.read(); + // The update was refused before it got here if the track can't resolve it. + let Ok(window) = Window::new(&ranges, state.newest_produced, &*state) else { + return; + }; + drop(state); let runs = Arc::new(admitted(&window, 0, None)); if let Ok(mut current) = published.write() && current.revision < revision @@ -5625,8 +5808,12 @@ impl Control { /// Replace this subscriber's preferences, updating the producer's aggregate. /// /// Returns [`Error::Closed`] if the track already ended; the update is - /// meaningless at that point and can usually be ignored. + /// meaningless at that point and can usually be ignored. Refuses a time anchor on + /// an untimed track with [`Error::TimestampMismatch`], leaving the preferences as they were. pub fn update(&self, subscription: Subscription) -> Result<()> { + if !self.timed && subscription.has_time() { + return Err(Error::TimestampMismatch); + } self.subscription.update(subscription)?; if let Some(republish) = &self.republish { republish.publish(&self.subscription); @@ -5635,6 +5822,14 @@ impl Control { } } +/// Refuse `subscription` when it asks an untimed track for a presentation time. +fn refuse_untimed(info: &Info, subscription: &Subscription) -> Result<()> { + match info.timescale.is_none() && subscription.has_time() { + true => Err(Error::TimestampMismatch), + false => Ok(()), + } +} + impl Subscriber { /// The track's [`Info`], resolved when the subscription was established. /// @@ -5674,6 +5869,7 @@ impl Subscriber { /// Create a handle for updating this subscriber's delivery preferences. pub fn control(&self) -> Control { + let timed = self.info.timescale.is_some(); match &self.inner { Inner::Plain(cursor) => Control { subscription: cursor.subscription.clone(), @@ -5681,10 +5877,12 @@ impl Subscriber { state: cursor.state.weak(), published: cursor.published.weak(), }), + timed, }, Inner::Resume(resume, _) => Control { subscription: resume.subscription().clone(), republish: None, + timed, }, } } @@ -6002,7 +6200,9 @@ impl Subscriber { match &self.inner { Inner::Plain(cursor) => { let state = cursor.state.read(); - Window::new(ranges, state.newest_produced).resolved() + Window::new(ranges, state.newest_produced, &*state) + .map(|window| window.resolved()) + .unwrap_or_default() } Inner::Resume(..) => Vec::new(), } @@ -6162,8 +6362,10 @@ impl Subscriber { /// the cached groups they newly cover, even ones that arrived before the groups /// already read. Returns [`Error::Closed`] /// if the track already ended; the update is meaningless at that point and can usually - /// be ignored. + /// be ignored. Refuses a time anchor on an untimed track with + /// [`Error::TimestampMismatch`], leaving the preferences as they were. pub fn update(&mut self, subscription: Subscription) -> Result<()> { + refuse_untimed(&self.info, &subscription)?; let channel = match &self.inner { Inner::Plain(cursor) => &cursor.subscription, Inner::Resume(resume, _) => resume.subscription(), @@ -14787,4 +14989,174 @@ mod test { } assert_eq!(dropped, vec![2..4, 6..8]); } + + /// A time start is the cached group containing it and a time end stops just past that + /// group; one past every start held stays open until a group starts after it. + #[test] + #[cfg(feature = "async")] + fn time_anchors_resolve_against_the_cache() { + let mut producer = track_producer("test", Info::timed()); + for second in 0..4 { + append_at(&mut producer, second * 1000); + } + let time = |millis| Anchor::Time(Timestamp::from_millis(millis).unwrap()); + + let mut seek = producer.subscribe(replay().with_ranges([Range { + start: time(1500), + end: Some(time(2000)), + }])); + assert_eq!(drain(&mut seek), vec![1, 2]); + + // A start before every group reaches back to the oldest one. + let mut early = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Time(Timestamp::from_micros(0).unwrap()), + end: Some(time(500)), + }])); + assert_eq!(drain(&mut early), vec![0]); + + // Past the newest start: open until a group is known to start after the end. + let mut live = producer.subscribe(replay().with_ranges([Range { + start: time(3000), + end: Some(time(4500)), + }])); + assert_eq!(drain(&mut live), vec![3]); + append_at(&mut producer, 4000); + append_at(&mut producer, 5000); + append_at(&mut producer, 6000); + assert_eq!(drain(&mut live), vec![4]); + } + + /// A group joined mid-group stamps a later frame than its start, so it never proves a + /// time end falls before it. + #[test] + #[cfg(feature = "async")] + fn a_partial_group_proves_no_start() { + let mut producer = track_producer("test", Info::timed()); + append_at(&mut producer, 0); + append_at(&mut producer, 1000); + let mut partial = producer.append_group().unwrap(); + partial.start_at(1).unwrap(); + partial + .write_frame(Timestamp::from_millis(2500).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + partial.finish().unwrap(); + + let time = |millis| Anchor::Time(Timestamp::from_millis(millis).unwrap()); + let mut seek = producer.subscribe(replay().with_ranges([Range { + start: time(1500), + end: Some(time(2200)), + }])); + assert_eq!(drain(&mut seek), vec![1, 2]); + } + + /// A time end stays open across a group that has yet to start: it may start before the + /// end, so a later group starting after it settles nothing. + #[test] + #[cfg(feature = "async")] + fn a_time_end_waits_on_an_unstarted_group_below_it() { + let producer = track_producer("test", Info::timed()); + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(at(0), bytes::Bytes::from_static(b"x")).unwrap(); + let mut hole = producer.create_group(group::Info { sequence: 1 }).unwrap(); + let mut two = producer.create_group(group::Info { sequence: 2 }).unwrap(); + two.write_frame(at(2000), bytes::Bytes::from_static(b"x")).unwrap(); + + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(500))), + }])); + hole.write_frame(at(1000), bytes::Bytes::from_static(b"x")).unwrap(); + assert_eq!(drain(&mut subscriber), vec![0]); + } + + /// A time end stays open across a sequence yet to arrive: it may start before the end, + /// so a later group starting after it settles nothing. Once that sequence is reported + /// dropped, nothing below the later group can start before the end, so it settles. + #[test] + #[cfg(feature = "async")] + fn a_time_end_waits_on_an_absent_group_below_it() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let ranges = [Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(500))), + }]; + for dropped in [false, true] { + let producer = track_producer("test", Info::timed()); + let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(at(0), bytes::Bytes::from_static(b"x")).unwrap(); + let mut two = producer.create_group(group::Info { sequence: 2 }).unwrap(); + two.write_frame(at(2000), bytes::Bytes::from_static(b"x")).unwrap(); + + let mut subscriber = producer.subscribe(replay().with_ranges(ranges)); + assert_eq!( + subscriber.resolved_ranges(), + vec![Some(Resolved { start: 0, end: None })], + "group 1 may still start before the end" + ); + if dropped { + producer.drop_groups(1..2); + assert_eq!(drain(&mut subscriber), vec![0]); + assert_eq!( + subscriber.resolved_ranges(), + vec![Some(Resolved { start: 0, end: Some(2) })], + "the drop settles the end" + ); + continue; + } + let mut one = producer.create_group(group::Info { sequence: 1 }).unwrap(); + one.write_frame(at(1000), bytes::Bytes::from_static(b"x")).unwrap(); + assert_eq!(drain(&mut subscriber), vec![0]); + } + } + + /// An untimed track refuses a time anchor wherever the ranges reach it. + #[test] + #[cfg(feature = "async")] + fn an_untimed_track_refuses_time_anchors() { + let mut producer = untimed_producer(); + let timed = replay().with_ranges([Range { + start: Anchor::Time(Timestamp::from_millis(0).unwrap()), + end: None, + }]); + + let pending = producer.consume().subscribe(timed.clone()); + assert!(matches!( + pending.poll_ok(&kio::Waiter::noop()), + Poll::Ready(Err(Error::TimestampMismatch)) + )); + + let mut subscriber = producer.subscribe(replay()); + assert!(matches!( + subscriber.update(timed.clone()), + Err(Error::TimestampMismatch) + )); + assert!(matches!( + subscriber.control().update(timed.clone()), + Err(Error::TimestampMismatch) + )); + assert_eq!(subscriber.subscription(), replay(), "a refused update changes nothing"); + + let mut refused = producer.subscribe(timed); + assert!(matches!( + refused.recv_group().now_or_never(), + Some(Err(Error::TimestampMismatch)) + )); + producer.append_datagram(None, &b"x"[..]).unwrap(); + assert!(matches!( + refused.recv_datagram().now_or_never(), + Some(Err(Error::TimestampMismatch)) + )); + + // Ranges it can serve lift the refusal. + refused.update(replay()).unwrap(); + let group = producer.append_group().unwrap(); + group.finish().unwrap(); + let read = refused.recv_group().now_or_never().expect("the group is cached"); + assert_eq!( + read.unwrap().map(|group| group.sequence), + Some(1), + "the datagram took sequence 0" + ); + } } diff --git a/rs/moq-net/src/model/track/demand/ranges.rs b/rs/moq-net/src/model/track/demand/ranges.rs index 120723920d5..88bae86d296 100644 --- a/rs/moq-net/src/model/track/demand/ranges.rs +++ b/rs/moq-net/src/model/track/demand/ranges.rs @@ -4,7 +4,8 @@ //! where it ends, so a position is covered while the running sum stays above zero. A treap //! keeps each subtree's sum and lowest running sum, so a change touches two boundaries and //! asks one range-minimum question, whatever else the audience holds. Ranges anchored to -//! the latest group can't be placed on that line and are counted by value instead. +//! the latest group or to a time can't be placed on that line and are counted by value +//! instead. use std::cell::OnceCell; use std::collections::BTreeMap; @@ -139,6 +140,8 @@ impl Ranges { match range.start { Anchor::Absolute(start) => self.absolute.tail().is_some_and(|tail| tail <= start), Anchor::Relative(back) => self.farthest().is_some_and(|farthest| back <= farthest), + // Where a time falls among the groups is the serving hop's to say. + Anchor::Time(_) => false, } } } diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 4a3ab3425d5..85431c7543b 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,9 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads (#5929) +### Ranges filter reads, and anchors can name a time (#5929) A subscription now reads only the groups its own `ranges` cover. +`Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. +`TrackConsumer.update(subscription:)` now `throws`, on a time anchor an untimed track can't serve; call it with `try`. ### Track demand reports the wanted group (#5930) diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index 97c8cce74e2..9b9fd71b54c 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -115,8 +115,8 @@ public typealias ProtocolError = MoqFFI.MoqProtocolError extension Subscription { /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is `nil`. public typealias Range = MoqFFI.MoqRange - /// Where one end of a `Range` falls: `.absolute(group:frame:)` or - /// `.relative(groups:)` back from the latest group. + /// Where one end of a `Range` falls: `.absolute(group:frame:)`, `.relative(groups:)` + /// back from the latest group, or `.time(timestampUs:)` for the group containing it. public typealias Anchor = MoqFFI.MoqAnchor /// Which pending group the publisher sends first: `.ascending` or `.descending`. public typealias Order = MoqFFI.MoqOrder diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index c4566ba3286..efd34bfe137 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -44,9 +44,10 @@ public final class TrackConsumer: AsyncSequence, Sendable { try ffi.info() } - /// Change this subscriber's delivery preferences. - public func update(subscription: Subscription) { - ffi.update(subscription: subscription) + /// Change this subscriber's delivery preferences. Throws on a time anchor the track + /// can't serve. + public func update(subscription: Subscription) throws { + try ffi.update(subscription: subscription) } /// Cancel all current and future reads. From 3d031d7b33c08dc4058f08929097a365afd98bde Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 09:35:16 -0700 Subject: [PATCH 31/48] feat(moq-net): regression tests, settle bench, and docs for time anchors Cover the three time-end findings with tests that fail without each fix: one speculative group past an end at the live edge, readers waking as groups start, datagrams judged by their own timestamp, and a replaced copy keeping its time end under a failover cap. Bench the settle path behind a missing sequence across readers and groups. Update the anchor docs, split the migration notes into their own section, and retire the time-anchors quest. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 7 +- dart/moq/MIGRATE.md | 7 +- doc/concept/moq-lite.md | 6 +- go/wrapper/MIGRATE.md | 7 +- kt/moq/MIGRATE.md | 7 +- py/moq-rs/MIGRATE.md | 7 +- quest/m1/subscribe-ranges/README.md | 5 +- quest/m1/subscribe-ranges/dynamic.md | 4 +- quest/m1/subscribe-ranges/time-anchors.md | 56 ---------- rs/moq-c/MIGRATE.md | 7 +- rs/moq-ffi/MIGRATE.md | 7 +- rs/moq-net/MIGRATE.md | 7 +- rs/moq-net/benches/track.rs | 91 +++++++++++++++- rs/moq-net/src/model/resume.rs | 42 ++++++++ rs/moq-net/src/model/subscription.rs | 14 +-- rs/moq-net/src/model/track.rs | 123 ++++++++++++++++++++++ swift/MIGRATE.md | 7 +- 17 files changed, 312 insertions(+), 92 deletions(-) delete mode 100644 quest/m1/subscribe-ranges/time-anchors.md diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 5b470aceed7..2f566d9809b 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `ranges` cover. `Anchor` gains a `Time` case holding `timestamp_us`, the group containing that presentation time, so exhaustive visits need a case for it. `TrackConsumer::update` now returns a result, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand::is_used`, `used`, and `unused` are gone. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index a52afe354e7..dd33e2a501d 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `ranges` cover. `Anchor` gains a time variant, so exhaustive switches need a case for it. `TrackConsumer.update` now throws on a time anchor an untimed track can't serve. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.isUsed()`, `used()`, and `unused()` are gone. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 15be2807448..e889553cb1d 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -243,9 +243,9 @@ served. 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 -lets through every group handed out before its first frame showed it starts past -the end, since holding each group until its first frame arrives would cost -latency. +may let one group through before its first frame shows it starts past the end: +the first group that has yet to start is handed out without waiting for that +frame, and the next one waits on it. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index a5eb1419222..72ff707cc71 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `Ranges` cover. `AnchorTime` names the group containing a presentation time, and type switches over `Anchor` need a case for it. `TrackConsumer.Update` also fails on a time anchor an untimed track can't serve. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `Ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.IsUsed`, `Used`, and `Unused` are gone. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 21287bb9c09..64f3d6a2b9a 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `ranges` cover. `Anchor` gains `AnchorTime`, so exhaustive `when` expressions need a branch for it. `TrackConsumer.update` now throws `MoqException` on a time anchor an untimed track can't serve. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.isUsed()`, `used()`, and `unused()` are gone. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 695c7c7654c..d51a935c54d 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `ranges` cover. `Anchor` gains a `TIME` variant, the group containing a presentation time. `TrackConsumer.update` now raises on a time anchor an untimed track can't serve. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.is_used()`, `used()`, and `unused()` are gone. diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index b67786518ed..58ad9d97825 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -72,9 +72,7 @@ relay bounds a sparse FETCH only if the model can request ranges: has no time filter. It is part of lite-07 and must land before [lite-07 finalizes](/quest/m1/lite07-finalize.md), which this line gates. This amends the 09-29 premise that ranges are group-only because a group's timestamp isn't always - known: timed tracks know it, and untimed ones refuse. Split out of #5929 - (2026-10-19) into [time anchors](/quest/m1/subscribe-ranges/time-anchors.md), - which lands in its own PR. + known: timed tracks know it, and untimed ones refuse. The cache half landed in its own PR after #5929. - Ranges are frame-precise (`Position`), not whole groups. The IETF joining FETCH for a mid-group SUBSCRIBE's uncached prefix stays, because today's bridge relies on it (maintainer, 09-29). @@ -92,7 +90,6 @@ every sequence delivered or dropped. - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) - range FETCH upstream per missing run, and non-blocking FETCH served downstream - [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` model and lite-07 parity - [Frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) - a local subscriber reads only the frames its ranges cover -- [Time anchors](/quest/m1/subscribe-ranges/time-anchors.md) - a range start or end may name a presentation time ## Related diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md index 5e7b850d66e..3a2ddfa8d82 100644 --- a/quest/m1/subscribe-ranges/dynamic.md +++ b/quest/m1/subscribe-ranges/dynamic.md @@ -12,8 +12,8 @@ kept beside it. 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. Time anchors land separately; see -[time anchors](/quest/m1/subscribe-ranges/time-anchors.md). +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` and `fetch_group` rather than keep both. Update every caller (relay, moq-archive, hls, the ladder) in the same PR, diff --git a/quest/m1/subscribe-ranges/time-anchors.md b/quest/m1/subscribe-ranges/time-anchors.md deleted file mode 100644 index b66dd31b474..00000000000 --- a/quest/m1/subscribe-ranges/time-anchors.md +++ /dev/null @@ -1,56 +0,0 @@ -# [L] Time anchors - -## Goal - -A range start or end may name a presentation time: `Time(pts)` in the track's -timescale. A start resolves to the group containing pts, an end to just after that -group, and an untimed track refuses either. It lands in its own PR, before -[lite-07 finalizes](/quest/m1/lite07-finalize.md). - -## Plan - -Split out of #5929 (maintainer, 2026-10-19), which ships absolute and relative -anchors only. Start from the `quest/m1/subscribe-ranges/time-end` branch, which -holds that PR's time anchors through every binding: `Anchor::Time`, the -`Holdings` resolution in `Window`, the end settlement (`Holdings::after`, -`TrackState::accounted`), and the failover handling. Merge main into it and drop -what #5929 changed since. - -Decide first whether time starts ship alone. A start resolves once from the -cache, but keeping it without time ends needs a start-only anchor type in every -binding, which is why #5929 dropped both. - -Open findings from #5929's review, each needing a regression test: - -- **Per-read rescan behind a peer-controlled gap** (Codex 4320985614). Settling an - unresolved end walks back from the live edge on every unsettled read. A peer that - leaves an early sequence missing and keeps sending later groups makes each read - revisit a growing prefix until the gap's grace runs out. Keep an incremental - settle boundary, so each read scans only what arrived since. -- **Failover replacing an unsettled end** (Codex 4320985627). A copy capped at a - route switch reports an unsettled time end as unresolved, so the cap replaces - the `Time` anchor for good, and a group starting before the time that arrives - later on the old copy is delivered. Keep the time filter on the reader, separate - from the copy cap. -- **One speculative group past an end.** An unresolved end covers every sequence - through `u64::MAX`, so each group handed out before its first frame can leak past - it at the live edge. Cap it at the newest held group known to start at or before - pts, plus the first unstarted group above it, and recompute as groups start. - Raising the cap must wake readers: register on the speculative group's start - (`group::Producer::poll_started`) from both `Cursor::poll_recv_group` and - `poll_next_group`. A held group that never shows a start (a copy joined - mid-group) must not pin the cap. Datagrams are not in `lookup`, so on a - datagram-only timed track an end never resolves: resolve datagram starts too, - or leave datagram reads uncapped. Reproduced on #5929: range - `time(3000)..time(3500)` with three groups opened before any frame delivers - all three. - -Also pass a time anchor the cache can't resolve to the `track::Dynamic` handler -in its range request, once [dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -land, and update `quest/m1/binding-timestamps.md` for the anchor's -`timestamp_us` fields. - -## Related - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - range requests a `Dynamic` handler resolves -- [Archive line](/quest/m1/archive/README.md) - DVR seek, which needs time anchors diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 93f78e4b047..23b393ab95c 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A raw track consumer now reads only the groups its `ranges` cover, rather than every group from the lowest start to the last absolute end. `moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. `moq_consume_track_update` fails with a negative code when the track refuses an update, such as a time anchor on an untimed track, leaving the subscription as it was. +### Ranges filter reads (#5929) + +A raw track consumer now reads only the groups its `ranges` cover, rather than every group from the lowest start to the last absolute end. + ### Demand reports the wanted group (#5930) `moq_track_info` gains `refresh` and `refresh_present`, which changes its ABI; a zeroed field leaves refresh off. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index d89e202a65f..8cd072ebea6 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,12 +5,15 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `ranges` cover, rather than starting at the lowest one and taking whatever arrives. `MoqAnchor` gains `Time { timestamp_us }`, the group containing that presentation time, so exhaustive matches need an arm for it. `MoqTrackConsumer::update` now returns `Result<(), MoqError>`, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover, rather than starting at the lowest one and taking whatever arrives. + ### Track demand reports the wanted group (#5930) `MoqTrackDemand::is_used`, `used`, and `unused` are gone. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 55615b1dac6..21c453c3c34 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Anchors can name a time + +`track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. +Subscribing or updating with one on an untimed track fails with `Error::TimestampMismatch`. + ### Ranges filter each subscriber's reads (#5929) A `track::Subscriber` now reads only the groups its own `Subscription::ranges` cover, resolved again whenever `update` replaces them; before, they only set where its reads started. A reader that relied on groups another subscriber asked for must widen its own ranges. -`track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. -Subscribing or updating with one on an untimed track fails with `Error::TimestampMismatch`. Once a track finishes, a subscriber with nothing left under its `set_groups` cap ends rather than waiting for the cap to rise; raise it before the track finishes to read past it. ### `largest()` reports only upstream state, within a reader's `set_groups` bounds diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 5d34818d408..e8efca227e3 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -65,6 +65,11 @@ //! for the groups outside its ranges, so a widening scans only the span it newly covers, //! and a scan of the cache or the audience shows up as a slope. //! +//! `track_time_end` lands groups one at a time behind a sequence that never arrives, each +//! drained by readers whose range ends at a time no group reaches, swept over the groups and +//! 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. +//! //! Run with `cargo bench -p moq-net --bench track`. // Custom timings measure the real time the work took, not the ambient clock. @@ -79,7 +84,7 @@ use moq_net::time::Instant; use bytes::Bytes; use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use moq_net::{Error, Timestamp, broadcast, cache, track}; +use moq_net::{Error, Timestamp, broadcast, cache, group, track}; /// A millisecond track: the frames written here carry timestamps. fn timed() -> track::Info { @@ -1261,6 +1266,87 @@ fn bench_range_widening(c: &mut Criterion) { bench.finish(); } +/// Readers whose range ends at a time no group reaches yet, as groups land behind a missing +/// sequence; see [`bench_time_end`]. +struct TimeEnd { + _broadcast: broadcast::Producer, + track: track::Producer, + readers: Vec, + groups: u64, +} + +impl TimeEnd { + fn new(readers: usize, groups: u64) -> Self { + let broadcast = broadcast::Info::default().produce(); + let track = broadcast.create_track("bench", timed()).unwrap(); + let mut zero = track.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(Timestamp::from_millis(0).unwrap(), Bytes::from_static(b"x")) + .unwrap(); + zero.finish().unwrap(); + // Group 1 never arrives, and the budget outlasts the run, so the end never settles. + let subscription = track::Subscription::default() + .with_max_delay(AGED_BUDGET) + .with_ranges([track::Range { + start: track::Anchor::Absolute(track::Position::group(0)), + end: Some(track::Anchor::Time(Timestamp::from_secs(u32::MAX.into()).unwrap())), + }]); + let waiter = kio::Waiter::noop(); + let readers = (0..readers) + .map(|_| { + let mut reader = track.subscribe(subscription.clone()); + while let Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + reader + }) + .collect(); + Self { + _broadcast: broadcast, + track, + readers, + groups, + } + } + + fn run(&mut self) { + let waiter = kio::Waiter::noop(); + for sequence in 2..self.groups + 2 { + let mut group = self.track.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(Timestamp::from_millis(sequence).unwrap(), Bytes::from_static(b"x")) + .unwrap(); + group.finish().unwrap(); + for reader in &mut self.readers { + let mut handed = 0; + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + handed += 1; + } + assert_eq!(handed, 1); + } + } + } +} + +/// Groups landing one at a time behind a missing sequence, each drained by readers whose +/// time end it cannot settle, swept over the groups and the readers. Each group must cost +/// each reader the same, so a rescan behind the gap shows up as a slope over the groups. +fn bench_time_end(c: &mut Criterion) { + let mut group = c.benchmark_group("track_time_end"); + group.sample_size(10); + for readers in [1usize, 8, 64] { + for groups in [64u64, 512, 4096] { + group.throughput(Throughput::Elements(groups * readers as u64)); + group.bench_function(BenchmarkId::new(format!("{readers}r"), groups), |b| { + b.iter_batched_ref( + || TimeEnd::new(readers, groups), + TimeEnd::run, + BatchSize::PerIteration, + ); + }); + } + } + group.finish(); +} + criterion_group!( benches, bench_range_widening, @@ -1282,6 +1368,7 @@ criterion_group!( bench_sparse_read, bench_gap_flush, bench_wall, - bench_empty_run + bench_empty_run, + bench_time_end ); criterion_main!(benches); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 3001848ca43..bc1e7e011f3 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -2639,6 +2639,48 @@ mod test { ); } + /// Capping a replaced copy never replaces a time end it has not settled: the copy keeps + /// filtering by time, so a group below the cap that starts past the end stays out. + #[test] + #[cfg(feature = "async")] + fn capping_a_replaced_copy_keeps_its_time_end() { + let routes = Producer::new(); + let a = copy(); + routes.serve(a.consume()); + let mut zero = a.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(ts(3000), b"x".as_ref()).unwrap(); + zero.finish().unwrap(); + let range = track::Range { + start: track::Anchor::Absolute(Position::group(0)), + end: Some(track::Anchor::Time(ts(3500))), + }; + let subscription = Subscription::default() + .with_max_delay(Duration::from_secs(30)) + .with_ranges([range]); + let sub = routes.consume().subscribe(kio::Producer::new(subscription).into()); + let mut reader = sub.reader.lock().unwrap(); + let (groups, mirrored) = (reader.groups, reader.mirrored.clone()); + let old = &mut reader.copies[0]; + old.replaced = true; + old.until = Some(Some(4)); + old.update(&mirrored); + assert_eq!(old.wire(&mirrored).ranges, vec![range], "the time end outlives the cap"); + + for (sequence, millis) in [(1, 3600), (2, 3700)] { + let mut group = a.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(millis), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + let Poll::Ready(Ok(copy)) = old.poll_ready(groups, &kio::Waiter::noop()) else { + panic!("the copy answered"); + }; + let mut read = Vec::new(); + while let Some(Ok(Some(group))) = copy.recv_group().now_or_never() { + read.push(group.sequence); + } + assert_eq!(read, vec![0]); + } + #[test] fn a_replaced_copy_at_the_last_sequence_has_no_overflow_cap() { for (last, end) in [(Some(u64::MAX), None), (None, Some(Position::default()))] { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 7d38e9e3a85..9eedffa0d70 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -124,13 +124,13 @@ pub enum Anchor { /// /// Resolved from the starts of the groups the serving hop holds. When none starts at /// or before it, a start reaches back to the oldest group, and while no group has - /// started at all it waits like `Relative(0)`. An end stays open until a group - /// starting after it is known and every sequence below that one is accounted for - /// (started, dropped, or never to start), so at the live edge a reader may get every - /// group handed out before its first frame showed it starts past the end, and a feed - /// that reports no drops leaves it open across a missing group until the wait for that - /// group runs out. An untimed track - /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). + /// started at all it waits like `Relative(0)`. An end settles once a group starting + /// after it is known and every sequence below that one is accounted for (started, + /// dropped, or never to start). Until then reads stop at the first held group that + /// may still start, so at the live edge at most that one group past the end is read + /// before its first frame shows where it starts. A datagram is judged by its own + /// timestamp. An untimed track refuses it with + /// [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index b602a8a8d5a..01e6fcb243c 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -9973,6 +9973,37 @@ mod test { assert_eq!(group.sequence, 3); } + /// A subscriber read by either cursor, so one test covers both. + #[cfg(feature = "async")] + enum Reader { + Arrival(Subscriber), + Ordered(Ordered), + } + + #[cfg(feature = "async")] + impl Reader { + fn new(subscriber: Subscriber, ordered: bool) -> Self { + match ordered { + true => Self::Ordered(subscriber.ordered()), + false => Self::Arrival(subscriber), + } + } + + fn drain(&mut self) -> Vec { + match self { + Self::Arrival(subscriber) => drain(subscriber), + Self::Ordered(ordered) => drain_ordered(ordered), + } + } + + fn resolved(&mut self) -> Vec> { + match self { + Self::Arrival(subscriber) => subscriber.resolved_ranges(), + Self::Ordered(ordered) => ordered.inner.resolved_ranges(), + } + } + } + /// Every group the ordered cursor can read right now, in sequence order. #[cfg(feature = "async")] fn drain_ordered(subscriber: &mut Ordered) -> Vec { @@ -15110,6 +15141,98 @@ mod test { } } + /// A time end at the live edge reads at most one group past it: groups opened before + /// their first frame wait on the one below them starting, rather than every one being + /// handed out while the end is unsettled. + #[test] + #[cfg(feature = "async")] + fn a_time_end_reads_one_group_past_it_at_the_live_edge() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let ranges = [Range { + start: Anchor::Time(at(3000)), + end: Some(Anchor::Time(at(3500))), + }]; + for ordered in [false, true] { + let mut producer = track_producer("test", Info::timed()); + append_at(&mut producer, 3000); + let mut groups: Vec<_> = (1..4) + .map(|sequence| producer.create_group(group::Info { sequence }).unwrap()) + .collect(); + let subscriber = producer.subscribe(replay().with_ranges(ranges)); + let mut reader = Reader::new(subscriber, ordered); + assert_eq!(reader.drain(), vec![0, 1], "1 is read on speculation, 2 and 3 wait"); + groups[0].write_frame(at(3600), bytes::Bytes::from_static(b"x")).unwrap(); + groups[1].write_frame(at(4000), bytes::Bytes::from_static(b"x")).unwrap(); + assert_eq!(reader.drain(), Vec::::new(), "ordered: {ordered}"); + assert_eq!( + reader.resolved(), + vec![Some(Resolved { start: 0, end: Some(1) })], + "1 starting past the end settles it" + ); + } + } + + /// A read parked on a time end's cap wakes when the speculative group starts, and when + /// a later group starts inside the end while the speculative one stalls. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_read_parked_on_a_time_end_wakes_as_groups_start() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let producer = track_producer("test", Info::timed()); + let mut groups: Vec<_> = (0..4) + .map(|sequence| producer.create_group(group::Info { sequence }).unwrap()) + .collect(); + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(3500))), + }])); + assert_eq!(drain(&mut subscriber), vec![0]); + + let parked = moq_clock::sim::spawn(async move { + let one = subscriber.recv_group().await.unwrap().unwrap().sequence; + let two = subscriber.recv_group().await.unwrap().unwrap().sequence; + (one, two, subscriber) + }); + moq_clock::sim::yield_now().await; + assert!(!parked.is_finished(), "1 waits on 0 starting"); + groups[0].write_frame(at(3000), bytes::Bytes::from_static(b"x")).unwrap(); + moq_clock::sim::yield_now().await; + assert!(!parked.is_finished(), "1 is read, and 2 waits on 1 starting"); + + // 1 stalls, so only 2 starting inside the end lifts the cap past it. + groups[2].write_frame(at(3200), bytes::Bytes::from_static(b"x")).unwrap(); + let (one, two, mut subscriber) = parked.await.unwrap(); + assert_eq!((one, two), (1, 2)); + assert_eq!(drain(&mut subscriber), vec![3], "3 is the speculative one now"); + groups[3].write_frame(at(3600), bytes::Bytes::from_static(b"x")).unwrap(); + groups[1].write_frame(at(3100), bytes::Bytes::from_static(b"x")).unwrap(); + assert_eq!( + subscriber.resolved_ranges(), + vec![Some(Resolved { start: 0, end: Some(3) })] + ); + } + + /// A datagram is a whole group of one frame, so its own timestamp says whether it falls + /// inside a time end, without one ever settling from the groups. + #[test] + #[cfg(feature = "async")] + fn a_datagram_is_judged_against_a_time_end_by_its_timestamp() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(3500))), + }])); + for millis in [3000, 3400, 3600, 3700] { + producer.append_datagram(at(millis), &b"x"[..]).unwrap(); + } + let mut read = Vec::new(); + while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { + read.push(datagram.sequence); + } + assert_eq!(read, vec![0, 1]); + } + /// An untimed track refuses a time anchor wherever the ranges reach it. #[test] #[cfg(feature = "async")] diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 85431c7543b..896c17f1f0d 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,12 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Ranges filter reads, and anchors can name a time (#5929) +### Anchors can name a time -A subscription now reads only the groups its own `ranges` cover. `Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. `TrackConsumer.update(subscription:)` now `throws`, on a time anchor an untimed track can't serve; call it with `try`. +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.isUsed`, `used()`, and `unused()` are gone. From 08ba0010c51b199ffedf668117cb0bf3cd65432b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 09:39:54 -0700 Subject: [PATCH 32/48] style(moq-net): format time anchor tests and bench Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/track.rs | 6 +--- rs/moq-net/src/model/subscription/window.rs | 31 +++++++++++++++++---- rs/moq-net/src/model/track.rs | 24 ++++++++++++---- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index e8efca227e3..3f96639fc29 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -1336,11 +1336,7 @@ fn bench_time_end(c: &mut Criterion) { for groups in [64u64, 512, 4096] { group.throughput(Throughput::Elements(groups * readers as u64)); group.bench_function(BenchmarkId::new(format!("{readers}r"), groups), |b| { - b.iter_batched_ref( - || TimeEnd::new(readers, groups), - TimeEnd::run, - BatchSize::PerIteration, - ); + b.iter_batched_ref(|| TimeEnd::new(readers, groups), TimeEnd::run, BatchSize::PerIteration); }); } } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index bab42da9e69..9bd42c285ba 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -566,11 +566,17 @@ mod tests { fn next_held(&self, from: u64) -> Option<(u64, Held)> { self.asked.set(self.asked.get() + 1); - self.held.range(from..).next().map(|(&sequence, held)| (sequence, *held)) + self.held + .range(from..) + .next() + .map(|(&sequence, held)| (sequence, *held)) } fn gone(&self, sequence: u64) -> Option { - self.gone.iter().find(|gone| gone.contains(&sequence)).map(|gone| gone.end) + self.gone + .iter() + .find(|gone| gone.contains(&sequence)) + .map(|gone| gone.end) } fn complete(&self) -> bool { @@ -730,7 +736,11 @@ mod tests { let mut groups = Groups::new([Groups::started(3, 3000), Groups::started(6, 6000)]); let mut window = Window::new(&[range(time(3000), time(4000))], Some(6), &groups).unwrap(); assert!(!window.is_settled(), "4 and 5 may still arrive inside the end"); - assert_eq!(covered(&window, 8), vec![3, 4, 5], "but never 6, known to start past it"); + assert_eq!( + covered(&window, 8), + vec![3, 4, 5], + "but never 6, known to start past it" + ); // 4 arrives yet to start: the one read on speculation, so 5 waits. groups.held.insert(4, Held::Pending); @@ -758,7 +768,12 @@ mod tests { #[test] fn an_unsettled_time_end_reads_one_group_on_speculation() { let mut groups = Groups::new([(3, Held::Pending), (4, Held::Pending), (5, Held::Pending)]); - let mut window = Window::new(&[range(Anchor::Absolute(Position::group(3)), time(3500))], Some(5), &groups).unwrap(); + let mut window = Window::new( + &[range(Anchor::Absolute(Position::group(3)), time(3500))], + Some(5), + &groups, + ) + .unwrap(); assert_eq!(covered(&window, 8), vec![3]); groups.held.insert(3, Held::Started(at(3000))); @@ -814,8 +829,12 @@ mod tests { /// A datagram is one frame, so its own timestamp places it against a time end. #[test] fn a_datagram_is_judged_by_its_own_timestamp() { - let window = Window::new(&[range(Anchor::Absolute(Position::group(0)), time(3500))], None, &Groups::default()) - .unwrap(); + let window = Window::new( + &[range(Anchor::Absolute(Position::group(0)), time(3500))], + None, + &Groups::default(), + ) + .unwrap(); assert!(window.admits_datagram(9, Some(at(3500)))); assert!(!window.admits_datagram(9, Some(at(3600)))); } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 01e6fcb243c..8cfa62bc99a 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15161,8 +15161,12 @@ mod test { let subscriber = producer.subscribe(replay().with_ranges(ranges)); let mut reader = Reader::new(subscriber, ordered); assert_eq!(reader.drain(), vec![0, 1], "1 is read on speculation, 2 and 3 wait"); - groups[0].write_frame(at(3600), bytes::Bytes::from_static(b"x")).unwrap(); - groups[1].write_frame(at(4000), bytes::Bytes::from_static(b"x")).unwrap(); + groups[0] + .write_frame(at(3600), bytes::Bytes::from_static(b"x")) + .unwrap(); + groups[1] + .write_frame(at(4000), bytes::Bytes::from_static(b"x")) + .unwrap(); assert_eq!(reader.drain(), Vec::::new(), "ordered: {ordered}"); assert_eq!( reader.resolved(), @@ -15195,17 +15199,25 @@ mod test { }); moq_clock::sim::yield_now().await; assert!(!parked.is_finished(), "1 waits on 0 starting"); - groups[0].write_frame(at(3000), bytes::Bytes::from_static(b"x")).unwrap(); + groups[0] + .write_frame(at(3000), bytes::Bytes::from_static(b"x")) + .unwrap(); moq_clock::sim::yield_now().await; assert!(!parked.is_finished(), "1 is read, and 2 waits on 1 starting"); // 1 stalls, so only 2 starting inside the end lifts the cap past it. - groups[2].write_frame(at(3200), bytes::Bytes::from_static(b"x")).unwrap(); + groups[2] + .write_frame(at(3200), bytes::Bytes::from_static(b"x")) + .unwrap(); let (one, two, mut subscriber) = parked.await.unwrap(); assert_eq!((one, two), (1, 2)); assert_eq!(drain(&mut subscriber), vec![3], "3 is the speculative one now"); - groups[3].write_frame(at(3600), bytes::Bytes::from_static(b"x")).unwrap(); - groups[1].write_frame(at(3100), bytes::Bytes::from_static(b"x")).unwrap(); + groups[3] + .write_frame(at(3600), bytes::Bytes::from_static(b"x")) + .unwrap(); + groups[1] + .write_frame(at(3100), bytes::Bytes::from_static(b"x")) + .unwrap(); assert_eq!( subscriber.resolved_ranges(), vec![Some(Resolved { start: 0, end: Some(3) })] From cf2fb1ca09e8dd73744a515ee036deec0287a1f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:00:26 -0700 Subject: [PATCH 33/48] docs: link time anchor migration notes to #6074 Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 2 +- dart/moq/MIGRATE.md | 2 +- go/wrapper/MIGRATE.md | 2 +- kt/moq/MIGRATE.md | 2 +- py/moq-rs/MIGRATE.md | 2 +- rs/moq-c/MIGRATE.md | 2 +- rs/moq-ffi/MIGRATE.md | 2 +- rs/moq-net/MIGRATE.md | 2 +- swift/MIGRATE.md | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 2f566d9809b..8e7af1c0068 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `Anchor` gains a `Time` case holding `timestamp_us`, the group containing that presentation time, so exhaustive visits need a case for it. `TrackConsumer::update` now returns a result, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index dd33e2a501d..3352c0dd031 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `Anchor` gains a time variant, so exhaustive switches need a case for it. `TrackConsumer.update` now throws on a time anchor an untimed track can't serve. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 72ff707cc71..739f179bfb8 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `AnchorTime` names the group containing a presentation time, and type switches over `Anchor` need a case for it. `TrackConsumer.Update` also fails on a time anchor an untimed track can't serve. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 64f3d6a2b9a..1c567cd68ed 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `Anchor` gains `AnchorTime`, so exhaustive `when` expressions need a branch for it. `TrackConsumer.update` now throws `MoqException` on a time anchor an untimed track can't serve. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index d51a935c54d..b2c399e0fee 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `Anchor` gains a `TIME` variant, the group containing a presentation time. `TrackConsumer.update` now raises on a time anchor an untimed track can't serve. diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 23b393ab95c..a271aa911f7 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. `moq_consume_track_update` fails with a negative code when the track refuses an update, such as a time anchor on an untimed track, leaving the subscription as it was. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 8cd072ebea6..eb63e9e7f86 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,7 +5,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased -### Anchors can name a time +### 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. `MoqTrackConsumer::update` now returns `Result<(), MoqError>`, failing with `TimestampMismatch` on a time anchor an untimed track can't serve. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 21c453c3c34..7d80b032866 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### 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. Subscribing or updating with one on an untimed track fails with `Error::TimestampMismatch`. diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 896c17f1f0d..0d6250466b0 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Anchors can name a time +### Anchors can name a time (#6074) `Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. `TrackConsumer.update(subscription:)` now `throws`, on a time anchor an untimed track can't serve; call it with `try`. From bce205ea65b94b14c878a8472cfb674b297e2096 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:20:46 -0700 Subject: [PATCH 34/48] fix(moq-net): judge a waiting window's datagrams against time starts A track that only sends datagrams starts no group, so a time start never resolves and the window stays pending, which let every datagram through. A datagram is a one-frame group, so while the ranges wait it is judged by its own timestamp against each range's time start and end. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 39 +++++++++++++++------ rs/moq-net/src/model/track.rs | 20 +++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 9bd42c285ba..2979be06b14 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -329,17 +329,26 @@ impl Window { } /// Whether a datagram is in range: a whole group of one frame, so its own `timestamp` - /// says exactly whether it falls inside a time end. + /// says exactly whether it falls inside a time end. While the ranges wait, which is + /// for good on a track that only sends datagrams, it is judged against both times. pub fn admits_datagram(&self, sequence: u64, timestamp: Option) -> bool { - self.pending - || self.spans.iter().any(|span| { - span.start <= sequence - && match span.end { - End::Before(end) => sequence < end, - End::Open => true, - End::Time(time) => timestamp.is_none_or(|timestamp| !later(timestamp, time.pts)), - } - }) + let Some(timestamp) = timestamp else { + return self.pending || self.contains(sequence); + }; + if self.pending { + return self.ranges.iter().filter(|range| !range.is_empty()).any(|range| { + !matches!(range.start, Anchor::Time(pts) if later(pts, timestamp)) + && !matches!(range.end, Some(Anchor::Time(pts)) if later(timestamp, pts)) + }); + } + self.spans.iter().any(|span| { + span.start <= sequence + && match span.end { + End::Before(end) => sequence < end, + End::Open => true, + End::Time(time) => !later(timestamp, time.pts), + } + }) } /// The lowest group in range, `None` while a relative anchor waits. Nothing in range @@ -839,6 +848,16 @@ mod tests { assert!(!window.admits_datagram(9, Some(at(3600)))); } + #[test] + fn a_waiting_window_judges_a_datagram_against_both_times() { + let window = Window::new(&[range(time(3000), time(3500))], None, &Groups::default()).unwrap(); + assert!(window.is_pending(), "no group has started"); + assert!(!window.admits_datagram(0, Some(at(2900)))); + assert!(window.admits_datagram(1, Some(at(3000)))); + assert!(window.admits_datagram(2, Some(at(3500)))); + assert!(!window.admits_datagram(3, Some(at(3600)))); + } + #[test] fn an_untimed_track_refuses_time_anchors() { assert!(matches!( diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 28e76f20f25..04a13ea4698 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15506,6 +15506,26 @@ mod test { assert_eq!(read, vec![0, 1]); } + /// A track of only datagrams starts no group, so a time start is judged against each + /// datagram's own timestamp rather than letting every one through. + #[test] + fn a_datagram_is_judged_against_a_time_start_by_its_timestamp() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Time(at(3500)), + end: None, + }])); + for millis in [3000, 3400, 3500, 3700] { + producer.append_datagram(at(millis), &b"x"[..]).unwrap(); + } + let mut read = Vec::new(); + while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { + read.push(datagram.sequence); + } + assert_eq!(read, vec![2, 3]); + } + /// An untimed track refuses a time anchor wherever the ranges reach it. #[test] #[cfg(feature = "async")] From ca6e68bf1b96e4b92aa8974c85dbb6af314ec61e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:21:34 -0700 Subject: [PATCH 35/48] fix(moq-net): format the session bench's history in its id #6028 added a `history` argument to the bench id without a specifier, which fails to compile the bench on main. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index bda41066c2c..c4fe599cdb4 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -155,7 +155,7 @@ impl Shape { fn id(&self, version: &str) -> String { format!( - "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}{}{}{}{}{}", + "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}{}{}{}{}{}{}", self.relays, self.publishers, self.broadcasts, From 7e09fee2650ecbf84f7ce79783eec7c513740754 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:33:58 -0700 Subject: [PATCH 36/48] fix(moq-net): judge every datagram against its range's times A datagram the starts index never sees can sit between the groups that settle a time end, so settling to sequences alone let one presenting past the end through. A datagram is a one-frame group, so its own timestamp now checks each range's time start and end even after they resolve. Note in moq-c's migration that an update queued before the subscription is established is refused through the callback. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/MIGRATE.md | 1 + rs/moq-net/src/model/subscription/window.rs | 35 +++++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 061877fae3a..6ce1689b31f 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. `moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. `moq_consume_track_update` fails with a negative code when the track refuses an update, such as a time anchor on an untimed track, leaving the subscription as it was. +An update made before the subscription is established applies once it is, so a refusal then ends the consumer through its callback. ### JSON values carry their timestamp (#5677) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 2979be06b14..8e5379f85be 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -329,25 +329,26 @@ impl Window { } /// Whether a datagram is in range: a whole group of one frame, so its own `timestamp` - /// says exactly whether it falls inside a time end. While the ranges wait, which is - /// for good on a track that only sends datagrams, it is judged against both times. + /// says exactly whether it falls inside a range's times, even once groups have + /// resolved them to sequences. While the ranges wait, which is for good on a track + /// that only sends datagrams, the times alone decide. pub fn admits_datagram(&self, sequence: u64, timestamp: Option) -> bool { let Some(timestamp) = timestamp else { - return self.pending || self.contains(sequence); + return self.contains(sequence); + }; + let timely = |range: &Range| { + !matches!(range.start, Anchor::Time(pts) if later(pts, timestamp)) + && !matches!(range.end, Some(Anchor::Time(pts)) if later(timestamp, pts)) }; if self.pending { - return self.ranges.iter().filter(|range| !range.is_empty()).any(|range| { - !matches!(range.start, Anchor::Time(pts) if later(pts, timestamp)) - && !matches!(range.end, Some(Anchor::Time(pts)) if later(timestamp, pts)) - }); + return self.ranges.iter().filter(|range| !range.is_empty()).any(timely); } self.spans.iter().any(|span| { span.start <= sequence && match span.end { End::Before(end) => sequence < end, - End::Open => true, - End::Time(time) => !later(timestamp, time.pts), - } + End::Open | End::Time(_) => true, + } && timely(&self.ranges[span.index]) }) } @@ -848,6 +849,20 @@ mod tests { assert!(!window.admits_datagram(9, Some(at(3600)))); } + #[test] + fn a_settled_window_still_judges_a_datagram_by_its_times() { + // 1 is a datagram the starts index never sees: the end settles before 2 by + // sequence, but 1 presents past it. + let groups = Groups { + gone: vec![1..2], + ..Groups::new([Groups::started(0, 0), Groups::started(2, 10_000)]) + }; + let window = Window::new(&[range(time(0), time(5000))], None, &groups).unwrap(); + assert_eq!(window.resolved(), vec![Some(Resolved { start: 0, end: Some(2) })]); + assert!(window.admits_datagram(1, Some(at(4000)))); + assert!(!window.admits_datagram(1, Some(at(7000)))); + } + #[test] fn a_waiting_window_judges_a_datagram_against_both_times() { let window = Window::new(&[range(time(3000), time(3500))], None, &Groups::default()).unwrap(); From fed180d3017227f2d14ffffdefc36392b14e3683 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:35:30 -0700 Subject: [PATCH 37/48] test(moq-net): build the single gone run without tripping clippy Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 8e5379f85be..4d7c3cf96fa 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -854,7 +854,7 @@ mod tests { // 1 is a datagram the starts index never sees: the end settles before 2 by // sequence, but 1 presents past it. let groups = Groups { - gone: vec![1..2], + gone: std::iter::once(1..2).collect(), ..Groups::new([Groups::started(0, 0), Groups::started(2, 10_000)]) }; let window = Window::new(&[range(time(0), time(5000))], None, &groups).unwrap(); From 1554bb302cae177110989368bf6e65caf386da28 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:39:28 -0700 Subject: [PATCH 38/48] fix(moq-net): keep absolute groups on a waiting window's datagrams A time start over a track that only sends datagrams never resolves, so the waiting check judged datagrams by their times alone and ignored an absolute start or end beside them. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 4d7c3cf96fa..f606a7b2a5d 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -330,8 +330,8 @@ impl Window { /// Whether a datagram is in range: a whole group of one frame, so its own `timestamp` /// says exactly whether it falls inside a range's times, even once groups have - /// resolved them to sequences. While the ranges wait, which is for good on a track - /// that only sends datagrams, the times alone decide. + /// resolved them to sequences. While the ranges wait, which is for good on a time + /// start over a track that only sends datagrams, the times and absolute groups decide. pub fn admits_datagram(&self, sequence: u64, timestamp: Option) -> bool { let Some(timestamp) = timestamp else { return self.contains(sequence); @@ -341,7 +341,12 @@ impl Window { && !matches!(range.end, Some(Anchor::Time(pts)) if later(timestamp, pts)) }; if self.pending { - return self.ranges.iter().filter(|range| !range.is_empty()).any(timely); + return self.ranges.iter().filter(|range| !range.is_empty()).any(|range| { + !matches!(range.start, Anchor::Absolute(start) if sequence < start.group) + && !matches!(range.end, Some(Anchor::Absolute(end)) + if end.final_sequence().is_some_and(|end| sequence >= end)) + && timely(range) + }); } self.spans.iter().any(|span| { span.start <= sequence @@ -873,6 +878,20 @@ mod tests { assert!(!window.admits_datagram(3, Some(at(3600)))); } + #[test] + fn a_waiting_window_judges_a_datagram_against_absolute_groups_too() { + let window = Window::new( + &[range(time(3000), Some(Anchor::Absolute(Position::group(10))))], + None, + &Groups::default(), + ) + .unwrap(); + assert!(window.is_pending(), "no group has started"); + assert!(window.admits_datagram(9, Some(at(3500)))); + assert!(!window.admits_datagram(10, Some(at(3500)))); + assert!(!window.admits_datagram(9, Some(at(2900)))); + } + #[test] fn an_untimed_track_refuses_time_anchors() { assert!(matches!( From f33cbe813038c5fc7d0bca9d0b841c7b22cfcf66 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:50:07 -0700 Subject: [PATCH 39/48] fix(moq-net): charge the starts index to each cached group Each cached group's frame-0 start now sits in the track's starts index, so the per-group overhead charges that entry as it does lookup, arrival, and evict. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 04a13ea4698..11c8103034a 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -624,12 +624,14 @@ impl Slot { /// Heap the track keeps per cached group, excluding the group itself /// ([`group::CACHE_OVERHEAD`]). /// -/// One [`Slot`] under its sequence in `lookup`, plus a hint in each of `arrival` and -/// `evict`. Doubled because both containers run half empty in the worst case: a +/// One [`Slot`] under its sequence in `lookup`, its start in `starts` once frame 0 +/// arrives, plus a hint in each of `arrival` and `evict`. Doubled because the +/// containers run half empty in the worst case: a /// `BTreeMap` node sits between half and fully packed, and a `VecDeque` holds up to /// twice the entries in it. Half of [`cache::ENTRY_OVERHEAD`]; see it for why this is /// derived rather than measured. -pub(super) const CACHE_OVERHEAD: u64 = 2 * (size_of::() + size_of::() + 2 * size_of::<(u64, u32)>()) as u64; +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 From c6a87f4a6e16b7b5ee66d86f24e36ef240403fa5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:58:00 -0700 Subject: [PATCH 40/48] fix(moq-net): settle a time end on a group that starts mid-settle A group the held walk stopped on as pending can present its first frame past the end before the settle walk reaches it. The settle walk then moved the end there without advancing, and spun on the same group: it now settles there. Recompute drift whenever a parked read's window moves, so staleness is judged against the new servable edge. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 61 ++++++++++++++++++++- rs/moq-net/src/model/track.rs | 4 ++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index f606a7b2a5d..701180d4320 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -232,7 +232,12 @@ impl TimeEnd { match next { Some((sequence, held)) if sequence == self.accounted => match held { Held::Pending => return None, - Held::Started(at) if later(at, self.pts) => self.after = Some(sequence), + // It started past `pts` since the walk above stopped on it: everything + // below is accounted for, so the end falls here. + Held::Started(at) if later(at, self.pts) => { + self.after = Some(sequence); + return self.after; + } _ => self.accounted += 1, }, _ => { @@ -854,6 +859,60 @@ mod tests { assert!(!window.admits_datagram(9, Some(at(3600)))); } + /// Groups whose `racing` one starts past the end after the first time it is asked + /// about, as when its first frame lands between the walks of one settle. + struct Racing { + groups: Groups, + racing: u64, + asked: Cell, + } + + impl Holdings for Racing { + fn timed(&self) -> bool { + true + } + + fn containing(&self, pts: Timestamp) -> Containing { + self.groups.containing(pts) + } + + fn next_start(&self, from: u64) -> Option<(u64, Timestamp)> { + self.groups.next_start(from) + } + + fn next_held(&self, from: u64) -> Option<(u64, Held)> { + let next = self.groups.next_held(from); + if next.is_some_and(|(sequence, _)| sequence == self.racing) && self.asked.replace(true) { + return Some((self.racing, Held::Started(at(6000)))); + } + next + } + + fn gone(&self, sequence: u64) -> Option { + self.groups.gone(sequence) + } + + fn complete(&self) -> bool { + false + } + } + + #[test] + fn a_time_end_settles_on_a_group_that_starts_between_its_walks() { + let racing = Racing { + groups: Groups::new([Groups::started(0, 0), (1, Held::Pending), Groups::started(2, 7000)]), + racing: 1, + asked: Cell::new(false), + }; + let window = Window::new( + &[range(Anchor::Absolute(Position::group(0)), time(5000))], + None, + &racing, + ) + .unwrap(); + assert_eq!(window.resolved(), vec![Some(Resolved { start: 0, end: Some(1) })]); + } + #[test] fn a_settled_window_still_judges_a_datagram_by_its_times() { // 1 is a datagram the starts index never sees: the end settles before 2 by diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 11c8103034a..5e118d304c7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5522,8 +5522,10 @@ impl Cursor { None => { let floor = self.floor(); let polled = self.poll(waiter, |state| state.poll_recv_group(self.index, floor)); + // A moved window moved the servable edge `drift` was judged against. if polled.is_pending() && self.watch_time(waiter) { self.admit(); + drift = ready!(self.poll_drift(&self.servable, waiter))?; continue; } let Some((producer, found_index)) = ready!(polled)? else { @@ -5668,7 +5670,9 @@ impl Cursor { let group = loop { let window = &self.filter.window; let polled = self.poll(waiter, |state| state.poll_next_in_range(floor, end, window)); + // A moved window moved the servable edge `drift` was judged against. if polled.is_pending() && self.watch_time(waiter) { + drift = ready!(self.poll_drift(&self.servable, waiter))?; continue; } let Some(producer) = ready!(polled)? else { From 9f3c64f0b57b5312af4fa53936f15ba32369f540 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 10:59:22 -0700 Subject: [PATCH 41/48] docs(moq-net): state the per-datagram rule for time anchors A datagram is judged on its own, by its timestamp against the range's times and its sequence against the groups resolved so far, never through the settle walk. Record the maintainer's choice and why refusing time anchors on datagram tracks can't work. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 2 +- quest/m1/subscribe-ranges/README.md | 6 ++++++ rs/moq-net/src/model/subscription.rs | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index e889553cb1d..1525c7a0b4b 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -245,7 +245,7 @@ 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 may let one group through before its first frame shows it starts past the end: the first group that has yet to start is handed out without waiting for that -frame, and the next one waits on it. +frame, and the next one waits on it. A datagram is judged by its own timestamp. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 58ad9d97825..9b6fd138f8d 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -73,6 +73,12 @@ relay bounds a sparse FETCH only if the model can request ranges: [lite-07 finalizes](/quest/m1/lite07-finalize.md), which this line gates. This amends the 09-29 premise that ranges are group-only because a group's timestamp isn't always known: timed tracks know it, and untimed ones refuse. The cache half landed in its own PR after #5929. + A datagram is judged on its own (maintainer, 2026-10-20): its timestamp + against the range's times, its sequence against the groups resolved so far, + never through the settle walk. Refusing time anchors on datagram tracks was + considered and can't work: nothing signals that a track carries datagrams, + and the lite draft lets a publisher send any single-frame group as a + datagram, including a copy of one it also streams. - Ranges are frame-precise (`Position`), not whole groups. The IETF joining FETCH for a mid-group SUBSCRIBE's uncached prefix stays, because today's bridge relies on it (maintainer, 09-29). diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 9eedffa0d70..78801d723ef 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -128,8 +128,9 @@ pub enum Anchor { /// after it is known and every sequence below that one is accounted for (started, /// dropped, or never to start). Until then reads stop at the first held group that /// may still start, so at the live edge at most that one group past the end is read - /// before its first frame shows where it starts. A datagram is judged by its own - /// timestamp. An untimed track refuses it with + /// before its first frame shows where it starts. A datagram skips all of that: it is + /// judged on its own, its timestamp against the range's times and its sequence against + /// the range's groups as far as they have resolved. An untimed track refuses it with /// [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } From 5b96b3f925f4a5a1989c1a8d29968741f0312ee8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 11:10:42 -0700 Subject: [PATCH 42/48] fix(moq-net): end a time end's walks at the last sequence A group at u64::MAX starting inside the end made the next unknown sequence saturate back onto it, so both walks spun on the same group. Nothing sits above the last sequence, so the walks now stop there. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 47 ++++++++++++++++----- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 701180d4320..1113621da20 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -178,9 +178,12 @@ impl TimeEnd { } } - /// The lowest sequence not known to be in range. - fn unknown(&self, start: u64) -> u64 { - self.known.map_or(start, |known| known.saturating_add(1)).max(start) + /// The lowest sequence not known to be in range, `None` once the last sequence is. + fn unknown(&self, start: u64) -> Option { + match self.known { + Some(known) => known.checked_add(1).map(|next| next.max(start)), + None => Some(start), + } } /// Walk the held groups on from where the last call stopped. Returns where the end @@ -188,7 +191,8 @@ impl TimeEnd { fn advance(&mut self, start: u64, holdings: &impl Holdings) -> Option { // The starts index passes a group that may still start, so a stalled one never // keeps a later group known to start inside the end from being read. - while let Some((sequence, at)) = holdings.next_start(self.unknown(start)) + while let Some(from) = self.unknown(start) + && let Some((sequence, at)) = holdings.next_start(from) && self.after.is_none_or(|after| sequence < after) { match later(at, self.pts) { @@ -198,9 +202,9 @@ impl TimeEnd { } // The first held group that may still start is the one read on speculation. - self.held = self.held.max(self.unknown(start)); self.speculative = None; - while let Some((sequence, held)) = holdings.next_held(self.held) + while let Some(from) = self.unknown(start) + && let Some((sequence, held)) = holdings.next_held(self.held.max(from)) && self.after.is_none_or(|after| sequence < after) { match held { @@ -213,7 +217,9 @@ impl TimeEnd { break; } } - self.held = sequence.saturating_add(1).max(self.unknown(start)); + // Nothing sits above the last sequence, so the walk ends on it. + let Some(next) = sequence.checked_add(1) else { break }; + self.held = next; } self.limit = match (self.speculative, self.after) { _ if holdings.complete() => None, @@ -226,7 +232,8 @@ impl TimeEnd { // Settle once every sequence up to the first group starting past `pts` can no // longer start inside it. let after = self.after?; - self.accounted = self.accounted.max(self.unknown(start)); + // Everything at or below `known` is in range, and `after` lies above it. + self.accounted = self.accounted.max(self.unknown(start).unwrap_or(after)); while self.accounted < after { let next = holdings.next_held(self.accounted); match next { @@ -317,12 +324,12 @@ impl Window { } /// The lowest sequence whose start could still move an unsettled time end, `None` - /// without one. + /// without one or once the last sequence is in range. pub fn watch_starts(&self) -> Option { self.spans .iter() .filter_map(|span| match span.end { - End::Time(time) => Some(time.unknown(span.start)), + End::Time(time) => time.unknown(span.start), _ => None, }) .min() @@ -913,6 +920,26 @@ mod tests { assert_eq!(window.resolved(), vec![Some(Resolved { start: 0, end: Some(1) })]); } + #[test] + fn a_time_end_stops_walking_at_the_last_sequence() { + let groups = Groups::new([Groups::started(u64::MAX, 3000)]); + let window = Window::new( + &[range(Anchor::Absolute(Position::group(u64::MAX)), time(5000))], + None, + &groups, + ) + .unwrap(); + assert_eq!( + window.resolved(), + vec![Some(Resolved { + start: u64::MAX, + end: None + })] + ); + assert!(window.contains(u64::MAX)); + assert_eq!(window.watch_starts(), None, "nothing above the last sequence can start"); + } + #[test] fn a_settled_window_still_judges_a_datagram_by_its_times() { // 1 is a datagram the starts index never sees: the end settles before 2 by From 68a40c7e51f21c61e824d8bfed4ac19481f8bc47 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 11:22:52 -0700 Subject: [PATCH 43/48] fix(moq-net): read a missing sequence past a time end on speculation The held walk jumped over missing sequences to the next held group, so when that group was never to start, still pending, or past the end, every sequence in the gap that arrived later fell under the cap and was read. A walk over sequence positions now treats the first missing one that may still arrive as the speculative group, and only jumps to a group known to start inside the end. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 76 +++++++++++++++------ 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 1113621da20..1004ac0cecb 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -201,31 +201,51 @@ impl TimeEnd { } } - // The first held group that may still start is the one read on speculation. + // The first sequence above `known` that may still start is the one read on + // speculation: a held group yet to start, or a missing one that may arrive. Only a + // group starting inside the end is jumped to, since it puts all below it in range. self.speculative = None; - while let Some(from) = self.unknown(start) - && let Some((sequence, held)) = holdings.next_held(self.held.max(from)) - && self.after.is_none_or(|after| sequence < after) - { - match held { - Held::Started(at) if later(at, self.pts) => self.after = Some(sequence), - Held::Started(_) => self.known = Some(sequence), - Held::Never => {} - Held::Pending => { - self.held = sequence; - self.speculative = Some(sequence); - break; - } + while let Some(from) = self.unknown(start) { + let at = self.held.max(from); + if self.after.is_some_and(|after| at >= after) { + break; + } + let next = holdings + .next_held(at) + .filter(|(sequence, _)| self.after.is_none_or(|after| *sequence < after)); + match next { + Some((sequence, held)) if sequence == at => match held { + Held::Started(time) if later(time, self.pts) => self.after = Some(at), + Held::Started(_) => self.known = Some(at), + Held::Never => {} + Held::Pending => { + self.held = at; + self.speculative = Some(at); + break; + } + }, + Some((sequence, Held::Started(time))) if !later(time, self.pts) => self.known = Some(sequence), + _ => match holdings.gone(at) { + Some(end) if end > at => { + self.held = end; + continue; + } + _ => { + self.held = at; + self.speculative = (!holdings.complete()).then_some(at); + break; + } + }, } // Nothing sits above the last sequence, so the walk ends on it. - let Some(next) = sequence.checked_add(1) else { break }; + let Some(next) = at.checked_add(1) else { break }; self.held = next; } self.limit = match (self.speculative, self.after) { _ if holdings.complete() => None, (Some(speculative), _) => speculative.checked_add(1), (None, Some(after)) => Some(after), - // Every held group is accounted for: the next to arrive is the speculative one. + // Every sequence from here on is gone, or the last one is in range. (None, None) => None, }; @@ -765,13 +785,13 @@ mod tests { assert!(!window.is_settled(), "4 and 5 may still arrive inside the end"); assert_eq!( covered(&window, 8), - vec![3, 4, 5], - "but never 6, known to start past it" + vec![3, 4], + "only 4, the first that may still arrive, on speculation, and never 6" ); - // 4 arrives yet to start: the one read on speculation, so 5 waits. + // 4 arrives yet to start: still the one read on speculation, so 5 waits. groups.held.insert(4, Held::Pending); - assert!(window.settle(Some(6), &groups)); + assert!(!window.settle(Some(6), &groups), "4 was already the speculative one"); assert_eq!(covered(&window, 8), vec![3, 4]); groups.held.insert(4, Held::Started(at(3500))); assert!(window.settle(Some(6), &groups)); @@ -920,6 +940,22 @@ mod tests { assert_eq!(window.resolved(), vec![Some(Resolved { start: 0, end: Some(1) })]); } + #[test] + fn a_missing_sequence_past_a_time_end_is_the_one_read_on_speculation() { + // 1 and 2 are missing, and 3 never starts: the walk may not pass the gap, or every + // sequence in it that arrives later is read past the end. + let mut groups = Groups::new([Groups::started(0, 3000), (3, Held::Never)]); + let mut window = Window::new(&[range(time(3000), time(3500))], Some(3), &groups).unwrap(); + assert_eq!(covered(&window, 5), vec![0, 1]); + groups.held.insert(2, Held::Pending); + groups.held.insert(1, Held::Pending); + window.settle(Some(3), &groups); + assert_eq!(covered(&window, 5), vec![0, 1], "2 waits on 1 starting"); + groups.held.insert(1, Held::Started(at(3600))); + window.settle(Some(3), &groups); + assert_eq!(window.resolved(), vec![Some(Resolved { start: 0, end: Some(1) })]); + } + #[test] fn a_time_end_stops_walking_at_the_last_sequence() { let groups = Groups::new([Groups::started(u64::MAX, 3000)]); From 4ef73d3f9d9ae80cc43a810330d2690eba8c533c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 11:28:04 -0700 Subject: [PATCH 44/48] perf(moq-net): leave a time end uncapped past every held group Capping at the next sequence to arrive moved the cap with every group and rebuilt the window each time. A settle judges each arrival before it is read, so the live edge needs no cap; a missing sequence is read on speculation only below a held group or a known end. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 1004ac0cecb..120e38dc93a 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -225,6 +225,12 @@ impl TimeEnd { } }, Some((sequence, Held::Started(time))) if !later(time, self.pts) => self.known = Some(sequence), + // Past every held group: the next to arrive is the speculative one, judged + // once a settle sees it, before it is read. + None if self.after.is_none() => { + self.held = at; + break; + } _ => match holdings.gone(at) { Some(end) if end > at => { self.held = end; @@ -245,7 +251,7 @@ impl TimeEnd { _ if holdings.complete() => None, (Some(speculative), _) => speculative.checked_add(1), (None, Some(after)) => Some(after), - // Every sequence from here on is gone, or the last one is in range. + // Every held group is accounted for: the next to arrive is the speculative one. (None, None) => None, }; @@ -947,6 +953,7 @@ mod tests { let mut groups = Groups::new([Groups::started(0, 3000), (3, Held::Never)]); let mut window = Window::new(&[range(time(3000), time(3500))], Some(3), &groups).unwrap(); assert_eq!(covered(&window, 5), vec![0, 1]); + assert_eq!(window.speculative().collect::>(), vec![1]); groups.held.insert(2, Held::Pending); groups.held.insert(1, Held::Pending); window.settle(Some(3), &groups); From 3ea298982dd8b5d6bc8f37e41f16abd6af93ec3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 11:40:51 -0700 Subject: [PATCH 45/48] fix(moq-net): place a datagram's group against a time end by its start A datagram filling a gap counted as the sequence having arrived, so a time end settled across it without knowing where that group starts, and the same group streaming later was read past the end. A datagram is its group's frame 0, so the starts index now holds each buffered datagram's timestamp, dropped when the datagram leaves the buffer unless the group is cached. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 87 ++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5e118d304c7..cc32530d0d5 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -351,9 +351,10 @@ pub(crate) struct TrackState { // the read guard a first frame holds, so it needs its own lock, never contended. bounds: Mutex>, - // Where each cached group's frame 0 presented, by sequence, received groups too: what - // a time anchor resolves against without walking groups that have not started. Filled - // through the read guard a first frame holds, like `bounds`. + // Where each cached group's frame 0 presented, by sequence, received groups too, and + // each buffered datagram's: what a time anchor resolves against without walking groups + // that have not started. Filled through the read guard a first frame holds, like + // `bounds`; datagrams hold at most `MAX_DATAGRAMS` entries beyond the cached groups. starts: Mutex>, // The sequence and start of the highest-sequence group that left the cache holding a @@ -962,17 +963,42 @@ impl TrackState { /// Push a datagram, dropping the oldest when the send buffer is full. fn push_datagram(&mut self, datagram: super::datagram::Stored) { - if self.datagrams.len() == MAX_DATAGRAMS { - self.datagrams.pop_front(); + if self.datagrams.len() == MAX_DATAGRAMS + && let Some(oldest) = self.datagrams.pop_front() + { + self.unindex_datagram(oldest.sequence); self.datagram_offset += 1; } let sequence = datagram.sequence; + let start = datagram.timestamp; self.datagrams.push_back(datagram); let now = self.cache.pool().now_wall(); self.record_arrival(sequence, now); + // A datagram is its group's frame 0, so it says where that group starts: a time + // end must not count the sequence as arrived without knowing, since the same group + // may still stream. + if let Some(start) = start { + self.starts + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .entry(sequence) + .or_insert(start); + self.cache.wakes().started(sequence); + } self.shown(sequence); } + /// A datagram left the send buffer: forget where its group starts, unless the group is + /// cached and indexes its own start. + fn unindex_datagram(&mut self, sequence: u64) { + if !self.lookup.contains_key(&sequence) { + self.starts + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .remove(&sequence); + } + } + /// Find the smallest-sequence cached group in `window` satisfying /// `next_sequence <= seq < end_sequence (if set)`. Used by /// [`Subscriber::next_group`] so the range can be widened (or unset) @@ -1569,10 +1595,13 @@ impl TrackState { .get_mut() .unwrap_or_else(PoisonError::into_inner) .retain(|sequence, _| lookup.contains_key(sequence)); + let datagrams = &self.datagrams; self.starts .get_mut() .unwrap_or_else(PoisonError::into_inner) - .retain(|sequence, _| lookup.contains_key(sequence)); + .retain(|sequence, _| { + lookup.contains_key(sequence) || datagrams.iter().any(|datagram| datagram.sequence == *sequence) + }); } /// Take `sequence` out of the cache, keeping the withheld count in step. @@ -1583,10 +1612,12 @@ impl TrackState { .get_mut() .unwrap_or_else(PoisonError::into_inner) .remove(&sequence); - self.starts - .get_mut() - .unwrap_or_else(PoisonError::into_inner) - .remove(&sequence); + if !self.holds_datagram(sequence) { + self.starts + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .remove(&sequence); + } Some(slot) } @@ -2136,7 +2167,9 @@ impl TrackState { .find(|(_, slot)| slot.is_shown()) .map(|(sequence, _)| *sequence); self.datagram_offset += self.datagrams.len(); - self.datagrams.clear(); + for datagram in std::mem::take(&mut self.datagrams) { + self.unindex_datagram(datagram.sequence); + } } /// The route answered with its largest position (`None` for nothing yet): the cache @@ -15493,6 +15526,38 @@ mod test { /// A datagram is a whole group of one frame, so its own timestamp says whether it falls /// inside a time end, without one ever settling from the groups. + /// A datagram arriving in a gap says where its group starts, so a time end does not + /// settle as if the gap held a group inside it: the same group may still stream. + #[test] + #[cfg(feature = "async")] + fn a_datagram_in_a_gap_places_its_group_against_a_time_end() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(3500))), + }])); + let mut zero = producer.append_group().unwrap(); + zero.write_frame(at(3000), &b"x"[..]).unwrap(); + zero.finish().unwrap(); + producer.insert_datagram(1, at(3600), &b"x"[..]).unwrap(); + let mut two = producer.create_group(group::Info { sequence: 2 }).unwrap(); + two.write_frame(at(4000), &b"x"[..]).unwrap(); + two.finish().unwrap(); + assert_eq!(drain(&mut subscriber), vec![0]); + assert_eq!( + subscriber.resolved_ranges(), + vec![Some(Resolved { start: 0, end: Some(1) })], + "the end falls before the datagram, which starts past it" + ); + + // The same group streams after its datagram: it starts past the end too. + let mut one = producer.create_group(group::Info { sequence: 1 }).unwrap(); + one.write_frame(at(3600), &b"x"[..]).unwrap(); + one.finish().unwrap(); + assert_eq!(drain(&mut subscriber), Vec::::new()); + } + #[test] #[cfg(feature = "async")] fn a_datagram_is_judged_against_a_time_end_by_its_timestamp() { From 771c1558ca43e4a419241091b3b018057b4aad23 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 11:51:32 -0700 Subject: [PATCH 46/48] fix(moq-net): judge datagrams by their anchors, and keep the starts ascending A datagram is judged on its own: its timestamp against a range's times and its sequence against its absolute and resolved relative groups, never against where a time end settled. A datagram that would rewind the starts index is left out of it, so the index stays ascending for the groups a time end settles against. Co-Authored-By: Claude Opus 5.5 --- quest/m1/subscribe-ranges/README.md | 10 ++-- rs/moq-net/src/model/subscription.rs | 4 +- rs/moq-net/src/model/subscription/window.rs | 48 +++++++++--------- rs/moq-net/src/model/track.rs | 55 ++++++++++++++++++--- 4 files changed, 80 insertions(+), 37 deletions(-) diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 9b6fd138f8d..604c55872c5 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -74,11 +74,11 @@ relay bounds a sparse FETCH only if the model can request ranges: premise that ranges are group-only because a group's timestamp isn't always known: timed tracks know it, and untimed ones refuse. The cache half landed in its own PR after #5929. A datagram is judged on its own (maintainer, 2026-10-20): its timestamp - against the range's times, its sequence against the groups resolved so far, - never through the settle walk. Refusing time anchors on datagram tracks was - considered and can't work: nothing signals that a track carries datagrams, - and the lite draft lets a publisher send any single-frame group as a - datagram, including a copy of one it also streams. + against the range's times, its sequence against the absolute and resolved + relative groups, never through the settle walk. Refusing time anchors on + datagram tracks was considered and can't work: nothing signals that a track + carries datagrams, and the lite draft lets a publisher send any single-frame + group as a datagram, including a copy of one it also streams. - Ranges are frame-precise (`Position`), not whole groups. The IETF joining FETCH for a mid-group SUBSCRIBE's uncached prefix stays, because today's bridge relies on it (maintainer, 09-29). diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 78801d723ef..98177f43798 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -130,8 +130,8 @@ pub enum Anchor { /// may still start, so at the live edge at most that one group past the end is read /// before its first frame shows where it starts. A datagram skips all of that: it is /// judged on its own, its timestamp against the range's times and its sequence against - /// the range's groups as far as they have resolved. An untimed track refuses it with - /// [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). + /// the range's absolute groups and the relative ones resolved so far. An untimed track + /// refuses it with [`Error::TimestampMismatch`](crate::Error::TimestampMismatch). Time(Timestamp), } diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 120e38dc93a..7dd3f980520 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -366,33 +366,35 @@ impl Window { self.pending || self.groups.contains(&sequence) } - /// Whether a datagram is in range: a whole group of one frame, so its own `timestamp` - /// says exactly whether it falls inside a range's times, even once groups have - /// resolved them to sequences. While the ranges wait, which is for good on a time - /// start over a track that only sends datagrams, the times and absolute groups decide. + /// Whether a datagram is in range, judged on its own and never through a time end's + /// settling: its `timestamp` against each range's times, and its `sequence` against the + /// range's absolute groups and the relative ones resolved so far. pub fn admits_datagram(&self, sequence: u64, timestamp: Option) -> bool { let Some(timestamp) = timestamp else { return self.contains(sequence); }; - let timely = |range: &Range| { - !matches!(range.start, Anchor::Time(pts) if later(pts, timestamp)) - && !matches!(range.end, Some(Anchor::Time(pts)) if later(timestamp, pts)) - }; - if self.pending { - return self.ranges.iter().filter(|range| !range.is_empty()).any(|range| { - !matches!(range.start, Anchor::Absolute(start) if sequence < start.group) - && !matches!(range.end, Some(Anchor::Absolute(end)) - if end.final_sequence().is_some_and(|end| sequence >= end)) - && timely(range) - }); - } - self.spans.iter().any(|span| { - span.start <= sequence - && match span.end { - End::Before(end) => sequence < end, - End::Open | End::Time(_) => true, - } && timely(&self.ranges[span.index]) - }) + self.ranges + .iter() + .enumerate() + .filter(|(_, range)| !range.is_empty()) + .any(|(index, range)| { + // A relative anchor still waiting on a latest group bounds nothing yet. + let span = self.spans.iter().find(|span| span.index == index); + let started = match range.start { + Anchor::Time(pts) => !later(pts, timestamp), + Anchor::Absolute(start) => sequence >= start.group, + Anchor::Relative(_) => span.is_none_or(|span| sequence >= span.start), + }; + let ended = match range.end { + None => true, + Some(Anchor::Time(pts)) => !later(timestamp, pts), + Some(Anchor::Absolute(end)) => end.final_sequence().is_none_or(|end| sequence < end), + Some(Anchor::Relative(_)) => { + span.is_none_or(|span| !matches!(span.end, End::Before(end) if sequence >= end)) + } + }; + started && ended + }) } /// The lowest group in range, `None` while a relative anchor waits. Nothing in range diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index cc32530d0d5..fa55a59d083 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -976,13 +976,21 @@ impl TrackState { self.record_arrival(sequence, now); // A datagram is its group's frame 0, so it says where that group starts: a time // end must not count the sequence as arrived without knowing, since the same group - // may still stream. - if let Some(start) = start { - self.starts - .get_mut() - .unwrap_or_else(PoisonError::into_inner) - .entry(sequence) - .or_insert(start); + // may still stream. Datagrams skip the start checks groups get, so only one that + // keeps the index ascending is indexed; another counts as arrived alone. + let starts = self.starts.get_mut().unwrap_or_else(PoisonError::into_inner); + if let Some(start) = start + && !starts.contains_key(&sequence) + && starts + .range(..sequence) + .next_back() + .is_none_or(|(_, below)| !later(*below, start)) + && starts + .range(sequence.saturating_add(1)..) + .next() + .is_none_or(|(_, above)| !later(start, *above)) + { + starts.insert(sequence, start); self.cache.wakes().started(sequence); } self.shown(sequence); @@ -15526,6 +15534,39 @@ mod test { /// A datagram is a whole group of one frame, so its own timestamp says whether it falls /// inside a time end, without one ever settling from the groups. + /// A datagram that rewinds is left out of the starts, which stay ascending for the groups + /// a time end settles against, and every datagram is still judged by its own timestamp. + #[test] + #[cfg(feature = "async")] + fn a_rewinding_datagram_stays_out_of_the_starts() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Time(at(75))), + }])); + for millis in [0, 100, 50, 200] { + producer.append_datagram(at(millis), &b"x"[..]).unwrap(); + } + assert_eq!( + producer + .state + .read() + .starts + .lock() + .unwrap() + .keys() + .copied() + .collect::>(), + vec![0, 1, 3] + ); + let mut read = Vec::new(); + while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { + read.push(datagram.sequence); + } + assert_eq!(read, vec![0, 2], "each judged by its own timestamp"); + } + /// A datagram arriving in a gap says where its group starts, so a time end does not /// settle as if the gap held a group inside it: the same group may still stream. #[test] From 682b22481caeca4866c15a8ddcec0e001447e7dd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 13:04:55 -0700 Subject: [PATCH 47/48] revert(moq-net): datagrams stay ephemeral, out of the starts index A buffered datagram no longer records where its group starts. Indexing them kept a time end from settling across a sequence that arrived only as a datagram, but it needed eviction, duplicate, and ordering bookkeeping against the stream starts, each with its own edge cases. The edge it covered, a stream copy of a group already sent as a datagram arriving after the end settled, is accepted and documented at the settle walk. Datagrams are still judged per datagram by their own timestamps. Co-Authored-By: Claude Opus 5.5 --- quest/m1/subscribe-ranges/README.md | 6 +- rs/moq-net/src/model/subscription/window.rs | 5 + rs/moq-net/src/model/track.rs | 128 ++------------------ 3 files changed, 21 insertions(+), 118 deletions(-) diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 604c55872c5..05b6dbeb7f4 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -78,7 +78,11 @@ relay bounds a sparse FETCH only if the model can request ranges: relative groups, never through the settle walk. Refusing time anchors on datagram tracks was considered and can't work: nothing signals that a track carries datagrams, and the lite draft lets a publisher send any single-frame - group as a datagram, including a copy of one it also streams. + group as a datagram, including a copy of one it also streams. Datagrams stay + ephemeral (maintainer, 2026-10-20): a buffered datagram never indexes where + its group starts, so a time end settles across a sequence that arrived only + as a datagram, and a stream copy of that group arriving later is read even + if it starts past the end. - Ranges are frame-precise (`Position`), not whole groups. The IETF joining FETCH for a mid-group SUBSCRIBE's uncached prefix stays, because today's bridge relies on it (maintainer, 09-29). diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 7dd3f980520..f52c06823a9 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -257,6 +257,11 @@ impl TimeEnd { // Settle once every sequence up to the first group starting past `pts` can no // longer start inside it. + // + // Datagrams stay ephemeral and never index where their group starts, so a sequence + // that arrived only as a datagram counts as gone here. A stream copy of that group + // arriving later is read even if it starts past `pts`: accepted, since a publisher + // that streams a group it also sent as a datagram is rare. let after = self.after?; // Everything at or below `known` is in range, and `after` lies above it. self.accounted = self.accounted.max(self.unknown(start).unwrap_or(after)); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index fa55a59d083..5e118d304c7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -351,10 +351,9 @@ pub(crate) struct TrackState { // the read guard a first frame holds, so it needs its own lock, never contended. bounds: Mutex>, - // Where each cached group's frame 0 presented, by sequence, received groups too, and - // each buffered datagram's: what a time anchor resolves against without walking groups - // that have not started. Filled through the read guard a first frame holds, like - // `bounds`; datagrams hold at most `MAX_DATAGRAMS` entries beyond the cached groups. + // Where each cached group's frame 0 presented, by sequence, received groups too: what + // a time anchor resolves against without walking groups that have not started. Filled + // through the read guard a first frame holds, like `bounds`. starts: Mutex>, // The sequence and start of the highest-sequence group that left the cache holding a @@ -963,50 +962,17 @@ impl TrackState { /// Push a datagram, dropping the oldest when the send buffer is full. fn push_datagram(&mut self, datagram: super::datagram::Stored) { - if self.datagrams.len() == MAX_DATAGRAMS - && let Some(oldest) = self.datagrams.pop_front() - { - self.unindex_datagram(oldest.sequence); + if self.datagrams.len() == MAX_DATAGRAMS { + self.datagrams.pop_front(); self.datagram_offset += 1; } let sequence = datagram.sequence; - let start = datagram.timestamp; self.datagrams.push_back(datagram); let now = self.cache.pool().now_wall(); self.record_arrival(sequence, now); - // A datagram is its group's frame 0, so it says where that group starts: a time - // end must not count the sequence as arrived without knowing, since the same group - // may still stream. Datagrams skip the start checks groups get, so only one that - // keeps the index ascending is indexed; another counts as arrived alone. - let starts = self.starts.get_mut().unwrap_or_else(PoisonError::into_inner); - if let Some(start) = start - && !starts.contains_key(&sequence) - && starts - .range(..sequence) - .next_back() - .is_none_or(|(_, below)| !later(*below, start)) - && starts - .range(sequence.saturating_add(1)..) - .next() - .is_none_or(|(_, above)| !later(start, *above)) - { - starts.insert(sequence, start); - self.cache.wakes().started(sequence); - } self.shown(sequence); } - /// A datagram left the send buffer: forget where its group starts, unless the group is - /// cached and indexes its own start. - fn unindex_datagram(&mut self, sequence: u64) { - if !self.lookup.contains_key(&sequence) { - self.starts - .get_mut() - .unwrap_or_else(PoisonError::into_inner) - .remove(&sequence); - } - } - /// Find the smallest-sequence cached group in `window` satisfying /// `next_sequence <= seq < end_sequence (if set)`. Used by /// [`Subscriber::next_group`] so the range can be widened (or unset) @@ -1603,13 +1569,10 @@ impl TrackState { .get_mut() .unwrap_or_else(PoisonError::into_inner) .retain(|sequence, _| lookup.contains_key(sequence)); - let datagrams = &self.datagrams; self.starts .get_mut() .unwrap_or_else(PoisonError::into_inner) - .retain(|sequence, _| { - lookup.contains_key(sequence) || datagrams.iter().any(|datagram| datagram.sequence == *sequence) - }); + .retain(|sequence, _| lookup.contains_key(sequence)); } /// Take `sequence` out of the cache, keeping the withheld count in step. @@ -1620,12 +1583,10 @@ impl TrackState { .get_mut() .unwrap_or_else(PoisonError::into_inner) .remove(&sequence); - if !self.holds_datagram(sequence) { - self.starts - .get_mut() - .unwrap_or_else(PoisonError::into_inner) - .remove(&sequence); - } + self.starts + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .remove(&sequence); Some(slot) } @@ -2175,9 +2136,7 @@ impl TrackState { .find(|(_, slot)| slot.is_shown()) .map(|(sequence, _)| *sequence); self.datagram_offset += self.datagrams.len(); - for datagram in std::mem::take(&mut self.datagrams) { - self.unindex_datagram(datagram.sequence); - } + self.datagrams.clear(); } /// The route answered with its largest position (`None` for nothing yet): the cache @@ -15534,71 +15493,6 @@ mod test { /// A datagram is a whole group of one frame, so its own timestamp says whether it falls /// inside a time end, without one ever settling from the groups. - /// A datagram that rewinds is left out of the starts, which stay ascending for the groups - /// a time end settles against, and every datagram is still judged by its own timestamp. - #[test] - #[cfg(feature = "async")] - fn a_rewinding_datagram_stays_out_of_the_starts() { - let at = |millis| Timestamp::from_millis(millis).unwrap(); - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(replay().with_ranges([Range { - start: Anchor::Absolute(Position::group(0)), - end: Some(Anchor::Time(at(75))), - }])); - for millis in [0, 100, 50, 200] { - producer.append_datagram(at(millis), &b"x"[..]).unwrap(); - } - assert_eq!( - producer - .state - .read() - .starts - .lock() - .unwrap() - .keys() - .copied() - .collect::>(), - vec![0, 1, 3] - ); - let mut read = Vec::new(); - while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { - read.push(datagram.sequence); - } - assert_eq!(read, vec![0, 2], "each judged by its own timestamp"); - } - - /// A datagram arriving in a gap says where its group starts, so a time end does not - /// settle as if the gap held a group inside it: the same group may still stream. - #[test] - #[cfg(feature = "async")] - fn a_datagram_in_a_gap_places_its_group_against_a_time_end() { - let at = |millis| Timestamp::from_millis(millis).unwrap(); - let mut producer = track_producer("test", Info::timed()); - let mut subscriber = producer.subscribe(replay().with_ranges([Range { - start: Anchor::Absolute(Position::group(0)), - end: Some(Anchor::Time(at(3500))), - }])); - let mut zero = producer.append_group().unwrap(); - zero.write_frame(at(3000), &b"x"[..]).unwrap(); - zero.finish().unwrap(); - producer.insert_datagram(1, at(3600), &b"x"[..]).unwrap(); - let mut two = producer.create_group(group::Info { sequence: 2 }).unwrap(); - two.write_frame(at(4000), &b"x"[..]).unwrap(); - two.finish().unwrap(); - assert_eq!(drain(&mut subscriber), vec![0]); - assert_eq!( - subscriber.resolved_ranges(), - vec![Some(Resolved { start: 0, end: Some(1) })], - "the end falls before the datagram, which starts past it" - ); - - // The same group streams after its datagram: it starts past the end too. - let mut one = producer.create_group(group::Info { sequence: 1 }).unwrap(); - one.write_frame(at(3600), &b"x"[..]).unwrap(); - one.finish().unwrap(); - assert_eq!(drain(&mut subscriber), Vec::::new()); - } - #[test] #[cfg(feature = "async")] fn a_datagram_is_judged_against_a_time_end_by_its_timestamp() { From f108fa9034b5caaa9c5bf15d8fa5771500b8941a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 13:40:25 -0700 Subject: [PATCH 48/48] test(moq-net): gate the datagram time start test on async recv_datagram exists only with the async feature, so the no-default features build failed to compile the test. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5e118d304c7..a216548f912 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15515,6 +15515,7 @@ mod test { /// A track of only datagrams starts no group, so a time start is judged against each /// datagram's own timestamp rather than letting every one through. #[test] + #[cfg(feature = "async")] fn a_datagram_is_judged_against_a_time_start_by_its_timestamp() { let at = |millis| Timestamp::from_millis(millis).unwrap(); let mut producer = track_producer("test", Info::timed());