Skip to content

refactor(net): one owner and one closer per IETF request stream - #6130

Merged
kixelated merged 4 commits into
mainfrom
quest/m1/js-request-owner
Oct 21, 2026
Merged

kixelated merged 4 commits into
mainfrom
quest/m1/js-request-owner

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Completes the quest/m1/js-request-owner quest, a follow-up of moq-dev/moq#5998.

Summary

Every IETF request stream in js/net now has a single owner: RequestStream in js/net/src/ietf/request_stream.ts.

  • One reader. From the first wait on it, the handle keeps reading whole frames. It consumes GOAWAYs and acts on the end as soon as it arrives. It holds at most one untaken message, so backpressure stays bounded. next() allows one taker at a time, so no reader can be left detached or read twice.
  • One terminal signal. ended settles when the peer cancels the request: by FIN on drafts that treat FIN as cancel, or by resetting either direction. It also settles when the request closes locally or over a violation. It never rejects, and every consumer races it.
  • One closer. close(), abort(err) and serve(task) are the only ways a request ends. A protocol violation closes the session with its own code exactly once, even if the request has already ended. The dispatcher in connection.ts no longer closes a request again after handing it off, because a second close could only send a competing code.

All of these move onto the handle, on both sides:

  • SUBSCRIBE
  • FETCH
  • TRACK_STATUS
  • SUBSCRIBE_NAMESPACE
  • SUBSCRIBE_TRACKS
  • PUBLISH_NAMESPACE

Removed:

  • the cancelled() and ended() helpers
  • five inline quic.close + session.close pairs in the outbound PUBLISH_NAMESPACE path
  • the #violated re-closes in the outbound SUBSCRIBE and SUBSCRIBE_NAMESPACE paths
  • the per-handler nextMessage + u16 + skip-body reads

Handler entry points keep their (msg, stream: Stream) signatures, so existing tests drive them unchanged.

Line count: the per-request code in publisher, subscriber and connection is about net-flat (-294 / +293). The handle itself adds about 140 lines to request_stream.ts. In exchange, read and close behavior lives in one place instead of each handler wiring its own.

Fixes, each with a regression test that fails without it

  • A second GOAWAY on a request nobody was reading yet went unnoticed. An unanswered SUBSCRIBE is one example. The handle now reads from the first wait, so the violation closes the session. Tests: publisher.test.ts "a second GOAWAY on an unanswered SUBSCRIBE closes the session", and the request_stream.test.ts cases.
  • A violation on an outbound namespace subscription closed the session twice. Test: subscriber.test.ts "a namespace subscription closes the session once over a violation".
  • malformed() treated a stream reset whose code the browser did not report (source: "stream") as a protocol violation. It now passes it through as a reset. Covered by the existing session-limits.test.ts SUBSCRIBE cancel case, which now uses that realistic reset shape.

Rust

I checked whether the Rust request tasks close twice the same way.

  • Messages::failed in rs/moq-net/src/ietf/request_stream.rs already closes the session at the source.
  • The tasks then close again on is_protocol_violation (publisher.rs SUBSCRIBE_NAMESPACE and the dispatcher, subscriber.rs). That second close uses the code from the same error, so it never competes with the first. serve_bidis already asserts a single code.
  • Rust Messages is already the single reader that JS now mirrors: framed reads plus GOAWAY state.

So Rust needs no change. Closing at the source is the shape chosen in moq-dev/moq#5998, and this PR moves JS onto it.

Public API and wire impact

Public API: none. Everything is @internal. Wire: none.

Checks

  • just check: passed (@moq/net 2418 pass, 0 fail).
  • bunx tsc --noEmit in js/net: clean.
  • just test interop --all: not run. There is no wire or FFI change.

Follow-ups

  • In an outbound SUBSCRIBE_NAMESPACE, a local close during the counted initial set appears to make the read loop see an end and throw "namespace stream ended inside the initial set". If so, the session closes over our own close. This predates this PR and is unconfirmed. Recommend a small quest to reproduce it, then fix it.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits October 20, 2026 17:55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every IETF request stream in js/net now goes through a RequestStream
