Skip to content

feat(moq-net): enforce the subscriber latency budget - #2890

Merged
kixelated merged 32 commits into
devfrom
claude/latency-enforcement
Aug 19, 2026
Merged

feat(moq-net): enforce the subscriber latency budget#2890
kixelated merged 32 commits into
devfrom
claude/latency-enforcement

Conversation

@kixelated

@kixelated kixelated commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Subscription::latency / latencyMax was propagated as metadata but the track model never acted on it. This enforces it in rs/moq-net at both ends of a subscription, which is the ceiling that #2784 needs now that #2785 is reverted on dev.

The JS mirror is stacked on top as #2919 (originally filed as #2892), so each language reviews on its own.

  • Group selection. track::Subscriber's poll_recv_group / poll_next_group / poll_read_frame skip a group that has drifted past the subscription's budget, so one poll walks a whole backlog off. Both ends go through the same code: a relay serves downstream via poll_recv_group, and a local consumer reads the same way. fetch_group is exempt, since a fetch names historical content explicitly.
  • Two measures. Presentation time (a group's first frame against the newest one above it) and wall-clock arrival time, either of which can expire a group. The arrival clock is monotonic, so it does not react to system-clock changes, and it backstops a stalled or empty group that has no timestamp yet.
  • Measured from the reader, not the group. A group is not late because it opened long ago; what can be late is the content its reader has yet to take. A cursor that has drained a group sits at the group's newest frame. Measuring from the first frame instead makes a 2s GOP two seconds behind by construction, so any budget shorter than one GOP drops the tail of every GOP. A group nobody has started reading still sits at its own start, which is what leaves selection unchanged.
  • After handoff, only a read that would park is judged. A group with frames in hand always drains: the budget bounds a group that has stalled while the live edge moved on, not a reader slower than the wire. Wire publishers keep the check at the one stall the group cursor cannot see, parking on transport flow control, so a blocked stream cannot pin credit for content that has gone stale.
  • A drained group ends rather than fails. A group-level read only parks once the cursor has taken every frame, so expiring there costs the reader nothing: what it was waiting for was the producer's FIN, and a group abandoned at its own end is indistinguishable from one that ended there. Error::Old is reserved for a cursor still holding unread content (a half-read payload, a publisher with buffered frames). Without this a relay reset the downstream stream for a group it had delivered in full, since the FIN and the next group's first frame are separate streams and the reader is routinely parked at the group's end when the verdict is taken.
  • Route takeovers. Every replacement copy in a spliced group inherits the logical subscription, reader cap, and route-segment bound, while ordinary cache eviction can still fail over to another copy.
  • Consumers. The mux, native audio/video decoders, libmoq, GStreamer, and the shipped Hang subscriber example apply their configured latency to the initial subscription. moq_mux::container::Consumer::new inherits that already-negotiated budget.
  • Stats. Skipped content is reported as stale: { bytes, frames, groups, datagrams } in JSON stats and as matching moq_relay_stale_*_total Prometheus counters. Expiry after handoff counts only the unread tail, and cloned readers share the one attribution.

Public API changes

Targets dev for one breaking cleanup:

  • Breaking: remove moq_mux::container::Consumer::with_latency. Consumer::new now inherits the initial moq_net::track::Subscription latency; set_latency remains for mid-stream changes.
  • New moq_net::stats::Content counter shape, and a stats::Traffic::stale: Content field on the existing #[non_exhaustive], serde-defaulted struct.
  • New kio::ConsumerWeak::poll, mirroring Consumer::poll. Additive: it lets a watcher register for changes without joining the consumer count.

Wire behavior changes

The encoding is unchanged; no draft update is needed, and the existing draft already specifies the two age backstops. What a peer observes differently:

  • A group that has drifted past the subscriber's requested budget is not sent at all. The default budget is zero, so a delayed live subscriber deliberately advances to the live edge; callers that need history must request a replay window.
  • A group stream is reset with Old when the group expires while its transport write is flow-control blocked, or while waiting on stream credit. It was previously held open.
  • A group that expires with nothing unread is FIN'd rather than reset, so a relay no longer signals truncation for a group it delivered in full.
  • moq-transport carries no receiver latency parameter, so serving-side IETF subscriptions request an effectively unbounded but wire-encodable window and leave receiver-specific enforcement to the receiving subscriber.

Test plan

  • nix develop --command just check / just check-all
  • nix develop --command just test all: 3258 Rust tests pass, 2 skipped; all JS suites green.
  • Regression tests cover partial-frame, blocked-send, blocked-final-payload, exhausted-stream-credit and transport-flow-control-blocked expiry, fetch-only/live-edge isolation, system-clock changes, handed-out groups, clean EOF after a final-frame drain, unread-tail and cloned-reader stats, replacement copies after route takeover, latency-update wakeups, and the relay Prometheus renderer.
  • Two of them were verified to fail with only their own half of the fix reverted: real_time_reads_a_live_stream_without_truncating_it (2s GOPs read as they arrive at the default budget, with the FIN landing after the successor's first frame) and a_budget_is_measured_from_the_readers_position (a straggler frame arriving after the next group opened still reaches a reader inside its budget).
  • Measured on the frame-read hot path, 20k frames with 50 groups cached: 13ns/frame on dev, 9-14ns here.

(Written by Opus 5)

@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: b80213e295

ℹ️ 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 on lines +2689 to 2693
// Drop a group the drift budget has given up on and keep scanning, so one
// poll walks a whole backlog off rather than handing it out group by group.
if ready!(self.poll_stale(&consumer, drift, waiter))? {
self.stale += 1;
continue;

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 Enforce the latency budget in JS subscribers

The new subscriber-side enforcement exists only in Rust. The corresponding js/net/src/track.ts methods Subscriber.recvGroup, nextGroup, and readFrameSequence still return buffered groups without consulting Subscription.latencyMax or comparing timestamps. Browser subscribers therefore continue replaying a backlog whenever another subscriber widens the publisher's aggregate budget, contrary to the newly documented both-end semantics. The repository explicitly requires rs/moq-net API changes to be synchronized with js/net. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L170-L177

Useful? React with 👍 / 👎.

Comment thread rs/moq-net/src/model/track.rs Outdated
Comment on lines +2609 to +2613
let (latency, end) = {
let prefs = self.subscription.read();
(prefs.latency, prefs.end)
};
let cap = servable_cap(self.end_sequence, end, self.stale_cap);

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 Exclude the wire request end from the local drift cap

When this subscriber has a Subscription::end, poll_drift treats that wire preference as a local delivery cap. However, subscription bounds are aggregated and do not filter this handle, so another unbounded subscriber can cause groups beyond this end to be received here. In that case the live edge remains pinned at the requested end, every later candidate has a sequence above the edge and is therefore never stale, allowing an arbitrarily old backlog through even with the default real-time budget. Only end_sequence and the outer reader cap should constrain the local edge. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-net/src/model/track.rs Outdated
Comment on lines +371 to +373
if self.is_stale(*sequence, drift.edge, drift.budget) {
continue;
}

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 Count stale groups skipped by read_frame

When callers use Subscriber::read_frame, stale groups are discarded inside TrackState::poll_read_frame by this branch, but the skipped count is never returned to PlainSubscriber::stale. Consequently the subsequent count_stale call records zero, so the published Traffic::stale metric under-reports precisely for consumers using the frame-level helper. This occurs whenever one or more stale groups precede the frame that is returned. (Written by GPT-5.6 Sol)

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: d32e6e27ef

ℹ️ 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 on lines +486 to +487
let subscription = self.track.subscription().with_latency(latency);
let _ = self.track.update(subscription);

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 Pass latency in the initial subscription

When a caller requests nonzero latency through an exporter or decoder, the track has already been subscribed with the default preferences before this post-accept update runs (for example, container/source.rs subscribes at lines 198-218). On a remote Lite publisher, that initial REAL_TIME subscription can walk off the cached backlog and advance its cursor while the subscription is being established; this later update cannot re-offer those skipped groups. As a result, recorders configured with a larger latency budget can still start at the live edge, so carry the configured latency in the initial Subscription instead.

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: f43edc839e

ℹ️ 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 rs/moq-net/src/model/track.rs Outdated
Comment on lines +386 to +388
if self.is_stale(*sequence, drift.edge, drift.budget) {
stale += 1;
continue;

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 Account for stale groups when read_frame reaches EOF

When a completed track's remaining groups are all stale, this scan increments its local stale count and then returns Ok(None) without producing a FrameHit. PlainSubscriber::poll_read_frame only transfers hit.stale from the Some path, so the public wrapper records zero skips. The new FrameHit propagation fixes the previously reported case where a newer frame is returned, but the terminal path still causes Traffic::stale to under-report whenever read_frame skips the tail of a finished track. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L100-L100

Useful? React with 👍 / 👎.

Comment thread doc/bin/relay/config.md Outdated
when the datagram enters or leaves the model, so an egress datagram dropped by
congestion or an oversized body still counts.

- `stale`: cumulative groups skipped because they drifted further behind the live

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think stale should be split into { bytes, frames, groups, datagrams } as well. The size of a group is quite arbitrary.

We should probably move the existing counters into a transmitted section or something IDK. But keep backwards compatibility with the current scheme for now.

Comment thread rs/moq-cli/src/publish.rs Outdated
/// These tests publish a whole feed before exporting it, which the default
/// [`Latency::REAL_TIME`](moq_mux::Latency::REAL_TIME) collapses to the live edge:
/// completeness has to be asked for, exactly as a real recorder does.
const BATCH: std::time::Duration = std::time::Duration::from_secs(3600);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

3600 seconds seems reaaally high? I don't understand what we're doing here. At least use something reasonable, like 30s IDK.

/// These tests write or import a whole broadcast and only then export it, which the
/// exporter's default [`Latency::REAL_TIME`](crate::Latency::REAL_TIME) collapses to the
/// live edge: completeness has to be asked for, exactly as a real recorder does.
const BATCH: std::time::Duration = std::time::Duration::from_secs(3600);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't understand this name. why BATCH?

Comment thread rs/moq-mux/src/codec/h264/export.rs Outdated
// The whole track is written before the exporter runs, so it needs a budget
// wide enough to read it: the default skips everything but the live edge.
let mut export = Export::new(crate::source::announced(&consumer), Once(Some(catalog)))
.with_latency(crate::Latency::max(std::time::Duration::from_secs(3600)));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

3600 seconds seems reaaally high? I don't understand what we're doing here.

Comment thread rs/moq-mux/src/container/consumer.rs Outdated
/// Both layers enforce the same budget, and normally should: the transport skipping
/// a group the consumer was going to skip anyway just saves the bandwidth. These
/// tests are the exception, since they are about the consumer's half of it.
fn consumer_only(track: moq_net::track::Subscriber, latency: Latency) -> Consumer<Container> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

confusing name.

The eventual plan is to move the Container ordered consumer into moq-net. So moq-net would be responsible for skipping late groups/frames based on the transport timestamp, not the container timestamp. But that requires moq-lite-05 so we're waiting for a liiiitle bit first.

@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: 459328a34d

ℹ️ 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 rs/moq-net/src/model/track.rs Outdated
Comment on lines +594 to +595
let Some(timestamp) = slot.group.timestamp() else {
return false;

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 Apply a wall-clock budget to unstamped groups

When an older group opens but its first frame stalls while a newer stamped group arrives, this branch permanently exempts the older group from latency filtering. A raw subscriber can therefore receive that group and block reading it despite requesting REAL_TIME; it may remain blocked until retention eviction, or indefinitely if no later write drives another eviction scan. The same exemption exists in js/net/src/track.ts. The normative expiration rule in drafts/draft-lcurley-moq-lite.md:511-518 requires wall-clock age to backstop missing timestamps and explicitly covers empty groups, so track arrival time needs to participate in the stale decision. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L189-L189

Useful? React with 👍 / 👎.

Comment on lines +486 to +487
let subscription = self.track.subscription().with_latency(latency);
let _ = self.track.update(subscription);

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 Put decoder latency on the initial subscription

When a decoder requests nonzero latency, updating the subscription here happens only after SUBSCRIBE_OK, so a remote publisher can already have served the initial default REAL_TIME request and skipped backlog that this update cannot recover. Fresh evidence after the earlier exporter fix is that rs/moq-video/src/decode/consumer.rs:41-49, rs/moq-audio/src/decode/consumer.rs:51-59, rs/libmoq/src/consume.rs:363-367, and rs/moq-gst/src/source/imp.rs:445-447 still subscribe without their configured latency and only apply it through this post-accept call. Include the latency in those initial subscriptions, or reshape construction so the container consumer's budget is available before subscribing. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L119-L123

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: 0396224d67

ℹ️ 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/net/src/track.ts Outdated
if (hooks.groupTimestamp(group) !== undefined && (!edge || group.sequence > edge.sequence)) edge = group;
}

const requested = this.#state.update.peek()?.latencyMax ?? 0;

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 Carry the watch latency into media subscriptions

When a browser viewer selects any nonzero latency, this still resolves to zero because the watch audio and video paths subscribe with only a priority (js/watch/src/audio/decoder.ts:275 and js/watch/src/video/decoder.ts:299) and pass their latency only to the later container consumer. The new transport filter can therefore discard every buffered group except the live edge before that consumer gets to apply its configured tolerance, breaking buffered playback and recovery from jitter. Fresh evidence after JS enforcement was added is that these production subscriptions still omit latencyMax; include the configured budget in the initial subscription and keep it synchronized when the setting changes. (Written by GPT-5.6 Sol)

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: cbf92f27d6

ℹ️ 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 rs/moq-net/src/model/track.rs Outdated
Comment on lines 2771 to 2775
if ready!(self.poll_stale(&consumer, drift, waiter))? {
self.stale.add(consumer.content());
continue;
}
return Poll::Ready(Ok(Some(consumer)));

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 Keep enforcing latency after handing out a group

When a subscriber polls while an empty group is still the live edge, this path returns that group immediately; if its first frame then stalls and a newer group arrives, the latency check never runs on it again. In the Rust wire path, TrackRun moves the returned group into an independent GroupServe, and the JS publisher similarly starts #runGroup, neither of which monitors drift, so a zero-budget raw reader can block indefinitely and a publisher can leave the stale stream consuming flow control instead of resetting it. Fresh evidence beyond the earlier report is that the new wall-clock check still runs only before this handoff, while both current group-serving paths continue independently afterward. Apply the budget to already-delivered groups or explicitly cancel their serving tasks when they become stale, in both implementations.

AGENTS.md reference: AGENTS.md:L172-L177

Useful? React with 👍 / 👎.

Comment thread rs/moq-net/src/latency.rs Outdated
Comment on lines +35 to +38
/// A group that has yet to present a frame is never skipped, and neither is one on a track
/// where nothing newer has: with no timestamp there is nothing to measure, and the frames
/// may simply not have arrived yet. Such a group is bounded by the publisher's retention
/// window instead, which aborts it once it ages out.

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 Document wall-clock expiration for unstamped groups

This new public Latency documentation says a group without a first-frame timestamp is never skipped and is bounded only by retention, but the implementation now deliberately marks it stale from the wall-clock arrival delta in TrackState::is_stale, as covered by wall_clock_expires_an_unstamped_group. Consumers reading the API docs will therefore get the opposite behavior for empty or stalled groups; describe the wall-clock backstop here as the concept documentation does.

AGENTS.md reference: AGENTS.md:L97-L99

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: 95376e02d0

ℹ️ 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/net/src/track.ts Outdated
const drift = this.#drift();
for (let i = 0; i < groups.length; ) {
const group = groups[i];
if (end !== undefined && group.sequence > end) break;

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 Keep endAt from capping frame-level reads

When a caller sets endAt() but consumes through readFrame() or readFrameSequence(), this new break excludes every group above the cap and, after a clean track close, leaves the read waiting indefinitely for the cursor to change. The public endAt contract only caps nextGroup and recvGroup, and the Rust Subscriber::poll_read_frame still does not apply end_sequence, so this introduces a JS-only behavior change. Use the cap to bound the drift anchor without removing those groups from the frame reader's readable set. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L170-L177

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: 716564a52e

ℹ️ 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".

// Each segment keeps its own route-specific expiry policy. Applying the
// head segment's policy to the assembled group would use the wrong edge
// after a takeover.
expiry: 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.

P1 Badge Preserve expiry when resolving replacement group copies

When a handed-out spliced group crosses a route takeover, leaving the outer expiry unset allows the latency budget to be bypassed. resume::Group::poll_current resolves subsequent segment copies directly through track.poll_peek_group, which returns an unguarded consumer, so after the original latched copy expires or fails, a replacement can continue serving the stale group without any further expiry checks. Fresh evidence beyond the earlier handed-out-group report is this replacement-copy path, which was not covered by attaching expiry only to the initially returned segment group.

Useful? React with 👍 / 👎.

Comment thread rs/moq-net/src/model/track.rs Outdated
/// [`Poll::Ready`]; the track ending surfaces as the error the caller was going to
/// get anyway.
fn poll_drift(&self, waiter: &kio::Waiter) -> Poll<Result<Drift>> {
let latency = self.subscription.read().latency;

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 Wake pending frame reads when latency changes

When poll_read_frame is pending because all completed buffered candidates exceed the old budget while the newest group has no complete frame, widening the latency through SubscriberControl::update should make those candidates readable. This direct read() does not register the supplied waiter on the subscription channel, however, so the pending read remains asleep until unrelated track activity occurs even though the next poll would accept the frame. Poll the subscription with the waiter, or otherwise propagate its updates to the read waiter.

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: f46193e71f

ℹ️ 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/net/src/group.ts Outdated
Comment on lines +300 to +302
/** True once the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */
get isClosed(): boolean {
return this.#state.closed.peek() !== undefined;
return this.#expiration() !== undefined || this.#state.closed.peek() !== undefined;

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 Settle closed when a subscriber group expires

When a handed-out group becomes stale while its producer remains open, #expiration() makes isClosed true and causes frame reads to fail, but the public closed promise still points only at #state.closed and never settles. A consumer that uses await group.closed for lifecycle cleanup can therefore wait indefinitely after this handle has become terminal, contradicting the documented promise that isClosed complements. Represent expiry in the per-consumer closed signal as well. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L97-L99

Useful? React with 👍 / 👎.

Comment thread rs/moq-net/src/model/group.rs Outdated
Comment on lines +959 to +963
fn poll_expired(&mut self, waiter: &kio::Waiter) -> bool {
if !self.expired && self.expiry.as_ref().is_some_and(|expiry| expiry.is_expired(waiter)) {
self.expired = true;
self.stats.stale(self.content());
}

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 Count only unread content when a handed-out group expires

When a caller reads part of a group and that group later exceeds its latency budget, self.content() snapshots the entire shared cache, including frames and bytes already returned by this cursor. Those payloads have already incremented the delivered counters, so adding the full snapshot to stale over-reports skipped traffic and violates the documented guarantee that stale and delivered content are disjoint. Compute the stale amount from this consumer's remaining cursor, and ensure cloned consumers cannot count the same delivery repeatedly. (Written by GPT-5.6 Sol)

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: 5f36cf71e5

ℹ️ 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/net/src/track.ts Outdated
Comment on lines +697 to +698
const candidate = this.#state.timeline.get(group.sequence);
if (candidate?.group !== group) return false;

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 Expire handed-out mirrors before deleting their timeline entry

When publisher retention evicts a cleanly closed group that a subscriber already holds, #evict deletes this timeline entry and closes the mirror without an error, while its buffered frames remain readable. This check then treats the missing candidate as not stale, so the reader can continue draining content beyond both the publisher retention window and its clamped latency budget. Fresh evidence beyond the earlier handed-out-group report is this eviction path, which removes the data needed by the expiry guard before the next read; expire the mirror before removal or preserve a terminal expiry verdict. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L174-L177

Useful? React with 👍 / 👎.

Comment thread js/net/src/group.ts Outdated
await group;
return;
}
await Promise.race([group, this.#expiry.changed()]);

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 Cancel the losing latency-change waiter

When a reader consumes a long open group without newer groups arriving, each frame resolves group, but the losing this.#expiry.changed() promise remains subscribed because an outer Promise.race cannot cancel it. The next frame read creates another waiter, so streaming an indefinite catalog or data group accumulates track-signal callbacks linearly until some unrelated group, option, or track change finally releases them. Use one cancellable registration spanning both readiness sources, as Signal.race does internally. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L100-L100

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: 4870c58110

ℹ️ 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/net/src/track.ts Outdated
Comment on lines +703 to +706
const wallStale =
drift.wall !== undefined &&
drift.wall.sequence > group.sequence &&
drift.wall.time - candidate.time > drift.budget;

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 Treat a zero budget as immediately stale

When multiple groups are queued within the same millisecond and have equal timestamps, as commonly happens with synchronous writeString or writeJson calls, both calculated ages are zero, so these strict > drift.budget comparisons let every older group through under the default latencyMax: 0. A zero budget is documented to return only the latest group once any newer sequence exists; special-case zero rather than changing the positive-budget boundary, and make the corresponding change to the identical comparisons in rs/moq-net/src/model/track.rs:590-608. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L174-L177

Useful? React with 👍 / 👎.

Comment thread js/net/src/track.ts Outdated
Comment on lines 471 to 477
hooks.evaluateGroupExpiry(mirror);
sink.groups.mutate((groups) => {
const i = groups.indexOf(mirror);
if (i >= 0) groups.splice(i, 1);
});
if (sink.timeline.get(mirror.sequence)?.group === mirror) sink.timeline.delete(mirror.sequence);
mirror.close();

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 Abort held mirrors when pruning retention

When #prune is triggered by a new subscriber rather than a new publication, this evaluation can remain false even though the entry exceeded retention: for example, groups queued 10 ms apart with a 100 ms budget, followed by 200 ms of inactivity before another subscriber joins. Fresh evidence after the prior fix is that #addSink invokes #prune without adding a newer edge; the code then deletes the timeline entry and cleanly closes the mirror, whose buffered frames remain readable and whose future expiry checks return false because the candidate is gone. Thus a handed-out mirror can still pin and deliver data beyond retention, so eviction itself needs to abort or clear it independently of the drift verdict. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L174-L177

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: 7bdf908e35

ℹ️ 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/net/src/track.ts Outdated
): boolean {
const candidate = this.#state.timeline.get(group.sequence);
if (candidate?.group !== group) return false;

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 Use a monotonic clock for wall-clock drift

When the browser's system clock changes between publishing the candidate and live-edge groups, the timeline values captured with Date.now() make this subtraction either jump forward and discard fresh buffered content or go negative and let an unstamped stalled group bypass a positive latency budget. Rust uses a monotonic Instant for the matching behavior, so record these arrival times with performance.now()/Time.Milli.now() as well. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L172-L177

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: 5bea9b2934

ℹ️ 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 rs/moq-net/src/model/group.rs Outdated
Comment on lines +1119 to +1120
if self.poll_expired(waiter) {
return Poll::Ready(Err(Error::Old));

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 Propagate expiry into in-flight frame consumers

When a partial frame header is available before its payload finishes, poll_next_frame passes its frame::Consumer out after this single expiry check. If a newer group then advances the live edge, frame::Consumer::{poll_read_chunk,poll_read_all} watches only the group abort state, so a raw reader and the Lite/IETF GroupServe implementations, which store the frame and poll it directly, can remain blocked indefinitely and keep serving a stale stream despite REAL_TIME. Fresh evidence after the earlier handed-out-group fix is that expiry is now attached to group::Consumer but is lost at this frame handoff; carry it into the frame handle and mirror the cancellation in JS's current-frame send path.

AGENTS.md reference: AGENTS.md:L177-L177

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: 1b6ff09c9e

ℹ️ 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 on lines +2904 to +2906
if ready!(self.poll_stale(&group, drift, waiter))? {
self.stale.add(group.content());
continue;

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 latency budgets across IETF subscriptions

When a connection negotiates IETF moq-transport and the serving side has multiple groups available, this filtering consumes every group except the live edge even if the client requested a nonzero latency budget. The IETF publishers construct the model subscription with only priority (rs/moq-net/src/ietf/publisher.rs:541-546 and js/net/src/ietf/publisher.ts:143), because this protocol path does not transmit Subscription::latency; consequently the serving subscriber always uses the zero default and irreversibly discards backlog before the receiving subscriber can apply its actual budget. Use a non-dropping publisher-side fallback for protocols without this parameter, leaving the receiver to enforce its requested ceiling.

Useful? React with 👍 / 👎.

end: None,
current: None,
dead: None,
stale_stats: Default::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 Meter expiry during spliced frame reads

When a tagged spliced subscriber uses Subscriber::read_frame and its in-progress group expires after handoff, this meter remains untagged: resume::Subscriber::poll_read_frame keeps the group internally, so the outer track wrapper never calls set_stale_meter, then the resume reader swallows Error::Old and advances. The expiry therefore writes its unread tail into a no-op meter, while take_stale has nothing to drain, causing Traffic::stale to under-report this route-takeover/frame-level scenario. Pass the logical subscriber's meter into internally drained groups or return their expired content through the cursor accounting path.

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: 16c7cea4b9

ℹ️ 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 rs/moq-net/src/ietf/publisher.rs Outdated
fn serving_subscription(subscriber_priority: u8) -> Subscription {
Subscription {
priority: super::priority::from_wire(subscriber_priority),
latency: Latency::max(Duration::MAX),

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 Use a wire-encodable unlimited latency fallback

Fresh evidence after the earlier IETF finding is that the chosen non-dropping sentinel cannot be encoded when this IETF subscription is forwarded to a Lite upstream. Before the upstream track info is known, Duration::MAX reaches Duration::encode, whose millisecond conversion exceeds the 62-bit varint and fails the subscription; the matching Number.POSITIVE_INFINITY in js/net/src/ietf/publisher.ts fails at BigInt(v) for the same gateway flow. Use a finite wire-representable ceiling in both implementations so an IETF client served through a Lite route can subscribe successfully.

AGENTS.md reference: AGENTS.md:L174-L177

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: 4f06a6a13b

ℹ️ 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 on lines +513 to +514
if self.expired || self.read_idx >= self.size() {
return self.expired;

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 Keep checking expiry while the transport drains a frame

When a complete frame is handed to the Lite or IETF publisher, poll_read_chunk advances read_idx to the frame size, so this early return permanently disables its expiry policy. Both current serving loops then detach the returned Bytes into GroupState::Serve::chunk and can remain blocked in writer.poll_write (lite/publisher.rs:2970-2979, ietf/publisher.rs:1566-1575) without polling either the frame or group again. Under congestion, a newer group can therefore make this stream stale while the already-read payload continues consuming flow control indefinitely, so the latency budget still needs to guard the transport-owned chunk until it drains. This is fresh evidence beyond the earlier in-flight-frame finding because the new frame guard is present, but expiry is lost at the subsequent frame-to-chunk handoff. (Written by GPT-5.6 Sol)

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: a8738599e2

ℹ️ 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 rs/moq-net/src/lite/publisher.rs Outdated
Comment on lines +2960 to +2962
// Keep the group guard live while the transport owns a detached
// frame chunk and may be blocked on flow control.
if self.group.poll_expired(waiter) {

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 Start expiry checks before opening the group stream

When unidirectional stream credit is exhausted, a group remains in GroupState::Open at lines 2908-2916 and registers only the transport's open-stream waker, so it never reaches this expiry check and newer groups cannot wake it. Both the Lite and IETF Rust publishers can therefore accumulate expired group machines indefinitely and spend later stream credit opening and resetting stale groups ahead of live ones. Poll expiry before poll_open_uni as well as while serving. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread js/net/src/ietf/publisher.ts Outdated

const obj = new Frame({ payload: frame.payload, timestamp: frame.timestamp });
await obj.encode(stream, header.flags, timescale, this.#session.version);
await hooks.guardGroup(group, obj.encode(stream, header.flags, timescale, this.#session.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.

P1 Badge Guard the group header against expiry

When flow control blocks header.encode at line 263, this guard has not been installed yet, so a newer group can make this one stale while its stream remains blocked indefinitely instead of being reset. The Lite publisher has the same pre-guard gap around its stream type and GroupMessage writes. Guard the entire per-group serialization operation, including its header, rather than only object/frame encoding. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment on lines +486 to +487
let subscription = self.track.subscription().with_latency(latency);
let _ = self.track.update(subscription);

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 Put the example latency on the initial subscription

When a caller follows rs/hang/examples/subscribe.rs:76-82 against a remote Lite publisher, the initial subscription still carries only priority and therefore uses the zero latency budget; this update happens only after SUBSCRIBE_OK, when retained groups may already have been skipped irreversibly. Fresh evidence after the earlier initial-subscription fixes is that this shipped example still uses the late-only path despite advertising a 500 ms tolerance. Include that latency in the example's initial Subscription. (Written by GPT-5.6 Sol)

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: af78893073

ℹ️ 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".

fn live_edge(&self, cap: Option<u64>) -> Option<Edge> {
let mut wall: Option<WallEdge> = None;
let mut presentation: Option<PresentationEdge> = None;
for slot in self.lookup.values() {

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 Exclude fetch-only groups from the live edge

When a concurrent FETCH inserts a higher sequence through insert_group_request, that group is deliberately absent from arrival, but this scan still selects it from lookup. With the default zero budget, it immediately makes every lower visible group stale, so recv_group and read_frame can irreversibly discard their live backlog even though those cursors can never receive the fetched group as a replacement. Build their drift edge only from visible arrival entries, or otherwise track visibility on each slot. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L100-L100

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: c773fe92f1

ℹ️ 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/net/src/ietf/publisher.ts Outdated
});

await header.encode(stream, this.#session.version);
await hooks.guardGroup(group, header.encode(stream, this.#session.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.

P1 Badge Reset streams when guarded header writes expire

When flow control stalls header.encode and a newer group causes this group to expire, guardGroup rejects here, but this await sits outside the try that resets stream. Fresh evidence after the earlier header finding is that the added guard only races the passed encoding promise and cannot cancel it, so control reaches finally and closes only the model group while the underlying unidirectional stream may later finish its partial header without ever being FINed or reset, retaining stream credit under congestion. Put the guarded header write inside the reset-catching scope. (Written by GPT-5.6 Sol)

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: d820fc1fba

ℹ️ 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 rs/moq-net/src/stats.rs
Comment on lines +321 to +325
/// Content skipped because it drifted past a subscriber's
/// [`Latency`](crate::Latency) budget. Disjoint from the top-level payload
/// counters: skipped content is never handed over. A steady rate here means
/// subscribers are consistently behind the live edge.
pub stale: Content,

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 Expose stale counters through Prometheus

When operators monitor a relay through /metrics, the new skipped-content totals are invisible: rs/moq-relay/src/internal.rs::render_metrics still exports only the original Traffic payload fields and never emits stale.bytes, stale.frames, stale.groups, or stale.datagrams. This prevents Prometheus users from detecting the sustained behind-live-edge condition that this field is intended to reveal, even though the JSON stats tracks expose it. Add corresponding moq_relay_stale_*_total counters to the renderer. (Written by GPT-5.6 Sol)

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: fd934a30ba

ℹ️ 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 rs/moq-net/src/model/group.rs Outdated
Comment on lines +1120 to +1121
if self.poll_expired(waiter) {
return Poll::Ready(Err(Error::Old));

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 expire a group after its final frame

When a returned group has already delivered its final frame, a newer group can advance the live edge before the caller polls once more for EOF, so this unconditional check returns Error::Old instead of Ok(None). The Lite and IETF serving loops can hit the same race after draining the last payload and reset an otherwise complete stream, while the matching JS #terminalError path rejects normal group-drain loops. Apply expiry only while unread or unfinished content remains, and mirror that terminal-state check in js/net. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L174-L178

Useful? React with 👍 / 👎.

Comment on lines +486 to +487
let subscription = self.track.subscription().with_latency(latency);
let _ = self.track.update(subscription);

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 Put builder latency on the initial subscription

When an external caller follows the public Consumer::new(track, format).with_latency(...) API after awaiting a remote Lite subscription, this update is too late: the publisher has already served the initial default REAL_TIME request and may have irreversibly skipped the retained backlog. Fresh evidence after the internal decoder and exporter fixes is that the public consumer documentation still directs callers to this builder for startup latency, so consumers outside this repository remain broken. Reshape the high-level entry point so its latency is available when the subscription is opened rather than relying on this post-accept update. (Written by GPT-5.6 Sol)

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

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: d97558dac8

ℹ️ 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 rs/moq-net/src/model/track.rs Outdated
Comment on lines +2668 to +2672
if let Some(edge) = edge.and_then(|edge| edge.presentation)
&& let Some(slot) = state.lookup.get(&edge.sequence)
{
let _ = slot.group.poll_timestamp(waiter);
}

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 Wake expiry when a newer group gains its first timestamp

When a handed-out group is stalled between frames and a higher-sequence group already exists without a timestamp, this registers only the stalled candidate and the current presentation edge. If the higher group later receives its first timestamp and that timestamp pushes the candidate beyond a positive latency budget, only the higher group's channel is notified, so the stalled reader never rechecks expiry and can remain blocked until unrelated track activity occurs. Propagate first-timestamp changes through the track state or register on every unstamped group that could become the presentation edge. (Written by GPT-5.6 Sol)

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: c5b2e2f311

ℹ️ 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 rs/moq-net/src/lite/publisher.rs Outdated
let outcome = 'serve: {
// Keep the group guard live while the transport owns a detached
// frame chunk and may be blocked on flow control.
if self.group.poll_expired(waiter) {

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 Keep expiry armed while draining the final frame

When a completed group's final frame has been handed to the writer and flow control blocks its detached chunk, this guard cannot expire it: advancing the group cursor made Plain::expiry_pending false because index == fin, and the frame consumer is not polled again while chunk is present. A newer group can therefore make this stream stale while the final payload remains blocked and consumes flow control indefinitely; the identical guard in ietf/publisher.rs has the same gap. Fresh evidence after the earlier transport-drain report is that the newly added group-level guard explicitly disables itself for this common final-frame state, so the transport-owned chunk needs an expiry check independent of the group's unread-frame cursor.

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: 69eeafc98c

ℹ️ 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/net/src/group.ts Outdated
Comment on lines +315 to +316
this.#terminal = new Lagged();
if (this.#closed.peek() === undefined) this.#closed.set(this.#terminal);

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 Preserve clean EOF when evicting drained mirrors

When retention pruning reaches a cleanly closed mirror after its caller has already drained every frame, this unconditionally replaces the group's clean terminal state with Lagged. A later readFrame() therefore throws after it previously returned EOF, and a caller that first accesses closed after eviction observes an error instead of null. Fresh evidence beyond the earlier latency-expiry report is that #terminalError now preserves drained completion, but this separate retention-eviction path still does not; only mark the mirror lagged when buffered unread frames are actually discarded. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L97-L99

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: 0673f87f0e

ℹ️ 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 on lines +1209 to +1210
if self.expired {
return Poll::Ready(Err(Error::Old));

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 Keep clean EOF sticky after latency expiry

When an open group has yielded every currently available frame but has not received FIN, a newer group can make its pending read expire; the Some(false) branch returns Ok(None), but poll_expired_while_pending has already set self.expired, so the next poll takes this early return and produces Error::Old. Callers that probe again after EOF or alternate between read_frame, next_frame, and finished therefore see a clean completion turn into an error. Fresh evidence after the earlier final-frame fix is that the clean branch was added without recording a sticky clean-terminal state. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L100-L100

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: 5340a26d29

ℹ️ 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".

.lookup
.get(&edge.wall.sequence)
.is_some_and(|live| live.stamp == edge.wall.stamp && !live.group.is_aborted())
&& (budget.is_zero()

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 Use a non-dropping fallback for Lite01/02

When a connection negotiates Lite01 or Lite02, lite::Subscribe::decode_msg substitutes Duration::ZERO because those versions cannot carry latency_max, and lite/publisher.rs passes that value into the model subscription. This new zero-budget branch then discards every buffered group except the live edge, even when the receiving subscriber requested a nonzero budget locally, so legacy peers irreversibly lose backlog before receiver-side enforcement. Preserve the pre-enforcement, non-dropping behavior for protocol versions without this field, as is already done for IETF subscriptions. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

kixelated and others added 23 commits August 18, 2026 17:32
Post-handoff expiry judged every read, which cost two things.

Semantics: under the default real-time budget, the arrival of the next
group is exactly what makes the current one no longer newest, so a
reader draining a buffered group lost its tail every time. That is not
what the budget is for. It bounds a group that has *stalled* while the
live edge moved on, so judge only a read that found nothing and is about
to park. A group with frames in hand now always drains to its end.

Cost: evaluating the policy walks the track's group cache under the
track lock, which every subscriber of that track shares, and it also
disabled the prefetch fast path in `read_frame`. Reading 20k frames with
50 groups cached went from 13ns/frame to 998ns/frame. Off the ready path
it is back to 14ns.

The wire publishers keep the check where the group cursor cannot see the
stall: parking on transport flow control, so a blocked stream cannot pin
credit for content that has gone stale. Everything else they park on
(the next frame, the current frame's tail) now reports expiry itself, so
the per-pass check and `Writer::has_pending` are gone.

Also stop pinning track demand. `GroupExpiry` held a `kio::Consumer` of
the track state, which joins the consumer count that `broadcast::Demand`
reads, so a handed-out group kept an upstream subscription open past the
last real subscriber. A relay serves one for the life of its stream.
It holds a `ConsumerWeak` now, which needed a `poll` on that type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a group boundary got wrong.

A group was measured against the live edge from its *first* frame, so a
2s GOP read live always looked 2s behind, however current its reader
was. Any budget shorter than one GOP therefore dropped the tail of
every GOP, which is why the fixtures here wanted 30-second windows. A
group is not late because it opened long ago; what can be late is the
content its reader has yet to take. Both measures now start from the
cursor: the next frame it would read (or the group's newest, once it
has taken them all) for presentation time, and the group's last write
for wall-clock time. A group nobody has started reading still sits at
its own start, so group selection is unchanged.

And expiry always reported `Error::Old`. A group-level read only parks
once the cursor has taken every frame, so expiring there loses nothing:
what it was waiting for was the producer's FIN, and a group abandoned
at its own end is indistinguishable from one that ended there. It now
ends cleanly, and `Old` is reserved for a cursor that still holds
unread content (a half-read payload, a publisher with buffered frames).
That matters most at a relay, which was resetting the downstream stream
for a group it had delivered in full: the FIN and the next group's first
frame are separate streams, so the reader is routinely parked at the
group's end when the verdict is taken.

Both regressions are covered, and each new test fails with only its own
half reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`TrackRun` outgrew the other variants once it started carrying the group
expiry guards, and the enum is moved on every state transition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An API diff shows nothing when the change is which content gets
delivered, or whether a stream is reset rather than FIN'd. That is still
something another implementation has to match, so it belongs in the
description and in a review's checklist alongside the public API list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`with_test_latency` was a `#[cfg(test)]` method on the public Consumer
that only called `set_latency` and returned self. It existed to keep the
old `with_latency` chaining style at the call sites after that method was
removed, which meant this PR deleted a public builder and then quietly
re-added it for its own use.

Worse, it made the tests skip the API the removal was in aid of. The
point of `Consumer::new` inheriting the subscription's budget is that a
caller sets latency on the `Subscription` before subscribing; every test
was instead subscribing with the default and patching the consumer
afterwards, so nothing but `new_inherits_the_initial_subscription_latency`
exercised the real path. They now all do.

`container_latency_only` still gives the two layers different budgets on
purpose, so it keeps the explicit `set_latency` plus a `control.update`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`subscribe_all` said nothing about what "all" meant, read like a
subscribe-to-every-track operation, and covered two different signatures
in two files. It is a replay window: these tests write every group up
front and only then read, so they have to ask for history rather than
the live edge.

Renamed to `replay()`, returning a `Subscription`, which is the shape
moq-net and moq-bench already use for the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SI subscription added by #2909 uses the default budget, which this
branch now enforces: real-time takes only the newest group, so a track
carrying table snapshots at a low rate would lose the revisions between
them. SI needs the same window as the media it describes, which the
export already threads to every other source.

The test that repoints an SI entry writes both GOPs up front and only
then reads, so it asks for that window too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/latency-enforcement branch from 0a4b614 to a73c5d7 Compare August 19, 2026 04:24

@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: a73c5d75f4

ℹ️ 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".

loop {
match &mut self.state {
GroupState::Open => {
if self.group.poll_expired(waiter) {

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 Expire empty groups while waiting for stream credit

When a finished empty group, such as the discontinuity markers produced by moq-mux, is handed out before stream credit is available, this call never evaluates its expiry policy because Plain::expiry_pending is false at index == fin == 0. The state machine therefore registers only on poll_open_uni; newer groups cannot wake or discard it, and later credit still opens the obsolete marker's stream. Fresh evidence after the earlier stream-credit report is that the added guard calls the non-forcing variant; use poll_expired_while_pending(waiter, true) here and in the IETF equivalent. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L100-L100

Useful? React with 👍 / 👎.

kixelated and others added 2 commits August 18, 2026 21:30
The clean-end branch reported `Ok(None)` but left only `expired` set, so
the next poll took the failure guard and turned the same group into
`Error::Old`. A caller is allowed to probe past the end of a group, and
`moq_mux::container::Consumer` does exactly that when it follows a read
with `poll_finished`.

The outcome is now sticky in its own right, as it already was in the
JS mirror.

Found by the Codex reviewer on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lite01/02 have no latency field in SUBSCRIBE, so `decode_msg` substitutes
`Duration::ZERO`. That is indistinguishable from a peer asking for real
time, and this branch now acts on it: the serving side discards every
group but the live edge for a subscriber that never declined the backlog.

Those two are `NEGOTIATED[0]` and `[1]`, so this is the ordinary wire for
current moq-lite peers rather than a legacy corner.

They now get a window wide enough not to drop, leaving enforcement to the
receiving subscriber, which is what the IETF path already does for the
same reason. `Version::carries_latency` is the one place that answers
whether a decoded zero means "real time" or "not stated".

Found by the Codex reviewer on the PR.

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

Copy link
Copy Markdown
Collaborator Author

Swept the open review threads before merging.

@kixelated's five are all addressed by later commits; GitHub still shows them unresolved but outdated:

comment status
stale should be split into {bytes, frames, groups, datagrams} done; existing counters left at the top level for backwards compatibility as asked
3600s in moq-cli/publish.rs gone
3600s in codec/h264/export.rs gone
BATCH name in fmp4/export_test.rs gone
consumer_only confusing name renamed container_latency_only, with a doc comment on why the two layers differ there

Noted on the last one that the eventual plan is to move the ordered container consumer into moq-net once lite-05 lands, so moq-net skips on the transport timestamp rather than the container's. Nothing in this PR forecloses that.

Two Codex findings were real and are now fixed, each with a regression test verified to fail without it:

  • group.rs — a group the budget ended (drained, nothing lost) reported Ok(None) but left only expired set, so the next poll turned the same group into Error::Old. moq_mux::container::Consumer probes exactly that way, following a read with poll_finished. The clean outcome is sticky now, as it already was in the JS mirror.
  • lite/publisher.rs — Lite01/02 have no latency field, so SUBSCRIBE decodes as Duration::ZERO, which is indistinguishable from a peer asking for real time. This branch acted on it and served those peers the live edge only. Those two are NEGOTIATED[0] and [1], so it was the ordinary wire for current peers, not a legacy corner. They now get a non-dropping window with enforcement left to the receiver, matching what the IETF path already did. Mirrored in the JS PR.

One more found while rebasing: #2909 landed SiTrack on dev after this PR was written, and it subscribes with the default budget. SI carries table snapshots at a low rate, so real-time would drop the revisions between them; it now takes the export's replay window like every other source.

The remaining Codex threads are stale against later commits (initial-subscription latency, resume replacement copies, spliced stale metering, Prometheus counters, JS enforcement). JS enforcement lives in #2926.

(Written by Opus 5)

@kixelated
kixelated merged commit 4365fca into dev Aug 19, 2026
3 checks passed
@kixelated
kixelated deleted the claude/latency-enforcement branch August 19, 2026 05:02
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