Skip to content

feat(net)!: resolve the subscribe start from max age, with Group Start as an absolute floor - #3158

Merged
kixelated merged 4 commits into
devfrom
claude/subscribe-start-from-max-age
Aug 29, 2026
Merged

feat(net)!: resolve the subscribe start from max age, with Group Start as an absolute floor#3158
kixelated merged 4 commits into
devfrom
claude/subscribe-start-from-max-age

Conversation

@kixelated

@kixelated kixelated commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

A subscriber with a 3s jitter buffer used to start empty and spend 3s filling it: an absent Group Start meant "the latest group", so a join always began at the live edge no matter how much older content the subscriber could still play. Now Subscriber Max Age is the only thing that decides where delivery begins, and a join is handed the head of what it can still use.

  • Group Start is an absolute floor on lite-06. The wire carries the raw minimum group sequence (default 0), replacing the sequence + 1 encoding where 0 meant the latest group. A floor is not a request: it only bounds how far back the budget may reach, so migrations and range requests still pin exactly where they need to without dragging backlog in.
  • The budget is the only gate. The model does not resolve a start by scanning the cache; the subscriber's cursor is floored at the named group (or 0) and delivery's existing staleness rule decides what above the floor is worth sending. A zero budget (the default) delivers only the latest group, so a subscription that says nothing behaves exactly as before. One bound decides both what is sent and what is expired, so the publisher never sends history the subscriber would discard on arrival.
  • A late lower group within the budget is delivered. Arriving below everything already delivered is not what makes content stale. This is what the resume layer's pruned-cursor semantics always required (a straggler from a dying route still drains), and a consumer that needs sequence order reorders or drops it itself.
  • Pre-06 wires keep their published semantics. Lite-01..05 drafts define an absent start as the latest group (and 03..05's Max Age as a staleness tolerance only), so the publisher pins those to the live edge; only lite-06 lets the budget reach back. Re-encoding toward a pre-06 wire folds a vacuous floor back to absent, so a relay downgrade can never turn "no floor" into "replay from the beginning".
  • A vacuous floor canonicalizes to absent. On the 06 wire, Group Start = 0 with Frame Start = 0 decodes as no floor; group 0 stays nameable when a Frame Start qualifies it, so a mid-group-0 resume survives (a catalog never leaves group 0). Aggregation folds floors accordingly: the loosest floor wins, and any subscriber without one clears it.

Bug fixes surfaced by the review

  • The spliced (origin-backed) serving path never honored a floor. resume::poll_activate assigned each segment's inner cursor from the segment boundary, silently discarding the start the inner subscription carried, so an explicit Group Start evaporated on any track served through an origin. It now raises the floor instead (track::Subscriber::raise_start_to). This was the actual mechanism behind the broadcast_route_migration CI failure; that test now names its floor explicitly, which is what its 10s tolerance was standing in for.
  • The hang container consumer truncated a below-cursor group still downloading. The decode loop drains faster than the network delivers, so the moment an admitted backlog group's buffer ran empty, next() shifted it out and its remaining frames were silently lost (with continuous still reporting true). Removal now waits for the group to finish, with the latency skip still bounding a stalled head. Regression-tested with an incremental mid-download arrival.

Out-of-order delivery

Groups go out newest-first, so the head of a join arrives after the live edge served alongside it. Container.Consumer dropped anything below its delivery cursor, which threw that head away one layer down; it now admits it, since the subscription's own max age already bounds how far back one can be, and the rendering consumers place content by timestamp. A group the reset boundary proves reneged is still dropped. Consumers that need strict decode order are #3099's Ordered cursor's job, not this consumer's.

Public API changes

Subscription::start / startGroup semantics change from "start exactly here" to a floor (which is why this targets dev), and aggregation folds it as one (loosest wins, absent clears). No items added or removed; moq-ffi/libmoq doc comments updated to match (no signature change, so the generated bindings are untouched).

Test plan

cargo nextest run -p moq-net -p moq-tokio -p moq-relay -p moq-mux -p hang -p moq-gst (2429 tests), cargo clippy over the changed crates plus moq-ffi/libmoq/moq-cli, cargo fmt --check, bun test js/{net,hang,watch}/src (875 tests), bunx tsc -b, biome check, and just drafts check.

New coverage: the budget reaching back over the cache, a named start as a floor (cut off, vacuous, and above-the-edge cases), a late lower group within the budget delivering, the pre-06 pin per version (unbounded lite-01 budget, declared lite-05 tolerance, lite-06 resolution), the raw-vs-+1 wire encodings with the vacuous-floor canonicalization and pre-06 fold in both languages, Frame Start qualifying group 0, floor aggregation clearing on an unfloored subscriber, and the container consumer's mid-download truncation regression.

Follow-ups, deliberately not here

  • rs/moq-mux's container consumer still drops below-cursor groups, so a native consumer subscribing with a budget over lite-06 transfers the join backlog and discards it. The right shape for consumers that need decode order is refactor(net)!: make group delivery order a handle, and retire the Ordered wire field #3099's Ordered cursor rather than porting the admission rule, so this waits for that to land.
  • The publisher-side stale metric counts a warm-cache join's skipped backlog the same as a mid-stream fall-behind. Distinguishing them needs an arrival stamp on the counting path; the metric is unchanged from dev today.
  • In buffered mode subscribeMedia's maxAge (the buffer ceiling) still decides the join depth; whether a buffered viewer should join maxBuffer behind a real-time publisher is a pre-existing choice with its own trade-off.
  • refactor(net)!: make group delivery order a handle, and retire the Ordered wire field #3099 also rewrites lite-06's SUBSCRIBE layout (dropping the ordered byte); whichever lands second rebases the wire tests.

Supersedes #3114, which asked for startGroup: 0 from the client with a bound nothing enforced.

(Written by Claude Fable 5)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T00:17:20.729339Z 42f98a7 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbaed9a8ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread js/watch/src/audio/decoder.ts Outdated
// The ring places samples by timestamp, so a group that arrives below the delivery
// cursor still lands in its own slot. That is how the head a publisher serves ahead
// of the live edge survives being sent after it (groups go newest-first).
outOfOrder: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not decode backfilled audio groups out of order

When a nonzero maxAge causes the publisher to send the live edge before older cached groups, this option forwards the encoded chunks to the same AudioDecoder in arrival order, such as group 3 followed by 0, 1, and 2. The timestamp-indexed ring only orders the already-decoded PCM; it cannot restore the predictive/overlap state of codecs such as Opus or AAC, and the decoder is then left at an older codec state before subsequent live chunks arrive. Reorder the encoded groups before decoding, or fix the serving order, rather than enabling out-of-order input at this call site.

AGENTS.md reference: AGENTS.md:L133-L137

Useful? React with 👍 / 👎.

Comment thread rs/moq-net/src/lite/publisher.rs Outdated
// the default zero budget: a subscriber that tolerates some age gets the head of what
// it can still use, rather than joining at the live edge and discarding a track's
// opening groups that are sitting right here in the cache.
let resolved = resolved_start(&track, ctx.version);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the end cap before resolving the implicit start

When a SUBSCRIBE omits Group Start but supplies an end_group below the current live edge, resolved_start runs before track.end_at, so fresh_start measures freshness against groups the subscription cannot receive. For example, with live group 4, end group 2, and a short budget, this resolves to 3 or 4 and then creates an empty range instead of resolving against group 2 as the edge. Apply the cap first; the JS mirror in js/net/src/lite/publisher.ts has the same ordering and needs the matching fix and regression coverage.

AGENTS.md reference: AGENTS.md:L191-L191

Useful? React with 👍 / 👎.

Comment thread js/net/src/lite/publisher.ts Outdated
endGroup: msg.endGroup,
});
const startGroup = msg.startGroup ?? track.latest();
const startGroup = msg.startGroup ?? resolvedStart(track, this.version);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the exported startGroup documentation

