Skip to content

fix(net): finish legacy tracks whose FIN races a graceful close - #6969

Merged
kixelated merged 7 commits into
mainfrom
quest/m1/broadcast-lifecycle/js-close-fin
Oct 28, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m1/broadcast-lifecycle/js-close-fin

Conversation

@kixelated

@kixelated kixelated commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Before lite-05, the subscribe stream's FIN is the only signal that a track ended. When a peer gracefully closed a session right after finishing its tracks, a JS receiver on lite-01 through lite-04 delivered the final group and then failed the track with SessionCode.Cancel instead of finishing it. Two causes:

  • #serveSubscribe released the copy after a FIN without declaring an end, so the reader never saw the track finish, even on a session that stayed open.
  • The driver called Subscriber.close(err) as soon as the transport closed. That aborted every subscription, including ones whose publisher had already ended them, and it could run before the group streams and subscribe responses the closed transport had already delivered were read.

Approach

Mirror moq-net (SubscriptionCleanup and poll_drain, moq-dev/moq#6741):

  • Every version reads its subscribe stream's responses to the FIN in runResponses, rather than awaiting reader.closed: a pull-driven QMux stream closes only on a read. Before lite-05 that FIN marks the subscription finished, a lite-03/04 SUBSCRIBE_DROP is applied as Rust does, and a second SUBSCRIBE_OK is a protocol violation.
  • A legacy FIN marks the subscription finished. The track finishes once the tail settles, and Subscriber.close finishes a finished track before ending it, whether the close was clean or an error.
  • A closing Connection first closes the transport, which bounds every read. It then drains the unidirectional streams still being read and each subscribe stream's responses, and only then ends the subscriptions. The bound comes from the closed transport, not from a timer.

QMux keeps a finished stream's unread bytes across a CONNECTION_CLOSE, so the drain matters on a real transport, not only in tests.

Tests

  • The existing two-Connection final-group test now asserts a clean terminal read on all seven lite versions. It now also finishes its first group: since moq-dev/moq#6978, closing a track truncates unfinished groups, so leaving one unfinished correctly made the tail unsettled and aborted the track on lite-01..04.
  • a peer close drains the overtaken group: incoming uni streams are buffered outside the mock and delivered only after their FIN, so the close lands while the final group is still being decoded. Without the uni drain it fails on lite-01..06; lite-07 waits for the subscriber's FIN anyway.
  • a peer close drains the overtaken fin (lite-01..04): bidi FINs are delivered a task after the close. It fails without the subscribe-response drain.
  • a peer close finishes a FIN'd track over qmux (lite-01..04, real QMux pair): awaiting reader.closed instead of reading to the FIN times out, since the drain never ends.
  • a lite-0N publisher FIN finishes the track (subscriber unit test, mocked clock): a legacy FIN finishes the track on a session that stays open.

Each piece of the fix has a test that fails without it.

Impact

  • Public API: none. Subscriber.drain is @internal.
  • Wire: none.
  • Behavior: on lite-03/04, a SUBSCRIBE_DROP after OK is now applied instead of left unread, and a duplicate SUBSCRIBE_OK closes the session. Neither our JS nor Rust publisher sends either before lite-05.
  • Behavior: on lite-01..04, a track whose publisher FIN'd its subscription now ends with a declared end, instead of being released or aborted. On every version, a session that dies with group streams already delivered reads them before ending its tracks.

Choices made without the maintainer

  • The quest is complete, so this PR deletes it. It was the last child of quest/m1/broadcast-lifecycle, and every other item in that epic's Plan already landed (moq-dev/moq#6660, #6636, #6656, #6669, #6740, #6741), so the epic is deleted too. Its links in quest/m1/README.md and quest/m2/ietf-finish.md are removed.
  • After merging main, the closing driver ends the tracks through main's #fail helper from moq-dev/moq#6978, still after the drain. A lite protocol violation still calls #fail right away, skipping the drain, since the session is already broken.
  • quest/m1/cut-audit/js-close-cause.md (added on main) linked the deleted quest; it now links this PR.
  • Subscriber.close finishes a finished track and then applies the session error. JS's existing rule then decides: the track ends cleanly only if its tail settled, the same way a lite-05+ track with SUBSCRIBE_END already behaves. Rust's cleanup calls finish() on it instead of aborting. I kept the JS rule rather than change lite-05+ semantics here.

Follow-ups (not quests yet)

  • a server refuses a client offering only IETF versions in the moql SETUP (js/net/src/connection/connect.test.ts) failed once under just check load, then passed five reruns. The accept promise looks like it can reject before the test attaches .rejects.

  • The mock resolves a stream's FIN acknowledgement once the bytes are queued at the peer, not once they're read, and its close discards unread queued chunks. WebTransport likewise errors a session's receive streams on close, dropping their unread bytes. So on lite-01..06, a publisher's close after the ack can still cut a final group the subscriber's JS hasn't read yet; lite-07's subscriber FIN avoids this. Worth a quest to check whether the lite-01..06 publisher drain should wait for more than the transport ack.

  • JS and Rust may disagree on a publisher-ended track whose tail is unsettled at session death: JS aborts it, while Rust's cleanup calls finish(). Worth checking which one is right.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

@kixelated kixelated changed the title js/net finishes tracks whose FIN arrives with the close test(net): reproduce legacy FIN and session-close ordering Oct 28, 2026
kixelated and others added 2 commits October 27, 2026 21:42
Before lite-05 the subscribe stream's FIN is a track's only end, but js/net
released the copy without declaring one, and the driver aborted every
subscription as soon as the transport closed. Mirror moq-net: a FIN finishes
the track once its tail settles, and a closing session first drains the
group streams and subscribe responses the closed transport already delivered,
so a track its publisher ended finishes rather than aborts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title test(net): reproduce legacy FIN and session-close ordering fix(net): finish legacy tracks whose FIN races a graceful close Oct 28, 2026
kixelated and others added 2 commits October 28, 2026 08:03
…fecycle/js-close-fin

# Conflicts:
#	js/net/src/lite/connection.ts
Since #6978, closing a track truncates its unfinished groups, so the
test's never-finished first group made lite-01..04 abort at session
close. The test checks delivery of the final group, so finish the first
one explicitly. Also repoint the cut-audit quest's link to the deleted
js-close-fin quest at this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review October 28, 2026 15:08
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next included review available in 10 minutes.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a3f3965a-711f-4a36-b149-9b4369711d5a
Commits

Reviewing files that changed from the base of the PR and between 270395a and a8299c0.

Files selected for processing: 9
  • js/net/src/lite/connection.test.ts
  • js/net/src/lite/connection.ts
  • js/net/src/lite/subscriber.test.ts
  • js/net/src/lite/subscriber.ts
  • quest/m1/README.md
  • quest/m1/broadcast-lifecycle/README.md
  • quest/m1/broadcast-lifecycle/js-close-fin.md
  • quest/m1/cut-audit/js-close-cause.md
  • quest/m2/ietf-finish.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T16:47:43.782898Z a8299c0 Manual request
ℹ️ 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: 4022ae7399

ℹ️ 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/lite/connection.ts

@kixelated kixelated left a comment

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.

Automated review by review (OpenAI)
Reviewed commit: 4022ae7

P1, already independently reported: actively read the legacy response stream through EOF. Confirmed in js/net/src/lite/subscriber.ts:973-978 and the new drain at js/net/src/lite/connection.ts:332: QMux's zero-high-water-mark receive stream only closes inside pull(), so awaiting reader.closed after SUBSCRIBE_OK can leave the drain pending forever and prevent session cleanup. Use an EOF read that rejects unexpected trailing bytes, and cover the path with a QMux-backed legacy-version regression. Referencing the existing thread rather than adding a duplicate inline comment.

Direction: draining delivered groups and responses before terminating subscriptions is appropriate and avoids an arbitrary timer, but transport closure alone is not a sufficient bound until the legacy response path actively reads. No additional actionable bugs found in the inspected changes.

Verification: static inspection of connection/subscriber lifecycle, Reader, QMux receive behavior and regression tests only; tests and real-transport scenarios were not run.

Before lite-05 the subscriber only awaited the subscribe stream's
reader.closed after SUBSCRIBE_OK. A QMux receive stream closes only on a
read, so that promise never settled there: the track never finished and
the closing session's drain waited forever. Run runResponses on every
version instead, which reads to the FIN, applies a lite-03/04
SUBSCRIBE_DROP like Rust does, and rejects a second SUBSCRIBE_OK.

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

@kixelated kixelated left a comment

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.

Automated review by review (OpenAI)
Reviewed commit: a8299c0

The prior P1 QMux drain finding is fixed: legacy subscriptions now actively read responses through EOF instead of awaiting reader.closed. The new real-QMux-pair regression covers lite-01..04. Allowing defined lite-03/04 SUBSCRIBE_DROP messages while rejecting a duplicate SUBSCRIBE_OK is the appropriate refinement of the earlier trailing-data recommendation.

No new actionable bugs found in the delta. Reusing the existing response loop is simpler than a separate legacy drain path and preserves the intended finish-after-tail behavior.

Verification: static inspection of the one-commit delta, response decoder, lifecycle context, and added tests; tests and reported CI results were not independently executed.

@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: a8299c023c

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

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for head a8299c0:

  • CI: 11/11 checks green.
  • Reviews: Codex reviewed this head with no findings (👍); the OpenAI review confirmed the earlier P1 (reading legacy subscribe responses through EOF) is fixed and found nothing actionable. No unresolved threads.
  • quest check --base origin/main passes; the PR deletes the finished js-close-fin quest and its now-empty broadcast-lifecycle epic.
  • Accepting SUBSCRIBE_DROP after SUBSCRIBE_OK on lite-03/04 is intended.

Suggested follow-ups, not holding this merge:

  • The flaky IETF-only SETUP test in js/net/src/connection/connect.test.ts.
  • Whether the lite-01..06 publisher should wait for a close ack.
  • JS aborts while Rust finishes an ended track that still has incomplete groups; pick one behavior.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit a565d09 Oct 28, 2026
11 checks passed
@kixelated
kixelated deleted the quest/m1/broadcast-lifecycle/js-close-fin branch October 28, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant