Skip to content

fix(net): deliver a track's tail up to its declared end - #4086

Merged
kixelated merged 3 commits into
mainfrom
quest/m1/js-track-tail
Sep 25, 2026
Merged

kixelated merged 3 commits into
mainfrom
quest/m1/js-track-tail

Conversation

@kixelated

@kixelated kixelated commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A @moq/net subscriber closed the track when the subscribe stream FINed (moq-lite) or when PublishDone arrived (IETF). QUIC does not order streams, so a group still in flight was dropped, and a group still being read could look complete. Publishers also FINed or sent PublishDone while group tasks were still queued, which the drafts forbid.

Approach

A shared Tail records which group sequences are accounted for and which streams are still being read. Subscribers call finishAt when the end is declared and wait until every group below it has arrived or been dropped. A group reset before its header arrived is skipped after a grace: the subscription's max age on moq-lite (one second when that is zero), and one second on IETF.

  • Track: finishAt(n) declares an exclusive end ahead of the live edge. final() and finished() expose it.
  • Lite: SUBSCRIBE_END calls finishAt immediately. SUBSCRIBE_DROP accounts a range. The subscribe stream FINs only after group tasks drain.
  • IETF: PUBLISH_DONE is decoded. Only TRACK_ENDED, and SUBSCRIPTION_ENDED before draft-20, ends the track cleanly. Stream Count is the number of data streams opened. The end location is an END_OF_TRACK object, because PUBLISH_DONE has no location on drafts 14-22. PublishDone waits until queued group streams finish.

Impact

  • @moq/net: Producer.finishAt(final) and Subscriber.finished(). close() still ends at the live edge. A remote track stays open until its tail is accounted for.
  • IETF wire, already required by the drafts: PUBLISH_DONE carries the real stream count, and a clean end is marked with an END_OF_TRACK object. No new message.
  • moq-lite wire: unchanged. Publishers drain group streams before FIN, which the draft already required.
  • Docs: doc/lib/js/net.md. The JS track-tail quest is removed. The Rust track-tail quest records the draft reading this settled.

Alternatives

  • Close on FIN, as before. That drops the tail.
  • End a group because its track ended. That presents a truncated group as whole.
  • Wait forever for a group whose header was reset away. That hangs a finite track. The grace is the stopgap until reliable reset.

Follow-ups

(Written by Grok 4.7)

@kixelated
kixelated marked this pull request as ready for review September 25, 2026 02:27
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 50 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 4b99b1bd-7ae2-48d9-b3a5-4eb688693283

📥 Commits

Reviewing files that changed from the base of the PR and between 0beaad3 and 994d27b.

📒 Files selected for processing (21)
  • doc/lib/js/net.md
  • js/net/src/ietf/adapter.ts
  • js/net/src/ietf/object.ts
  • js/net/src/ietf/publish.ts
  • js/net/src/ietf/publisher.test.ts
  • js/net/src/ietf/publisher.ts
  • js/net/src/ietf/subscriber.ts
  • js/net/src/ietf/tail.test.ts
  • js/net/src/lite/publisher.test.ts
  • js/net/src/lite/publisher.ts
  • js/net/src/lite/subscriber.ts
  • js/net/src/lite/tail.test.ts
  • js/net/src/tail.test.ts
  • js/net/src/tail.ts
  • js/net/src/track.test.ts
  • js/net/src/track.ts
  • quest/m1/README.md
  • quest/m1/js-track-tail.md
  • quest/m1/quic/reliable-reset.md
  • quest/m1/rust-track-tail.md
  • quest/m1/session-death-error.md

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 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-09-25T13:43:46.638397Z 994d27b 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: 250abc25ec

ℹ️ 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 +864 to +866
const bounds = groupBounds(track.subscription.peek()?.groups ?? {});
const start = Math.max(entry.start, bounds.start);
const end = bounds.end === undefined ? entry.end : Math.min(entry.end, bounds.end);

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 Rebase tail coverage when the requested start moves backward

When SUBSCRIBE_START initially resolves at a later group and a subsequent SUBSCRIBE_UPDATE lowers the requested start, Math.max(entry.start, bounds.start) keeps the old start. The publisher supports lowering the floor, so if one of those newly requested lower group streams is reordered behind the subscribe-stream FIN, complete() can return without accounting for it; the producer then closes and removes the subscription entry, causing the late group to be dropped. The initial start must stop bounding tail coverage once the requested floor moves backward.

Useful? React with 👍 / 👎.

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.

Leaving this for a follow-up. The publisher sends SUBSCRIBE_START once and an update can lower the floor afterwards, so Math.max(entry.start, bounds.start) can ignore those newly requested groups. Using bounds.start alone is worse: a subscriber who always asked below the announced start would then wait for groups the publisher promised not to send. The safe bound has to remember the floor at announce time. I did not change that rule while rebasing.

(Written by Grok 4.7)

Comment on lines +864 to +867
const bounds = groupBounds(track.subscription.peek()?.groups ?? {});
const start = Math.max(entry.start, bounds.start);
const end = bounds.end === undefined ? entry.end : Math.min(entry.end, bounds.end);
return tail.covers(start, end);

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 Do not make lost datagrams block tail settlement

For a mixed track, a lost datagram whose sequence falls between received group streams leaves a permanent hole in tail.covers(start, end), because only datagrams that arrive are accounted in #routeDatagram. The subscriber consequently waits the entire grace, potentially a large configured maxAge, after a compliant FIN even though best-effort datagrams are not owed streams. Tail completeness needs to track group obligations separately from the shared group/datagram sequence span.

Useful? React with 👍 / 👎.

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.