This changes the meaning of an omitted start when maxAge is nonzero, but the exported Subscription.startGroup member in js/net/src/track.ts still says omission starts at the latest group. That stale comment is emitted into the package's public API documentation and will mislead callers about the newly introduced backfill behavior; describe publisher selection based on maxAge there as well.

AGENTS.md reference: AGENTS.md:L107-L109

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ef1346731

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if let Some(start_group) = bounds.start_group.or_else(|| track.latest()) {
track.start_at(start_group);
}
position_cursor(&mut track, ctx.version, bounds.start_group);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the resolved start across out-of-order arrivals

When a relay cached groups out of sequence, such as 3 followed by 0, 1, and 2 from independently delivered upstream streams, resolve_start correctly chooses 0 for a sufficient max-age budget, but this cursor still reads in arrival order. TrackRun::poll therefore receives 3 first, emits SUBSCRIBE_START(3), and calls start_at(3), permanently discarding the cached groups that the new max-age resolution selected. Preserve the model-resolved floor until the advertised start is determined from the full eligible range, rather than replacing it with the first arrival.

AGENTS.md reference: AGENTS.md:L45-L49

Useful? React with 👍 / 👎.

kixelated and others added 3 commits August 28, 2026 15:14
…x age

A SUBSCRIBE that names no Group Start meant the latest group, so a
subscriber joining a track already in flight skipped everything the
publisher was still holding for it. That is invisible on a continuous
broadcast and obvious on a publisher that opens a track per utterance:
the head of every track is discarded while it sits in the cache.

