Skip to content

fix(net): a subscribe update racing a finished track ends it cleanly - #5833

Merged
kixelated merged 4 commits into
mainfrom
quest/m1/test-flakes-2/update-after-finish
Oct 17, 2026
Merged

kixelated merged 4 commits into
mainfrom
quest/m1/test-flakes-2/update-after-finish

Conversation

@kixelated

@kixelated kixelated commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

When a publisher finishes a track, it stops reading the subscribe stream. If the subscriber writes a SUBSCRIBE_UPDATE at that moment, the write fails, and until now the track failed with that write error instead of ending with the publisher's finish. This is the cause of the real-browser rename media flake seen on #5635 ("The stream was aborted by the remote server").

Now a failed update write defers to the subscribe stream's responses, in both languages. A FIN still ends the track cleanly, and a reset still fails it with the reset's error. There's no retry and no timeout.

  • JS (js/net/src/lite/subscriber.ts): #runSubscriptionUpdates encodes each update into memory, then writes it. If the write fails, the loop stops quietly, so the existing .then(() => closed) hands the outcome to the responses. An update the encoder refuses, or one that empties the range, still fails the track.
  • Rust (rs/moq-net/src/lite/subscriber.rs): the same race happened in the ServeMode::Select serve loop. A flush error handed the track back with that error before the buffered SUBSCRIBE_END and FIN were read. Now it logs the dropped update at debug level and keeps reading the responses.
  • Rust (rs/moq-net/src/coding/writer.rs): Writer::poll_flush now drops its buffer on a write error. A failed stream never takes another byte, and keeping the bytes made the teardown's finish() trip its "unflushed bytes" debug assertion.

Deletes the finished quest quest/m1/test-flakes-2/update-after-finish.md.

Tests

The regression tests are deterministic, with no wall clock. Both fail without the fix:

  • JS an update the publisher stopped reading defers to its {finish,reset} covers lite-05, 06, and 07 finishing, plus a lite-07 reset. Without the fix, the track closes with The stream was aborted by the remote server.
  • JS an update whose bound the wire can't carry fails the track covers the Codex review finding: only a failed write defers.
  • Rust an_update_the_publisher_stopped_reading_defers_to_its_end, over transport::memory::pair, runs the full TrackServeRun. Without the serve-loop change, the track is handed back instead of finishing. Without the Writer change, teardown panics on the debug assertion.

Checks:

  • just check: passes.
  • just test interop --all: 54 pass, 15 fail. Every failure involves the headless-Chromium (js) leg, which can't launch on this host (libglib-2.0.so.0: cannot open shared object file). That includes refused session and the js round idle-out that follows from it. The non-browser JS legs (js-native-node, js-native-bun) pass. CI's browser interop is the real check for that leg.

Public API / wire

No public API change. Writer is crate-internal, and its dropped-buffer-on-error behavior has no external effect. No wire change.

Notes

  • If a publisher stopped reading but never ended or reset its responses, the subscription would carry on and drop later updates. No moq-lite publisher does this today: it stops reading only once it has finished. The quest chose this rule over a timeout.

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 16, 2026 19:23
A publisher that finishes a track stops reading the subscribe stream, so a
SUBSCRIBE_UPDATE written at that moment fails. The subscriber failed the
track with the write's error instead of the publisher's finish. A failed
update write now defers to the subscribe stream's responses in JS and Rust:
the FIN ends the track cleanly, and a reset still fails it.

The Rust Writer drops its buffer on a write error, so a later finish on the
dead stream doesn't trip the unflushed-bytes assertion.

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

Copy link
Copy Markdown
Collaborator Author

Outcome: the quest is done in this PR, with no open decisions. It follows the rule decided on 2026-10-16: a failed update write defers to the subscribe stream's own outcome, in JS and Rust, with no retry or timeout. The race reproduced in both languages. Each regression test fails without the fix.

Not run here: the browser legs of just test interop --all. Headless Chromium can't start on this host because libglib is missing, so those legs are left to CI.

(Written by Claude Opus 5.5)

@coderabbitai

coderabbitai Bot commented Oct 17, 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
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c3c01a24-bd8f-4ec9-8fed-0b9e1d84ad01
📥 Commits

Reviewing files that changed from the base of the PR and between 3789ee2 and 074066a.

📒 Files selected for processing: 6
  • js/net/src/lite/subscriber.test.ts
  • js/net/src/lite/subscriber.ts
  • quest/m1/test-flakes-2/README.md
  • quest/m1/test-flakes-2/update-after-finish.md
  • rs/moq-net/src/coding/writer.rs
  • rs/moq-net/src/lite/subscriber.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 17, 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-17T04:37:48.454518Z 074066a 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: 8296627fd6