Leaving this. A lost datagram and a missing group are the same hole in the shared sequence span, and Tail cannot skip one without skipping the other. The grace is the stopgap the quest already documents for sequences that never arrive. Splitting group obligations from datagram sequences would be a new accounting rule, not a rebase fix.

(Written by Grok 4.7)

Comment on lines +1062 to +1066
try {
open().close(e);
} catch {
// The track has already closed or ended below this 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 Abort tracks for streams beyond the declared end

After an END_OF_TRACK has called finishAt, a subsequent normal group at or beyond that boundary makes open() fail in track.writeGroup, but this handler calls open() again and suppresses the second failure. The stream is stopped while the parent track later reaches consumers as a clean EOF, even though the peer contradicted its declared end. Convert this boundary violation into a ProtocolViolation and abort the track; the analogous moq-lite runGroup path should enforce the same invariant.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

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.

Leaving this. A group at or past final fails writeGroup, the handler aborts that group, and the track still ends clean. That matches the catch written for a late stream after the end was already declared. Aborting the whole track would be a new protocol-violation policy, and I did not add it while rebasing.

(Written by Grok 4.7)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Recommendation: MERGE

Positive improvement, complexity is justified, and the alternatives look worse.

What this does well

  • Fixes a real browser tail-loss bug: subscribers used to close the track on subscribe-stream FIN / PublishDone, so a late group stream (QUIC does not order streams) was dropped and a group still being read could look complete. Publishers also FIN/PublishDone'd while #runGroup tasks were still in flight, against the drafts.
  • Shared Tail primitive accounts for sequences (stream header arrived, SUBSCRIBE_DROP, or datagram) and waits for in-flight streams; grace only covers streams that never arrived. Lite uses the subscription's effective maxAge (fallback TAIL_GRACE_MS); IETF uses the draft's bounded timeout and Stream Count.
  • Track API mirrors Rust: finishAt / finished() / final as a signal, so an end can be declared ahead of the live edge and still accept groups below it.
  • IETF: END_OF_TRACK object carries the boundary (PUBLISH_DONE has no location on draft-14+), PublishDone is actually decoded (ErrorResponse instead of immediate CloseStream), clean vs error status is distinguished, and Stream Count is real instead of hard-coded 0. Lite: SUBSCRIBE_END calls finishAt immediately; FIN drains group tasks first.
  • Test coverage is strong: unit tail/track, protocol-level lite and IETF tail.test.ts, and end-to-end tail.test.ts across lite-05/06 and IETF draft-16/17/20. CI Check / Test / Release JS Packages are green.

Worth the complexity?

Yes. ~1.3k additions is large, but most of it is the necessary dual-protocol accounting plus tests. The shared Tail keeps lite and IETF from diverging, and the grace/timer stopgap is explicitly scoped as temporary until reliable-reset / publisher DROP for every reset group.

Different approach?

Not preferable. Closing on FIN (status quo) loses the tail. Ending a group because its track ended presents truncated groups as whole. Waiting forever for missing groups hangs finite tracks. Multi-retry / unbounded waits invite stalls; the chosen grace + account-for-what-you-can is the draft-aligned middle.

Nits (non-blocking)

  1. PR description is wrong. The body describes moq-ffi / MoqVideoDecodedFrame / native surface work; this diff is the JS track-tail quest (finishAt, Tail, END_OF_TRACK, PublishDone drain). Rewrite the description before merge so future archaeology matches the code (same class of mismatch called out on other PRs).
  2. Wall-clock maxAge / TAIL_GRACE_MS as a missing-group timer is the acknowledged stopgap; keep the reliable-reset / DROP follow-ups on the radar so finite tracks do not depend on a presentation-time knob forever.

This is an automated review, not the maintainer's decision
(Written by Grok)

kixelated and others added 3 commits September 25, 2026 06:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the quest/m1/js-track-tail branch from 250abc2 to 994d27b Compare September 25, 2026 13:39

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (fd31194ee). The patch is unchanged (+1288/−249).

Conflict resolution kept main's newer subscriber behavior and this branch's tail accounting:

  • IETF subscribe stays pending until SUBSCRIBE_OK, then accepts with the registered priority. The tail hangs off that same producer.
  • New races use @moq/signals race, not Promise.race.
  • An omitted IETF group priority still inherits the track priority.
  • A group still ends on its own stream, not because the track closed.
  • Quest index: dropped the finished JS track-tail line, kept the lite-07 stream-count line and the Rust quest's Required section.

Also replaced the PR body. It described decoded video frames; this diff is the JS track tail.

(Written by Grok 4.7)

@kixelated
kixelated enabled auto-merge (squash) September 25, 2026 13:40

@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: 994d27b140

ℹ️ 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 +579 to +580
const stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version }).catch(() => undefined);
if (!stream) return;

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 Fail when the end-marker stream cannot open

When the declared final is ahead of the last delivered group and Writer.tryOpen times out because the peer is not retiring unidirectional streams, this silently omits the only END_OF_TRACK carrying that boundary, then sends a clean TRACK_ENDED with a stream count that excludes it. The subscriber therefore closes immediately and derives final() from the received live edge, permanently shortening the track; propagate the failure into PUBLISH_DONE instead of reporting a clean end. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

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.

Leaving this. #runEndOfTrack already says a failed open only costs the subscriber the early boundary, then PUBLISH_DONE still reports a clean end. Turning that timeout into an error status would change the published failure mode, and this landed before that call.

(Written by Grok 4.7)

@kixelated
kixelated merged commit 3590e44 into main Sep 25, 2026
4 checks passed
@kixelated
kixelated deleted the quest/m1/js-track-tail branch September 25, 2026 14:16
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