Skip to content

fix(net): keep IETF subscriptions alive after a request FIN (backport #4658) - #5133

Merged
kixelated merged 1 commit into
releasefrom
backport/ietf-fin-keeps-subscription
Oct 10, 2026
Merged

kixelated merged 1 commit into
releasefrom
backport/ietf-fin-keeps-subscription

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Release-only backport of #4658 (83ce47fe3 on main) for the 0.17.x line, ahead of IETF interop (imquic vs moq-relay on draft-22).

The failure

In rs/moq-net/src/ietf/publisher.rs the subscription task cancelled as soon as stream.reader.poll_closed fired:

  • On drafts 19-22 a requester may FIN its request stream right after SUBSCRIBE (draft-21 section 6.4.2.2). That FIN cancelled the subscription, so no media flowed.
  • On draft 17+ any REQUEST_UPDATE byte made Reader::poll_closed return an error, which also silently ended the subscription.

What is ported

  • ietf/request_stream.rs (private): fin_cancels(version) (drafts 14-18), poll_cancel (a clean FIN on d19+ sets finished and stops reading; afterwards only the writer's STOP_SENDING or a reset cancels), and the Update decoder for a draft-17+ REQUEST_UPDATE on a subscription.
  • SUBSCRIBE: the serve loop moves into run_subscription, same shape as main at fix(net): keep IETF requests alive after FIN #4658. Drafts 14-16 keep today's behavior (anything on the virtual stream ends it). From draft 17 it reads REQUEST_UPDATE frames, and from draft 19 a FIN keeps serving. The END_OF_TRACK marker wait uses the same gate.
  • SUBSCRIBE_NAMESPACE: Target::poll_closed uses poll_cancel, so the same gate applies. This was a few lines and self-contained.
  • moq-tokio transport: the wrapped send stream's poll_closed now watches an idle, unfinished stream and is interruptible by later writes, so STOP_SENDING is observed while idle. This hunk is a near-verbatim copy from fix(net): keep IETF requests alive after FIN #4658. I did not plan to include it, but it is required: release's own IETF subscriber unsubscribes on d17+ with FIN plus STOP_SENDING (cancel_subscribe). Once a FIN stops cancelling on d19+, the STOP_SENDING is the only signal, and without this hunk moq-tokio::broadcast broadcast_rejoin_* fail on moq-transport-22 with "upstream never canceled".
  • doc/concept/standard.md: one paragraph on the FIN and REQUEST_UPDATE behavior. It says "Rust publisher" because the JS side is not ported.

REQUEST_UPDATE choice

Release can apply a priority change (track.update already exists), so I ported main's behavior instead of refusing every update. A priority-only update is applied and answered with REQUEST_OK. Any other parameter (FORWARD=0, filters, fill, timeouts, token, range filters, NEW_GROUP_REQUEST) is refused with REQUEST_ERROR NOT_SUPPORTED, and the subscription ends with PUBLISH_DONE UPDATE_FAILED (0x8). Nothing is dropped silently, and release behaves the same as main. A malformed frame ends the subscription with INTERNAL_ERROR.

Left out

  • The fetch and joining-fetch paths (run_fetch_stream, joined) still cancel on a d19+ FIN. The scope here is the subscription path. It is the same 3-line poll_cancel swap if interop needs it.
  • PUBLISH_NAMESPACE handling (subscriber.rs, where a peer's FIN keeps the route, plus our withdrawal aborting both directions on d19+). This depends on main-only withdrawal and active-count work (feat(net): MoQ Active Count for IETF namespace subscriptions #4268).
  • The draft 14-16 update parsing that fix(net): route draft 14-16 updates to their target #4961 added later on main. Those drafts are not the interop target, and their behavior is unchanged.
  • js/net, where the JS publisher still cancels on FIN.

Public API and wire impact

  • Public API: none. Every new item is pub(super) or pub(crate). PublishDoneStatus::UpdateFailed is a new variant on a pub(crate) enum.
  • Wire: a conformance fix with no format change. New observable behavior: on d19+ a requester FIN no longer ends SUBSCRIBE or SUBSCRIBE_NAMESPACE. On d17+ REQUEST_UPDATE is answered (REQUEST_OK, or REQUEST_ERROR plus PUBLISH_DONE UPDATE_FAILED). Drafts 14-18 FIN semantics are unchanged.

Tests

New tests in ietf::publisher::serve_tests, using mocked time (start_paused) and no sleeps. All of them drive the real run_subscribe_stream / run_subscribe_namespace_stream:

  • requester_fin_is_version_gated_for_subscriptions: d14-d22. FIN cancels on d14-18. On d19+ the subscription stays alive and a group written after the FIN reaches the data stream.
  • requester_reset_cancels_subscriptions: a reset still cancels on every draft.
  • subscription_update_applies_priority_without_ending_the_request: d19 and d22. Priority 10 is applied (model 245) and both updates are acked.
  • unsupported_subscription_update_fails_the_subscription: d17, d19, and d22. FORWARD=0 produces NOT_SUPPORTED and then UPDATE_FAILED.
  • namespace_requester_fin_is_version_gated: d14-d22.

Before the fix, all four original tests failed: d19 ended on FIN, the update ended the subscription, and the unsupported update finished Ok with "track ended". After the fix:

  • cargo nextest run -p moq-net: 1518 passed.
  • cargo nextest run -p moq-tokio: 431 passed, including the broadcast_rejoin_* tests that caught the STOP_SENDING gap.
  • just check against origin/release: exit 0. Under load, one run timed out on moq-uring::teardown a_published_close_has_already_left_the_client. That test is raw io_uring QUIC with no moq-net in it, and it passed on retry and alone.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

…4658)

On drafts 19-22 a requester may FIN its request stream right after
SUBSCRIBE (draft-21 section 6.4.2.2). The publisher treated that FIN as a
cancellation, so no media flowed. On draft 17+ any REQUEST_UPDATE byte also
made Reader::poll_closed fail and silently ended the subscription.

Port the subscription half of #4658 to release:
- From draft 19, a clean FIN on a SUBSCRIBE or SUBSCRIBE_NAMESPACE stream
  stops reading but keeps serving; RESET_STREAM or STOP_SENDING cancels.
  Drafts 14-18 keep FIN cancellation.
- From draft 17, a subscription REQUEST_UPDATE is parsed: a priority change
  is applied and acknowledged with REQUEST_OK, anything else is refused
  with NOT_SUPPORTED and ends the subscription with PUBLISH_DONE
  UPDATE_FAILED.
- moq-tokio's wrapped send stream observes STOP_SENDING while idle, which
  the Rust subscriber's FIN plus STOP_SENDING unsubscribe needs once a FIN
  no longer cancels.

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

chatgpt-codex-connector Bot commented Oct 9, 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-09T22:31:19.241151Z 8e3b232 PR opened
ℹ️ 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.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Automated review of 8e3b2326 (backport of #4658 to release)

The backport looks faithful: d19+ FIN no longer cancels SUBSCRIBE / SUBSCRIBE_NAMESPACE, d17+ REQUEST_UPDATE is read and answered, and the moq-tokio idle-STOP_SENDING hunk is justified by cancel_subscribe relying on STOP_SENDING once FIN stops cancelling. No blocking issues found.

Non-blocking

  1. publisher.rs PUBLISH_DONE mapping: Err(Error::Unsupported) => UpdateFailed keys off the error variant, so any Unsupported that surfaces from TrackServe/fill (not just the refused update) would also be reported as UPDATE_FAILED (0x8). A dedicated marker from run_subscription (or returning the status directly) would keep that precise.
  2. serve_tests::fin_cancels re-implements the gate instead of calling request_stream::fin_cancels, so the tests can't catch a drift in the real function. Calling the real one (it's pub(super)) is cheaper.
  3. The moq-tokio change removes the terminal gate for every wrapped send stream, not just IETF request streams: an idle, unfinished stream now has its closed() future owning the stream until a write interrupts it. The tests cover reclaim-on-write and stop-while-idle; worth one test that finish() while a Closing watch is in flight still delivers the FIN (the new interrupt in finish), since that is the path cancel_subscribe takes.
  4. Fetch / joining-fetch still cancel on d19+ FIN (called out in the body). If imquic FINs after FETCH the same way, interop will hit it next; fine to defer but worth tracking.
  5. CI is still pending.

Verdict: MERGE once CI is green.

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

@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: 8e3b23261c

ℹ️ 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 404 to +408
self.finish = true;
if matches!(self.state.get_mut().unwrap(), Some(SendState::Closing(_)))
&& let Some(tx) = self.interrupt.take()
{
let _ = tx.send(());

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 a deferred FIN when dropping the close watch

When poll_closed has put an unfinished stream into SendState::Closing, finish() takes this branch and only records self.finish. If the caller then drops the stream before polling again, the Drop implementation's Closing branch salvages only resets, so it drops the owning future and the backend performs its default reset instead of the FIN for which finish() already returned success. The closing-state drop path needs to reclaim the interrupted future and apply a deferred finish too.

AGENTS.md reference: rs/AGENTS.md:L35-L35

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 this is a real gap, but not one to fix only on release. This hunk is a verbatim copy of #4658, and main has the same Drop Closing branch today, so the fix belongs on main first and then a backport.

It is also not reachable from moq-net on release. The only caller that starts an idle send watch is the publisher's run_subscription (stream.writer.poll_closed), and that stream always ends with a PUBLISH_DONE write, which reclaims it, followed by close().await. Every other writer.finish() call site never polls writer.poll_closed first. I'm tracking it as a follow-up: the Closing drop path should take the deferred finish and apply it in the salvage task, like the Writing branch already does.

(Written by Claude Opus 5.5)

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.

Confirmed, this is a real bug. Before #4658, Closing was reached only after finish/reset, so no FIN could be deferred there. Now an idle, unfinished stream can sit in Closing, and finish() then drop loses the FIN to the backend's drop reset. moq-net's Writer resets on drop unless closed, so its callers don't hit this today, but it is a contract bug in the adapter. main has it too.

The fix applies a deferred finish in the Closing drop branch, like the Writing branch, with the regression test a_deferred_finish_survives_a_drop_mid_watch, which fails without the fix. It is on main in #5156 and on release in #5164, since this PR already merged.

(Written by Claude Opus 5.5)

Comment on lines +868 to +872
if let Some(priority) = update.priority {
let mut subscription = serve.track.subscription();
subscription.priority = super::priority::from_wire(priority);
if let Err(err) = serve.track.update(subscription) {
return Some((Err(err), filled));

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 Reprioritize group streams already in flight

When a priority update arrives after a group stream has opened, this only updates the track subscription. Each existing GroupServe captured the old priority when it was constructed and never observes later subscription changes, so a long-running or flow-control-blocked stream remains scheduled at the old priority even after the update is acknowledged. Propagate the new priority to active group writers as well as using it for future groups.

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.

Not fixing this in the backport. main behaves the same way: GroupServe sets the priority once for each stream it opens, so an update reaches new groups but not the ones already in flight. This PR's goal is to stop a d19+ FIN from cancelling the subscription. Re-prioritizing in-flight group writers is new behavior that should land on main before it comes to release. I'm tracking it as a follow-up.

(Written by Claude Opus 5.5)

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.

Confirmed. Each GroupServe captured the priority once, so an acknowledged update reached only groups opened afterwards. main has the same gap. Lite already moves in-flight groups through a per-group kio consumer, so I did the same here instead of refusing priority updates, which would end a subscription over a benign request.

TrackServe now owns the priority in a kio channel. Every group stream follows it while open: at open, while serving, and while waiting for the FIN acknowledgement. The regression test subscription_update_reprioritizes_open_group_streams fails without the fix. It is on main in #5162 and on release in #5164.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for 8e3b2326

  • I checked the backport against fix(net): keep IETF requests alive after FIN #4658 (83ce47fe3). The moq-tokio transport hunk matches it line for line, apart from release's u8 priority type. run_subscription, the poll_cancel gates, UPDATE_FAILED, and the SUBSCRIBE_NAMESPACE gate have the same shape as main. request_stream::Update keeps only the draft-17+ parsing, and this release-only adaptation is sound.
  • Reviews: Codex reviewed the head and raised two P2 findings. I replied to both in their threads. Neither is release-specific, and both should be fixed on main first. Grok's verdict is MERGE.
  • release gained fix(net): accept an End of Group status on a marked subgroup stream (backport #5081) #5127, which only touches subscriber.rs. The PR still merges clean, and CI is green.
  • Follow-ups, both on main first: (1) the SendStream Drop Closing branch should apply a deferred finish; (2) a priority REQUEST_UPDATE should re-prioritize group streams already in flight. Fetch and joining fetch still cancel on a d19+ FIN on release, as the body notes.

Merging with a merge commit, per the release rules.

(Written by Claude Opus 5.5)

@kixelated
kixelated merged commit ae8a3ab into release Oct 10, 2026
7 checks passed
@kixelated
kixelated deleted the backport/ietf-fin-keeps-subscription branch October 10, 2026 00:37
@moq-bot moq-bot Bot mentioned this pull request Oct 10, 2026
kixelated added a commit that referenced this pull request Oct 10, 2026
fix(net): follow-ups to #5133 (deferred FIN on drop, reprioritize open group streams)
@moq-bot moq-bot Bot mentioned this pull request Oct 11, 2026
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