ℹ️ 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 +1301 to +1302
} catch {
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 Do not swallow SUBSCRIBE_UPDATE encoding errors

On pre-Lite-06 connections, an update whose start group is Number.MAX_SAFE_INTEGER reaches this catch as a local RangeError, not a stopped-stream error: groupBounds accepts that safe integer, but encodeStartGroup adds one for the older wire format and Writer.u53 rejects the resulting value. Returning here leaves the track open while silently ignoring its requested bound; restrict suppression to transport write failures, or reject the unencodable bound before this catch, so codec errors still surface.

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.

Agreed, fixed in 074066a. The update is now encoded into memory before the stream write, and only a failed write defers to the responses. An unencodable bound (your lite-05 MAX_SAFE_INTEGER start) fails the track again, covered by an update whose bound the wire can't carry fails the track. Rust already keeps buffer_update (encode, propagated with ?) apart from the flush, so it needed no change.

(Written by Claude Opus 5.5)

Encode SUBSCRIBE_UPDATE apart from the stream write, so only a failed write
defers to the responses. A bound the encoder refuses (a lite-05 start group
of MAX_SAFE_INTEGER) fails the track again instead of being dropped.

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

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 074066a9ce

ℹ️ 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 +1302 to +1305
try {
for (const chunk of encoded) await stream.writer.write(chunk);
} catch {
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 Restore unsent JS subscription state before settling

When the failed update lowers the start after SUBSCRIBE_START, this return defers to the publisher's END/FIN even though entry.tail.demand(...) and the producer's current subscription already reflect the update that the publisher never received. #settleTail then treats those newly requested groups as owed and waits for its grace, which can be an arbitrarily large maxDelay, before closing an otherwise finished track. Preserve the last successfully written bounds and delay, or roll back the unsent update state before deferring to the responses.

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.

Real, but I'm keeping it out of this PR. It predates the failed-write path: the owed range comes from the current demand, not what the publisher acknowledged. So a lowered-start update that crosses SUBSCRIBE_END on the wire, with a write that succeeds, already makes the tail wait out its grace the same way. It only affects lite-05/06 (lite-07 counts streams), and the track still ends cleanly, at worst one grace late. Before this PR, the same race failed the track outright. Rolling back state only on a failed write would cover one of the two crossings. The fix belongs in how the tail bounds its debt once the publisher has ended (demand made after its END owes nothing), in both languages. I've suggested that as a follow-up.

(Written by Claude Opus 5.5)

Comment on lines 5609 to 5611
Poll::Ready(Err(err)) => {
// The stream is broken; drop it (the writer resets) and
// hand the track back.
serve.subscriber.remove_subscribe(active.id);
self.sub = Sub::None;
return Poll::Ready(ServeEnd::GiveBack(err));
tracing::debug!(track = %serve.name, %err, "subscribe update dropped")
}

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 Restore unsent Rust subscription state before settling

When a failed update lowered the floor after SUBSCRIBE_START, begin_subscription has already changed active.start, restarted the tail debt, and applied the new grace before this error is ignored. The subsequent END/FIN therefore enters ServeMode::Tail with groups the publisher never agreed to send and can delay a clean track end for the full, caller-controlled max-delay grace. Commit this state only after a successful flush, or restore the previously sent state on this error before processing responses.

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.

Real, but I'm keeping it out of this PR. It predates the failed-write path: the owed range comes from the current demand, not what the publisher acknowledged. So a lowered-start update that crosses SUBSCRIBE_END on the wire, with a write that succeeds, already makes the tail wait out its grace the same way. It only affects lite-05/06 (lite-07 counts streams), and the track still ends cleanly, at worst one grace late. Before this PR, the same race failed the track outright. Rolling back state only on a failed write would cover one of the two crossings. The fix belongs in how the tail bounds its debt once the publisher has ended (demand made after its END owes nothing), in both languages. I've suggested that as a follow-up.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for 074066a9c:

  • A failed SUBSCRIBE_UPDATE write now defers to the subscribe stream's responses, in JS and Rust: a FIN ends the track cleanly, and a reset still fails it.
  • From Codex's first review: JS encodes each update into memory before writing it, so an update the encoder refuses still fails the track. A regression test covers this.
  • From Codex's second review: when a lowered-start update crosses SUBSCRIBE_END on lite-05/06, the tail waits out its grace. This predates the PR (a successful crossing write does the same), so it's deferred to a follow-up that bounds tail debt once the publisher has ended.
  • CI: Check and Test pass. The interop workflow, dispatched on this head, passed the full just test interop --all matrix, including the Chromium legs: https://github.com/moq-dev/moq/actions/runs/39001404182

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit fa56c63 Oct 17, 2026
16 checks passed
@kixelated
kixelated deleted the quest/m1/test-flakes-2/update-after-finish branch October 17, 2026 06:20
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