Resolve the start from the budget the subscriber already sent instead.
Max Age says how far behind the live edge a group may be before it is
skipped, so the oldest group worth starting at is the oldest one that
budget does not already convict. A zero budget (the default) still
resolves to the latest group, since every older group is stale the
moment a newer one exists, so an existing subscriber sees no change.

The two bounds cannot disagree: the same predicate that expires a group
on the way out picks where to begin, so a publisher never sends history
the subscriber would discard on arrival, nor withholds a group it would
have taken. A wire that cannot carry Max Age is served with an unbounded
budget so backlog is never dropped under a legacy subscriber, and that
must not read as a request to replay the cache, so those sessions keep
resolving to the latest group.

Groups go out newest-first, so the resolved head arrives after the live
edge that was served alongside it. The container consumer dropped
anything below its delivery cursor, which threw the head away again one
layer down. Audio can take those: the ring is timestamp indexed, so a
late group lands in its own slot however it arrives. Video cannot, so
the strict cursor stays the default and audio opts in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…helper

The start a subscription resolves from its own max age was a method on
track::Subscriber that every serving path had to remember to call, and a
publisher that forgot got the old live-edge behaviour silently. It is
also nothing a caller outside the model has any use for.

Resolve it where a subscription becomes a cursor instead. A new
subscriber starts at the group its subscription named, or the oldest
cached one its max age still considers fresh, so the model answers the
question once for every serving path (and honours an explicit start
without the publisher translating it). The public surface goes back to
what it was: no new API on either side.

The lite publisher keeps one line of its own, for a wire that cannot
carry Max Age: those sessions are served an unbounded budget so a legacy
subscriber never has backlog dropped under it, and the cursor that
budget would resolve to is the whole cache.

A FETCH scan on the JS side walks a subscriber from the cache head, so
it needs the exemption the Rust fetch path gets structurally: renamed
the internal hook to say what it now covers, since a fetch names one old
group explicitly and is neither late against the live edge nor bound by
a live subscription's start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groups are sent newest-first, so the head of a subscription arrives
after the live edge it was served alongside. The container consumer
dropped anything below its cursor, which threw that head away one layer
below where the publisher had just served it.

Both consumers can place a late group. Audio writes into a
timestamp-indexed ring, which ignores anything behind the playhead and
fills its own slot otherwise. Video drops a late frame at render, by
timestamp, twice on the way. So there is no caller the strict cursor was
protecting, and no reason for the opt-in this started as.

How far back a group may be is the subscription's own max age, applied
before it reaches here. A group the reset boundary proves reneged is
still dropped, since that is a timeline the publisher withdrew rather
than one that arrived late.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/subscribe-start-from-max-age branch from 1bd666e to 49b5027 Compare August 28, 2026 22:17

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49b5027dac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread drafts/draft-lcurley-moq-lite.md Outdated
**Group Start**:
The first group to deliver.
A value of 0 means the latest group (default).
A value of 0 means the publisher chooses (default).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile the remaining absent-start documentation