handle. It owns the reader: from the first wait it keeps reading whole
frames, consumes GOAWAYs, and acts on the end as it arrives, holding at
most one untaken message. Its `ended` promise settles on cancel, reset,
or a local close and never rejects, so every consumer races the same
terminal signal. Its close, abort, and serve are the single closer: a
protocol violation closes the session with its code once, and the
dispatcher no longer closes a request again after handing it off.

SUBSCRIBE, FETCH, TRACK_STATUS, SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS and
PUBLISH_NAMESPACE move onto it on both sides, replacing the cancelled()
and ended() helpers and the inline quic.close and #violated calls.

Fixes found on the way, each with a regression test:
- A second GOAWAY on a request nobody was reading yet went unnoticed.
- A namespace subscription violation closed the session twice.

Public API: none. Wire: none.

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

coderabbitai Bot commented Oct 21, 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 33 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
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9ac1bfed-4a19-41a7-94a2-80dd48f2c1ee
📥 Commits

Reviewing files that changed from the base of the PR and between a7412ae and 5769dc5.

📒 Files selected for processing: 10
  • js/net/src/ietf/connection.ts
  • js/net/src/ietf/publisher.test.ts
  • js/net/src/ietf/publisher.ts
  • js/net/src/ietf/request_stream.test.ts
  • js/net/src/ietf/request_stream.ts
  • js/net/src/ietf/subscriber.test.ts
  • js/net/src/ietf/subscriber.ts
  • js/net/src/session-limits.test.ts
  • quest/m1/README.md
  • quest/m1/js-request-owner.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 21, 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-21T01:58:45.838631Z 5769dc5 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: 6d6a84a06d

ℹ️ 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/request_stream.ts Outdated
Comment on lines +280 to +284
if (this.#session.version > Version.DRAFT_16)
this.writer.closed.then(
() => this.#end(),
() => this.#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 Observe the request reader's terminal state

When a peer sends one complete follow-up message and then FINs or resets only its sending direction, #readAhead() parks that message in #ahead, while this code observes only the response writer. Until a consumer calls next() again, ended never settles. In Publisher.#serveSubscribe, only request.ended is watched while broadcast or track resolution may wait forever, so such a peer can prevent cancellation from aborting the lookup and permanently retain a charged subscription slot. Watch reader.closed independently, as the previous cancellation helper did, so stream termination is noticed even behind an untaken message.

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

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.

Agreed, fixed in dd227b3. The first wait now also watches reader.closed apart from the read ahead: a reset ends the request on every draft, and a FIN ends it where FIN cancels, matching the old cancelled() helper. Regression test: request_stream.test.ts "a request ends behind a message nobody took" (draft 18 FIN and reset, draft 21 reset), which hangs without the fix.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 20, 2026 18:49
The read ahead parks one untaken message and stops reading, so a peer's
FIN or reset after it went unseen until the next take. Watch the reader's
end apart from the read ahead, as the cancellation helper this replaced did.

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

Copy link
Copy Markdown
Collaborator Author

Merge summary:

  • Fixed the Codex P1: RequestStream now watches reader.closed on the first wait, apart from the read ahead, so a peer's reset (any draft) or FIN (where FIN cancels) ends the request even while an untaken message sits in the slot. This restores what the removed cancelled() helper covered. Regression test in request_stream.test.ts hangs without the fix.
  • Merged origin/main cleanly; no quest conflicts (quest check ok).
  • just check passed locally (@moq/net 2421 pass, 0 fail).
  • Review: Codex thumbs-up on 5769dc5. CodeRabbit was rate-limited.

Follow-up still open from the description: an outbound SUBSCRIBE_NAMESPACE closed locally during its counted initial set may close the session over our own close (predates this PR, unconfirmed).

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 0645a28 Oct 21, 2026
10 checks passed
@kixelated
kixelated deleted the quest/m1/js-request-owner branch October 21, 2026 04:10
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