From 5fdbb668fc80273f352cbcc3926801892a48cec7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 18 Oct 2026 23:28:36 -0700 Subject: [PATCH 01/20] fix(net)!: report a reader's largest within its local bounds track::Subscriber and track::Ordered largest()/poll_largest() report the largest location within the bounds set_groups set, instead of the track's. Past a cap of ..N they report the newest cached group below N; below the floor, nothing. Moving the bounds wakes a parked poll and reports again within the new ones, even below the last report. The resume cursor passes the reader's bounds to the serving copy. The JS Track.Subscriber and Track.Ordered largest() follow the same rules. moq-c's raw reader lifts the previous update's cap before reading the edge its relative ranges count from. Record the maintainer's change of the set_groups rename to limit in the serve quest. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 8 + js/net/src/track.test.ts | 96 +++++++++- js/net/src/track.ts | 72 +++++-- quest/m1/public-model/serve.md | 32 ++-- rs/moq-c/src/consume.rs | 4 +- rs/moq-net/MIGRATE.md | 8 + rs/moq-net/src/model/cache.rs | 53 ++---- rs/moq-net/src/model/resume.rs | 54 +++++- rs/moq-net/src/model/track.rs | 331 ++++++++++++++++++++++++++++----- 9 files changed, 542 insertions(+), 116 deletions(-) diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 36a3e004aed..513d5a5dc3c 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,14 @@ How to update your code for each breaking change, newest first. ## Unreleased +### A reader's `largest()` reports within its `setGroups` bounds + +`Track.Subscriber.largest()` and `Track.Ordered.largest()` report the largest location within the bounds set with `setGroups` or `withGroups`, where they used to report the track's. +Past an exclusive end of `N`, they report the newest cached group below `N`; below the floor, nothing. +Moving the bounds reports again within the new ones, even below the last report. +Unbounded readers and `Track.Producer.largest()` are unchanged. +To read the track's edge from a capped reader, lift the cap with `setGroups({})` first. + ### Track readers follow `largest()` instead of reading `latest()` (#5725) `latest()` on `Track.Subscriber` and `Track.Ordered` is gone, and `Subscriber.largest()` is no longer a synchronous buffered snapshot. diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index a5a9aea56da..2d96c568b1b 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1911,6 +1911,98 @@ test("a datagram raises the largest", async () => { producer.close(); }); +/** Write untimed group `sequence` with `frames` frames, left open. */ +function framedGroup(producer: TrackProducer, sequence: number, frames: number): GroupProducer { + const group = new GroupProducer(sequence); + producer.writeGroup(group); + for (let i = 0; i < frames; i++) group.writeFrame({ payload: enc.encode("x") }); + return group; +} + +/** Whether `promise` is still pending once queued work settles. */ +async function isPending(promise: Promise): Promise { + await settle(); + return (await Promise.race([promise.then(() => false), Promise.resolve(true)])) as boolean; +} + +// A cap below the live edge reports the newest cached group under it, follows that group's +// frames, and ignores the edge moving on. +test("largest reports within a cap below the live edge", async () => { + const producer = new TrackProducer("test").accept({}); + const groups = [0, 1, 2, 3, 4, 5].map((sequence) => framedGroup(producer, sequence, 2)); + + const reader = producer.subscribe().withGroups({ end: { excluded: 3 } }); + expect(await reader.largest()).toEqual({ group: 2, frame: 1 }); + + const next = reader.largest(); + expect(await isPending(next)).toBe(true); + groups[2].writeFrame({ payload: enc.encode("x") }); + expect(await next).toEqual({ group: 2, frame: 2 }); + + framedGroup(producer, 6, 1); + groups[5].writeFrame({ payload: enc.encode("x") }); + expect(await isPending(reader.largest())).toBe(true); + + const ordered = producer + .subscribe() + .ordered() + .withGroups({ end: { included: 3 } }); + expect(await ordered.largest()).toEqual({ group: 3, frame: 1 }); + producer.close(); +}); + +// A gap under the cap reports the newest group the cache holds there, and a floor above the +// edge reports nothing until a group reaches it. +test("largest skips gaps under the cap and waits for the floor", async () => { + const producer = new TrackProducer("test").accept({}); + framedGroup(producer, 0, 1); + framedGroup(producer, 1, 3); + framedGroup(producer, 5, 1); + + const capped = producer.subscribe().withGroups({ end: { excluded: 4 } }); + expect(await capped.largest()).toEqual({ group: 1, frame: 2 }); + + const floored = producer.subscribe().withGroups({ start: { included: 8 } }); + const next = floored.largest(); + expect(await isPending(next)).toBe(true); + framedGroup(producer, 8, 0); + expect(await next).toEqual({ group: 8, frame: 0 }); + producer.close(); +}); + +// Moving the cap wakes a pending read and reports the largest within the new bounds, lower ones +// included; bounds that did not move report nothing again. +test("largest re-reports when the cap moves", async () => { + const producer = new TrackProducer("test").accept({}); + for (let sequence = 0; sequence < 6; sequence++) framedGroup(producer, sequence, 1); + + const reader = producer + .subscribe() + .ordered() + .withGroups({ end: { excluded: 3 } }); + expect(await reader.largest()).toEqual({ group: 2, frame: 0 }); + + let next = reader.largest(); + expect(await isPending(next)).toBe(true); + reader.setGroups({ end: { excluded: 5 } }); + expect(await next).toEqual({ group: 4, frame: 0 }); + + next = reader.largest(); + expect(await isPending(next)).toBe(true); + reader.setGroups({ end: { excluded: 2 } }); + expect(await next).toEqual({ group: 1, frame: 0 }); + + reader.setGroups({ end: { excluded: 2 } }); + next = reader.largest(); + expect(await isPending(next)).toBe(true); + + reader.setGroups({}); + expect(await next).toEqual({ group: 5, frame: 0 }); + reader.setGroups({ start: { included: 3 } }); + expect(await isPending(reader.largest())).toBe(true); + producer.close(); +}); + // A reader that starts at the largest group while it is still empty is at the live edge: it // gets that group, which ends with nothing, then the next one. test("a live-edge reader starts on an empty largest group", async () => { @@ -2566,8 +2658,10 @@ test("a mid-group end survives eviction and a filtered late reader", async () => group.close(); producer.close(); await clock.advance(10_000 + CACHE_WINDOW_MS + 1); + expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 4 }); + // The cap excludes the only group, so nothing is within it. const reader = producer.subscribe().withGroups({ end: { excluded: 5 } }); - expect(await reader.largest()).toEqual({ group: 5, frame: 4 }); + expect(await reader.largest()).toBeUndefined(); expect(reader.end()).toEqual({ group: 5, frame: 5 }); reader.close(); } finally { diff --git a/js/net/src/track.ts b/js/net/src/track.ts index fa75702e863..c2858608d78 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -438,6 +438,9 @@ class TrackState { largest = new Signal(undefined); // Set once {@link largest} can no longer advance while the track stays open. largestEnded = new Signal(false); + // Bumped by a write that leaves {@link largest} where it was: a reader capped below it + // follows the groups under its cap. + written = new Signal(0); constructor(subscription?: Subscription) { this.update = new Signal(subscription === undefined ? undefined : subscriptionDefaults(subscription)); @@ -471,6 +474,11 @@ async function resolveInfo(state: TrackState): Promise { } } +// Whether `a` is past `b`. +function after(a: Location, b: Location): boolean { + return a.group > b.group || (a.group === b.group && a.frame > b.frame); +} + async function nextLargest( state: TrackState, seen: Location | undefined, @@ -478,10 +486,7 @@ async function nextLargest( ): Promise { for (;;) { const largest = state.largest.peek(); - if ( - largest && - (!seen || largest.group > seen.group || (largest.group === seen.group && largest.frame > seen.frame)) - ) { + if (largest && (!seen || after(largest, seen))) { commit(largest); return largest; } @@ -999,10 +1004,8 @@ export class Producer { #advanceLargest(largest: Location | undefined): void { if (!largest) return; const current = this.#state.largest.peek(); - if ( - current && - (current.group > largest.group || (current.group === largest.group && current.frame >= largest.frame)) - ) { + if (current && !after(largest, current)) { + for (const state of [this.#state, ...this.#sinks]) state.written.update((n) => n + 1); return; } this.#largestOpen = true; @@ -1422,7 +1425,9 @@ export class Subscriber { readonly name: string; #state: TrackState; + // What {@link largest} last reported, and whether the bounds moved since. #largestSeen?: Location; + #largestMoved = false; #nextSequence = 0; #cursor = new Signal<{ start: number; end?: number }>({ start: 0 }); // A FETCH's own budget in place of the subscription's; unlike SUBSCRIBE's zero @@ -1674,13 +1679,49 @@ export class Subscriber { } /** - * The track's largest location, its newest group or datagram at the newest frame (0 while - * empty): the current one at first, then each newer one (skipping any in between), and - * `undefined` once the track ends. Seeded by the publisher's report, so it can name a frame - * before its group arrives. + * The largest location within {@link setGroups}: the current one at first, then each newer + * one (skipping any in between), and `undefined` once the track ends. + * + * While the track's largest is within the bounds, that is its newest group or datagram at + * the newest frame (0 while empty), seeded by the publisher's report, so it can name a frame + * before its group arrives. Past the cap, it is the newest cached group below the cap, so an + * exclusive end of `N` reports group `N - 1` once it arrives; below the floor, nothing. + * Moving the bounds reports again within the new ones, even below the last report. */ async largest(): Promise { - return nextLargest(this.#state, this.#largestSeen, (largest) => (this.#largestSeen = largest)); + for (;;) { + const bounds = this.#cursor.peek(); + const within = this.#largestWithin(bounds); + const seen = this.#largestSeen; + if (this.#largestMoved) { + // New bounds report the largest within them again, even one below the last report. + this.#largestMoved = false; + this.#largestSeen = within; + if (within && !(seen && within.group === seen.group && within.frame === seen.frame)) return within; + } else if (within && (!seen || after(within, seen))) { + this.#largestSeen = within; + return within; + } + if (this.#state.closed.peek() !== undefined || this.#state.largestEnded.peek()) return undefined; + await Signal.race( + this.#state.largest, + this.#state.written, + this.#state.closed, + this.#state.largestEnded, + this.#cursor, + ); + } + } + + // The track's largest within `bounds`; past the cap, the newest cached group below it. + #largestWithin(bounds: { start: number; end?: number }): Location | undefined { + const largest = this.#state.largest.peek(); + if (!largest || largest.group < bounds.start) return undefined; + if (bounds.end === undefined || largest.group < bounds.end) return largest; + const timeline = this.#state.timeline; + const group = timeline[timelineIndex(timeline, bounds.end) - 1]; + if (!group || group.sequence < bounds.start) return undefined; + return { group: group.sequence, frame: Math.max(group.frameCount - 1, 0) }; } // The newest frame buffered for this subscriber, which a wire answer names as its edge: @@ -1730,10 +1771,12 @@ export class Subscriber { * Limit subsequent reads without rewinding read progress or changing the wire request. * An omitted start preserves the current floor; an omitted end removes the cap. * Raising the end makes unread buffered groups available again. + * {@link largest} reports within these bounds. */ setGroups(groups: Groups): void { const { start, end } = groupBounds(groups); this.#cursor.update((cursor) => ({ start: Math.max(cursor.start, start), end })); + this.#largestMoved = true; } // Serving counterpart of setGroups: a named start replaces the floor, matching @@ -1744,6 +1787,7 @@ export class Subscriber { start: groups.start === undefined ? cursor.start : start, end, })); + this.#largestMoved = true; } /** Close the track (optionally with an error), closing any pending groups. Idempotent. */ @@ -2157,7 +2201,7 @@ export class Ordered { return this.#subscriber.subscription; } - /** The track's largest location; see {@link Subscriber.largest}. */ + /** The largest location within {@link setGroups}; see {@link Subscriber.largest}. */ largest(): Promise { return this.#subscriber.largest(); } diff --git a/quest/m1/public-model/serve.md b/quest/m1/public-model/serve.md index ea41b4811a5..0e16470df76 100644 --- a/quest/m1/public-model/serve.md +++ b/quest/m1/public-model/serve.md @@ -28,10 +28,11 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: - **Cursor** (`track::Subscriber::start_at`, `raise_start_to`, `end_at`, `subscription::Cap`; both publishers). **Decision**: - ✅ One public method assigns the floor and the cap in either direction, - renamed from `set_groups` to `set_bounds` (JS `setBounds`) on - `track::Subscriber` and `track::Ordered`, since it sets the reader's - bounds. Each reader already hands a sequence out once (#5446), so a - SUBSCRIBE_UPDATE that widens the start re-offers only unread groups. + renamed from `set_groups` to `limit` (JS `limit`) on + `track::Subscriber` and `track::Ordered`. The maintainer changed this + from `set_bounds` to `limit` on 2026-10-18. Each reader already hands a + sequence out once (#5446), so a SUBSCRIBE_UPDATE that widens the start + re-offers only unread groups. `start_at`, `raise_start_to`, `end_at`, and `Cap` go. Precondition: lowering the floor re-offers unread cached groups in every mode. Today only the resume cursor does; the arrival-order (Plain) cursor walks a @@ -39,20 +40,23 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: groups it never handed out. Rewrite the doc ("without rewinding read progress") to say what lowering does. JS's chaining form, `withGroups` on `Track.Subscriber` and `Track.Ordered`, becomes - `withBounds`. + `withLimit`. - A second public `replace_groups`. -- **Bounds naming** (decided 2026-10-16 in #5717): ✅ the siblings follow the - rename, so every range setter says bounds. `group::Consumer::set_frames` - becomes `set_bounds`, and `Subscription::with_groups` becomes - `with_bounds`, with the JS mirrors (the `Subscription.groups` field - becomes `bounds`, and any group-level frame bounds setter matches). +- **Bounds naming** (decided 2026-10-16 in #5717, changed to `limit` by the + maintainer on 2026-10-18): ✅ the siblings follow the rename: + `group::Consumer::limit` (today `set_frames`), `Subscription::with_limit` + (today `with_groups`), JS `limit` / `withLimit`, and the field + `Subscription.limit` (today `Subscription.groups`). Open question: two of + these read poorly. The `Subscription.limit` field and `with_limit` name a + request to the publisher, so they blur with the reader's local `limit`, + and alone `limit` reads as a count rather than a range. A floor-only call + such as `limit(5..)` also reads oddly, since "limit" suggests a cap. - `group::Consumer::start_at`, `end_at` (both publishers): remove the need; - the public `set_bounds` (today `set_frames`) and `skip_to` cover them. + the public `limit` (today `set_frames`) and `skip_to` cover them. - `Subscription::{start, end, set_bounds, with_start, with_end}` (both publishers, IETF subscriber, from #5732): the covering span a wire without ranges carries. Derive it from the public `ranges` instead, or make it - public. Either way, rename the crate `set_bounds` so it doesn't clash with - the public `with_bounds`. + public. - `subscription::Position::final_sequence` (lite publisher): public, the one-line wire translation of an exclusive end that both languages already duplicate. `track::Consumer::final_sequence` (IETF publisher) goes; @@ -76,7 +80,7 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: JS items (`js/net/src/`): - `hooks.replaceGroups` (lite publisher): mirror the cursor decision, so - `Track.Subscriber.setBounds` assigns the floor and the hook goes. + `Track.Subscriber.limit` assigns the floor and the hook goes. - `hooks.tryRecvGroup` with its `Recv` union, and `hooks.groupChanged` (lite publisher). The public `Subscriber.tryRecvGroup()` returns `undefined` for both a held boundary and a drained end, so a synchronous diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 2597f60932a..469a140c8d0 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -685,7 +685,9 @@ 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. + // range, the default) a raw reader joins at the latest group. Lift the last update's + // cap first: the largest reports within it, and a relative range counts from the edge. + track.set_groups(..); let latest = match track.poll_largest(&kio::Waiter::noop()) { std::task::Poll::Ready(Some(position)) => Some(position.group), _ => None, diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index bdcf4edb0b6..73ed8f04f22 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,14 @@ How to update your code for each breaking change, newest first. ## Unreleased +### A reader's `largest()` reports within its `set_groups` bounds + +`track::Subscriber::largest()` and `track::Ordered::largest()` (and their `poll_largest`) report the largest location within the bounds set with `set_groups`, where they used to report the track's. +Past a cap of `..N`, they report the newest cached group below `N`; below the floor, nothing. +Moving the bounds reports again within the new ones, even below the last report. +Unbounded readers, `track::Producer`, and `track::Consumer` are unchanged. +To read the track's edge from a capped reader, lift the cap with `set_groups(..)` first. + ### Track readers follow `largest()` instead of reading `latest()` (#5725) `latest()` on `track::{Producer, Consumer, Subscriber, Ordered}` is gone. diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index ceedefb22d0..b071c373606 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -658,12 +658,16 @@ struct Largest { open: bool, // The track ended, so no newer position can follow. ended: bool, + // Bumped by every write, raising `position` or not: a reader capped below it follows + // the groups under its cap. + revision: u64, } impl Track { /// Raise the track's largest location to `position`, waking only its own waiters. pub(super) fn advance_largest(&self, position: Position) { let mut largest = self.largest.lock(); + largest.revision = largest.revision.wrapping_add(1); if largest.position.is_none_or(|current| position > current) { largest.position = Some(position); largest.open = true; @@ -692,39 +696,22 @@ impl Track { } } - /// Poll for a largest location newer than `seen`, recording it there. `None` once the - /// track ended with nothing newer. - pub(super) fn poll_largest(&self, seen: &mut Option, waiter: &kio::Waiter) -> Poll> { - let newer = |largest: &Largest| { - largest - .position - .filter(|position| seen.is_none_or(|seen| *position > seen)) - }; - let largest = ready!( - self.largest - .poll(waiter, |largest| match newer(largest).is_some() || largest.ended { - true => Poll::Ready(()), - false => Poll::Pending, - }) - ); - let next = newer(&largest); - if next.is_some() { - *seen = next; - } - Poll::Ready(next) - } - - /// The largest frame newer than `seen` without waiting, recording it there. - pub(super) fn take_largest(&self, seen: &mut Option) -> Option { - let next = self - .largest - .read() - .position - .filter(|position| seen.is_none_or(|seen| *position > seen)); - if next.is_some() { - *seen = next; - } - next + /// The largest location, and the revision it was read at. + pub(super) fn largest(&self) -> (Option, u64) { + let largest = self.largest.read(); + (largest.position, largest.revision) + } + + /// Ready once a write moves the largest past `revision` (`true`), or the track ended + /// without one (`false`). + pub(super) fn poll_largest_changed(&self, revision: u64, waiter: &kio::Waiter) -> Poll { + let largest = ready!(self.largest.poll(waiter, |largest| { + match largest.revision != revision || largest.ended { + true => Poll::Ready(()), + false => Poll::Pending, + } + })); + Poll::Ready(largest.revision != revision) } /// Open an account against `pool` for the track behind `state`. diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index c958c4b7641..9b9de161b89 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -139,10 +139,16 @@ impl Consumer { self.state.read().copy.clone() } - /// Poll for a largest location newer than `seen` on the serving copy, recording it there. - /// A copy ending is its route failing, so the logical track ends only with the route: - /// at once when the front ends it, or with the last copy once the front concluded. - pub(super) fn poll_largest(&self, seen: &mut Option, waiter: &kio::Waiter) -> Poll> { + /// Poll for a largest location within `bounds` newer than `seen` on the serving copy, + /// recording it there. A copy ending is its route failing, so the logical track ends + /// only with the route: at once when the front ends it, or with the last copy once the + /// front concluded. + pub(super) fn poll_largest( + &self, + seen: &mut Option, + bounds: track::Bounds, + waiter: &kio::Waiter, + ) -> Poll> { loop { let (generation, copy) = { let route = self.state.read(); @@ -150,7 +156,7 @@ impl Consumer { }; let end = self.end(); // A replacement copy can start below what an earlier one reported. - match copy.map(|copy| copy.poll_largest_after(seen, waiter)) { + match copy.map(|copy| copy.poll_largest_after(seen, bounds, waiter)) { Some(Poll::Ready(Some(largest))) => return Poll::Ready(Some(largest)), _ if matches!(end, Some(Some(_))) => return Poll::Ready(None), Some(Poll::Pending) if end.is_some() => return Poll::Pending, @@ -439,10 +445,24 @@ impl Subscriber { self.reader().latest() } - /// Poll for a largest location newer than `seen`; see [`Consumer::poll_largest`]. - pub(super) fn poll_largest(&mut self, seen: &mut Option, waiter: &kio::Waiter) -> Poll> { + /// Poll for a largest location within `bounds` newer than `seen`; see [`Consumer::poll_largest`]. + pub(super) fn poll_largest( + &mut self, + seen: &mut Option, + bounds: track::Bounds, + waiter: &kio::Waiter, + ) -> Poll> { let route = self.reader().route.clone(); - route.poll_largest(seen, waiter) + route.poll_largest(seen, bounds, waiter) + } + + /// The local group filter, as [`track::Subscriber::poll_largest`] reports within it. + pub(super) fn bounds(&self) -> track::Bounds { + let (start, end) = self.reader().groups; + track::Bounds { + start, + end: end.exclusive(), + } } /// The groups the copies' drift budget skipped since the last call, counted against @@ -1404,6 +1424,24 @@ mod test { assert_eq!(sub.largest().now_or_never(), Some(None)); } + /// A front's reader reports the largest within its cap on the serving copy. + #[test] + #[cfg(feature = "async")] + fn largest_honours_the_cap_on_the_serving_copy() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, HOUR); + for sequence in 0..5 { + write(&a, sequence); + } + sub.set_groups(..3); + assert_eq!(sub.largest().now_or_never(), Some(Some(Position::group(2)))); + sub.set_groups(..); + assert_eq!(sub.largest().now_or_never(), Some(Some(Position::group(4)))); + } + /// A track reader waiting before any route serves is woken by the first one's frame. /// The front ending the track ends the stream, though the copy's producer stays open. #[test] diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 75dfa3981ed..185271fcb32 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1961,24 +1961,26 @@ impl TrackState { || self.datagrams.iter().any(|datagram| datagram.sequence >= sequence) } - /// The newest live group the cache holds, and the number of frames it has so far. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - fn newest(&self) -> Option<(u64, u64)> { + /// The largest position the cache holds: the newest live group's last frame (its head, + /// while it has none). + fn largest(&self) -> Option { + self.largest_within(Bounds::default()) + } + + /// [`Self::largest`] among the groups within `bounds`. + fn largest_within(&self, bounds: Bounds) -> Option { + if bounds.end.is_some_and(|end| end <= bounds.start) { + return None; + } + let end = bounds.end.map_or(Bound::Unbounded, Bound::Excluded); let (sequence, slot) = self .lookup - .iter() + .range((Bound::Included(bounds.start), end)) .rev() .find(|(_, slot)| slot.visible && !slot.pending)?; - Some((*sequence, slot.group.frame_count() as u64)) - } - - /// The largest position the cache holds: the newest live group's last frame (its head, - /// while it has none). - #[cfg_attr(not(feature = "async"), allow(dead_code))] - fn largest(&self) -> Option { - self.newest().map(|(group, frames)| Position { - group, - frame: frames.saturating_sub(1), + Some(Position { + group: *sequence, + frame: (slot.group.frame_count() as u64).saturating_sub(1), }) } @@ -2095,20 +2097,47 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) { state.close(); } -/// Poll `cache` for a largest location newer than `seen`. `ended` polls the track for -/// [`TrackState::largest_settled`], or its channel closing without ending the cache (a -/// dropped request), either of which ends the stream. +/// A reader's local group bounds: the floor, and the exclusive cap (`None` for none). +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Bounds { + pub start: u64, + pub end: Option, +} + +/// Poll `cache` for a largest location within `bounds` newer than `seen`, recording it there. +/// +/// The track's largest while it is within them; past the cap, the newest group the cache +/// holds below it, which `below` reads from the track; nothing while below the floor. +/// `ended` polls the track for [`TrackState::largest_settled`], or its channel closing +/// without ending the cache (a dropped request), either of which ends the stream. fn poll_largest( cache: &cache::Track, seen: &mut Option, + bounds: Bounds, + below: impl Fn() -> Option, waiter: &kio::Waiter, ended: impl FnOnce(&kio::Waiter) -> Poll<()>, ) -> Poll> { - if let Poll::Ready(next) = cache.poll_largest(seen, waiter) { - return Poll::Ready(next); + loop { + let (largest, revision) = cache.largest(); + let within = largest.and_then(|largest| match largest.group { + group if group < bounds.start => None, + group if super::subscription::before_end(group, bounds.end) => Some(largest), + // Read outside the largest lock: the track and group locks come first. + _ => below(), + }); + if let Some(next) = within.filter(|next| seen.is_none_or(|seen| *next > seen)) { + *seen = Some(next); + return Poll::Ready(Some(next)); + } + match cache.poll_largest_changed(revision, waiter) { + Poll::Ready(true) => continue, + Poll::Ready(false) => return Poll::Ready(None), + Poll::Pending => {} + } + ready!(ended(waiter)); + return Poll::Ready(None); } - ready!(ended(waiter)); - Poll::Ready(cache.take_largest(seen)) } /// Ready once the track's largest can no longer advance; see [`TrackState::largest_settled`]. @@ -2696,9 +2725,15 @@ impl Producer { /// Poll for the track's largest location: the current one, then each newer one, and `None` once the track ends. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { let cache = self.state.read().cache.clone(); - poll_largest(&cache, &mut self.largest, waiter, |waiter| { - self.state.poll(waiter, |state| largest_ended(state)).map(|_| ()) - }) + let state = &self.state; + poll_largest( + &cache, + &mut self.largest, + Bounds::default(), + || state.read().largest(), + waiter, + |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), + ) } /// The track's largest location, its newest group or datagram at the newest frame (0 while empty): the current one, then each newer one, and `None` once the track ends. @@ -2785,7 +2820,7 @@ impl Producer { // A producer-side (in-process) subscribe is not egress: stay untagged. stats: stats::Scope::default(), _stats_sub: stats::Subscription::default(), - largest: None, + largest: Reported::default(), } } @@ -3819,17 +3854,18 @@ impl Consumer { /// Poll for the track's largest location: the current one, then each newer one, and `None` once the track ends. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { let mut seen = self.largest; - let next = self.poll_largest_after(&mut seen, waiter); + let next = self.poll_largest_after(&mut seen, Bounds::default(), waiter); self.largest = seen; next } - /// Poll for a largest frame newer than `seen`, recording it there. A front's logical - /// track follows the route serving it. Reads the instance served now without asking for - /// it, so a broadcast serving nothing under the name ends the stream. + /// Poll for a largest frame within `bounds` newer than `seen`, recording it there. A + /// front's logical track follows the route serving it. Reads the instance served now + /// without asking for it, so a broadcast serving nothing under the name ends the stream. pub(super) fn poll_largest_after( &self, seen: &mut Option, + bounds: Bounds, waiter: &kio::Waiter, ) -> Poll> { self.with_state(|state| { @@ -3841,10 +3877,15 @@ impl Consumer { (state.routes.clone(), state.cache.clone()) }; match routes { - Some(routes) => routes.poll_largest(seen, waiter), - None => poll_largest(&cache, seen, waiter, |waiter| { - state.poll(waiter, |state| largest_ended(state)).map(|_| ()) - }), + Some(routes) => routes.poll_largest(seen, bounds, waiter), + None => poll_largest( + &cache, + seen, + bounds, + || state.read().largest_within(bounds), + waiter, + |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), + ), } }) } @@ -3953,7 +3994,7 @@ impl Subscribing { delivered: Delivered::default(), stats: self.stats.clone(), _stats_sub: self.stats.subscribe(), - largest: None, + largest: Reported::default(), })) } @@ -4393,8 +4434,35 @@ pub struct Subscriber { // The subscription guard: bumps `subscriptions` (and the egress viewer refcount) // while held, closing them on drop. Empty (no-op) for an untagged track. _stats_sub: stats::Subscription, - // Last position this handle yielded through `largest`. - largest: Option, + largest: Reported, +} + +/// What a [`Subscriber`]'s `largest` stream last reported. +#[derive(Default)] +struct Reported { + position: Option, + // The bounds moved since, so the next poll reports within the new ones. + moved: bool, + // The task that last polled, woken when the bounds move. One is enough: polling + // takes `&mut self`. + waker: Option, +} + +impl Reported { + /// Remember the polling task, to wake when the bounds move. + fn park(&mut self, waker: &std::task::Waker) { + if !self.waker.as_ref().is_some_and(|last| last.will_wake(waker)) { + self.waker = Some(waker.clone()); + } + } + + /// The bounds moved: report within them again, waking a parked poll. + fn bounds_moved(&mut self) { + self.moved = true; + if let Some(waker) = self.waker.take() { + waker.wake(); + } + } } /// Unread groups a logical reader can still take from a replaced copy. @@ -5441,6 +5509,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. + /// [`Self::poll_largest`] reports within these bounds. /// This changes only local delivery; use [`Subscription::ranges`] and /// [`Self::update`] to change the requested groups. pub fn set_groups(&mut self, groups: impl RangeBounds) { @@ -5465,6 +5534,7 @@ impl Subscriber { // leave a finished group below the old floor unread. Inner::Resume(resume, _) => resume.start_at(sequence), } + self.largest.bounds_moved(); } /// Raise the read cursor's floor to `sequence`, keeping any higher floor already set. @@ -5474,6 +5544,7 @@ impl Subscriber { Inner::Plain(cursor) => cursor.min_sequence = cursor.min_sequence.max(sequence), Inner::Resume(resume, _) => resume.raise_start_to(sequence), } + self.largest.bounds_moved(); } /// Cap this subscriber's read cursor at `end`, or remove the cap with `..`. @@ -5498,6 +5569,18 @@ impl Subscriber { } Inner::Resume(resume, _) => resume.end_at(end.into()), } + self.largest.bounds_moved(); + } + + /// The local bounds `largest` reports within. + fn bounds(&self) -> Bounds { + match &self.inner { + Inner::Plain(cursor) => Bounds { + start: cursor.min_sequence, + end: cursor.end_sequence, + }, + Inner::Resume(resume, _) => resume.bounds(), + } } /// This subscriber's current preferences. @@ -5518,20 +5601,47 @@ impl Subscriber { channel.update(subscription) } - /// Poll for the track's largest location: the current one, then each newer one, and `None` once the track ends. + /// Poll for the largest location within [`Self::set_groups`]: the current one, then each newer one, and `None` once the track ends. + /// + /// While the track's largest is within the bounds, that is its newest group or datagram + /// at the newest frame (0 while empty). Past the cap, it is the newest cached group + /// below the cap, so `..N` reports group `N - 1` once it arrives; below the floor, + /// nothing. Moving the bounds reports again within the new ones, even below the last + /// report. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { + self.largest.park(waiter.waker()); + let bounds = self.bounds(); + if std::mem::take(&mut self.largest.moved) { + // New bounds report the largest within them again, even one below the last report. + let last = self.largest.position.take(); + match self.poll_largest_within(bounds, waiter) { + Poll::Ready(Some(position)) if Some(position) == last => {} + poll => return poll, + } + } + self.poll_largest_within(bounds, waiter) + } + + fn poll_largest_within(&mut self, bounds: Bounds, waiter: &kio::Waiter) -> Poll> { + let seen = &mut self.largest.position; match &mut self.inner { Inner::Plain(cursor) => { - let cache = cursor.state.read().cache.clone(); - poll_largest(&cache, &mut self.largest, waiter, |waiter| { - cursor.state.poll(waiter, |state| largest_ended(state)).map(|_| ()) - }) + let state = &cursor.state; + let cache = state.read().cache.clone(); + poll_largest( + &cache, + seen, + bounds, + || state.read().largest_within(bounds), + waiter, + |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), + ) } - Inner::Resume(resume, _) => resume.poll_largest(&mut self.largest, waiter), + Inner::Resume(resume, _) => resume.poll_largest(seen, bounds, waiter), } } - /// The track's largest location, its newest group or datagram at the newest frame (0 while empty): the current one, then each newer one, and `None` once the track ends. + /// The largest location within [`Self::set_groups`]; see [`Self::poll_largest`]. #[cfg(feature = "async")] pub async fn largest(&mut self) -> Option { kio::wait(|waiter| self.poll_largest(waiter)).await @@ -5676,12 +5786,12 @@ impl Ordered { kio::wait(|waiter| self.poll_finished(waiter)).await } - /// Poll for the track's largest location: the current one, then each newer one, and `None` once the track ends. + /// Poll for the largest location within [`Self::set_groups`]; see [`Subscriber::poll_largest`]. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { self.inner.poll_largest(waiter) } - /// The track's largest location, its newest group or datagram at the newest frame (0 while empty): the current one, then each newer one, and `None` once the track ends. + /// The largest location within [`Self::set_groups`]; see [`Subscriber::poll_largest`]. #[cfg(feature = "async")] pub async fn largest(&mut self) -> Option { kio::wait(|waiter| self.poll_largest(waiter)).await @@ -6253,6 +6363,137 @@ mod test { assert_eq!(subscriber.largest().await, Some(Position { group: 8, frame: 0 })); } + /// Wakes record that a parked poll was woken. + struct Flag(std::sync::atomic::AtomicBool); + + impl futures::task::ArcWake for Flag { + fn wake_by_ref(flag: &Arc) { + flag.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + impl Flag { + fn waiter() -> (Arc, kio::Waiter) { + let flag = Arc::new(Self(Default::default())); + let waiter = kio::Waiter::new(futures::task::waker(flag.clone())); + (flag, waiter) + } + + fn take(&self) -> bool { + self.0.swap(false, std::sync::atomic::Ordering::SeqCst) + } + } + + /// Write group `sequence` with `frames` frames, left open. + fn framed(producer: &Producer, sequence: u64, frames: usize) -> group::Producer { + let mut group = producer.create_group(group::Info { sequence }).unwrap(); + for _ in 0..frames { + group.write_frame(None, &b"x"[..]).unwrap(); + } + group + } + + /// A cap below the live edge reports the newest cached group under it, follows that + /// group's frames, and ignores the edge moving on. + #[test] + fn largest_reports_within_a_cap_below_the_live_edge() { + let producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let mut groups: Vec<_> = (0..6).map(|sequence| framed(&producer, sequence, 2)).collect(); + + let mut reader = producer.subscribe(replay()); + reader.set_groups(..3); + assert_eq!( + reader.poll_largest(&noop), + Poll::Ready(Some(Position { group: 2, frame: 1 })) + ); + + let (flag, waiter) = Flag::waiter(); + assert!(reader.poll_largest(&waiter).is_pending()); + groups[2].write_frame(None, &b"x"[..]).unwrap(); + assert!(flag.take(), "a frame below the cap woke the reader"); + assert_eq!( + reader.poll_largest(&noop), + Poll::Ready(Some(Position { group: 2, frame: 2 })) + ); + + groups.push(framed(&producer, 6, 1)); + groups[5].write_frame(None, &b"x"[..]).unwrap(); + assert!(reader.poll_largest(&noop).is_pending(), "the edge moved past the cap"); + + // An inclusive cap reports the group it names. + let mut ordered = producer.subscribe(replay()).ordered(); + ordered.set_groups(..=3); + assert_eq!( + ordered.poll_largest(&noop), + Poll::Ready(Some(Position { group: 3, frame: 1 })) + ); + } + + /// A gap under the cap reports the newest group the cache holds there, and a floor + /// above the edge reports nothing until a group reaches it. + #[test] + fn largest_skips_gaps_under_the_cap_and_waits_for_the_floor() { + let producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let _groups = [ + framed(&producer, 0, 1), + framed(&producer, 1, 3), + framed(&producer, 5, 1), + ]; + + let mut capped = producer.subscribe(replay()); + capped.set_groups(..4); + assert_eq!( + capped.poll_largest(&noop), + Poll::Ready(Some(Position { group: 1, frame: 2 })) + ); + + let mut floored = producer.subscribe(replay()); + floored.set_groups(8..); + assert!(floored.poll_largest(&noop).is_pending(), "the edge is below the floor"); + let _late = framed(&producer, 8, 0); + assert_eq!(floored.poll_largest(&noop), Poll::Ready(Some(Position::group(8)))); + } + + /// Moving the cap wakes a parked reader and reports the largest within the new bounds, + /// lower ones included; bounds that did not move report nothing again. + #[test] + fn largest_rereports_when_the_cap_moves() { + let producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let _groups: Vec<_> = (0..6).map(|sequence| framed(&producer, sequence, 1)).collect(); + + let mut reader = producer.subscribe(replay()).ordered(); + reader.set_groups(..3); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(2)))); + + let (flag, waiter) = Flag::waiter(); + assert!(reader.poll_largest(&waiter).is_pending()); + reader.set_groups(..5); + assert!(flag.take(), "raising the cap woke the reader"); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(4)))); + + assert!(reader.poll_largest(&waiter).is_pending()); + reader.set_groups(..2); + assert!(flag.take(), "lowering the cap woke the reader"); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(1)))); + + reader.set_groups(..2); + assert!( + reader.poll_largest(&noop).is_pending(), + "the same bounds report nothing new" + ); + + reader.set_groups(..); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); + reader.set_groups(3..); + assert!( + reader.poll_largest(&noop).is_pending(), + "a floor below the edge leaves the largest where it was" + ); + } + #[test] #[cfg(feature = "async")] fn append_datagram_roundtrip() { From fd7f347177b7236ca71d7bf0281aaa8abc451531 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 00:22:14 -0700 Subject: [PATCH 02/20] fix(net): keep a capped largest open while its group grows The track's settled end ended a capped reader's largest stream even while the group it reports under the cap could still take frames. Past the cap, the stream now ends on the track's settled end only once that group has finished; the track closing still ends it. A group finishing bumps the largest revision so a capped reader wakes on it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.ts | 1 + js/net/src/internal.ts | 5 +++ js/net/src/track.test.ts | 26 ++++++++++++ js/net/src/track.ts | 31 ++++++++++----- rs/moq-net/src/model/cache.rs | 6 ++- rs/moq-net/src/model/track.rs | 75 ++++++++++++++++++++++++++--------- 6 files changed, 113 insertions(+), 31 deletions(-) diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 08ffa4ab5db..5aa4ac16d8e 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -466,6 +466,7 @@ export class Consumer { makeConsumer = (state) => new Consumer(state); hooks.groupTimestamp = (group) => (group.#state.unverified ? undefined : group.#state.timestamp); hooks.groupLatest = (group) => (group.#state.unverified ? undefined : group.#state.latest); + hooks.groupFinished = (group) => group.#state.closed.peek() === null; hooks.unverifyGroup = (group) => { group.#state.unverified = true; }; diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 6869b62baef..9601efc7ee9 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -317,6 +317,8 @@ export const hooks: { /** Return a group's first timestamp, retained even after its first frame is read. */ groupTimestamp: (group: GroupConsumer) => Timestamp | undefined; groupLatest: (group: GroupConsumer) => Timestamp | undefined; + /** Whether a group's producer closed cleanly, whatever this consumer has left to read. */ + groupFinished: (group: GroupConsumer) => boolean; /** Return when a group joined its track at this hop, on the installed clock. */ groupArrived: (group: GroupConsumer) => Instant; /** Record when a track's mirror of a group joined that track at this hop. */ @@ -416,6 +418,9 @@ export const hooks: { groupLatest: () => { throw new Error("group.ts not loaded"); }, + groupFinished: () => { + throw new Error("group.ts not loaded"); + }, groupArrived: () => { throw new Error("group.ts not loaded"); }, diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 2d96c568b1b..a8e81008d34 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1970,6 +1970,32 @@ test("largest skips gaps under the cap and waits for the floor", async () => { producer.close(); }); +// A settled track ends the stream past the cap only once the group under the cap stops growing: +// its frames and its finish still reach a capped reader. +test("largest under the cap stays open while its group grows", async () => { + const producer = new TrackProducer("test").accept({}); + const open = framedGroup(producer, 2, 1); + framedGroup(producer, 5, 1).close(); + producer.finishAt({ group: 6, frame: 0 }); + + const uncapped = producer.subscribe(); + expect(await uncapped.largest()).toEqual({ group: 5, frame: 0 }); + expect(await uncapped.largest()).toBeUndefined(); + + const reader = producer.subscribe().withGroups({ end: { excluded: 3 } }); + expect(await reader.largest()).toEqual({ group: 2, frame: 0 }); + let next = reader.largest(); + expect(await isPending(next)).toBe(true); + open.writeFrame({ payload: enc.encode("x") }); + expect(await next).toEqual({ group: 2, frame: 1 }); + + next = reader.largest(); + expect(await isPending(next)).toBe(true); + open.close(); + expect(await next).toBeUndefined(); + producer.close(); +}); + // Moving the cap wakes a pending read and reports the largest within the new bounds, lower ones // included; bounds that did not move report nothing again. test("largest re-reports when the cap moves", async () => { diff --git a/js/net/src/track.ts b/js/net/src/track.ts index c2858608d78..794cd5d30c9 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -438,7 +438,7 @@ class TrackState { largest = new Signal(undefined); // Set once {@link largest} can no longer advance while the track stays open. largestEnded = new Signal(false); - // Bumped by a write that leaves {@link largest} where it was: a reader capped below it + // Bumped by a write or finish that leaves {@link largest} where it was: a reader capped below it // follows the groups under its cap. written = new Signal(0); @@ -1340,6 +1340,8 @@ export class Producer { // A group name fixes its content, so no legal write raises the largest past it. #finishLargest(sequence: number): void { if (this.#state.largest.peek()?.group === sequence) this.#largestOpen = false; + // A reader capped below the largest may be following this group. + for (const state of [this.#state, ...this.#sinks]) state.written.update((n) => n + 1); this.#endLargest(); } @@ -1685,13 +1687,14 @@ export class Subscriber { * While the track's largest is within the bounds, that is its newest group or datagram at * the newest frame (0 while empty), seeded by the publisher's report, so it can name a frame * before its group arrives. Past the cap, it is the newest cached group below the cap, so an - * exclusive end of `N` reports group `N - 1` once it arrives; below the floor, nothing. - * Moving the bounds reports again within the new ones, even below the last report. + * exclusive end of `N` reports group `N - 1` once it arrives, and the stream stays open while + * that group can still grow; below the floor, nothing. Moving the bounds reports again within + * the new ones, even below the last report. */ async largest(): Promise { for (;;) { const bounds = this.#cursor.peek(); - const within = this.#largestWithin(bounds); + const { within, growing } = this.#largestWithin(bounds); const seen = this.#largestSeen; if (this.#largestMoved) { // New bounds report the largest within them again, even one below the last report. @@ -1702,7 +1705,9 @@ export class Subscriber { this.#largestSeen = within; return within; } - if (this.#state.closed.peek() !== undefined || this.#state.largestEnded.peek()) return undefined; + if (this.#state.closed.peek() !== undefined) return undefined; + // The track's settled end does not end a group under the cap that can still grow. + if (this.#state.largestEnded.peek() && !growing) return undefined; await Signal.race( this.#state.largest, this.#state.written, @@ -1713,15 +1718,19 @@ export class Subscriber { } } - // The track's largest within `bounds`; past the cap, the newest cached group below it. - #largestWithin(bounds: { start: number; end?: number }): Location | undefined { + // The track's largest within `bounds`; past the cap, the newest cached group below it, and + // whether that group can still grow: it has not finished, and an aborted one can be recreated. + #largestWithin(bounds: { start: number; end?: number }): { within?: Location; growing: boolean } { const largest = this.#state.largest.peek(); - if (!largest || largest.group < bounds.start) return undefined; - if (bounds.end === undefined || largest.group < bounds.end) return largest; + if (!largest || largest.group < bounds.start) return { growing: false }; + if (bounds.end === undefined || largest.group < bounds.end) return { within: largest, growing: false }; const timeline = this.#state.timeline; const group = timeline[timelineIndex(timeline, bounds.end) - 1]; - if (!group || group.sequence < bounds.start) return undefined; - return { group: group.sequence, frame: Math.max(group.frameCount - 1, 0) }; + if (!group || group.sequence < bounds.start) return { growing: false }; + return { + within: { group: group.sequence, frame: Math.max(group.frameCount - 1, 0) }, + growing: !hooks.groupFinished(group), + }; } // The newest frame buffered for this subscriber, which a wire answer names as its edge: diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index b071c373606..8b77ed62d3d 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -658,8 +658,8 @@ struct Largest { open: bool, // The track ended, so no newer position can follow. ended: bool, - // Bumped by every write, raising `position` or not: a reader capped below it follows - // the groups under its cap. + // Bumped by every write and finish, raising `position` or not: a reader capped below it + // follows the groups under its cap. revision: u64, } @@ -678,6 +678,8 @@ impl Track { /// A group name fixes its content, so no legal write raises the largest past it. pub(super) fn finish_largest(&self, sequence: u64) { let mut largest = self.largest.lock(); + // A reader capped below the largest may be following this group. + largest.revision = largest.revision.wrapping_add(1); if largest.position.is_some_and(|position| position.group == sequence) { largest.open = false; } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 185271fcb32..a87a1848ec5 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1964,11 +1964,12 @@ impl TrackState { /// The largest position the cache holds: the newest live group's last frame (its head, /// while it has none). fn largest(&self) -> Option { - self.largest_within(Bounds::default()) + self.largest_within(Bounds::default()).map(|(largest, _)| largest) } - /// [`Self::largest`] among the groups within `bounds`. - fn largest_within(&self, bounds: Bounds) -> Option { + /// [`Self::largest`] among the groups within `bounds`, and whether its group can still + /// grow: it has not finished, and an aborted one can be recreated. + fn largest_within(&self, bounds: Bounds) -> Option<(Position, bool)> { if bounds.end.is_some_and(|end| end <= bounds.start) { return None; } @@ -1978,10 +1979,11 @@ impl TrackState { .range((Bound::Included(bounds.start), end)) .rev() .find(|(_, slot)| slot.visible && !slot.pending)?; - Some(Position { + let largest = Position { group: *sequence, frame: (slot.group.frame_count() as u64).saturating_sub(1), - }) + }; + Some((largest, !slot.group.is_finished())) } fn poll_finished(&self) -> Poll> { @@ -2107,25 +2109,27 @@ pub(super) struct Bounds { /// Poll `cache` for a largest location within `bounds` newer than `seen`, recording it there. /// /// The track's largest while it is within them; past the cap, the newest group the cache -/// holds below it, which `below` reads from the track; nothing while below the floor. -/// `ended` polls the track for [`TrackState::largest_settled`], or its channel closing -/// without ending the cache (a dropped request), either of which ends the stream. +/// holds below it, which `below` reads from the track with whether that group can still +/// grow; nothing while below the floor. The track closing ends the stream. So does `ended`, +/// which polls the track for [`TrackState::largest_settled`], or its channel closing without +/// ending the cache (a dropped request), unless the group below the cap can still grow. fn poll_largest( cache: &cache::Track, seen: &mut Option, bounds: Bounds, - below: impl Fn() -> Option, + below: impl Fn() -> Option<(Position, bool)>, waiter: &kio::Waiter, ended: impl FnOnce(&kio::Waiter) -> Poll<()>, ) -> Poll> { loop { let (largest, revision) = cache.largest(); - let within = largest.and_then(|largest| match largest.group { - group if group < bounds.start => None, - group if super::subscription::before_end(group, bounds.end) => Some(largest), + let (within, growing) = match largest { + Some(largest) if largest.group < bounds.start => (None, false), + Some(largest) if super::subscription::before_end(largest.group, bounds.end) => (Some(largest), false), // Read outside the largest lock: the track and group locks come first. - _ => below(), - }); + Some(_) => below().map_or((None, false), |(below, growing)| (Some(below), growing)), + None => (None, false), + }; if let Some(next) = within.filter(|next| seen.is_none_or(|seen| *next > seen)) { *seen = Some(next); return Poll::Ready(Some(next)); @@ -2133,6 +2137,8 @@ fn poll_largest( match cache.poll_largest_changed(revision, waiter) { Poll::Ready(true) => continue, Poll::Ready(false) => return Poll::Ready(None), + // Its frames and its finish bump the revision this waits on. + Poll::Pending if growing => return Poll::Pending, Poll::Pending => {} } ready!(ended(waiter)); @@ -2730,7 +2736,7 @@ impl Producer { &cache, &mut self.largest, Bounds::default(), - || state.read().largest(), + || state.read().largest_within(Bounds::default()), waiter, |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), ) @@ -5605,9 +5611,9 @@ impl Subscriber { /// /// While the track's largest is within the bounds, that is its newest group or datagram /// at the newest frame (0 while empty). Past the cap, it is the newest cached group - /// below the cap, so `..N` reports group `N - 1` once it arrives; below the floor, - /// nothing. Moving the bounds reports again within the new ones, even below the last - /// report. + /// below the cap, so `..N` reports group `N - 1` once it arrives, and the stream stays + /// open while that group can still grow; below the floor, nothing. Moving the bounds + /// reports again within the new ones, even below the last report. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { self.largest.park(waiter.waker()); let bounds = self.bounds(); @@ -6456,6 +6462,39 @@ mod test { assert_eq!(floored.poll_largest(&noop), Poll::Ready(Some(Position::group(8)))); } + /// A settled track ends the stream past the cap only once the group under the cap stops + /// growing: its frames and its finish still reach a capped reader. + #[test] + fn largest_under_the_cap_stays_open_while_its_group_grows() { + let mut producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let mut open = framed(&producer, 2, 1); + framed(&producer, 5, 1).finish().unwrap(); + producer.finish_at(Position::group(6)).unwrap(); + + let mut uncapped = producer.subscribe(replay()); + assert_eq!(uncapped.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); + assert_eq!(uncapped.poll_largest(&noop), Poll::Ready(None), "the track settled"); + + let mut reader = producer.subscribe(replay()); + reader.set_groups(..3); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(2)))); + let (flag, waiter) = Flag::waiter(); + assert!(reader.poll_largest(&waiter).is_pending(), "group 2 can still grow"); + + open.write_frame(None, &b"x"[..]).unwrap(); + assert!(flag.take(), "a frame under the cap woke the reader"); + assert_eq!( + reader.poll_largest(&noop), + Poll::Ready(Some(Position { group: 2, frame: 1 })) + ); + + assert!(reader.poll_largest(&waiter).is_pending()); + open.finish().unwrap(); + assert!(flag.take(), "finishing the group under the cap woke the reader"); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(None)); + } + /// Moving the cap wakes a parked reader and reports the largest within the new bounds, /// lower ones included; bounds that did not move report nothing again. #[test] From d8b68f0f489b92c89effdecf1816f17c98b0e9fa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 08:27:56 -0700 Subject: [PATCH 03/20] quest: record the range naming and floor decisions for #5924 Co-Authored-By: Claude Opus 5.5 --- quest/m1/public-model/serve.md | 54 +++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/quest/m1/public-model/serve.md b/quest/m1/public-model/serve.md index 0e16470df76..3e3860a5f70 100644 --- a/quest/m1/public-model/serve.md +++ b/quest/m1/public-model/serve.md @@ -27,32 +27,46 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: the private snapshot that replaced the public `latest()` (#5725). - **Cursor** (`track::Subscriber::start_at`, `raise_start_to`, `end_at`, `subscription::Cap`; both publishers). **Decision**: - - ✅ One public method assigns the floor and the cap in either direction, - renamed from `set_groups` to `limit` (JS `limit`) on - `track::Subscriber` and `track::Ordered`. The maintainer changed this - from `set_bounds` to `limit` on 2026-10-18. Each reader already hands a - sequence out once (#5446), so a SUBSCRIBE_UPDATE that widens the start - re-offers only unread groups. + - ✅ One public consuming builder assigns the floor and the cap in either + direction: `with_range(self, range) -> Self` on `track::Subscriber` and + `track::Ordered` (JS `withRange`), replacing `set_groups` and the + chaining `withGroups`. The maintainer decided "range" on 2026-10-19, + replacing the 2026-10-18 `limit` and the earlier `set_bounds`. Each + reader already hands a sequence out once (#5446), so a SUBSCRIBE_UPDATE + that widens the start re-offers only unread groups. `start_at`, `raise_start_to`, `end_at`, and `Cap` go. Precondition: lowering the floor re-offers unread cached groups in every mode. Today only the resume cursor does; the arrival-order (Plain) cursor walks a monotonic arrival index, so it must rescan below its old floor for groups it never handed out. Rewrite the doc ("without rewinding read - progress") to say what lowering does. JS's chaining form, - `withGroups` on `Track.Subscriber` and `Track.Ordered`, becomes - `withLimit`. + progress") to say what lowering does. + - Rebinding: a consuming builder fits readers bounded once at creation, + but these callers move a live reader's bounds through `&mut` today, so + the rename needs a way to rebind (take and replace the reader, or a + crate-private setter the builder wraps): + - `rs/moq-c/src/consume.rs` `apply_raw_subscription` re-applies the range + on every subscription update, first clearing it so a relative range + counts from the edge. + - The lite and IETF publishers move the cursor on SUBSCRIBE_UPDATE + (`start_at`, `end_at`; JS `hooks.replaceGroups` and the IETF + publisher's `setGroups({ start })`). + - `js/json` snapshot and `js/flate` `latest()` raise the floor of the same + reader on each read; `js/room` chat sets it after `largest()` resolves. + - `rs/moq-hls` export narrows a fetched group with `set_frames(..end)`. + - Readers that set a floor once right after `poll_largest` (moq-audio, + moq-video, moq-cli play, moq-json window, moq-mux) fit the builder. + - ✅ The range floor counts as a local bound (maintainer, 2026-10-19): + `largest()` reports within both the floor and the cap, so a reader whose + floor sits above the live edge reports nothing until a group reaches it + (#5924). - A second public `replace_groups`. -- **Bounds naming** (decided 2026-10-16 in #5717, changed to `limit` by the - maintainer on 2026-10-18): ✅ the siblings follow the rename: - `group::Consumer::limit` (today `set_frames`), `Subscription::with_limit` - (today `with_groups`), JS `limit` / `withLimit`, and the field - `Subscription.limit` (today `Subscription.groups`). Open question: two of - these read poorly. The `Subscription.limit` field and `with_limit` name a - request to the publisher, so they blur with the reader's local `limit`, - and alone `limit` reads as a count rather than a range. A floor-only call - such as `limit(5..)` also reads oddly, since "limit" suggests a cap. +- **Bounds naming** (decided 2026-10-16 in #5717, changed to "range" by the + maintainer on 2026-10-19): ✅ the siblings follow: + `Subscription::with_range` replaces `with_groups` as the single-range form + of `with_ranges`, and `group::Consumer::set_frames` becomes the consuming + `with_range`, with the JS mirrors. - `group::Consumer::start_at`, `end_at` (both publishers): remove the need; - the public `limit` (today `set_frames`) and `skip_to` cover them. + the public `with_range` (today `set_frames`) and `skip_to` cover them. - `Subscription::{start, end, set_bounds, with_start, with_end}` (both publishers, IETF subscriber, from #5732): the covering span a wire without ranges carries. Derive it from the public `ranges` instead, or make it @@ -80,7 +94,7 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: JS items (`js/net/src/`): - `hooks.replaceGroups` (lite publisher): mirror the cursor decision, so - `Track.Subscriber.limit` assigns the floor and the hook goes. + `Track.Subscriber.withRange` assigns the floor and the hook goes. - `hooks.tryRecvGroup` with its `Recv` union, and `hooks.groupChanged` (lite publisher). The public `Subscriber.tryRecvGroup()` returns `undefined` for both a held boundary and a drained end, so a synchronous From abaf2e2c990a72b0e5527d4e9e80d66cb013e358 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 11:59:10 -0700 Subject: [PATCH 04/20] fix(moq-c): count a raw update's relative start from the track's edge The raw reader's own largest reports within the bounds an update is replacing, so read the edge from the unbounded track handle instead of lifting the cap first. Co-Authored-By: Claude Opus 5.5 --- quest/m1/public-model/serve.md | 9 ++- rs/moq-c/src/consume.rs | 107 ++++++++++++++++++--------------- rs/moq-c/src/test.rs | 90 +++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 51 deletions(-) diff --git a/quest/m1/public-model/serve.md b/quest/m1/public-model/serve.md index 3e3860a5f70..d6bbc531484 100644 --- a/quest/m1/public-model/serve.md +++ b/quest/m1/public-model/serve.md @@ -44,9 +44,12 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: but these callers move a live reader's bounds through `&mut` today, so the rename needs a way to rebind (take and replace the reader, or a crate-private setter the builder wraps): - - `rs/moq-c/src/consume.rs` `apply_raw_subscription` re-applies the range - on every subscription update, first clearing it so a relative range - counts from the edge. + - `rs/moq-c/src/consume.rs` `RawReader::apply` re-applies the range on + every subscription update, counting a relative start from the track + `Consumer`'s unbounded largest. `set_groups` only raises the floor, so an + update cannot lower a floor an earlier one put above the live edge + (Codex on #5924). Making the floor assign fixes it; land that with a + moq-c test where a later relative update delivers the next group. - The lite and IETF publishers move the cursor on SUBSCRIBE_UPDATE (`start_at`, `end_at`; JS `hooks.replaceGroups` and the IETF publisher's `setGroups({ start })`). diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 469a140c8d0..cc108c00f9a 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -111,6 +111,53 @@ struct RawTaskEntry { callback: OnStatus, } +/// A raw track's reader, with the track handle its subscription updates resolve against. +struct RawReader { + // A relative range counts from this track's largest: the reader's own reports only + // within the bounds the update is replacing. Kept unpolled, so each clone reports the + // current largest rather than only a newer one. + edge: moq_net::track::Consumer, + track: moq_net::track::Ordered, +} + +impl RawReader { + fn apply(&mut self, 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 self.edge.clone().poll_largest(&kio::Waiter::noop()) { + 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()); + self.track.set_groups(( + start.map_or(Bound::Unbounded, Bound::Included), + end.map_or(Bound::Unbounded, moq_net::track::Position::group_end), + )); + // A closed track makes the update meaningless; the reader already sees the close. + let _ = self.track.update(subscription); + } +} + /// Outcome of polling a raw track source alongside its control channels. enum RawStep { /// A delivered value: the next group, or the next frame within a group. @@ -665,9 +712,11 @@ 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 edge = broadcast.track(&name)?; + let track = edge.subscribe(subscription.clone()).await?.ordered(); + let mut reader = RawReader { edge, track }; + reader.apply(subscription); + Self::run_raw(on_frame, reader, channel.1, updates).await })) .await; @@ -682,47 +731,9 @@ impl Consume { Ok(id) } - 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. Lift the last update's - // cap first: the largest reports within it, and a relative range counts from the edge. - track.set_groups(..); - 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), - )); - // A closed track makes the update meaningless; the reader already sees the close. - let _ = track.update(subscription); - } - async fn run_raw( callback: OnStatus, - mut track: moq_net::track::Ordered, + mut reader: RawReader, mut close: oneshot::Receiver<()>, mut updates: mpsc::UnboundedReceiver>, ) -> Result<(), Error> { @@ -736,10 +747,10 @@ 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 Self::poll_raw_control(&mut close, &mut updates, &mut reader, waiter) { return Poll::Ready(Ok(RawStep::Stop)); } - match ready!(track.poll_next_group(waiter))? { + match ready!(reader.track.poll_next_group(waiter))? { Some(group) => Poll::Ready(Ok(RawStep::Item(group))), None => Poll::Ready(Ok(RawStep::End)), } @@ -753,7 +764,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 Self::poll_raw_control(&mut close, &mut updates, &mut reader, waiter) { return Poll::Ready(Ok(RawStep::Stop)); } match ready!(group.poll_read_frame(waiter))? { @@ -782,12 +793,12 @@ impl Consume { /// 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 + /// re-polled. Borrows `reader` 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, + reader: &mut RawReader, waiter: &moq_net::kio::Waiter, ) -> bool { let mut cx = waiter.context(); @@ -797,7 +808,7 @@ impl Consume { loop { match updates.poll_recv(&mut cx) { - Poll::Ready(Some(subscription)) => Self::apply_raw_subscription(track, subscription), + Poll::Ready(Some(subscription)) => reader.apply(subscription), Poll::Ready(None) => return true, Poll::Pending => return false, } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index ac8de2aea69..30c3dc8cb35 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1804,6 +1804,96 @@ fn raw_track_subscription_options_and_update() { assert_eq!(moq_origin_close(origin), 0); } +#[test] +fn raw_track_relative_update_counts_from_the_track_edge() { + let origin = id(moq_origin_create()); + let path = b"raw-track-relative"; + let broadcast = publish_broadcast(origin, path); + + let track_name = b"data"; + let track = id(unsafe { + moq_publish_track( + broadcast, + track_name.as_ptr() as *const c_char, + track_name.len(), + std::ptr::null(), + ) + }); + let publish = |payload: &[u8]| { + assert_eq!( + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), 0) }, + 0 + ); + }; + let anchor = |kind: moq_anchor_kind, group: u64| moq_anchor { + kind: kind as u32, + group, + frame: 0, + }; + let subscribe = |ranges: &[moq_range]| moq_subscription { + priority: 0, + max_delay_us: 60_000_000, + ranges: ranges.as_ptr(), + ranges_len: ranges.len(), + order: 0, + }; + let recv = |frame_cb: &Callback| { + 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) }.to_vec(); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + received + }; + + publish(b"zero"); + let consume = request_broadcast(origin, path); + + // Group 0 only: the reader's cap sits below where the track will be. + let first = [moq_range { + start: anchor(moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE, 0), + end: anchor(moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE, 1), + end_present: true, + }]; + let frame_cb = Callback::new(); + let consumer = id(unsafe { + moq_consume_track( + consume, + track_name.as_ptr() as *const c_char, + track_name.len(), + &subscribe(&first), + Some(channel_callback), + frame_cb.ptr, + ) + }); + assert_eq!(recv(&frame_cb), b"zero"); + + publish(b"one"); + publish(b"two"); + + // The latest group is the track's, group 2, not the newest one under the old cap. + let latest = [moq_range { + start: anchor(moq_anchor_kind::MOQ_ANCHOR_RELATIVE, 0), + end: anchor(moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE, 0), + end_present: false, + }]; + assert_eq!(unsafe { moq_consume_track_update(consumer, &subscribe(&latest)) }, 0); + assert_eq!(recv(&frame_cb), b"two"); + + 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()); From a38cd55fe16008e4b1bb6ed4bc1e57bde7fa5f3d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:11:13 -0700 Subject: [PATCH 05/20] fix(net): peek the track's unbounded largest after merging #5947 peek_largest answers SUBSCRIBE_START, a wire answer, so it reads the track's largest regardless of the reader's local bounds. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 818aca3acf1..88f7c316efd 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5662,15 +5662,16 @@ impl Subscriber { kio::wait(|waiter| self.poll_largest(waiter)).await } - /// The largest location known now, without waiting or moving [`Self::poll_largest`] on: - /// past what the cache holds once the source reported a newer one. + /// The track's largest location known now, ignoring [`Self::set_groups`], without waiting + /// or moving [`Self::poll_largest`] on: past what the cache holds once the source + /// reported a newer one. #[cfg_attr(not(feature = "async"), allow(dead_code))] // private: capability gap pub(crate) fn peek_largest(&mut self) -> Option { let mut seen = None; match &mut self.inner { - Inner::Plain(cursor) => cursor.state.read().cache.take_largest(&mut seen), - Inner::Resume(resume, _) => match resume.poll_largest(&mut seen, &kio::Waiter::noop()) { + Inner::Plain(cursor) => cursor.state.read().cache.largest().0, + Inner::Resume(resume, _) => match resume.poll_largest(&mut seen, Bounds::default(), &kio::Waiter::noop()) { Poll::Ready(largest) => largest, Poll::Pending => None, }, From 93887704d4b54ccd571556a45c425a1b90215afb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:06:03 -0700 Subject: [PATCH 06/20] perf(net): wake only capped largest readers on a finish below the edge JS bumped a written signal on every sink per finish, and every parked largest() reader raced it. Share one signal across the sinks and race it only while capped below the largest. Sweep tracks x subscribers in a new js/net largest bench (nightly), and add a Rust track_largest_finish bench over subscribers: there a finish already wakes each parked reader through its settled-end park on the track state, as on main, and the revision bump measures no extra cost. Co-Authored-By: Claude Opus 5.5 --- js/justfile | 2 +- js/net/bench/largest.ts | 96 +++++++++++++++++++++++++++++++++++++ js/net/src/track.ts | 13 +++-- rs/moq-net/benches/track.rs | 75 +++++++++++++++++++++++++++++ sh/gh/nightly.sh | 2 +- 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 js/net/bench/largest.ts diff --git a/js/justfile b/js/justfile index a4d2159f98f..103ef844e9a 100644 --- a/js/justfile +++ b/js/justfile @@ -47,7 +47,7 @@ test: test-changed list: python3 ../sh/js/workspace.py test '{{ list }}' -# Run one net benchmark: broadcasts, forward, requests, reader, frames, track, interests, observed, varint, lite-varint, priority, or request-ids. +# Run one net benchmark: broadcasts, forward, requests, reader, frames, track, largest, interests, observed, varint, lite-varint, priority, or request-ids. bench name: bun install --frozen-lockfile bun net/bench/{{ name }}.ts diff --git a/js/net/bench/largest.ts b/js/net/bench/largest.ts new file mode 100644 index 00000000000..2fc7e706d28 --- /dev/null +++ b/js/net/bench/largest.ts @@ -0,0 +1,96 @@ +/** Sweep tracks and subscribers parked on `largest()` for the cost of finishing one-frame groups. */ + +import { Producer as GroupProducer } from "../src/group.ts"; +import { Producer, type Subscriber } from "../src/track.ts"; + +const trackCounts = [1, 16]; +const subscriberCounts = [1, 16, 256]; +const groups = 50; +const reps = 7; +// Finishing a group already costs a pass over its subscribers' mirrors. Unbounded readers +// parked on `largest()` must not add another: only a reader capped below the largest follows +// a finish that leaves the largest in place. Waking every parked reader on each finish costs +// twice as much with one subscriber and nine times at 256; the margin is loose for a noisy machine. +const maxOverhead = 4; +const payload = new Uint8Array(80); + +type Readers = "none" | "unbounded" | "capped"; + +// Let every woken reader re-evaluate before the next step. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +// Follow `largest()` until the track ends, the way a live reader parks on it. +async function follow(subscriber: Subscriber): Promise { + while ((await subscriber.largest()) !== undefined) {} +} + +async function measure(trackCount: number, subscriberCount: number, readers: Readers): Promise { + const producers = Array.from({ length: trackCount }, () => new Producer("bench").accept({})); + const following: Promise[] = []; + const subscribers: Subscriber[] = []; + for (const producer of producers) { + for (let index = 0; index < subscriberCount; index++) { + const subscriber = producer.subscribe(); + // Capped readers stop at group 1, below every group the sweep finishes. + if (readers === "capped") subscriber.setGroups({ end: { excluded: 1 } }); + if (readers !== "none") following.push(follow(subscriber)); + subscribers.push(subscriber); + } + } + // Group 0 is under every cap, so a capped reader has something to report and follow. + for (const producer of producers) { + const group = new GroupProducer(0); + producer.writeGroup(group); + group.writeFrame({ payload }); + group.close(); + } + await flush(); + + let elapsed = 0; + for (let sequence = 1; sequence <= groups; sequence++) { + const open = producers.map((producer) => { + const group = new GroupProducer(sequence); + producer.writeGroup(group); + group.writeFrame({ payload }); + return group; + }); + await flush(); + + const start = performance.now(); + for (const group of open) group.close(); + await flush(); + elapsed += performance.now() - start; + } + + for (const producer of producers) producer.close(); + await Promise.all(following); + for (const subscriber of subscribers) subscriber.close(); + return (elapsed * 1000) / (groups * trackCount); +} + +// The fastest run is the one least disturbed by the rest of the machine. +async function fastest(trackCount: number, subscriberCount: number, readers: Readers): Promise { + const samples: number[] = []; + for (let rep = 0; rep < reps; rep++) samples.push(await measure(trackCount, subscriberCount, readers)); + return Math.min(...samples); +} + +// Warm the JIT so the first row isn't the slowest. +for (const readers of ["none", "unbounded", "capped"] as const) await measure(1, 16, readers); + +console.log("tracks,subscribers,readers,finish_us"); +for (const trackCount of trackCounts) { + for (const subscriberCount of subscriberCounts) { + const rows = {} as Record; + for (const readers of ["none", "unbounded", "capped"] as const) { + rows[readers] = await fastest(trackCount, subscriberCount, readers); + console.log(`${trackCount},${subscriberCount},${readers},${rows[readers].toFixed(2)}`); + } + // Capped readers follow every finish below the edge, so only unbounded readers must be free. + if (rows.unbounded > rows.none * maxOverhead) { + throw new Error( + `${subscriberCount} unbounded readers cost ${rows.unbounded.toFixed(2)} us per finish, over ${maxOverhead}x ${rows.none.toFixed(2)} without them`, + ); + } + } +} diff --git a/js/net/src/track.ts b/js/net/src/track.ts index b2015905651..6cc82146c5a 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -443,7 +443,8 @@ class TrackState { // Set once {@link largest} can no longer advance while the track stays open. largestEnded = new Signal(false); // Bumped by a write or finish that leaves {@link largest} where it was: a reader capped below it - // follows the groups under its cap. + // follows the groups under its cap. One signal shared with every sink, so a bump costs the + // same however many subscribe. written = new Signal(0); constructor(subscription?: Subscription) { @@ -833,6 +834,7 @@ export class Producer { if (info) sink.info.set(info); sink.largest.set(this.#state.largest.peek()); sink.largestEnded.set(this.#state.largestEnded.peek()); + sink.written = this.#state.written; const closed = this.#state.closed.peek(); if (closed === undefined) { @@ -1031,7 +1033,7 @@ export class Producer { if (!largest) return; const current = this.#state.largest.peek(); if (current && !after(largest, current)) { - for (const state of [this.#state, ...this.#sinks]) state.written.update((n) => n + 1); + this.#state.written.update((n) => n + 1); return; } this.#largestOpen = true; @@ -1410,7 +1412,7 @@ export class Producer { #finishLargest(sequence: number): void { if (this.#state.largest.peek()?.group === sequence) this.#largestOpen = false; // A reader capped below the largest may be following this group. - for (const state of [this.#state, ...this.#sinks]) state.written.update((n) => n + 1); + this.#state.written.update((n) => n + 1); this.#endLargest(); } @@ -1782,12 +1784,15 @@ export class Subscriber { if (this.#state.closed.peek() !== undefined) return undefined; // The track's settled end does not end a group under the cap that can still grow. if (this.#state.largestEnded.peek() && !growing) return undefined; + // Only a reader capped below the largest follows writes that leave it in place. + const largest = this.#state.largest.peek(); + const capped = bounds.end !== undefined && largest !== undefined && largest.group >= bounds.end; await Signal.race( this.#state.largest, - this.#state.written, this.#state.closed, this.#state.largestEnded, this.#cursor, + ...(capped ? [this.#state.written] : []), ); } } diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 73756c0f415..de8db016744 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -45,6 +45,13 @@ //! on the next one, re-polling whichever reader the write woke: a frame wakes no track //! reader, so waking them shows up as a slope over the subscribers. //! +//! `track_largest_finish` finishes one-frame groups while every subscriber is parked on +//! `poll_largest`, re-polling whichever reader the finish woke, against `none`, which parks +//! no reader there. A parked reader also waits on the track state for its settled end, which +//! a finish changes, so each finish wakes every one of them; a reader capped below the +//! largest re-reads the groups under its cap too. A cost that grows faster than that, or a +//! wake per write, shows up as a steeper slope. +//! //! `track_fetch_expiry` polls budgeted fetched readers over independent fanout and //! retained-history axes, so expiry work that scans either table appears as a slope. //! @@ -549,6 +556,73 @@ fn bench_fanout_frame(c: &mut Criterion) { group.finish(); } +fn bench_largest_finish(c: &mut Criterion) { + let mut group = c.benchmark_group("track_largest_finish"); + // `none` parks no reader on `poll_largest`: the finish's cost without any. + for readers in ["none", "unbounded", "capped"] { + for subscribers in FANOUT { + group.bench_with_input( + BenchmarkId::new(readers, subscribers), + &subscribers, + |b, &subscribers| { + let mut fanout = Fanout::new(subscribers); + let queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let waiters: Vec<_> = (0..subscribers) + .map(|index| { + let fired = Arc::new(Fired { + index, + queue: queue.clone(), + }); + kio::Waiter::new(futures::task::waker(fired)) + }) + .collect(); + // Group 0 sits under every cap, so a capped reader has a report to follow. + let mut first = fanout.track.append_group().unwrap(); + first.write_frame(Timestamp::ZERO, fanout.payload.clone()).unwrap(); + first.finish().unwrap(); + for (subscriber, waiter) in fanout.subscribers.iter_mut().zip(&waiters) { + if readers == "none" { + break; + } + if readers == "capped" { + subscriber.set_groups(..1); + } + assert!(matches!(subscriber.poll_largest(waiter), Poll::Ready(Some(_)))); + assert!(subscriber.poll_largest(waiter).is_pending()); + } + // Only the finish and the re-polls it causes are timed, not the write. + b.iter_custom(|iterations| { + let mut elapsed = Duration::ZERO; + for _ in 0..iterations { + let mut group = fanout.track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, fanout.payload.clone()).unwrap(); + // Read the group off, so retention stays what a live reader leaves behind. + for subscriber in &mut fanout.subscribers { + let noop = kio::Waiter::noop(); + while subscriber.poll_recv_group(&noop).is_ready() {} + } + let woken = std::mem::take(&mut *queue.lock().unwrap()); + for index in woken { + while fanout.subscribers[index].poll_largest(&waiters[index]).is_ready() {} + } + let start = Instant::now(); + group.finish().unwrap(); + // A woken reader re-polls, as its task would. + let woken = std::mem::take(&mut *queue.lock().unwrap()); + for index in woken { + while fanout.subscribers[index].poll_largest(&waiters[index]).is_ready() {} + } + elapsed += start.elapsed(); + } + elapsed + }); + }, + ); + } + } + group.finish(); +} + fn bench_fetch_expiry(c: &mut Criterion) { let mut group = c.benchmark_group("track_fetch_expiry"); for history in FETCH_HISTORY { @@ -1204,6 +1278,7 @@ criterion_group!( bench_parked_abort, bench_fanout, bench_fanout_frame, + bench_largest_finish, bench_fetch_expiry, bench_parallel_write, bench_aborted_scan, diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index 151a45ad001..16826f4e5f2 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -87,7 +87,7 @@ case "$1" in criterion gst -p moq-gst --bench catalog ;; js) - for benchmark in broadcasts request-ids requests forward reader frames varint lite-varint track interests priority; do + for benchmark in broadcasts request-ids requests forward reader frames varint lite-varint track largest interests priority; do javascript "$benchmark" just js bench "$benchmark" done javascript catalog just js catalog-bench From 8b75b5b5bbd36e3397b8c2d7dfd40bc3572ec71c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:27:06 -0700 Subject: [PATCH 07/20] test(net): make isPending observe a settled largest() Racing promise.then(() => false) against an already resolved true always picks true, so every pending assertion passed even when largest() resolved. Record settlement with a flag after a macrotask settle instead. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index e77eda4fa83..eb99a84572c 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1927,8 +1927,13 @@ function framedGroup(producer: TrackProducer, sequence: number, frames: number): /** Whether `promise` is still pending once queued work settles. */ async function isPending(promise: Promise): Promise { + let settled = false; + void promise.then( + () => (settled = true), + () => (settled = true), + ); await settle(); - return (await Promise.race([promise.then(() => false), Promise.resolve(true)])) as boolean; + return !settled; } // A cap below the live edge reports the newest cached group under it, follows that group's From 43cfd41a632d1d46bd3ebb3222a63f523f46854f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:54:27 -0700 Subject: [PATCH 08/20] fix(net)!: report largest only from upstream state, within the bounds A reader's largest() reports the track's largest only while it is within its bounds, and only once the track's feed is live: never a cached group standing in for it past the cap, and never an idle copy's leftover edge. Drops the cache-derived fallback and its revision counter. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/cache.rs | 60 +++++--- rs/moq-net/src/model/resume.rs | 4 +- rs/moq-net/src/model/track.rs | 251 +++++++++++++-------------------- 3 files changed, 138 insertions(+), 177 deletions(-) diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 8b77ed62d3d..a482d65d93d 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -658,16 +658,12 @@ struct Largest { open: bool, // The track ended, so no newer position can follow. ended: bool, - // Bumped by every write and finish, raising `position` or not: a reader capped below it - // follows the groups under its cap. - revision: u64, } impl Track { /// Raise the track's largest location to `position`, waking only its own waiters. pub(super) fn advance_largest(&self, position: Position) { let mut largest = self.largest.lock(); - largest.revision = largest.revision.wrapping_add(1); if largest.position.is_none_or(|current| position > current) { largest.position = Some(position); largest.open = true; @@ -678,8 +674,6 @@ impl Track { /// A group name fixes its content, so no legal write raises the largest past it. pub(super) fn finish_largest(&self, sequence: u64) { let mut largest = self.largest.lock(); - // A reader capped below the largest may be following this group. - largest.revision = largest.revision.wrapping_add(1); if largest.position.is_some_and(|position| position.group == sequence) { largest.open = false; } @@ -698,22 +692,44 @@ impl Track { } } - /// The largest location, and the revision it was read at. - pub(super) fn largest(&self) -> (Option, u64) { - let largest = self.largest.read(); - (largest.position, largest.revision) - } - - /// Ready once a write moves the largest past `revision` (`true`), or the track ended - /// without one (`false`). - pub(super) fn poll_largest_changed(&self, revision: u64, waiter: &kio::Waiter) -> Poll { - let largest = ready!(self.largest.poll(waiter, |largest| { - match largest.revision != revision || largest.ended { - true => Poll::Ready(()), - false => Poll::Pending, - } - })); - Poll::Ready(largest.revision != revision) + /// Poll for a largest location within `bounds` newer than `seen`, recording it there. + /// `None` once the track ended with nothing newer. + pub(super) fn poll_largest( + &self, + seen: &mut Option, + bounds: track::Bounds, + waiter: &kio::Waiter, + ) -> Poll> { + let newer = |largest: &Largest| { + largest + .position + .filter(|position| bounds.contains(position.group) && seen.is_none_or(|seen| *position > seen)) + }; + let largest = ready!( + self.largest + .poll(waiter, |largest| match newer(largest).is_some() || largest.ended { + true => Poll::Ready(()), + false => Poll::Pending, + }) + ); + let next = newer(&largest); + if next.is_some() { + *seen = next; + } + Poll::Ready(next) + } + + /// The largest frame within `bounds` newer than `seen` without waiting, recording it there. + pub(super) fn take_largest(&self, seen: &mut Option, bounds: track::Bounds) -> Option { + let next = self + .largest + .read() + .position + .filter(|position| bounds.contains(position.group) && seen.is_none_or(|seen| *position > seen)); + if next.is_some() { + *seen = next; + } + next } /// Open an account against `pool` for the track behind `state`. diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 695ee83ba16..0ae7bf00364 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1439,7 +1439,7 @@ mod test { assert_eq!(sub.largest().now_or_never(), Some(None)); } - /// A front's reader reports the largest within its cap on the serving copy. + /// A front's reader reports the serving copy's largest only within its cap. #[test] #[cfg(feature = "async")] fn largest_honours_the_cap_on_the_serving_copy() { @@ -1452,7 +1452,7 @@ mod test { write(&a, sequence); } sub.set_groups(..3); - assert_eq!(sub.largest().now_or_never(), Some(Some(Position::group(2)))); + assert!(sub.largest().now_or_never().is_none(), "the largest is past the cap"); sub.set_groups(..); assert_eq!(sub.largest().now_or_never(), Some(Some(Position::group(4)))); } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 88f7c316efd..4757d0701c8 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1970,29 +1970,25 @@ impl TrackState { || self.datagrams.iter().any(|datagram| datagram.sequence >= sequence) } - /// The largest position the cache holds: the newest live group's last frame (its head, - /// while it has none). - fn largest(&self) -> Option { - self.largest_within(Bounds::default()).map(|(largest, _)| largest) - } - - /// [`Self::largest`] among the groups within `bounds`, and whether its group can still - /// grow: it has not finished, and an aborted one can be recreated. - fn largest_within(&self, bounds: Bounds) -> Option<(Position, bool)> { - if bounds.end.is_some_and(|end| end <= bounds.start) { - return None; - } - let end = bounds.end.map_or(Bound::Unbounded, Bound::Excluded); + /// The newest live group the cache holds, and the number of frames it has so far. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn newest(&self) -> Option<(u64, u64)> { let (sequence, slot) = self .lookup - .range((Bound::Included(bounds.start), end)) + .iter() .rev() .find(|(_, slot)| slot.visible && !slot.pending)?; - let largest = Position { - group: *sequence, - frame: (slot.group.frame_count() as u64).saturating_sub(1), - }; - Some((largest, !slot.group.is_finished())) + Some((*sequence, slot.group.frame_count() as u64)) + } + + /// The largest position the cache holds: the newest live group's last frame (its head, + /// while it has none). + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn largest(&self) -> Option { + self.newest().map(|(group, frames)| Position { + group, + frame: frames.saturating_sub(1), + }) } fn poll_finished(&self) -> Poll> { @@ -2115,43 +2111,42 @@ pub(super) struct Bounds { pub end: Option, } +impl Bounds { + /// Whether group `sequence` is within these bounds. + pub fn contains(&self, sequence: u64) -> bool { + sequence >= self.start && super::subscription::before_end(sequence, self.end) + } +} + /// Poll `cache` for a largest location within `bounds` newer than `seen`, recording it there. /// -/// The track's largest while it is within them; past the cap, the newest group the cache -/// holds below it, which `below` reads from the track with whether that group can still -/// grow; nothing while below the floor. The track closing ends the stream. So does `ended`, -/// which polls the track for [`TrackState::largest_settled`], or its channel closing without -/// ending the cache (a dropped request), unless the group below the cap can still grow. +/// The largest is what the source reported and what its feed delivered since, never what +/// the cache happens to hold, so `live` holds the stream until the track's feed is live: an +/// idle copy's largest is only what an ended feed left. Past the cap or below the floor it +/// reports nothing. `ended` polls the track for [`TrackState::largest_settled`], or its +/// channel closing without ending the cache (a dropped request), either of which ends the +/// stream. fn poll_largest( cache: &cache::Track, seen: &mut Option, bounds: Bounds, - below: impl Fn() -> Option<(Position, bool)>, waiter: &kio::Waiter, + live: impl FnOnce(&kio::Waiter) -> Poll<()>, ended: impl FnOnce(&kio::Waiter) -> Poll<()>, ) -> Poll> { - loop { - let (largest, revision) = cache.largest(); - let (within, growing) = match largest { - Some(largest) if largest.group < bounds.start => (None, false), - Some(largest) if super::subscription::before_end(largest.group, bounds.end) => (Some(largest), false), - // Read outside the largest lock: the track and group locks come first. - Some(_) => below().map_or((None, false), |(below, growing)| (Some(below), growing)), - None => (None, false), - }; - if let Some(next) = within.filter(|next| seen.is_none_or(|seen| *next > seen)) { - *seen = Some(next); - return Poll::Ready(Some(next)); - } - match cache.poll_largest_changed(revision, waiter) { - Poll::Ready(true) => continue, - Poll::Ready(false) => return Poll::Ready(None), - // Its frames and its finish bump the revision this waits on. - Poll::Pending if growing => return Poll::Pending, - Poll::Pending => {} - } - ready!(ended(waiter)); - return Poll::Ready(None); + ready!(live(waiter)); + if let Poll::Ready(next) = cache.poll_largest(seen, bounds, waiter) { + return Poll::Ready(next); + } + ready!(ended(waiter)); + Poll::Ready(cache.take_largest(seen, bounds)) +} + +/// Ready once the track's feed is live; see [`TrackState::readable`]. +fn largest_live(state: &TrackState) -> Poll<()> { + match state.readable() { + true => Poll::Ready(()), + false => Poll::Pending, } } @@ -2745,8 +2740,8 @@ impl Producer { &cache, &mut self.largest, Bounds::default(), - || state.read().largest_within(Bounds::default()), waiter, + |waiter| state.poll(waiter, |state| largest_live(state)).map(|_| ()), |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), ) } @@ -3897,8 +3892,8 @@ impl Consumer { &cache, seen, bounds, - || state.read().largest_within(bounds), waiter, + |waiter| state.poll(waiter, |state| largest_live(state)).map(|_| ()), |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), ), } @@ -5616,13 +5611,13 @@ impl Subscriber { channel.update(subscription) } - /// Poll for the largest location within [`Self::set_groups`]: the current one, then each newer one, and `None` once the track ends. + /// Poll for the track's largest location within [`Self::set_groups`]: the current one, then each newer one, and `None` once the track ends. /// - /// While the track's largest is within the bounds, that is its newest group or datagram - /// at the newest frame (0 while empty). Past the cap, it is the newest cached group - /// below the cap, so `..N` reports group `N - 1` once it arrives, and the stream stays - /// open while that group can still grow; below the floor, nothing. Moving the bounds - /// reports again within the new ones, even below the last report. + /// The largest is the newest group or datagram at the newest frame (0 while empty), as + /// the source reported it and its live feed delivered since, so nothing reports until + /// the track's feed is live. A largest past the cap or below the floor reports nothing: + /// the cache does not stand in for it. Moving the bounds reports again within the new + /// ones, even below the last report. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { self.largest.park(waiter.waker()); let bounds = self.bounds(); @@ -5647,8 +5642,8 @@ impl Subscriber { &cache, seen, bounds, - || state.read().largest_within(bounds), waiter, + |waiter| state.poll(waiter, |state| largest_live(state)).map(|_| ()), |waiter| state.poll(waiter, |state| largest_ended(state)).map(|_| ()), ) } @@ -5670,7 +5665,7 @@ impl Subscriber { pub(crate) fn peek_largest(&mut self) -> Option { let mut seen = None; match &mut self.inner { - Inner::Plain(cursor) => cursor.state.read().cache.largest().0, + Inner::Plain(cursor) => cursor.state.read().cache.take_largest(&mut seen, Bounds::default()), Inner::Resume(resume, _) => match resume.poll_largest(&mut seen, Bounds::default(), &kio::Waiter::noop()) { Poll::Ready(largest) => largest, Poll::Pending => None, @@ -6424,140 +6419,90 @@ mod test { group } - /// A cap below the live edge reports the newest cached group under it, follows that - /// group's frames, and ignores the edge moving on. + /// The largest reports only while it is within the bounds: a cap below it or a floor + /// above it reports nothing, rather than a cached group standing in for it. #[test] - fn largest_reports_within_a_cap_below_the_live_edge() { + fn largest_reports_only_within_the_bounds() { let producer = track_producer("test", Info::default()); let noop = kio::Waiter::noop(); - let mut groups: Vec<_> = (0..6).map(|sequence| framed(&producer, sequence, 2)).collect(); - - let mut reader = producer.subscribe(replay()); - reader.set_groups(..3); - assert_eq!( - reader.poll_largest(&noop), - Poll::Ready(Some(Position { group: 2, frame: 1 })) - ); + let _groups: Vec<_> = (0..6).map(|sequence| framed(&producer, sequence, 2)).collect(); - let (flag, waiter) = Flag::waiter(); - assert!(reader.poll_largest(&waiter).is_pending()); - groups[2].write_frame(None, &b"x"[..]).unwrap(); - assert!(flag.take(), "a frame below the cap woke the reader"); - assert_eq!( - reader.poll_largest(&noop), - Poll::Ready(Some(Position { group: 2, frame: 2 })) - ); - - groups.push(framed(&producer, 6, 1)); - groups[5].write_frame(None, &b"x"[..]).unwrap(); - assert!(reader.poll_largest(&noop).is_pending(), "the edge moved past the cap"); + let mut capped = producer.subscribe(replay()); + capped.set_groups(..3); + assert!(capped.poll_largest(&noop).is_pending(), "the largest is past the cap"); // An inclusive cap reports the group it names. let mut ordered = producer.subscribe(replay()).ordered(); - ordered.set_groups(..=3); + ordered.set_groups(..=5); assert_eq!( ordered.poll_largest(&noop), - Poll::Ready(Some(Position { group: 3, frame: 1 })) - ); - } - - /// A gap under the cap reports the newest group the cache holds there, and a floor - /// above the edge reports nothing until a group reaches it. - #[test] - fn largest_skips_gaps_under_the_cap_and_waits_for_the_floor() { - let producer = track_producer("test", Info::default()); - let noop = kio::Waiter::noop(); - let _groups = [ - framed(&producer, 0, 1), - framed(&producer, 1, 3), - framed(&producer, 5, 1), - ]; - - let mut capped = producer.subscribe(replay()); - capped.set_groups(..4); - assert_eq!( - capped.poll_largest(&noop), - Poll::Ready(Some(Position { group: 1, frame: 2 })) + Poll::Ready(Some(Position { group: 5, frame: 1 })) ); let mut floored = producer.subscribe(replay()); floored.set_groups(8..); - assert!(floored.poll_largest(&noop).is_pending(), "the edge is below the floor"); + assert!(floored.poll_largest(&noop).is_pending(), "the largest is below the floor"); let _late = framed(&producer, 8, 0); assert_eq!(floored.poll_largest(&noop), Poll::Ready(Some(Position::group(8)))); } - /// A settled track ends the stream past the cap only once the group under the cap stops - /// growing: its frames and its finish still reach a capped reader. + /// Moving the bounds wakes a parked reader and reports the largest within the new ones + /// again; bounds that leave it where it was report nothing new. #[test] - fn largest_under_the_cap_stays_open_while_its_group_grows() { - let mut producer = track_producer("test", Info::default()); - let noop = kio::Waiter::noop(); - let mut open = framed(&producer, 2, 1); - framed(&producer, 5, 1).finish().unwrap(); - producer.finish_at(Position::group(6)).unwrap(); - - let mut uncapped = producer.subscribe(replay()); - assert_eq!(uncapped.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); - assert_eq!(uncapped.poll_largest(&noop), Poll::Ready(None), "the track settled"); - - let mut reader = producer.subscribe(replay()); - reader.set_groups(..3); - assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(2)))); - let (flag, waiter) = Flag::waiter(); - assert!(reader.poll_largest(&waiter).is_pending(), "group 2 can still grow"); - - open.write_frame(None, &b"x"[..]).unwrap(); - assert!(flag.take(), "a frame under the cap woke the reader"); - assert_eq!( - reader.poll_largest(&noop), - Poll::Ready(Some(Position { group: 2, frame: 1 })) - ); - - assert!(reader.poll_largest(&waiter).is_pending()); - open.finish().unwrap(); - assert!(flag.take(), "finishing the group under the cap woke the reader"); - assert_eq!(reader.poll_largest(&noop), Poll::Ready(None)); - } - - /// Moving the cap wakes a parked reader and reports the largest within the new bounds, - /// lower ones included; bounds that did not move report nothing again. - #[test] - fn largest_rereports_when_the_cap_moves() { + fn largest_rereports_when_the_bounds_move() { let producer = track_producer("test", Info::default()); let noop = kio::Waiter::noop(); let _groups: Vec<_> = (0..6).map(|sequence| framed(&producer, sequence, 1)).collect(); let mut reader = producer.subscribe(replay()).ordered(); reader.set_groups(..3); - assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(2)))); - let (flag, waiter) = Flag::waiter(); assert!(reader.poll_largest(&waiter).is_pending()); - reader.set_groups(..5); - assert!(flag.take(), "raising the cap woke the reader"); - assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(4)))); + reader.set_groups(..); + assert!(flag.take(), "lifting the cap woke the reader"); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); assert!(reader.poll_largest(&waiter).is_pending()); reader.set_groups(..2); assert!(flag.take(), "lowering the cap woke the reader"); - assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(1)))); - - reader.set_groups(..2); - assert!( - reader.poll_largest(&noop).is_pending(), - "the same bounds report nothing new" - ); + assert!(reader.poll_largest(&noop).is_pending(), "the largest is past the new cap"); reader.set_groups(..); - assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); + assert_eq!( + reader.poll_largest(&noop), + Poll::Ready(Some(Position::group(5))), + "lifting it again reports the largest again" + ); reader.set_groups(3..); assert!( reader.poll_largest(&noop).is_pending(), - "a floor below the edge leaves the largest where it was" + "a floor below the largest leaves it where it was" ); } + /// An idle copy's largest is what an ended feed left, so nothing reports until the + /// route's answer shows, though the cache still holds groups. + #[test] + fn largest_waits_for_an_idle_copy_to_go_live() { + let mut producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let _groups: Vec<_> = (0..3).map(|sequence| framed(&producer, sequence, 1)).collect(); + producer.set_idle(); + + let mut reader = producer.subscribe(replay()); + let (flag, waiter) = Flag::waiter(); + assert!(reader.poll_largest(&waiter).is_pending(), "the copy is idle"); + + producer.set_live(Some(Position::group(5))); + producer.report_largest(Position::group(5)); + assert!(reader.poll_largest(&waiter).is_pending(), "the answer has not shown yet"); + flag.take(); + + let _answer = framed(&producer, 5, 1); + assert!(flag.take(), "the answer showing woke the reader"); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); + } + #[test] #[cfg(feature = "async")] fn append_datagram_roundtrip() { From 48b39cec96fabbeeacade14d816e290509e37599 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:10:16 -0700 Subject: [PATCH 09/20] fix(net): mirror upstream-only largest in JS and drop the cache stand-in A capped reader's largest() no longer reports the newest cached group under its cap; past the cap or below the floor it reports nothing. This removes the JS written signal, hooks.groupFinished, the timeline scan, and both largest finish benchmarks. Adds the track-live quest for a public live signal. Co-Authored-By: Claude Opus 5.5 --- js/justfile | 2 +- js/net/MIGRATE.md | 6 +-- js/net/bench/largest.ts | 96 ---------------------------------- js/net/src/group.ts | 1 - js/net/src/internal.ts | 5 -- js/net/src/track.test.ts | 81 +++++----------------------- js/net/src/track.ts | 59 +++++---------------- quest/m1/README.md | 1 + quest/m1/public-model/serve.md | 14 +++-- quest/m1/track-live.md | 31 +++++++++++ rs/moq-net/MIGRATE.md | 9 ++-- rs/moq-net/benches/track.rs | 75 -------------------------- rs/moq-net/src/model/track.rs | 15 ++++-- sh/gh/nightly.sh | 2 +- 14 files changed, 90 insertions(+), 307 deletions(-) delete mode 100644 js/net/bench/largest.ts create mode 100644 quest/m1/track-live.md diff --git a/js/justfile b/js/justfile index 103ef844e9a..a4d2159f98f 100644 --- a/js/justfile +++ b/js/justfile @@ -47,7 +47,7 @@ test: test-changed list: python3 ../sh/js/workspace.py test '{{ list }}' -# Run one net benchmark: broadcasts, forward, requests, reader, frames, track, largest, interests, observed, varint, lite-varint, priority, or request-ids. +# Run one net benchmark: broadcasts, forward, requests, reader, frames, track, interests, observed, varint, lite-varint, priority, or request-ids. bench name: bun install --frozen-lockfile bun net/bench/{{ name }}.ts diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 9314017eb7c..69c63b4d724 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,10 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased -### A reader's `largest()` reports within its `setGroups` bounds +### `largest()` reports within a reader's `setGroups` bounds -`Track.Subscriber.largest()` and `Track.Ordered.largest()` report the largest location within the bounds set with `setGroups` or `withGroups`, where they used to report the track's. -Past an exclusive end of `N`, they report the newest cached group below `N`; below the floor, nothing. +`Track.Subscriber.largest()` and `Track.Ordered.largest()` report the track's largest only while it is within the bounds set with `setGroups` or `withGroups`, where they used to ignore the bounds. +A largest past the cap or below the floor reports nothing; the cache never stands in for it. Moving the bounds reports again within the new ones, even below the last report. Unbounded readers and `Track.Producer.largest()` are unchanged. To read the track's edge from a capped reader, lift the cap with `setGroups({})` first. diff --git a/js/net/bench/largest.ts b/js/net/bench/largest.ts deleted file mode 100644 index 2fc7e706d28..00000000000 --- a/js/net/bench/largest.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** Sweep tracks and subscribers parked on `largest()` for the cost of finishing one-frame groups. */ - -import { Producer as GroupProducer } from "../src/group.ts"; -import { Producer, type Subscriber } from "../src/track.ts"; - -const trackCounts = [1, 16]; -const subscriberCounts = [1, 16, 256]; -const groups = 50; -const reps = 7; -// Finishing a group already costs a pass over its subscribers' mirrors. Unbounded readers -// parked on `largest()` must not add another: only a reader capped below the largest follows -// a finish that leaves the largest in place. Waking every parked reader on each finish costs -// twice as much with one subscriber and nine times at 256; the margin is loose for a noisy machine. -const maxOverhead = 4; -const payload = new Uint8Array(80); - -type Readers = "none" | "unbounded" | "capped"; - -// Let every woken reader re-evaluate before the next step. -const flush = () => new Promise((resolve) => setImmediate(resolve)); - -// Follow `largest()` until the track ends, the way a live reader parks on it. -async function follow(subscriber: Subscriber): Promise { - while ((await subscriber.largest()) !== undefined) {} -} - -async function measure(trackCount: number, subscriberCount: number, readers: Readers): Promise { - const producers = Array.from({ length: trackCount }, () => new Producer("bench").accept({})); - const following: Promise[] = []; - const subscribers: Subscriber[] = []; - for (const producer of producers) { - for (let index = 0; index < subscriberCount; index++) { - const subscriber = producer.subscribe(); - // Capped readers stop at group 1, below every group the sweep finishes. - if (readers === "capped") subscriber.setGroups({ end: { excluded: 1 } }); - if (readers !== "none") following.push(follow(subscriber)); - subscribers.push(subscriber); - } - } - // Group 0 is under every cap, so a capped reader has something to report and follow. - for (const producer of producers) { - const group = new GroupProducer(0); - producer.writeGroup(group); - group.writeFrame({ payload }); - group.close(); - } - await flush(); - - let elapsed = 0; - for (let sequence = 1; sequence <= groups; sequence++) { - const open = producers.map((producer) => { - const group = new GroupProducer(sequence); - producer.writeGroup(group); - group.writeFrame({ payload }); - return group; - }); - await flush(); - - const start = performance.now(); - for (const group of open) group.close(); - await flush(); - elapsed += performance.now() - start; - } - - for (const producer of producers) producer.close(); - await Promise.all(following); - for (const subscriber of subscribers) subscriber.close(); - return (elapsed * 1000) / (groups * trackCount); -} - -// The fastest run is the one least disturbed by the rest of the machine. -async function fastest(trackCount: number, subscriberCount: number, readers: Readers): Promise { - const samples: number[] = []; - for (let rep = 0; rep < reps; rep++) samples.push(await measure(trackCount, subscriberCount, readers)); - return Math.min(...samples); -} - -// Warm the JIT so the first row isn't the slowest. -for (const readers of ["none", "unbounded", "capped"] as const) await measure(1, 16, readers); - -console.log("tracks,subscribers,readers,finish_us"); -for (const trackCount of trackCounts) { - for (const subscriberCount of subscriberCounts) { - const rows = {} as Record; - for (const readers of ["none", "unbounded", "capped"] as const) { - rows[readers] = await fastest(trackCount, subscriberCount, readers); - console.log(`${trackCount},${subscriberCount},${readers},${rows[readers].toFixed(2)}`); - } - // Capped readers follow every finish below the edge, so only unbounded readers must be free. - if (rows.unbounded > rows.none * maxOverhead) { - throw new Error( - `${subscriberCount} unbounded readers cost ${rows.unbounded.toFixed(2)} us per finish, over ${maxOverhead}x ${rows.none.toFixed(2)} without them`, - ); - } - } -} diff --git a/js/net/src/group.ts b/js/net/src/group.ts index cb0ed3ff08a..6753af3592c 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -470,7 +470,6 @@ export class Consumer { makeConsumer = (state) => new Consumer(state); hooks.groupTimestamp = (group) => (group.#state.unverified ? undefined : group.#state.timestamp); hooks.groupLatest = (group) => (group.#state.unverified ? undefined : group.#state.latest); - hooks.groupFinished = (group) => group.#state.closed.peek() === null; hooks.unverifyGroup = (group) => { group.#state.unverified = true; }; diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 874772b8df9..f9928530478 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -330,8 +330,6 @@ export const hooks: { /** Return a group's first timestamp, retained even after its first frame is read. */ groupTimestamp: (group: GroupConsumer) => Timestamp | undefined; groupLatest: (group: GroupConsumer) => Timestamp | undefined; - /** Whether a group's producer closed cleanly, whatever this consumer has left to read. */ - groupFinished: (group: GroupConsumer) => boolean; /** Return when a group joined its track at this hop, on the installed clock. */ groupArrived: (group: GroupConsumer) => Instant; /** Record when a track's mirror of a group joined that track at this hop. */ @@ -434,9 +432,6 @@ export const hooks: { groupLatest: () => { throw new Error("group.ts not loaded"); }, - groupFinished: () => { - throw new Error("group.ts not loaded"); - }, groupArrived: () => { throw new Error("group.ts not loaded"); }, diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index eb99a84572c..4b9acc758de 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1936,42 +1936,21 @@ async function isPending(promise: Promise): Promise { return !settled; } -// A cap below the live edge reports the newest cached group under it, follows that group's -// frames, and ignores the edge moving on. -test("largest reports within a cap below the live edge", async () => { +// The largest reports only while it is within the bounds: a cap below it or a floor above it +// reports nothing, rather than a cached group standing in for it. +test("largest reports only within the bounds", async () => { const producer = new TrackProducer("test").accept({}); - const groups = [0, 1, 2, 3, 4, 5].map((sequence) => framedGroup(producer, sequence, 2)); + for (let sequence = 0; sequence < 6; sequence++) framedGroup(producer, sequence, 2); - const reader = producer.subscribe().withGroups({ end: { excluded: 3 } }); - expect(await reader.largest()).toEqual({ group: 2, frame: 1 }); - - const next = reader.largest(); - expect(await isPending(next)).toBe(true); - groups[2].writeFrame({ payload: enc.encode("x") }); - expect(await next).toEqual({ group: 2, frame: 2 }); - - framedGroup(producer, 6, 1); - groups[5].writeFrame({ payload: enc.encode("x") }); - expect(await isPending(reader.largest())).toBe(true); + const capped = producer.subscribe().withGroups({ end: { excluded: 3 } }); + expect(await isPending(capped.largest())).toBe(true); + // An inclusive cap reports the group it names. const ordered = producer .subscribe() .ordered() - .withGroups({ end: { included: 3 } }); - expect(await ordered.largest()).toEqual({ group: 3, frame: 1 }); - producer.close(); -}); - -// A gap under the cap reports the newest group the cache holds there, and a floor above the -// edge reports nothing until a group reaches it. -test("largest skips gaps under the cap and waits for the floor", async () => { - const producer = new TrackProducer("test").accept({}); - framedGroup(producer, 0, 1); - framedGroup(producer, 1, 3); - framedGroup(producer, 5, 1); - - const capped = producer.subscribe().withGroups({ end: { excluded: 4 } }); - expect(await capped.largest()).toEqual({ group: 1, frame: 2 }); + .withGroups({ end: { included: 5 } }); + expect(await ordered.largest()).toEqual({ group: 5, frame: 1 }); const floored = producer.subscribe().withGroups({ start: { included: 8 } }); const next = floored.largest(); @@ -1981,35 +1960,9 @@ test("largest skips gaps under the cap and waits for the floor", async () => { producer.close(); }); -// A settled track ends the stream past the cap only once the group under the cap stops growing: -// its frames and its finish still reach a capped reader. -test("largest under the cap stays open while its group grows", async () => { - const producer = new TrackProducer("test").accept({}); - const open = framedGroup(producer, 2, 1); - framedGroup(producer, 5, 1).close(); - producer.finishAt({ group: 6, frame: 0 }); - - const uncapped = producer.subscribe(); - expect(await uncapped.largest()).toEqual({ group: 5, frame: 0 }); - expect(await uncapped.largest()).toBeUndefined(); - - const reader = producer.subscribe().withGroups({ end: { excluded: 3 } }); - expect(await reader.largest()).toEqual({ group: 2, frame: 0 }); - let next = reader.largest(); - expect(await isPending(next)).toBe(true); - open.writeFrame({ payload: enc.encode("x") }); - expect(await next).toEqual({ group: 2, frame: 1 }); - - next = reader.largest(); - expect(await isPending(next)).toBe(true); - open.close(); - expect(await next).toBeUndefined(); - producer.close(); -}); - -// Moving the cap wakes a pending read and reports the largest within the new bounds, lower ones -// included; bounds that did not move report nothing again. -test("largest re-reports when the cap moves", async () => { +// Moving the bounds wakes a pending read and reports the largest within the new ones again; +// bounds that leave it where it was report nothing new. +test("largest re-reports when the bounds move", async () => { const producer = new TrackProducer("test").accept({}); for (let sequence = 0; sequence < 6; sequence++) framedGroup(producer, sequence, 1); @@ -2017,20 +1970,14 @@ test("largest re-reports when the cap moves", async () => { .subscribe() .ordered() .withGroups({ end: { excluded: 3 } }); - expect(await reader.largest()).toEqual({ group: 2, frame: 0 }); - let next = reader.largest(); expect(await isPending(next)).toBe(true); - reader.setGroups({ end: { excluded: 5 } }); - expect(await next).toEqual({ group: 4, frame: 0 }); + reader.setGroups({}); + expect(await next).toEqual({ group: 5, frame: 0 }); next = reader.largest(); expect(await isPending(next)).toBe(true); reader.setGroups({ end: { excluded: 2 } }); - expect(await next).toEqual({ group: 1, frame: 0 }); - - reader.setGroups({ end: { excluded: 2 } }); - next = reader.largest(); expect(await isPending(next)).toBe(true); reader.setGroups({}); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 6cc82146c5a..fdf17d189e3 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -442,10 +442,6 @@ class TrackState { largest = new Signal(undefined); // Set once {@link largest} can no longer advance while the track stays open. largestEnded = new Signal(false); - // Bumped by a write or finish that leaves {@link largest} where it was: a reader capped below it - // follows the groups under its cap. One signal shared with every sink, so a bump costs the - // same however many subscribe. - written = new Signal(0); constructor(subscription?: Subscription) { this.update = new Signal(subscription === undefined ? undefined : subscriptionDefaults(subscription)); @@ -834,7 +830,6 @@ export class Producer { if (info) sink.info.set(info); sink.largest.set(this.#state.largest.peek()); sink.largestEnded.set(this.#state.largestEnded.peek()); - sink.written = this.#state.written; const closed = this.#state.closed.peek(); if (closed === undefined) { @@ -1032,10 +1027,7 @@ export class Producer { #advanceLargest(largest: Location | undefined): void { if (!largest) return; const current = this.#state.largest.peek(); - if (current && !after(largest, current)) { - this.#state.written.update((n) => n + 1); - return; - } + if (current && !after(largest, current)) return; this.#largestOpen = true; this.#state.largest.set(largest); for (const sink of this.#sinks) sink.largest.set(largest); @@ -1411,8 +1403,6 @@ export class Producer { // A group name fixes its content, so no legal write raises the largest past it. #finishLargest(sequence: number): void { if (this.#state.largest.peek()?.group === sequence) this.#largestOpen = false; - // A reader capped below the largest may be following this group. - this.#state.written.update((n) => n + 1); this.#endLargest(); } @@ -1760,17 +1750,18 @@ export class Subscriber { * The largest location within {@link setGroups}: the current one at first, then each newer * one (skipping any in between), and `undefined` once the track ends. * - * While the track's largest is within the bounds, that is its newest group or datagram at - * the newest frame (0 while empty), seeded by the publisher's report, so it can name a frame - * before its group arrives. Past the cap, it is the newest cached group below the cap, so an - * exclusive end of `N` reports group `N - 1` once it arrives, and the stream stays open while - * that group can still grow; below the floor, nothing. Moving the bounds reports again within - * the new ones, even below the last report. + * The largest is the track's newest group or datagram at the newest frame (0 while empty), as + * the publisher reported it and its feed delivered since, so it can name a frame before its + * group arrives. A largest past the cap or below the floor reports nothing: the cache does not + * stand in for it. Moving the bounds reports again within the new ones, even below the last + * report. */ async largest(): Promise { for (;;) { - const bounds = this.#cursor.peek(); - const { within, growing } = this.#largestWithin(bounds); + const { start, end } = this.#cursor.peek(); + const largest = this.#state.largest.peek(); + const within = + largest && largest.group >= start && (end === undefined || largest.group < end) ? largest : undefined; const seen = this.#largestSeen; if (this.#largestMoved) { // New bounds report the largest within them again, even one below the last report. @@ -1781,37 +1772,11 @@ export class Subscriber { this.#largestSeen = within; return within; } - if (this.#state.closed.peek() !== undefined) return undefined; - // The track's settled end does not end a group under the cap that can still grow. - if (this.#state.largestEnded.peek() && !growing) return undefined; - // Only a reader capped below the largest follows writes that leave it in place. - const largest = this.#state.largest.peek(); - const capped = bounds.end !== undefined && largest !== undefined && largest.group >= bounds.end; - await Signal.race( - this.#state.largest, - this.#state.closed, - this.#state.largestEnded, - this.#cursor, - ...(capped ? [this.#state.written] : []), - ); + if (this.#state.closed.peek() !== undefined || this.#state.largestEnded.peek()) return undefined; + await Signal.race(this.#state.largest, this.#state.closed, this.#state.largestEnded, this.#cursor); } } - // The track's largest within `bounds`; past the cap, the newest cached group below it, and - // whether that group can still grow: it has not finished, and an aborted one can be recreated. - #largestWithin(bounds: { start: number; end?: number }): { within?: Location; growing: boolean } { - const largest = this.#state.largest.peek(); - if (!largest || largest.group < bounds.start) return { growing: false }; - if (bounds.end === undefined || largest.group < bounds.end) return { within: largest, growing: false }; - const timeline = this.#state.timeline; - const group = timeline[timelineIndex(timeline, bounds.end) - 1]; - if (!group || group.sequence < bounds.start) return { growing: false }; - return { - within: { group: group.sequence, frame: Math.max(group.frameCount - 1, 0) }, - growing: !hooks.groupFinished(group), - }; - } - // The newest frame buffered for this subscriber, which a wire answer names as its edge: // a newest group with no frames yet falls back to the newest older group that does, and it // reads as undefined once the newest group was evicted or received, since what is left can diff --git a/quest/m1/README.md b/quest/m1/README.md index 9ea8b8d6208..77b03045ced 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -119,6 +119,7 @@ lands before the cut. - [Gateway linger](/quest/m1/linger-gateways.md) - HLS, RTMP, SRT and RTC imports linger at a clean end, cut short by a newer epoch at the same path - [Exporters follow a rendition to a new track](/quest/m1/export-rendition-rebind.md) - the fMP4, TS, MKV and FLV exporters rebuild a rendition whose resolved `track` or `broadcast` changes under the same ID - [lite-06 max sequence](/quest/m1/lite06-max-sequence.md) - group sequence 2^62-1 reaches a lite-06 subscriber or is refused loudly, instead of vanishing +- [Track live signal](/quest/m1/track-live.md) - a reader can wait for its track's feed to be live, so a player skips old cached groups while a fresh upstream subscription starts - [C++ and OBS package proof](/quest/m1/cpp-package-check.md) - the installed artifacts pass before their first release diff --git a/quest/m1/public-model/serve.md b/quest/m1/public-model/serve.md index d6bbc531484..5211696edf6 100644 --- a/quest/m1/public-model/serve.md +++ b/quest/m1/public-model/serve.md @@ -19,10 +19,11 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: - `track::Subscriber::poll_start` (lite publisher): public. A publisher reports the resolved start to its peer (SUBSCRIBE_START, SUBSCRIBE_OK). - `track::Subscriber::poll_live` (both publishers): remove the need through - the public `poll_largest` (#5725). It does not wait out an idle copy the - way `poll_live` does: it follows the source's reported largest, which can - name a frame the cache does not hold yet, so the publishers still answer - with the buffered edge. Decide here whether wire answers move to it, and + the public `poll_largest` (#5725). It now waits out an idle copy too + (#5924), but it follows the source's reported largest, which can name a + frame the cache does not hold yet, so the publishers still answer with the + buffered edge. A public live signal is + [its own quest](/quest/m1/track-live.md). Decide here whether wire answers move to it, and with them `latest_sequence` on `track::{Producer,Consumer,Subscriber}`, the private snapshot that replaced the public `latest()` (#5725). - **Cursor** (`track::Subscriber::start_at`, `raise_start_to`, `end_at`, @@ -62,6 +63,11 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: `largest()` reports within both the floor and the cap, so a reader whose floor sits above the live edge reports nothing until a group reaches it (#5924). + - ✅ `largest()` reports only upstream-confirmed state (maintainer, + 2026-10-19): the source's reported largest and what its live feed + delivered since, never a cache-derived stand-in. It waits while the copy + is idle, though cached groups are still served, and past the cap it + reports nothing rather than the newest cached group below it (#5924). - A second public `replace_groups`. - **Bounds naming** (decided 2026-10-16 in #5717, changed to "range" by the maintainer on 2026-10-19): ✅ the siblings follow: diff --git a/quest/m1/track-live.md b/quest/m1/track-live.md new file mode 100644 index 00000000000..f5c5532e5d9 --- /dev/null +++ b/quest/m1/track-live.md @@ -0,0 +1,31 @@ +# [M] A reader can tell when its track is live + +## Goal + +A track reader in moq-net and `@moq/net` can wait for, and check, whether its +track's feed is live: a local producer from the start, a relay's copy once its +upstream subscription answers. A player uses it to skip the old cached groups +a warm copy serves while it waits for a fresh upstream subscription, instead +of rendering them. + +## Plan + +`largest()` already waits for live internally (#5924), so it never reports +what an ended feed left; this quest makes the signal itself public. + +- Rust has the state: `TrackState::feed` (`Live`, `Idle`, `Answered`) and + `readable()` in `rs/moq-net/src/model/track.rs`, the crate-private + `resume` `poll_live`, and the crate-private `Producer::is_live`. Expose one + reader-side `poll_live`/`live()` (or `is_live`) on `track::Subscriber` and + `track::Ordered`, and decide whether the publishers' crate-private + `poll_live` folds into it (see + [Public serving](/quest/m1/public-model/serve.md)). +- JS has no equivalent: a track never goes idle, so its reader is live from + the start. Mirror the name, and the idle state if JS gains warm copies. +- Decide what a reader does with groups served before live: keep serving + them (today) and let the caller skip by the signal, or offer a mode that + starts at the live edge. + +## Related + +- [Public serving](/quest/m1/public-model/serve.md) - the publishers' `poll_live` and the `largest()` decision diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index bbd53ce251a..727a7da1a6d 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,12 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased -### A reader's `largest()` reports within its `set_groups` bounds +### `largest()` reports only upstream state, within a reader's `set_groups` bounds -`track::Subscriber::largest()` and `track::Ordered::largest()` (and their `poll_largest`) report the largest location within the bounds set with `set_groups`, where they used to report the track's. -Past a cap of `..N`, they report the newest cached group below `N`; below the floor, nothing. +`track::Subscriber::largest()` and `track::Ordered::largest()` (and their `poll_largest`) report the track's largest only while it is within the bounds set with `set_groups`, where they used to ignore the bounds. +A largest past the cap or below the floor reports nothing; the cache never stands in for it. Moving the bounds reports again within the new ones, even below the last report. -Unbounded readers, `track::Producer`, and `track::Consumer` are unchanged. +Every `largest()`, `track::Producer` and `track::Consumer` included, now waits while a relay's copy is idle and reports once its new upstream subscription is live, rather than the largest an ended feed left. +Cached groups are still served meanwhile. To read the track's edge from a capped reader, lift the cap with `set_groups(..)` first. ### An expired group fails with `Old` even when drained (#5944) diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index de8db016744..73756c0f415 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -45,13 +45,6 @@ //! on the next one, re-polling whichever reader the write woke: a frame wakes no track //! reader, so waking them shows up as a slope over the subscribers. //! -//! `track_largest_finish` finishes one-frame groups while every subscriber is parked on -//! `poll_largest`, re-polling whichever reader the finish woke, against `none`, which parks -//! no reader there. A parked reader also waits on the track state for its settled end, which -//! a finish changes, so each finish wakes every one of them; a reader capped below the -//! largest re-reads the groups under its cap too. A cost that grows faster than that, or a -//! wake per write, shows up as a steeper slope. -//! //! `track_fetch_expiry` polls budgeted fetched readers over independent fanout and //! retained-history axes, so expiry work that scans either table appears as a slope. //! @@ -556,73 +549,6 @@ fn bench_fanout_frame(c: &mut Criterion) { group.finish(); } -fn bench_largest_finish(c: &mut Criterion) { - let mut group = c.benchmark_group("track_largest_finish"); - // `none` parks no reader on `poll_largest`: the finish's cost without any. - for readers in ["none", "unbounded", "capped"] { - for subscribers in FANOUT { - group.bench_with_input( - BenchmarkId::new(readers, subscribers), - &subscribers, - |b, &subscribers| { - let mut fanout = Fanout::new(subscribers); - let queue = Arc::new(std::sync::Mutex::new(Vec::new())); - let waiters: Vec<_> = (0..subscribers) - .map(|index| { - let fired = Arc::new(Fired { - index, - queue: queue.clone(), - }); - kio::Waiter::new(futures::task::waker(fired)) - }) - .collect(); - // Group 0 sits under every cap, so a capped reader has a report to follow. - let mut first = fanout.track.append_group().unwrap(); - first.write_frame(Timestamp::ZERO, fanout.payload.clone()).unwrap(); - first.finish().unwrap(); - for (subscriber, waiter) in fanout.subscribers.iter_mut().zip(&waiters) { - if readers == "none" { - break; - } - if readers == "capped" { - subscriber.set_groups(..1); - } - assert!(matches!(subscriber.poll_largest(waiter), Poll::Ready(Some(_)))); - assert!(subscriber.poll_largest(waiter).is_pending()); - } - // Only the finish and the re-polls it causes are timed, not the write. - b.iter_custom(|iterations| { - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let mut group = fanout.track.append_group().unwrap(); - group.write_frame(Timestamp::ZERO, fanout.payload.clone()).unwrap(); - // Read the group off, so retention stays what a live reader leaves behind. - for subscriber in &mut fanout.subscribers { - let noop = kio::Waiter::noop(); - while subscriber.poll_recv_group(&noop).is_ready() {} - } - let woken = std::mem::take(&mut *queue.lock().unwrap()); - for index in woken { - while fanout.subscribers[index].poll_largest(&waiters[index]).is_ready() {} - } - let start = Instant::now(); - group.finish().unwrap(); - // A woken reader re-polls, as its task would. - let woken = std::mem::take(&mut *queue.lock().unwrap()); - for index in woken { - while fanout.subscribers[index].poll_largest(&waiters[index]).is_ready() {} - } - elapsed += start.elapsed(); - } - elapsed - }); - }, - ); - } - } - group.finish(); -} - fn bench_fetch_expiry(c: &mut Criterion) { let mut group = c.benchmark_group("track_fetch_expiry"); for history in FETCH_HISTORY { @@ -1278,7 +1204,6 @@ criterion_group!( bench_parked_abort, bench_fanout, bench_fanout_frame, - bench_largest_finish, bench_fetch_expiry, bench_parallel_write, bench_aborted_scan, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 4757d0701c8..7f49a6817aa 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -6441,7 +6441,10 @@ mod test { let mut floored = producer.subscribe(replay()); floored.set_groups(8..); - assert!(floored.poll_largest(&noop).is_pending(), "the largest is below the floor"); + assert!( + floored.poll_largest(&noop).is_pending(), + "the largest is below the floor" + ); let _late = framed(&producer, 8, 0); assert_eq!(floored.poll_largest(&noop), Poll::Ready(Some(Position::group(8)))); } @@ -6465,7 +6468,10 @@ mod test { assert!(reader.poll_largest(&waiter).is_pending()); reader.set_groups(..2); assert!(flag.take(), "lowering the cap woke the reader"); - assert!(reader.poll_largest(&noop).is_pending(), "the largest is past the new cap"); + assert!( + reader.poll_largest(&noop).is_pending(), + "the largest is past the new cap" + ); reader.set_groups(..); assert_eq!( @@ -6495,7 +6501,10 @@ mod test { producer.set_live(Some(Position::group(5))); producer.report_largest(Position::group(5)); - assert!(reader.poll_largest(&waiter).is_pending(), "the answer has not shown yet"); + assert!( + reader.poll_largest(&waiter).is_pending(), + "the answer has not shown yet" + ); flag.take(); let _answer = framed(&producer, 5, 1); diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index 943d6235a4a..55ce45dc86d 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -87,7 +87,7 @@ case "$1" in criterion gst -p moq-gst --bench catalog ;; js) - for benchmark in broadcasts request-ids requests forward reader frames varint lite-varint track largest interests priority; do + for benchmark in broadcasts request-ids requests forward reader frames varint lite-varint track interests priority; do javascript "$benchmark" just js bench "$benchmark" done javascript catalog just js catalog-bench From dda7be9cd00c72f2a7f368c74cc3ad295767f529 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:27:16 -0700 Subject: [PATCH 10/20] fix(net): seed a front reader's floor from its subscription A resume reader started its local floor at 0, so a subscription whose absolute floor sat above the serving copy's edge reported a bounded largest() below it. Seed the floor from the subscription, as a plain cursor does. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 653021be5b5..f766c141df9 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -170,6 +170,9 @@ 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(); + // 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 = mirrored.floor(None).unwrap_or(0); let reader = Arc::new_cyclic(|this| { Mutex::new(Reader { this: this.clone(), @@ -180,7 +183,7 @@ impl Consumer { copies: Vec::new(), newest: None, datagram: None, - groups: (0, Cap::from(..)), + groups: (floor, Cap::from(..)), }) }); // Subscribed to the serving copy at once, so groups it has now are the reader's @@ -1473,6 +1476,30 @@ mod test { assert_eq!(sub.largest().now_or_never(), Some(Some(Position::group(4)))); } + /// A front's reader created with a floor above the serving copy's edge reports nothing + /// below it, as a plain reader does. + #[test] + #[cfg(feature = "async")] + fn largest_honours_the_subscription_floor_on_the_serving_copy() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let subscription = Subscription::default().with_max_delay(HOUR).with_groups(8..); + let mut sub = logical + .consume() + .subscribe(subscription) + .now_or_never() + .unwrap() + .unwrap(); + for sequence in 0..5 { + write(&a, sequence); + } + assert!(sub.largest().now_or_never().is_none(), "the largest is below the floor"); + write(&a, 8); + assert_eq!(sub.largest().now_or_never(), Some(Some(Position::group(8)))); + } + /// A track reader waiting before any route serves is woken by the first one's frame. /// The front ending the track ends the stream, though the copy's producer stays open. #[test] From 7f9b2b66862aa76736ab6a49d2bbdba2f32d9c54 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:45:26 -0700 Subject: [PATCH 11/20] fix(net): keep largest's last report until new bounds hold one Moving the bounds cleared the last report before the poll, so a move that pended (an idle copy, or bounds holding nothing yet) later repeated an unchanged largest. Keep the last report and the pending move until a largest lands within the new bounds, in Rust and JS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 5 +++- js/net/src/track.ts | 9 ++++--- rs/moq-net/src/model/track.rs | 44 +++++++++++++++++++++++++++++------ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 4b9acc758de..811d8e341bd 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1980,8 +1980,11 @@ test("largest re-reports when the bounds move", async () => { reader.setGroups({ end: { excluded: 2 } }); expect(await isPending(next)).toBe(true); + // Lifting it again leaves the last report standing. reader.setGroups({}); - expect(await next).toEqual({ group: 5, frame: 0 }); + expect(await isPending(next)).toBe(true); + framedGroup(producer, 6, 0); + expect(await next).toEqual({ group: 6, frame: 0 }); reader.setGroups({ start: { included: 3 } }); expect(await isPending(reader.largest())).toBe(true); producer.close(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index fdf17d189e3..5f17ea83bff 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1765,9 +1765,12 @@ export class Subscriber { const seen = this.#largestSeen; if (this.#largestMoved) { // New bounds report the largest within them again, even one below the last report. - this.#largestMoved = false; - this.#largestSeen = within; - if (within && !(seen && within.group === seen.group && within.frame === seen.frame)) return within; + // Until one is within them, the last report and the move both stand. + if (within) { + this.#largestMoved = false; + this.#largestSeen = within; + if (!(seen && within.group === seen.group && within.frame === seen.frame)) return within; + } } else if (within && (!seen || after(within, seen))) { this.#largestSeen = within; return within; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7f49a6817aa..e0719f344b8 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5621,12 +5621,20 @@ impl Subscriber { pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { self.largest.park(waiter.waker()); let bounds = self.bounds(); - if std::mem::take(&mut self.largest.moved) { + if self.largest.moved { // New bounds report the largest within them again, even one below the last report. + // Until one is within them, the last report and the move both stand. let last = self.largest.position.take(); match self.poll_largest_within(bounds, waiter) { - Poll::Ready(Some(position)) if Some(position) == last => {} - poll => return poll, + Poll::Pending => { + self.largest.position = last; + return Poll::Pending; + } + Poll::Ready(Some(position)) if Some(position) == last => self.largest.moved = false, + poll => { + self.largest.moved = false; + return poll; + } } } self.poll_largest_within(bounds, waiter) @@ -6474,10 +6482,9 @@ mod test { ); reader.set_groups(..); - assert_eq!( - reader.poll_largest(&noop), - Poll::Ready(Some(Position::group(5))), - "lifting it again reports the largest again" + assert!( + reader.poll_largest(&noop).is_pending(), + "lifting it again leaves the last report standing" ); reader.set_groups(3..); assert!( @@ -6486,6 +6493,29 @@ mod test { ); } + /// Bounds that move while the copy is idle keep the last report, so going live again + /// does not repeat it. + #[test] + fn largest_keeps_its_last_report_across_an_idle_move() { + let mut producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let _groups: Vec<_> = (0..3).map(|sequence| framed(&producer, sequence, 1)).collect(); + + let mut reader = producer.subscribe(replay()); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(2)))); + producer.set_idle(); + reader.set_groups(..); + assert!(reader.poll_largest(&noop).is_pending(), "the copy is idle"); + + producer.set_live(Some(Position::group(2))); + assert!( + reader.poll_largest(&noop).is_pending(), + "the same largest is not reported again" + ); + let _next = framed(&producer, 3, 1); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(3)))); + } + /// An idle copy's largest is what an ended feed left, so nothing reports until the /// route's answer shows, though the cache still holds groups. #[test] From ef5d31d2af456af68a19073da6796e27a6675758 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:10:49 -0700 Subject: [PATCH 12/20] fix(moq-c): defer a raw update's relative bounds until the edge is live The live gate leaves an idle relay copy's largest pending, which the raw reader's synchronous poll took as no edge, applying a relative range without its floor and never revisiting it. Apply the wire preferences at once and hold the local bounds until the edge reports, re-polled with the task's waiter. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/consume.rs | 65 +++++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index cc108c00f9a..112277a8941 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -118,17 +118,56 @@ struct RawReader { // current largest rather than only a newer one. edge: moq_net::track::Consumer, track: moq_net::track::Ordered, + // An update with a relative range, whose bounds wait for the edge to be live. + deferred: Option, } impl RawReader { - fn apply(&mut self, subscription: Option) { + fn new(edge: moq_net::track::Consumer, track: moq_net::track::Ordered) -> Self { + Self { + edge, + track, + deferred: None, + } + } + + /// Apply a subscription update: its wire preferences at once, and its local bounds once + /// any relative range has a live edge to count from. + fn apply(&mut self, subscription: Option, waiter: &kio::Waiter) { let subscription = subscription.unwrap_or_default(); + // A closed track makes the update meaningless; the reader already sees the close. + let _ = self.track.update(subscription.clone()); + let relative = subscription + .ranges + .iter() + .any(|range| matches!(range.start, moq_net::track::Anchor::Relative(_))); + match relative { + true => { + self.deferred = Some(subscription); + self.poll_deferred(waiter); + } + false => { + self.deferred = None; + self.set_bounds(&subscription, None); + } + } + } + + /// Apply a deferred update's bounds once the edge is live, or the track ended. + fn poll_deferred(&mut self, waiter: &kio::Waiter) { + let Some(subscription) = &self.deferred else { + return; + }; + if let Poll::Ready(largest) = self.edge.clone().poll_largest(waiter) { + let subscription = subscription.clone(); + self.deferred = None; + self.set_bounds(&subscription, largest.map(|position| position.group)); + } + } + + fn set_bounds(&mut self, subscription: &moq_net::track::Subscription, latest: Option) { // 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 self.edge.clone().poll_largest(&kio::Waiter::noop()) { - Poll::Ready(Some(position)) => Some(position.group), - _ => None, - }; let start = subscription .ranges .iter() @@ -153,8 +192,6 @@ impl RawReader { start.map_or(Bound::Unbounded, Bound::Included), end.map_or(Bound::Unbounded, moq_net::track::Position::group_end), )); - // A closed track makes the update meaningless; the reader already sees the close. - let _ = self.track.update(subscription); } } @@ -714,8 +751,9 @@ impl Consume { let res = crate::ffi::run(driver.watch(async move { let edge = broadcast.track(&name)?; let track = edge.subscribe(subscription.clone()).await?.ordered(); - let mut reader = RawReader { edge, track }; - reader.apply(subscription); + let mut reader = RawReader::new(edge, track); + // The task's own polls re-poll a deferred update with their waiter. + reader.apply(subscription, &kio::Waiter::noop()); Self::run_raw(on_frame, reader, channel.1, updates).await })) .await; @@ -788,7 +826,8 @@ impl Consume { } } - /// Poll the close and update channels, applying any subscription updates inline. + /// Poll the close and update channels, applying any subscription updates inline, and a + /// deferred update once its edge is live. /// /// Returns `true` when the task must stop: either the consumer was closed /// (`close` fired) or the update channel was dropped. The caller must then @@ -808,11 +847,13 @@ impl Consume { loop { match updates.poll_recv(&mut cx) { - Poll::Ready(Some(subscription)) => reader.apply(subscription), + Poll::Ready(Some(subscription)) => reader.apply(subscription, waiter), Poll::Ready(None) => return true, - Poll::Pending => return false, + Poll::Pending => break, } } + reader.poll_deferred(waiter); + false } pub fn raw_track_update( From 4a54f9e034587c489e0a67aa8ab0c88d0d620277 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:10:55 -0700 Subject: [PATCH 13/20] refactor(net): drop largest's re-report on a bounds move The largest is a running max of upstream state, so new bounds hold either the current edge, never below the last report, or nothing. The moved re-report path added nothing over "within the bounds and newer than the last report"; a bounds move now only wakes a parked read. Fix the docs and MIGRATE notes that promised a report below the last. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 2 +- js/net/src/track.test.ts | 4 ++-- js/net/src/track.ts | 19 ++++--------------- rs/moq-net/MIGRATE.md | 2 +- rs/moq-net/src/model/track.rs | 32 +++++++------------------------- 5 files changed, 15 insertions(+), 44 deletions(-) diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 69c63b4d724..2978fbf3baf 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `Track.Subscriber.largest()` and `Track.Ordered.largest()` report the track's largest only while it is within the bounds set with `setGroups` or `withGroups`, where they used to ignore the bounds. A largest past the cap or below the floor reports nothing; the cache never stands in for it. -Moving the bounds reports again within the new ones, even below the last report. +A cap moved below the largest stays pending. Moving the bounds wakes a pending read, which reports the largest once it is within them and newer than the last report. Unbounded readers and `Track.Producer.largest()` are unchanged. To read the track's edge from a capped reader, lift the cap with `setGroups({})` first. diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 811d8e341bd..0a38bd6defb 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1960,9 +1960,9 @@ test("largest reports only within the bounds", async () => { producer.close(); }); -// Moving the bounds wakes a pending read and reports the largest within the new ones again; +// Moving the bounds wakes a pending read, which reports the largest once it is within them; // bounds that leave it where it was report nothing new. -test("largest re-reports when the bounds move", async () => { +test("largest follows the bounds as they move", async () => { const producer = new TrackProducer("test").accept({}); for (let sequence = 0; sequence < 6; sequence++) framedGroup(producer, sequence, 1); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 5f17ea83bff..705eec896f8 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1492,9 +1492,8 @@ export class Subscriber { readonly name: string; #state: TrackState; - // What {@link largest} last reported, and whether the bounds moved since. + // What {@link largest} last reported. #largestSeen?: Location; - #largestMoved = false; #nextSequence = 0; #cursor = new Signal<{ start: number; end?: number }>({ start: 0 }); // A FETCH's own budget in place of the subscription's; unlike SUBSCRIBE's zero @@ -1753,8 +1752,8 @@ export class Subscriber { * The largest is the track's newest group or datagram at the newest frame (0 while empty), as * the publisher reported it and its feed delivered since, so it can name a frame before its * group arrives. A largest past the cap or below the floor reports nothing: the cache does not - * stand in for it. Moving the bounds reports again within the new ones, even below the last - * report. + * stand in for it, so a cap moved below the largest stays pending. Moving the bounds wakes a + * pending read, which reports the largest once it is within them and newer than the last report. */ async largest(): Promise { for (;;) { @@ -1763,15 +1762,7 @@ export class Subscriber { const within = largest && largest.group >= start && (end === undefined || largest.group < end) ? largest : undefined; const seen = this.#largestSeen; - if (this.#largestMoved) { - // New bounds report the largest within them again, even one below the last report. - // Until one is within them, the last report and the move both stand. - if (within) { - this.#largestMoved = false; - this.#largestSeen = within; - if (!(seen && within.group === seen.group && within.frame === seen.frame)) return within; - } - } else if (within && (!seen || after(within, seen))) { + if (within && (!seen || after(within, seen))) { this.#largestSeen = within; return within; } @@ -1832,7 +1823,6 @@ export class Subscriber { setGroups(groups: Groups): void { const { start, end } = groupBounds(groups); this.#cursor.update((cursor) => ({ start: Math.max(cursor.start, start), end })); - this.#largestMoved = true; } // Serving counterpart of setGroups: a named start replaces the floor, matching @@ -1843,7 +1833,6 @@ export class Subscriber { start: groups.start === undefined ? cursor.start : start, end, })); - this.#largestMoved = true; } /** Close the track (optionally with an error), closing any pending groups. Idempotent. */ diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 727a7da1a6d..3a5496916c3 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `track::Subscriber::largest()` and `track::Ordered::largest()` (and their `poll_largest`) report the track's largest only while it is within the bounds set with `set_groups`, where they used to ignore the bounds. A largest past the cap or below the floor reports nothing; the cache never stands in for it. -Moving the bounds reports again within the new ones, even below the last report. +A cap moved below the largest stays pending. Moving the bounds wakes a pending read, which reports the largest once it is within them and newer than the last report. Every `largest()`, `track::Producer` and `track::Consumer` included, now waits while a relay's copy is idle and reports once its new upstream subscription is live, rather than the largest an ended feed left. Cached groups are still served meanwhile. To read the track's edge from a capped reader, lift the cap with `set_groups(..)` first. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index e0719f344b8..c5a9d852d2e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4451,8 +4451,6 @@ pub struct Subscriber { #[derive(Default)] struct Reported { position: Option, - // The bounds moved since, so the next poll reports within the new ones. - moved: bool, // The task that last polled, woken when the bounds move. One is enough: polling // takes `&mut self`. waker: Option, @@ -4466,9 +4464,8 @@ impl Reported { } } - /// The bounds moved: report within them again, waking a parked poll. + /// The bounds moved: wake a parked poll, since the largest may now be within them. fn bounds_moved(&mut self) { - self.moved = true; if let Some(waker) = self.waker.take() { waker.wake(); } @@ -5616,27 +5613,12 @@ impl Subscriber { /// The largest is the newest group or datagram at the newest frame (0 while empty), as /// the source reported it and its live feed delivered since, so nothing reports until /// the track's feed is live. A largest past the cap or below the floor reports nothing: - /// the cache does not stand in for it. Moving the bounds reports again within the new - /// ones, even below the last report. + /// the cache does not stand in for it, so a cap moved below the largest stays pending. + /// Moving the bounds wakes a parked poll, which reports the largest once it is within + /// them and newer than the last report. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { self.largest.park(waiter.waker()); let bounds = self.bounds(); - if self.largest.moved { - // New bounds report the largest within them again, even one below the last report. - // Until one is within them, the last report and the move both stand. - let last = self.largest.position.take(); - match self.poll_largest_within(bounds, waiter) { - Poll::Pending => { - self.largest.position = last; - return Poll::Pending; - } - Poll::Ready(Some(position)) if Some(position) == last => self.largest.moved = false, - poll => { - self.largest.moved = false; - return poll; - } - } - } self.poll_largest_within(bounds, waiter) } @@ -6457,10 +6439,10 @@ mod test { assert_eq!(floored.poll_largest(&noop), Poll::Ready(Some(Position::group(8)))); } - /// Moving the bounds wakes a parked reader and reports the largest within the new ones - /// again; bounds that leave it where it was report nothing new. + /// Moving the bounds wakes a parked reader, which reports the largest once it is within + /// them; bounds that leave it where it was report nothing new. #[test] - fn largest_rereports_when_the_bounds_move() { + fn largest_follows_the_bounds_as_they_move() { let producer = track_producer("test", Info::default()); let noop = kio::Waiter::noop(); let _groups: Vec<_> = (0..6).map(|sequence| framed(&producer, sequence, 1)).collect(); From e09b26be8e0bb08033b95f62def327d44e756bcd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:28:36 -0700 Subject: [PATCH 14/20] fix(net): gate largest on the live feed, not on readability A warm copy that declared an end and then went idle stayed readable through its end, so largest() reported what the ended feed left. Gate on the feed being live, or on nothing more being able to move the largest (sealed, aborted, or settled). moq-c's raw reader also holds group delivery while a relative update's bounds are deferred, so no group lands under the old floor. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/consume.rs | 4 ++++ rs/moq-net/src/model/track.rs | 30 ++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 112277a8941..1855647055f 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -788,6 +788,10 @@ impl Consume { if Self::poll_raw_control(&mut close, &mut updates, &mut reader, waiter) { return Poll::Ready(Ok(RawStep::Stop)); } + // A group read before deferred bounds apply could sit under the old floor. + if reader.deferred.is_some() { + return Poll::Pending; + } match ready!(reader.track.poll_next_group(waiter))? { Some(group) => Poll::Ready(Ok(RawStep::Item(group))), None => Poll::Ready(Ok(RawStep::End)), diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index c5a9d852d2e..25a4b85174c 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2142,9 +2142,11 @@ fn poll_largest( Poll::Ready(cache.take_largest(seen, bounds)) } -/// Ready once the track's feed is live; see [`TrackState::readable`]. +/// Ready once the track's feed is live, or nothing more can move its largest. A declared +/// end that is not reached yet does not count: an idle copy's largest is still only what +/// its ended feed left. fn largest_live(state: &TrackState) -> Poll<()> { - match state.readable() { + match state.feed == Feed::Live || state.sealed || state.abort.is_some() || state.largest_settled() { true => Poll::Ready(()), false => Poll::Pending, } @@ -6498,6 +6500,30 @@ mod test { assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(3)))); } + /// An idle copy that declared an end it has not reached yet still waits for the live + /// feed, while one that finished reports its settled largest. + #[test] + fn largest_waits_for_an_idle_copy_with_a_future_end() { + let mut producer = track_producer("test", Info::default()); + let noop = kio::Waiter::noop(); + let _groups: Vec<_> = (0..3).map(|sequence| framed(&producer, sequence, 1)).collect(); + producer.finish_at(Position::group(10)).unwrap(); + producer.set_idle(); + + let mut reader = producer.subscribe(replay()); + assert!(reader.poll_largest(&noop).is_pending(), "the copy is idle"); + producer.set_live(Some(Position::group(5))); + let _answer = framed(&producer, 5, 1); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(5)))); + + let mut finished = track_producer("test", Info::default()); + framed(&finished, 0, 1).finish().unwrap(); + finished.set_idle(); + finished.finish().unwrap(); + let mut reader = finished.subscribe(replay()); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(Some(Position::group(0)))); + } + /// An idle copy's largest is what an ended feed left, so nothing reports until the /// route's answer shows, though the cache still holds groups. #[test] From 4b07968fb693d987de1960cd1506daa1068fd39c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 16:33:24 -0700 Subject: [PATCH 15/20] fix(net): hold largest's bounds waker only while parked poll_largest kept the polling task's waker even after it reported, so a later bounds move woke a finished task. Park it only on Pending and drop it on Ready. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 058dc0f99ad..86522a3ded4 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4481,7 +4481,7 @@ struct Reported { } impl Reported { - /// Remember the polling task, to wake when the bounds move. + /// Remember the parked task, to wake when the bounds move. fn park(&mut self, waker: &std::task::Waker) { if !self.waker.as_ref().is_some_and(|last| last.will_wake(waker)) { self.waker = Some(waker.clone()); @@ -5641,9 +5641,14 @@ impl Subscriber { /// Moving the bounds wakes a parked poll, which reports the largest once it is within /// them and newer than the last report. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { - self.largest.park(waiter.waker()); let bounds = self.bounds(); - self.poll_largest_within(bounds, waiter) + let poll = self.poll_largest_within(bounds, waiter); + // Only a parked poll waits on the bounds; a finished one is not woken by them. + match poll { + Poll::Pending => self.largest.park(waiter.waker()), + Poll::Ready(_) => self.largest.waker = None, + } + poll } fn poll_largest_within(&mut self, bounds: Bounds, waiter: &kio::Waiter) -> Poll> { @@ -6499,6 +6504,22 @@ mod test { ); } + /// A poll that reported is not parked, so moving the bounds afterwards does not wake it. + #[test] + fn largest_wakes_only_a_parked_poll_on_a_bounds_move() { + let producer = track_producer("test", Info::default()); + let _group = framed(&producer, 0, 1); + let mut reader = producer.subscribe(replay()); + let (flag, waiter) = Flag::waiter(); + assert_eq!(reader.poll_largest(&waiter), Poll::Ready(Some(Position::group(0)))); + reader.set_groups(..); + assert!(!flag.take(), "the finished poll was not woken"); + + assert!(reader.poll_largest(&waiter).is_pending()); + reader.set_groups(..); + assert!(flag.take(), "the parked poll was woken"); + } + /// Bounds that move while the copy is idle keep the last report, so going live again /// does not repeat it. #[test] From 8592e5fcab4d812ee3d2d332a2a93f06e698f4f4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 16:55:59 -0700 Subject: [PATCH 16/20] fix(net): keep a reader's largest stream ended once it ends A track that finished with its largest outside a reader's bounds ended the stream, but widening the bounds afterwards reported that largest anyway. Record the end per reader, in Rust and JS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 13 +++++++++++++ js/net/src/track.ts | 11 +++++++++-- rs/moq-net/src/model/track.rs | 30 +++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 56c6bed19a2..ae547a6794e 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1990,6 +1990,19 @@ test("largest follows the bounds as they move", async () => { producer.close(); }); +// Once the stream ended, widening the bounds over a largest it never reported does not bring +// it back. +test("largest stays ended when the bounds widen", async () => { + const producer = new TrackProducer("test").accept({}); + framedGroup(producer, 5, 1).close(); + producer.close(); + + const reader = producer.subscribe().withGroups({ end: { excluded: 3 } }); + expect(await reader.largest()).toBeUndefined(); + reader.setGroups({}); + expect(await reader.largest()).toBeUndefined(); +}); + // A reader that starts at the largest group while it is still empty is at the live edge: it // gets that group, which ends with nothing, then the next one. test("a live-edge reader starts on an empty largest group", async () => { diff --git a/js/net/src/track.ts b/js/net/src/track.ts index ac7c30ed8e1..f97e617f500 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1525,8 +1525,10 @@ export class Subscriber { #delivered = new Delivered(); // Whether a resumed reader has read groups, so a datagram read keeps copies they still need. #groupsRead = false; - // What {@link largest} last reported. + // What {@link largest} last reported, and whether it ended: moving the bounds afterwards + // does not bring it back. #largestSeen?: Location; + #largestDone = false; #nextSequence = 0; #cursor = new Signal<{ start: number; end?: number }>({ start: 0 }); // A FETCH's own budget in place of the subscription's; unlike SUBSCRIBE's zero @@ -1893,9 +1895,11 @@ export class Subscriber { * pending read, which reports the largest once it is within them and newer than the last report. */ async largest(): Promise { + if (this.#largestDone) return undefined; if (this.#resume) { const largest = await this.#resume.largest(this.#largestSeen); if (largest) this.#largestSeen = largest; + else this.#largestDone = true; return largest; } for (;;) { @@ -1908,7 +1912,10 @@ export class Subscriber { this.#largestSeen = within; return within; } - if (this.#state.closed.peek() !== undefined || this.#state.largestEnded.peek()) return undefined; + if (this.#state.closed.peek() !== undefined || this.#state.largestEnded.peek()) { + this.#largestDone = true; + return undefined; + } await Signal.race(this.#state.largest, this.#state.closed, this.#state.largestEnded, this.#cursor); } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 86522a3ded4..5e584790f2f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4475,6 +4475,8 @@ pub struct Subscriber { #[derive(Default)] struct Reported { position: Option, + // The stream ended: moving the bounds afterwards does not bring it back. + ended: bool, // The task that last polled, woken when the bounds move. One is enough: polling // takes `&mut self`. waker: Option, @@ -5641,12 +5643,18 @@ impl Subscriber { /// Moving the bounds wakes a parked poll, which reports the largest once it is within /// them and newer than the last report. pub fn poll_largest(&mut self, waiter: &kio::Waiter) -> Poll> { + if self.largest.ended { + return Poll::Ready(None); + } let bounds = self.bounds(); let poll = self.poll_largest_within(bounds, waiter); // Only a parked poll waits on the bounds; a finished one is not woken by them. match poll { Poll::Pending => self.largest.park(waiter.waker()), - Poll::Ready(_) => self.largest.waker = None, + Poll::Ready(next) => { + self.largest.waker = None; + self.largest.ended = next.is_none(); + } } poll } @@ -6504,6 +6512,26 @@ mod test { ); } + /// Once the stream ended, widening the bounds over a largest it never reported does not + /// bring it back. + #[test] + fn largest_stays_ended_when_the_bounds_widen() { + let producer = track_producer("test", Info::default()); + framed(&producer, 5, 1).finish().unwrap(); + producer.finish().unwrap(); + let noop = kio::Waiter::noop(); + + let mut reader = producer.subscribe(replay()); + reader.set_groups(..3); + assert_eq!(reader.poll_largest(&noop), Poll::Ready(None)); + reader.set_groups(..); + assert_eq!( + reader.poll_largest(&noop), + Poll::Ready(None), + "the stream already ended" + ); + } + /// A poll that reported is not parked, so moving the bounds afterwards does not wake it. #[test] fn largest_wakes_only_a_parked_poll_on_a_bounds_move() { From de99741cc85ab16833627d07797e527c110195b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 17:06:53 -0700 Subject: [PATCH 17/20] docs(quest): note the JS snapshot consumers drop a caller's cap Co-Authored-By: Claude Opus 5.5 --- quest/m1/public-model/serve.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/quest/m1/public-model/serve.md b/quest/m1/public-model/serve.md index 5211696edf6..7f30db46b6c 100644 --- a/quest/m1/public-model/serve.md +++ b/quest/m1/public-model/serve.md @@ -56,6 +56,11 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: publisher's `setGroups({ start })`). - `js/json` snapshot and `js/flate` `latest()` raise the floor of the same reader on each read; `js/room` chat sets it after `largest()` resolves. + The snapshot consumers (`js/json/src/snapshot/consumer.ts` `#read`, + `js/flate/src/snapshot/consumer.ts` `latest()`) call + `setGroups({ start: { included: latest.group } })`, whose omitted end + removes the caller's cap. The rebinding must keep that cap; land it with + a regression test where a capped snapshot reader stays below its cap. - `rs/moq-hls` export narrows a fetched group with `set_frames(..end)`. - Readers that set a floor once right after `poll_largest` (moq-audio, moq-video, moq-cli play, moq-json window, moq-mux) fit the builder. From 9c72801d14c7879e0243b5820ca359bd4b856785 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 19:29:57 -0700 Subject: [PATCH 18/20] fix(net): park largest's bounds wake on a kio waiter list The subscriber cloned the polling task's raw waker, so a dropped largest() future left a registration behind. Register the waiter on a kio WaiterList instead, whose entries die with their waiter. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3b0bdf5a5ac..1040b4a0266 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -4550,24 +4550,15 @@ struct Reported { position: Option, // The stream ended: moving the bounds afterwards does not bring it back. ended: bool, - // The task that last polled, woken when the bounds move. One is enough: polling - // takes `&mut self`. - waker: Option, + // The parked poll, woken when the bounds move. Its registration dies with its waiter, + // so a dropped `largest()` future leaves nothing behind. + parked: kio::WaiterList, } impl Reported { - /// Remember the parked task, to wake when the bounds move. - fn park(&mut self, waker: &std::task::Waker) { - if !self.waker.as_ref().is_some_and(|last| last.will_wake(waker)) { - self.waker = Some(waker.clone()); - } - } - /// The bounds moved: wake a parked poll, since the largest may now be within them. fn bounds_moved(&mut self) { - if let Some(waker) = self.waker.take() { - waker.wake(); - } + self.parked.wake(); } } @@ -5708,9 +5699,9 @@ impl Subscriber { let poll = self.poll_largest_within(bounds, waiter); // Only a parked poll waits on the bounds; a finished one is not woken by them. match poll { - Poll::Pending => self.largest.park(waiter.waker()), + Poll::Pending => waiter.register(&mut self.largest.parked), Poll::Ready(next) => { - self.largest.waker = None; + self.largest.parked = kio::WaiterList::new(); self.largest.ended = next.is_none(); } } @@ -6590,6 +6581,18 @@ mod test { ); } + /// A dropped poll leaves no registration behind for a bounds move to wake. + #[test] + fn largest_forgets_a_dropped_poll() { + let producer = track_producer("test", Info::default()); + let mut reader = producer.subscribe(replay()); + let (flag, waiter) = Flag::waiter(); + assert!(reader.poll_largest(&waiter).is_pending()); + drop(waiter); + reader.set_groups(..); + assert!(!flag.take(), "the dropped poll was not woken"); + } + /// A poll that reported is not parked, so moving the bounds afterwards does not wake it. #[test] fn largest_wakes_only_a_parked_poll_on_a_bounds_move() { From b1ad7823395d77be864f15f60b7afbb64a376c5e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 20:09:01 -0700 Subject: [PATCH 19/20] fix(media): wait for the live edge before a latest start moq-video, moq-audio and moq-cli play positioned a latest-only reader with a one-shot noop poll of largest(). On a warm relay copy still waiting on its source that poll is pending, so they set no floor and replayed the cached backlog once the copy went live. Await largest() instead, which ends with the track. Adds the user-facing meaning to the moq-lite concept page. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 5 +- rs/moq-audio/src/decode/consumer.rs | 122 +++++++++++++++++++++++++++- rs/moq-cli/src/play/media.rs | 11 ++- rs/moq-video/src/decode/consumer.rs | 9 +- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 898cae6ea35..362c9f9ebfc 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -255,7 +255,10 @@ untimed. A relay cancels its upstream subscription once nobody subscribes, but keeps its copy of the track for 30 seconds after the last reader leaves, so a returning reader or the next fetch finds it. That copy is not live meanwhile: readers get -nothing from it until the source answers again. +nothing from it until the source answers again, and its latest group is unknown +until then too, so a player starting at the latest waits for the answer instead +of starting from what the ended feed left. A reader's latest group stays within +the groups it limits itself to, and ends once the track does. The publisher may declare a retention window per track. Omission sets no limit; zero keeps only the live edge. Relays preserve that declaration, and may still diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index c4abe0d0e2b..a77ad17ebe7 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -43,7 +43,8 @@ pub enum Start { /// Decode every cached group. #[default] Oldest, - /// Start at the newest cached group, including earlier groups for decoder warmup. + /// Start at the newest group, including earlier groups for decoder warmup. Opening + /// waits for the track's live edge, or its end. Latest, } @@ -193,10 +194,12 @@ impl Consumer { .await?; // Latest skips cached history unless decoder warmup needs it. Move the // local cursor, not Subscription::ranges: the latter is aggregated - // across subscribers and can ask the publisher to rewind for everyone. + // across subscribers and can ask the publisher to rewind for everyone. The live edge + // is known only once the feed is live, so wait for it rather than read the backlog + // of a warm copy still waiting on its source; the wait ends with the track. if options.start == Start::Latest && warmup.is_zero() - && let std::task::Poll::Ready(Some(live_edge)) = subscriber.poll_largest(&moq_net::kio::Waiter::noop()) + && let Some(live_edge) = subscriber.largest().await { subscriber.set_groups(live_edge.group..); } @@ -759,6 +762,119 @@ mod tests { (stamps, frames) } + /// An origin at `hop`, its driver spawned. + fn origin(hop: u64) -> moq_net::origin::Producer { + let config = moq_net::origin::Config::new(moq_net::Hop::new(hop).unwrap()); + let (producer, driver) = moq_net::origin::Producer::new(config); + tokio::spawn(moq_net::time::run(driver)); + producer + } + + /// A lite-07 session over memory carrying `from`'s broadcasts into `to`, open while held. + async fn link( + from: &moq_net::origin::Producer, + to: &moq_net::origin::Producer, + ) -> (moq_net::Session, moq_net::Session) { + use moq_net::transport::memory; + + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + let mut config = memory::Config::default(); + config.protocol = Some(version.alpn().to_string()); + let (client_transport, server_transport) = memory::pair(config); + let client = moq_net::Client::new() + .with_versions(version.into()) + .with_publisher(from.consume()); + let server = moq_net::Server::new() + .with_versions(version.into()) + .with_subscriber(to.clone()); + tokio::join!( + async { + let (session, driver) = client + .connect_lite(moq_net::time::now(), client_transport) + .await + .unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + }, + async { + let (session, driver) = server + .accept_lite(moq_net::time::now(), server_transport) + .await + .unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + } + ) + } + + /// A relay copy whose upstream subscription went idle keeps the groups it cached. A + /// latest start waits for the copy to go live and joins at its live edge, rather than + /// replaying that backlog. + #[tokio::test(start_paused = true)] + async fn latest_skips_a_warm_copys_backlog() { + let publisher = origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast + .create_track("audio", hang::container::track_info(hang::catalog::PRIORITY.audio)) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + let mut demand = track.demand(); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), + ); + let mut write = |packet: u64| { + producer + .write(moq_mux::container::Frame { + timestamp: Timestamp::from_micros(packet * 20_000).unwrap(), + duration: None, + payload: vec![0; 960 * size_of::()].into(), + keyframe: true, + }) + .unwrap(); + producer.cut(None).unwrap(); + }; + for packet in 0..4 { + write(packet); + } + + let relay = origin(2); + let _link = link(&publisher, &relay).await; + let consumer = relay.consume(); + consumer.routed("bcast").await.unwrap(); + let remote = consumer.request_broadcast("bcast", None).await.unwrap(); + + // A first reader warms the relay's copy with every group. + { + let subscription = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let mut warm = remote.track("audio").unwrap().subscribe(subscription).await.unwrap(); + let mut groups = 0; + while groups < 4 { + warm.recv_group().await.unwrap().unwrap(); + groups += 1; + } + } + // The relay cancels the unread upstream subscription; its copy keeps the groups. + while demand.wanted_changed().await.unwrap().is_some() {} + write(4); + + let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 1); + let mut consumer = Consumer::new( + &remote, + &catalog, + "audio", + Options { + start: Start::Latest, + max_delay: Duration::from_secs(10), + ..Options::new() + }, + ) + .await + .unwrap(); + let frame = next_frame(&mut consumer).await.unwrap().unwrap(); + assert_eq!(frame.timestamp.as_micros(), 80_000, "the backlog was replayed"); + } + #[tokio::test(start_paused = true)] async fn warmup_trims_the_join_without_trimming_groups_or_declared_returns() { let (stamps, frames) = warmup_output(Start::Oldest, true).await; diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index afa944e04dc..7aa884a844d 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -258,12 +258,11 @@ impl Media { .with_max_delay(max_delay), ) .await?; - // Start at the local live edge without asking the shared publisher - // subscription to rewind to a cached sequence. Only what is known now: a - // track with nothing yet starts wherever its first group lands. - if let std::task::Poll::Ready(Some(largest)) = - subscriber.poll_largest(&moq_net::kio::Waiter::noop()) - { + // Start at the live edge without asking the shared publisher + // subscription to rewind to a cached sequence. The edge is known only + // once the feed is live, so wait for it rather than read the backlog of + // a warm copy still waiting on its source; the wait ends with the track. + if let Some(largest) = subscriber.largest().await { subscriber.set_groups(largest.group..); } let format = catalog::hang::Container::try_from(config)?; diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 85681fee175..e982a8ac425 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -39,7 +39,8 @@ pub enum Start { /// backlog at decode speed before reaching live media, which a viewer sees /// as playback jumping backwards and then sprinting to catch up. A /// rendition with a catalog `warmup` also decodes the group before it, for - /// the max-delay decoder (see [`Consumer`]). + /// the max-delay decoder (see [`Consumer`]). Opening waits for the track's + /// live edge, or its end. Latest, } @@ -287,8 +288,12 @@ impl Reader { // aggregated across every live subscriber, so naming a stale cached // sequence there asks the publisher to rewind the track for everyone // reading it. What a player wants is to skip what it already has. + // + // The live edge is known only once the feed is live: a warm copy still waiting on + // its source reports nothing yet, so wait for it rather than read the backlog. The + // wait ends with the track. if options.start == Start::Latest - && let std::task::Poll::Ready(Some(live_edge)) = subscriber.poll_largest(&moq_net::kio::Waiter::noop()) + && let Some(live_edge) = subscriber.largest().await { subscriber.set_groups(live_edge.group.saturating_sub(lookback)..); } From 3aaf53cde8c9816e7658bc5b3b009bdd53a79424 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 20:44:40 -0700 Subject: [PATCH 20/20] fix(room): wait for the newest chat window before reading chat::Subscriber::new positioned with a one-shot noop poll_largest, which pends on a relay copy still waiting on its source, so a new reader replayed the copy's cached backlog. It now waits for largest() in its first poll_recv, ending with the track, as the JS reader does. A repo-wide sweep found no other remote positioning caller polling with a noop waiter. Co-Authored-By: Claude Opus 5.5 --- rs/moq-room/src/chat.rs | 132 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 9 deletions(-) diff --git a/rs/moq-room/src/chat.rs b/rs/moq-room/src/chat.rs index e80316df8e7..22ebb790c6c 100644 --- a/rs/moq-room/src/chat.rs +++ b/rs/moq-room/src/chat.rs @@ -128,7 +128,14 @@ impl Publisher { /// Reads changes to a participant's retained chat window. pub struct Subscriber { - consumer: moq_json::window::Consumer, + state: State, +} + +enum State { + /// Waiting for the newest window, so reading starts there rather than replaying older ones. + /// Taken only while moving to `Reading`. + Positioning(Option>), + Reading(Box>), } impl Subscriber { @@ -137,20 +144,30 @@ impl Subscriber { Ok(Self::new(broadcast.track(TRACK)?.subscribe(None).await?)) } - /// Read window changes from an existing subscription. - pub fn new(mut track: track::Subscriber) -> Self { - // Start at the newest window known now rather than replaying older ones. - if let Poll::Ready(Some(largest)) = track.poll_largest(&kio::Waiter::noop()) { - track.set_groups(largest.group..); - } + /// Read window changes from an existing subscription, starting at the newest window. + pub fn new(track: track::Subscriber) -> Self { Self { - consumer: moq_json::window::Consumer::new(track, Default::default()), + state: State::Positioning(Some(Box::new(track))), } } /// Poll the next window change, returning errors separately from clean completion. pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll, Error>> { - Poll::Ready(Ok(ready!(self.consumer.poll_next(waiter))?)) + if let State::Positioning(pending) = &mut self.state { + let track = pending.as_mut().expect("positioning holds the track"); + // The newest window is known only once the feed is live: a relay copy still waiting + // on its source reports nothing yet, so wait rather than replay its backlog. The + // wait ends with the track. + if let Some(largest) = ready!(track.poll_largest(waiter)) { + track.set_groups(largest.group..); + } + let track = pending.take().expect("positioning holds the track"); + self.state = State::Reading(Box::new(moq_json::window::Consumer::new(*track, Default::default()))); + } + let State::Reading(consumer) = &mut self.state else { + unreachable!("positioned above"); + }; + Poll::Ready(Ok(ready!(consumer.poll_next(waiter))?)) } /// Wait for the next window change, or None on clean completion. @@ -203,6 +220,103 @@ mod tests { assert!(matches!(subscriber.recv().await, Err(Error::Json(_)))); } + /// An origin at `hop`, its driver spawned. + fn origin(hop: u64) -> moq_net::origin::Producer { + let config = moq_net::origin::Config::new(moq_net::Hop::new(hop).unwrap()); + let (producer, driver) = moq_net::origin::Producer::new(config); + tokio::spawn(moq_net::time::run(driver)); + producer + } + + /// A lite-07 session over memory carrying `from`'s broadcasts into `to`, open while held. + async fn link( + from: &moq_net::origin::Producer, + to: &moq_net::origin::Producer, + ) -> (moq_net::Session, moq_net::Session) { + use moq_net::transport::memory; + + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + let mut config = memory::Config::default(); + config.protocol = Some(version.alpn().to_string()); + let (client_transport, server_transport) = memory::pair(config); + let client = moq_net::Client::new() + .with_versions(version.into()) + .with_publisher(from.consume()); + let server = moq_net::Server::new() + .with_versions(version.into()) + .with_subscriber(to.clone()); + tokio::join!( + async { + let (session, driver) = client + .connect_lite(moq_net::time::now(), client_transport) + .await + .unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + }, + async { + let (session, driver) = server + .accept_lite(moq_net::time::now(), server_transport) + .await + .unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + } + ) + } + + /// A relay copy whose upstream subscription went idle keeps the windows it cached. A new + /// reader waits for the copy to go live and starts at its newest window, rather than + /// replaying a message that has since left it. + #[tokio::test(start_paused = true)] + async fn a_warm_copy_starts_at_the_newest_window() { + let publisher = origin(1); + let broadcast = publisher.create_broadcast("room").unwrap(); + let track = broadcast.create_track(TRACK, info()).unwrap(); + broadcast.announce(Default::default()).unwrap(); + let mut demand = track.demand(); + let mut chat = moq_json::window::Producer::::new( + track, + moq_json::window::ProducerConfig::default().with_op_ratio(0), + ); + let at = |millis| moq_net::Timestamp::from_millis(millis).unwrap(); + chat.push(moq_net::Timed::from(&"first".to_owned()).at(at(0))).unwrap(); + + let relay = origin(2); + let _link = link(&publisher, &relay).await; + let consumer = relay.consume(); + consumer.routed("room").await.unwrap(); + let remote = consumer.request_broadcast("room", None).await.unwrap(); + + // A first reader warms the relay's copy with the first window. + { + let mut warm = Subscriber::subscribe(&remote).await.unwrap(); + assert_eq!( + warm.recv().await.unwrap(), + Some(Event::Push { + index: 0, + value: "first".into() + }) + ); + } + // The relay cancels the unread upstream subscription; its copy keeps the window. + while demand.wanted_changed().await.unwrap().is_some() {} + chat.pop(moq_net::Timed::from(1).at(at(1))).unwrap(); + chat.push(moq_net::Timed::from(&"second".to_owned()).at(at(1))).unwrap(); + + // A delay budget long enough that the backlog is still worth delivering. + let subscription = track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let mut late = Subscriber::new(remote.track(TRACK).unwrap().subscribe(subscription).await.unwrap()); + assert_eq!( + late.recv().await.unwrap(), + Some(Event::Push { + index: 1, + value: "second".into() + }), + "the warm copy's backlog was replayed" + ); + } + #[tokio::test] async fn propagates_track_failure() { let mut broadcast = broadcast::Info::new().produce();