When Subscriber Max Age is nonzero, this new rule can start before the latest group, but a repo-wide search still finds the old guarantee in this draft's Track overview (line 129) and Appendix B (line 1438), plus the published Rust and binding surfaces in rs/moq-net/src/model/subscription.rs, rs/moq-ffi/src/consumer.rs, and rs/libmoq/src/api.rs. Beyond the already-noted JS member, these are additional stale surfaces that give implementers and Rust/C/binding consumers a contract contradicting this normative text, so update them together.

AGENTS.md reference: AGENTS.md:L194-L200

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Reviewed the behavior change end to end (model resolution, both wire publishers, the js container consumer, the draft text) and reproduced the failure modes locally. The direction is right: deriving the join point from the budget that already expires groups is the correct core idea, and it is strictly better than IETF DELIVERY_TIMEOUT (per-object, wall-clock, and a publisher-side drop that leaves the rest of the group undecodable). But as landed this breaks CI, has two demonstrable bugs in the js container consumer, leaves every Rust consumer downloading backlog it then throws away, and changes wire behavior for already-published draft versions. Findings in severity order.

1. CI is red, and the failing test is a real second constituency, not a stale assertion

broadcast_route_migration (rs/moq-tokio/tests/broadcast.rs:847) fails: expected ["a1"], got ["a0"]. That test subscribes with max_age = 10s used purely as a staleness tolerance, "a generous stale window so the continuation B already holds is served rather than aged out", while still expecting to join at the live edge. This PR fuses those two meanings, so the tolerance now also means "replay 10s of backlog on join".

It is not fixable by updating the test, because the new wire cannot express the old intent at all: Group Start = 0 no longer means "latest", a non-zero value is absolute, and there is no other encoding for "join live, but tolerate old groups mid-stream". Migration/splice consumers, moq-gst (source/imp.rs subscribes with a 1s budget as pure tolerance), and any third party using maxAge per the lite-03..05 drafts lose expressiveness silently.

Your own "Follow-ups" section documents the same conflation from the other side: subscribeMedia's maxAge is now load-bearing twice, and the ceiling/floor divergence in buffered mode has no right answer. That is three independent signs the knob is overloaded.

Recommendation: split the knob. Add a separate join bound (e.g. Subscriber Backfill, a duration, default 0, resolved as min(backfill, max_age, publisher window)) instead of overloading Subscriber Max Age. Lite-06 is WIP so the SUBSCRIBE field is free; the "two bounds cannot disagree" property survives via the clamp; every existing caller (migration, gst, ffi consumers) keeps today's semantics by default; and hang sends backfill = buffer floor, maxAge = ceiling, which dissolves the buffered-mode follow-up exactly.

2. The js container consumer truncates a below-cursor group that is still downloading

next()'s removal branch (js/hang/src/container/consumer.ts, the #active > seq || done shift) fires the moment a below-#active group's buffered frames are drained, even while its #runGroup is still reading. Before this PR that branch was unreachable for below-cursor groups (they were dropped at admission); now it is the common case, because the decode loop consumes frames faster than the network delivers them.

Repro (local test): live group 3 delivering, backlog group 1 arrives and delivers its first frame; the very next next() returns {frame: undefined, group: 1} and the group's second frame is silently lost. No #gap is set, so the following result still reports continuous: true, and the buffered-range accounting bridges the hole.

3. The js container consumer splits the open live group around the backlog, which video cannot decode

next() serves #groups[0] whenever its sequence is at or below #active, so when backlog lands while the open live-edge group is mid-delivery, delivery order becomes: live head frames, backlog groups, then the remainder of the live group mid-GOP. Repro delivery order: 3:f0, 3:f1, 2:f0, 2:f1, 3:f2. 3:f2 is a delta chunk whose references are gone from the decoder's state, fed with continuous: true and no discontinuity bump. WebCodecs will corrupt or error, and the video decoder's error callback does effect.close(), killing the track. Audio survives only because hang audio groups are single-frame. The new out-of-order test uses closed single-frame groups, so it can't see either this or #2.

