Skip to content

fix(net): withdraw timed-out draft 14-16 advertisements - #6133

Merged
kixelated merged 6 commits into
mainfrom
quest/m1/legacy-request-timeout-cleanup
Oct 21, 2026
Merged

kixelated merged 6 commits into
mainfrom
quest/m1/legacy-request-timeout-cleanup

Conversation

@kixelated

@kixelated kixelated commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem
On drafts 14-16 a PUBLISH_NAMESPACE rides the control stream, where a stream reset never reaches the peer. An advertisement that timed out after it was sent was never withdrawn, so the peer could hold the namespace until the session closed. This affected both JS (Deadline reset every stream it recorded) and Rust (advertise_namespace dropped the request once read_response gave up).

Approach

  • JS: the fix goes in Deadline's timeout path, as the #5741 review decided. A new internal Deadline.send(stream, write, withdraw?) records the request write and the message that withdraws it. When the deadline or credit fails, it waits for any write still in flight, sends the withdrawal if the request actually went out, and then resets. #advertise passes PUBLISH_NAMESPACE_DONE on drafts 14-16 and nothing on 17+, so 17+ still just resets. #end and the timeout share a new #done writer, which writes through the request's RequestStream from moq-dev/moq#6130. The RequestStream closer stays the only way the advertisement ends: the caller's abort after a deadline failure is unchanged.
  • Rust: when read_response gives up, the request now goes through end_request, which now takes the stream, path, and request ID instead of a full NamespaceRequest. That sends PUBLISH_NAMESPACE_DONE on drafts 14-16. On 17+ it cancels both directions with the same code the drop used.
  • Unanswered advertisements keep their existing retry behavior: terminal in JS (per moq-dev/moq#5741), retried in Rust. Only the cleanup changed.

Other timeouts checked on drafts 14-16

  • JS SUBSCRIBE: its virtual stream opens outside Deadline, and its timeout cleanup already sends UNSUBSCRIBE (#cancelSubscribe). That had no test, so this PR adds a mocked-time test for drafts 14, 15 and 16.
  • SUBSCRIBE_NAMESPACE (UNSUBSCRIBE_NAMESPACE on 14-15): neither language puts a timeout on the answer. JS bounds only the create, as moq-dev/moq#5741 chose for discovery, and on 14-15 the virtual open resolves before anything is written. Rust has no deadline on this path at all. No request can time out after it was sent, so nothing changed here and there is no timeout to test.
  • PUBLISH_NAMESPACE update (REQUEST_UPDATE): both languages already send PUBLISH_NAMESPACE_DONE on a draft-16 timeout. Drafts 14-15 have no update; they re-advertise and then withdraw the old request.

Impact

  • Public API: none (Deadline is @internal).
  • Wire: no new messages. A timed-out draft 14-16 advertisement is now followed by PUBLISH_NAMESPACE_DONE on the control stream.

Decisions (both accepted by the maintainer on 2026-10-20)

  1. JS SUBSCRIBE cleanup placement. The quest said "keep that covered by a test while moving the mechanism":
    • ✅ (accepted) Keep SUBSCRIBE's UNSUBSCRIBE on its own path and pin it with a test. That cleanup also runs on abandonment and on errors other than timeouts, which Deadline deliberately leaves to the caller. Routing only its timeout through Deadline would send UNSUBSCRIBE twice or need a second guard. Merging it into Deadline is out of scope for this PR.
    • Move SUBSCRIBE's legacy stream into Deadline.send now and drop the timeout branch from its caller cleanup.
  2. Deadline.wait now waits for the withdrawal before it throws, so the caller's own abort in its catch block cannot reset the stream before PUBLISH_NAMESPACE_DONE is written:
    • ✅ (accepted) Wait. Streams without a withdrawal are still reset right away. Rust's end_request waits the same way.
    • Throw right away and let callers skip their abort on a deadline failure. This avoids the wait, but every caller would have to know about it.

Validation

  • New tests, both confirmed to fail without the fix:
    • JS an unanswered draft {14,15,16} advertisement is withdrawn at its deadline
    • Rust an_unanswered_advertisement_is_withdrawn (drafts 14-16 send DONE; 17 resets)
  • New JS test an unanswered draft {14,15,16} subscribe is unsubscribed at its deadline pins the existing SUBSCRIBE cleanup.
  • All of these use mocked time.
  • After the merge, a_failed_advertisement_ends_only_its_namespace (new on main) hung on draft 14. Its peer held the re-offered advertisement on a real memory stream without reading it, so the timed-out withdrawal waited forever for the close to be acknowledged. The test now answers that advertisement, since it only checks that the advertisement is offered again. In production, drafts 14-16 run over the control-stream adapter, whose virtual close completes at once, so the loop does not wait there.
  • just check passed (@moq/net: 2,419 pass; Rust: 7,813 pass), and again after merging main, including moq-dev/moq#6130 (@moq/net 2,434; Rust 7,923). quest check passed.
  • The first just check run hit the known flake export::redundant_importers_switch_through_bufferbloat_on_lite_07 (/quest/m1/test-flakes-2/redundant-failover-drift.md). It is in moq-cli on lite and unrelated to this change. It passed when run alone and on the rerun.

Follow-ups

  • Rust never sends UNSUBSCRIBE_NAMESPACE on drafts 14-15. Today run_subscribe_namespace lasts as long as the session, so this is not urgent, but if a namespace subscription could ever end early, dropping its virtual stream would tell the peer nothing.
  • Deadline (shared with lite) still resets the raw stream at its deadline, as it did before moq-dev/moq#6130. Moving that reset, the withdrawal, and SUBSCRIBE's #cancelSubscribe into RequestStream would leave one closer for every IETF timeout. That is a separate refactor.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 3 commits October 20, 2026 17:55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On drafts 14-16 a PUBLISH_NAMESPACE rides the control stream, where a
reset tells the peer nothing, so one that timed out unanswered was never
withdrawn and the peer could hold it until the session closed.

JS: Deadline.send records the request write and the message that
withdraws it; on a deadline failure it waits out an in-flight write,
sends the withdrawal, then resets. The advertisement passes
PUBLISH_NAMESPACE_DONE on drafts 14-16 and the reset alone after.

Rust: advertise_namespace ends a request that got no answer through
end_request, which sends PUBLISH_NAMESPACE_DONE on drafts 14-16 and
cancels both directions after, as the drop did.

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

Copy link
Copy Markdown
Collaborator Author

Quest start outcome: implemented, with just check and quest check passing. This is a draft because two decisions are open, listed in the body. For each, the recommended option is already implemented:

  1. Keep the JS SUBSCRIBE UNSUBSCRIBE cleanup on its own path and pin it with a test. The quest's "moving the mechanism" was read as not merging SUBSCRIBE's cleanup into Deadline; that merge would fit better under /quest/m1/js-request-owner.md.
  2. Deadline.wait waits for the withdrawal before it throws.

The UNSUBSCRIBE_NAMESPACE part of the goal has no timeout to clean up after: neither language bounds the SUBSCRIBE_NAMESPACE answer once it is sent.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review October 21, 2026 02:45
@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 4 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: b5987299-3121-429e-beac-460401418885
📥 Commits

Reviewing files that changed from the base of the PR and between 0645a28 and 4fba16d.

📒 Files selected for processing: 7
  • js/net/src/ietf/publisher.test.ts
  • js/net/src/ietf/publisher.ts
  • js/net/src/ietf/subscriber.test.ts
  • js/net/src/util/deadline.ts
  • quest/m1/README.md
  • quest/m1/legacy-request-timeout-cleanup.md
  • rs/moq-net/src/ietf/publisher.rs
  • 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-21T02:48:07.661868Z 0ff52d2 Draft marked ready
ℹ️ 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: 0ff52d2cd7

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

static async #reset({ stream, written, withdraw }: Recorded, failure: Error) {
// The withdrawal names a request the peer must already have, so a write still in
// flight finishes first, and one that never landed is not withdrawn.
if (withdraw && written && (await written)) await withdraw().catch(() => 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.

P1 Badge Preserve the deadline while waiting for cleanup

When a draft-14–16 peer stops reading the shared control stream while the initial PUBLISH_NAMESPACE write is backpressured, written never resolves, so the timeout path now waits forever here instead of aborting at 10 seconds; the subsequent withdrawal can likewise block without a bound. This wedges the single publish-namespace loop and prevents every queued advertisement or withdrawal from progressing, so cleanup must remain bounded even when the peer is not consuming control data.

AGENTS.md reference: AGENTS.md:L18-L18

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.

Declining: the wait is already bounded by the session, and a deadline here would not unwedge anything.

  • On drafts 14-16 every request shares one control-stream writer behind a single write mutex. A peer that stops reading it blocks every later control write, including the next advertisement and every withdrawal. Aborting at 10 seconds would let this loop move on only to queue its next write behind the same mutex.
  • The loop's normal withdrawal already waits the same way: #withdraw → #end awaits #done and then writer.closed with no deadline. Rust's end_request also awaits the DONE encode and the close.
  • The cost to us is one parked task with no state growth. Waiting actually removes growth: before this change, each timed-out advertisement left its write queued and the loop went on to queue more.
  • The wait ends with the session. When the transport or the adapter closes, the queued write rejects, so written resolves false and the reset goes through.
  • Aborting without waiting would be wrong. A write already in the mutex queue still reaches the peer after the virtual stream is aborted, so a request the peer later sees would never be withdrawn. That stale state is what this PR fixes.

Waiting before throwing was a maintainer decision on this PR (Decisions, item 2).

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for 0ff52d2cd7f58f8e2febdf852f7bd17fec7fa2c5:

  • Change: on drafts 14-16, an advertisement that times out after it was sent is now withdrawn with PUBLISH_NAMESPACE_DONE on the control stream. In JS this goes through Deadline.send; in Rust, through end_request. Draft 17+ still just resets. A new test pins JS SUBSCRIBE's existing UNSUBSCRIBE-on-timeout.
  • Decisions (accepted by the maintainer on 2026-10-20): (1) JS SUBSCRIBE keeps its own UNSUBSCRIBE cleanup path, and merging it into Deadline is out of scope; (2) Deadline.wait waits for the withdrawal to be written before it throws.
  • Since the draft: merged main with no conflicts. just check passed again (@moq/net 2,419; Rust 7,881), and so did quest check.
  • Review: Codex raised one P1, an unbounded cleanup wait if the peer stops reading the control stream. I declined it in the thread: the wait ends with the session, every control write is already stuck behind the same mutex, the existing withdrawal path waits the same way, and aborting early would leave a queued request that is never withdrawn. CodeRabbit was rate limited and did not review.
  • Public API: none. Wire: no new messages.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 21, 2026
# Conflicts:
#	js/net/src/ietf/publisher.ts
#	rs/moq-net/src/ietf/publisher.rs
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 21, 2026
…on test

A draft-14 advertisement that times out is now withdrawn, and over the test's
real memory streams that withdrawal waits for the peer to acknowledge the
close. The test held the re-offered stream without reading it, so the loop
never got to withdraw the other namespace. Answer it instead, as the test only
checks that it is offered again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit e8beac8 Oct 21, 2026
11 checks passed
@kixelated
kixelated deleted the quest/m1/legacy-request-timeout-cleanup branch October 21, 2026 10: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