Skip to content

feat(js/net): enforce the subscriber latency budget - #2926

Open
kixelated wants to merge 3 commits into
devfrom
claude/latency-enforcement-js
Open

feat(js/net): enforce the subscriber latency budget#2926
kixelated wants to merge 3 commits into
devfrom
claude/latency-enforcement-js

Conversation

@kixelated

@kixelated kixelated commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #2890, which did the same work in rs/moq-net and has merged. Rebased onto dev, so this now carries only the js/ half.

Replaces #2919, which GitHub auto-closed as merged when a mistaken force-push briefly put these commits onto the base branch. Nothing was merged into dev; the base branch has since been corrected and carries no js/ changes.

Summary

js/net carried latencyMax as metadata and never acted on it. It now applies the same drift budget as it reads, and js/watch requests the playback latency ceiling from its first subscription instead of only after acceptance.

  • Drift is measured against both a presentation-time and a wall-clock live edge; either age can expire a group. The arrival clock backstops a stalled or empty group with no timestamp, and uses performance.now() so it does not react to system-clock changes.
  • Drift is measured from the reader's position, not the group's first frame. A reader that has drained a group sits at its newest frame, so a 2s GOP is no longer 2s behind by construction. A group nobody has started reading still sits at its own start, which leaves group selection unchanged.
  • Only a read that would wait 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. This also keeps the policy off the read path, where it would otherwise rescan the timeline and attach a signal subscription per operation.
  • A drained group the budget gives up on ends rather than fails, since giving up where the reader already stands loses nothing. The latency error is reserved for a caller still holding content: a publisher part-way through writing a frame, or retention eviction discarding frames nobody read (which reports the budget as the more specific reason, falling back to Lagged).
  • fetchGroup stays exempt: a fetch names historical content explicitly, so there is no live edge to be late against.

Public API changes

None. Enforcement and the watch subscription plumbing use package-internal helpers (hooks in internal.ts), so no exported surface moved.

Wire behavior changes

None on the encoding. What changes is what a local subscriber is handed: a group that has drifted past its own latencyMax is skipped rather than delivered, and one that stalls after handoff ends (or fails, if content was unread) instead of waiting indefinitely. The publisher side resets a group stream whose encode or write is blocked as the group expires, which it previously would not have done. 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.

Cross-package sync

Mirrors rs/moq-net in #2890. No draft change: the wire format and parameters are unchanged, and the existing draft already specifies the two age backstops.

Test plan

  • nix develop --command just check
  • nix develop --command just test: 3258 Rust tests pass, 2 skipped; all JS suites green, including 517 @moq/net and 147 @moq/watch.
  • Two regression tests 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 group's close landing after its successor's first frame.
    • a budget is measured from the reader's position — a straggler frame arriving after the next group opened still reaches a reader that is inside its budget.

(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: cdde14f60e

ℹ️ 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
Comment on lines 282 to +284
get closed(): GetPromise<Error | null> {
return this.#state.closed;
this.#watchClosed();
return this.#closed;

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 synchronous closed peeks

If consumer.closed is accessed while the group is open and the producer then closes it in the same turn, this wrapper remains pending until #runClosed() resumes from Signal.race in a later microtask. Consequently, the previously obtained handle reports closed.peek() === undefined even though the group is already closed, breaking the documented synchronous terminal-state check and allowing callers to proceed as if it were open. (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: 3b4eee0f9d

ℹ️ 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
Comment on lines +306 to +307
presentation: this.#state.frames.peek()[0]?.timestamp ?? this.#state.latest,
activity: this.#state.activity,

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 Measure guarded writes at the frame being written

With a multi-frame group, readFrame*() removes the current frame before both lite/publisher.ts#runGroup and ietf/publisher.ts#runGroup guard its potentially blocked encode/write. This position therefore uses the next buffered frame's timestamp, or the group's latest timestamp/activity, rather than the frame still being transmitted. Under flow control, an old payload can consequently appear inside the latency budget and remain blocked for up to the rest of the group's timeline, violating the subscriber's latency ceiling; preserve the removed frame's position for the guarded operation. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Base automatically changed from claude/latency-enforcement to dev August 19, 2026 05:02
kixelated and others added 3 commits August 18, 2026 22:02
The JS mirror of the moq-net work in the parent PR: js/net applies the
same drift budget as it reads, and js/watch requests the playback
latency ceiling from its first subscription.

Drift is measured against both a presentation-time and a wall-clock live
edge, from the reader's own position rather than the group's first frame,
so a reader keeping pace is not convicted by how long ago its group
opened. A group with frames in hand always drains; only a read that would
wait is judged, which is also what keeps the policy off the read path. A
drained group the budget gives up on ends rather than fails, since
nothing was lost; the latency error is reserved for a caller still
holding content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the moq-mux rename in the parent PR: `subscribeAll` said nothing
about what "all" meant and read like a subscribe-to-every-track
operation. It is a replay window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the moq-net fix in the parent PR. Drafts 01 and 02 have no
latency field in SUBSCRIBE, so it decodes as `0`, which this branch would
otherwise act on: the serving side discards every group but the live edge
for a subscriber that never declined the backlog. Those two are the most
preferred negotiated versions, so it is the ordinary wire.

`carriesLatency` is the one place that answers whether a decoded zero
means "real time" or "not stated".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/latency-enforcement-js branch from 3b4eee0 to 4bc781c Compare August 19, 2026 05:16

@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: 4bc781c3f4

ℹ️ 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
Comment on lines +735 to +740
changed: [
this.#state.groups,
this.#state.timelineChanged,
this.#state.update,
this.#cursor,
this.#state.closed,

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 guarded groups when track info is committed

If a subscriber receives and begins waiting on a group before Producer.accept() commits track info, #drift() initially uses the requested latency without the publisher retention clamp. A later accept({ latencyMax: ... }) updates state.info, but this dependency list does not observe that signal, so a pending read or guarded write can remain blocked indefinitely even when the newly known retention window makes it stale. Include this.#state.info among the expiry change sources.

Useful? React with 👍 / 👎.

Comment thread js/net/src/track.ts
Comment on lines +685 to +691
const requested = this.#state.update.peek()?.latencyMax ?? 0;
const retained = this.#state.info.peek()?.latencyMax;
return {
budget: this.#enforceLatency
? retained === undefined
? requested
: Math.min(requested, retained)

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 Clamp the producer aggregate to the retention window

When a track retains 2 seconds but a subscriber requests 10 seconds, this clamps only that subscriber's local reads; Producer.#updateSubscription() still exposes the unbounded result of combineSubscriptions() as 10 seconds. A relay or wire publisher therefore asks its upstream to serve content that this hop will immediately discard, and applications observing Request.subscription receive a budget the producer cannot honor. Clamp the aggregate against Info.latencyMax while preserving each subscriber's own requested value, matching the Rust implementation this JS counterpart is intended to track.

AGENTS.md reference: AGENTS.md:L176-L181

Useful? React with 👍 / 👎.

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