Recommendation for both: rather than admitting out-of-order groups into a decoder-bound path and patching the cursor rules, anchor the consumer's start deterministically. The publisher already announces the resolved start as SUBSCRIBE_START (the js subscriber currently drains it as "informational" in #drainResponses); initialize #active from it and deliver strictly in sequence order from there, with the existing latency skip as the escape hatch. That removes the "first arrival wins" race the old TTV comment already flagged as racey, makes the deleted "Consumer rejects stale groups" rule unnecessary rather than wrong, and TTV hides behind the jitter buffer (the first frame rendered is the head of the window; the poster-frame path covers perceived TTV).

4. Every Rust consumer downloads the backlog and throws it away

rs/moq-mux/src/container/consumer.rs still has exactly the rule this PR removed from js: drop = sequence < self.current, "skipping old group". That consumer is behind moq-gst, moq-ffi/libmoq (so every language binding), moq-rtmp, moq-srt, moq-cli, and moq-audio. Any of them subscribing with a nonzero budget against an upgraded publisher now transfers up to maxAge x bitrate on every join and drops it on arrival. The PR body's "Both consumers can place one" covers only the two js/watch consumers; the cross-package sync row for the container was skipped without being called out.

5. The new semantics apply to published draft versions

position_cursor / positionCursor gate on carries_max_age, i.e. Lite03+. But lite-03..05 are published drafts that say "A value of 0 means the latest group", and the changelog entry for this change sits under moq-lite-06. Deployed clients already send a nonzero maxAge (js watch sends its buffer size), so an upgraded relay changes their join behavior out from under them, and their released container consumers are precisely the download-and-drop ones from #4. Gate the resolution to Lite06Wip+ and pin 03..05 to the latest group alongside the legacy pin.

6. Smaller notes

  • The resolved-start predicate is "oldest group whose first frame is within budget" (fresh_start reuses is_stale, which judges an unread group by group.timestamp()). That is subtly stricter than "any group unless it provably can't contain a frame that recent": a long open group with an old head but fresh tail is excluded. I think first-frame is the right rule (you can't decode from mid-group, and it matches expiry), but the draft says "whose age is within Subscriber Max Age" without defining a group's age; pin it to the first frame explicitly.
  • PR body cites a test an_absent_start_resolves_from_the_budget; the actual test is a_legacy_wire_is_pinned_to_the_live_edge.
  • The js test "a start is clamped to the publisher's window" subscribes before any group exists, so it exercises the delivery-side clamp, not #resolveStart's. Populate the track first, like the Rust twin does.
  • The moq-tokio failure also means the test plan's scope (cargo test -p moq-net) missed a dependent crate; just test would have caught it.

On the IETF question

Agreed it should be an extension, and combining it with the timestamp draft is right: "age" only exists once objects carry timestamps at a declared timescale, so the dependency is structural, not incidental. Shape it as SUBSCRIBE parameters in draft-lcurley-moq-timestamp (Subscriber Max Age, plus Backfill if the knob splits), with the resolution rule as a SHOULD on the publisher when the filter is the live-edge one. The DELIVERY_TIMEOUT comparison in this PR's motivation is the argument for the extension's introduction almost verbatim: group-granular, media-timeline units, and both ends applying the same bound, versus per-object wall-clock dropping that strands the rest of the group.

(Written by Claude Fable 5)

Reworks the resolution design per review: the model no longer scans the cache
to resolve a start. A subscription's named start is a floor (min group), the
budget is the only thing that asks for data, and delivery's existing staleness
gate decides what above the floor is worth sending. A zero budget still
delivers only the latest group; a larger one reaches back over the head of
what the subscriber can still use. A late lower group within the budget is
now delivered rather than suppressed, which is what the resume layer's
pruned-cursor semantics always required.

On the lite-06 wire, Group Start becomes the raw absolute floor (default 0)
instead of sequence + 1 with 0 meaning the latest group. A vacuous floor (0
with no frame offset) canonicalizes to absent on decode, and folds back to
absent when re-encoded toward a pre-06 wire, whose explicit group 0 means
"replay from the beginning". Group 0 stays nameable when a Frame Start
qualifies it, so a mid-group-0 resume (a catalog never leaves group 0)
survives. Pre-06 wires are pinned to the latest group when they name no
start, matching their published drafts; only lite-06 resolves from the budget.

The spliced (origin-backed) serving path now honors floors at all:
poll_activate assigned the inner cursor's start, discarding the floor the
inner subscription carried, so an explicit start never survived an origin.
It raises the floor instead (track::Subscriber::raise_start_to). The
broadcast_route_migration test names its floor explicitly, which is the
migration semantics the old default start was standing in for.

The hang container consumer no longer truncates a below-cursor group whose
buffer momentarily drains mid-download: removal now waits for the group to
finish, with the latency skip still bounding a stalled head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42f98a79c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// partway through group 0, e.g. a catalog that never leaves it).
fn canonical_start_group(version: Version, start_group: Option<u64>, start_frame: u64) -> Option<u64> {
match (start_group, start_frame) {
(Some(0), 0) if version.resolves_start() => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply a zero floor when widening SUBSCRIBE_UPDATE

On lite-06, an update that widens Group Start from a positive value to the unfloored value 0 is canonicalized to None, but both publisher update handlers only call start_at/startAt when the decoded value is present. If the update is processed before the next group pop, the cursor therefore retains its previous positive floor and groups below it remain unavailable despite the widened request. Apply 0 explicitly for an absent lite-06 update in both this Rust path and the JS mirror in js/net/src/lite/publisher.ts.

AGENTS.md reference: AGENTS.md:L196-L200

Useful? React with 👍 / 👎.

@kixelated kixelated changed the title feat(net): resolve an absent subscribe start from the subscriber's max age feat(net)!: resolve the subscribe start from max age, with Group Start as an absolute floor Aug 29, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

Reworked per direction (single maxAge knob; Group Start absolute again; ordering deferred to .ordered() / #3099). Pushed as one commit on top: the cache-scanning start resolution is gone entirely. The floor is the only thing a named start contributes, the budget is the only gate, and delivery's existing staleness rule does the rest, so the model shrank rather than grew.

Two genuine bugs fell out of chasing the CI failure, both fixed here:

  • resume::poll_activate assigned each segment's inner cursor from the segment boundary, discarding any floor the inner subscription carried, so an explicit Group Start never survived an origin-backed serving path. It raises the floor now. That, not the test's expectations, was the real mechanism behind broadcast_route_migration failing; the test now names its floor (group 1) with the 10s tolerance kept as pure tolerance.
  • The hang container consumer truncated a below-cursor group whose buffer momentarily drained mid-download (the decode loop outruns the network), silently losing its tail while still reporting continuous. Removal now waits for done; regression test included.

Wire details worth a look: on lite-06 a vacuous floor (Group Start 0, Frame Start 0) canonicalizes to absent on decode, group 0 stays nameable when a Frame Start qualifies it (mid-group-0 catalog resume), and re-encoding toward a pre-06 wire folds the vacuous floor back to absent so a relay downgrade can't turn "no floor" into "replay from group 0". Pre-06 wires stay pinned to the latest group per their published drafts; only lite-06 resolves from the budget.

Left out on purpose: rs/moq-mux's container consumer still drops below-cursor groups, so native consumers with a budget will download-and-discard the join backlog on lite-06 until they sit on #3099's Ordered cursor. Noted in the PR body as a follow-up. #3099 and this PR both rewrite lite-06's SUBSCRIBE, so whichever lands second rebases the wire tests.

(Written by Claude Fable 5)

@kixelated
kixelated merged commit d8f9607 into dev Aug 29, 2026
5 checks passed
@kixelated
kixelated deleted the claude/subscribe-start-from-max-age branch August 29, 2026 02:09
kixelated added a commit that referenced this pull request Aug 29, 2026
…-ordered-handle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant