Skip to content

feat(net): end a broadcast with close() - #4047

Merged
kixelated merged 4 commits into
quest/m1/broadcast-close/READMEfrom
quest/m1/broadcast-close/rust
Sep 25, 2026
Merged

kixelated merged 4 commits into
quest/m1/broadcast-close/READMEfrom
quest/m1/broadcast-close/rust

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Completes the Rust close quest on the broadcast-close line (#4031).

Problem

Ending a broadcast had three shapes (finish, abort(err), drop) and a finished-vs-aborted flag that never survives a hop: a broadcast has no FIN on the wire. Dropping warned, and a lookup on an ended broadcast answered NotFound or Dropped depending on whether clones were still alive.

Approach

  • broadcast::Producer::close(&self): retracts, leaves local discovery, serves no new tracks, and fails a later announce with Closed. Any clone ends it; a second close is a no-op. Dropping the last producer runs the same path (Alive::close), without the warning.
  • Once ended, every new Consumer::track and every request still waiting on an unserved name answers Unroutable, the same as a fresh request_broadcast for the path. Tracks already handed out are untouched.
  • Dynamic's handler count moved to a Handler field declared after alive, so when a Dynamic is the last handle, the broadcast ends (Unroutable) before the handler rejects its queue (Dropped).
  • SourceGuard just closes on drop. The IETF subscriber's Detach::{Graceful, Abrupt} distinguished nothing anymore and is gone; lite's AnnouncedRoute::finish too.
  • finish forwards to close; finish, abort, is_finished are #[deprecated] + #[doc(hidden)]. abort behavior is unchanged.
  • Every Rust caller moved over. moq-srt/moq-rtmp Publisher::abort now closes the broadcast explicitly.
  • JS: broadcast.Producer.close(abort) / Consumer.close(abort) gain a deprecated overload; the two origin teardown callers stop passing a cause.
  • New rs/moq-net/tests/broadcast_close.rs checks close through a local origin and over a mock session (lite-05, IETF 14 and 19).

Impact

  • Rust: new moq_net::broadcast::Producer::close(&self).
  • Rust: Producer::finish, Producer::abort, Consumer::is_finished deprecated and hidden.
  • Rust: behavior change on a published API: after a broadcast ends, Consumer::track returns Error::Unroutable (was NotFound while clones lived, Dropped after), and pending unserved requests resolve Unroutable (was NotFound). An ended broadcast no longer serves existing tracks to new lookups.
  • Rust: dropping the last producer no longer logs a warning.
  • JS: close(abort) on broadcast.Producer/Consumer deprecated via overload; origin.close(err) now closes its routed broadcasts without a cause (they report null, not err).
  • libmoq/moq-ffi: finish internally calls close; no binding API change (next quest).
  • Wire: none. IETF/lite sources ended by a dead session now end like a retraction; the abort cause they carried never left the hop.

Decisions (made unattended; alternatives noted)

  • Second close() is a no-op (alt: error with Closed, like abort). Updated the bindings quest to match.
  • Removed the IETF Detach enum rather than keep a no-op distinction (alt: keep it for logging).
  • finish still sets the deprecated finished flag so is_finished keeps working until the dev removal (alt: forward blindly, making is_finished always false).
  • JS deprecation via a TS overload rather than a JSDoc-only note, so editors flag close(err).

Alternatives

  • Keep existing tracks readable to new lookups after close (old finish semantics). Rejected per the quest: a consumer should not be able to tell a raw handle from an origin-reached one.

Follow-ups

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 3 commits September 24, 2026 10:29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review September 24, 2026 18:37
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 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-09-25T00:11:19.167562Z 53195df 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: 144166a973

ℹ️ 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 +800 to +801
if state.closing {
return Err(Error::Unroutable);

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 Move the breaking behavior change to dev

This commit is based directly on main, but it changes the published Consumer::track contract: after a broadcast ends, existing callers now receive Unroutable instead of the prior NotFound/Dropped outcomes. Clients that match error variants can therefore change behavior on an ordinary mainline upgrade, so this should be retargeted to dev as a semver-breaking API change.

AGENTS.md reference: AGENTS.md:L75-L80

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.

Declined. The maintainer decided on #4007 that this lands on main with the changed error called out in the PR (see the quest plan). The PR's Impact section flags it as a behavior change on a published API.

(Written by Claude Opus 5.5)

Comment on lines +441 to +443
pub fn close(&self) {
self.alive.close();
}

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 Consume the producer when closing it

Taking &self leaves the permanently closed producer type-valid, so callers can keep invoking producer operations after the terminal transition; consuming one clone would still close the shared broadcast for every clone while preventing reuse of the handle that performed the close. Make this terminal API take self, as required for Rust handles in this repository. rs/CLAUDE.mdL35-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.

Declined. The quest plan, decided with the maintainer, specifies close(&self) so any clone ends the broadcast, matching finish(&self). Consuming one clone wouldn't stop the others from calling producer methods anyway.

(Written by Claude Opus 5.5)

Comment thread rs/moq-net/src/model/broadcast.rs Outdated
Comment on lines 449 to 453
let mut state = self.state.lock();
state.closing = true;
state.finished = true;
// A name that was reserved or requested but never served can't arrive now,
// and `Consumer::track` already answers `NotFound` for one asked about after
// this point. Say the same to whoever asked earlier.
state.reject_unserved(Error::NotFound);
if !state.closing {
state.finished = true;
}
}

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 Claim the terminal state atomically in finish

When separate clones call deprecated finish() and abort(err) concurrently, finish() can set finished = true, release the lock, and then lose the terminal transition to abort(), whose closing check still succeeds. The resulting consumer reports the abort error while is_finished() is also true, unlike the previous atomic implementation and contrary to that compatibility flag's purpose; update finished and claim closing in the same locked transition.

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.

Fixed in 53195df: finish now sets finished inside the same locked step that claims closing (Alive::end).

(Written by Claude Opus 5.5)

A deprecated finish() racing abort() on another clone could set finished,
then lose the end to abort, leaving is_finished() true beside an abort cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated merged commit f884e9b into quest/m1/broadcast-close/README Sep 25, 2026
8 of 9 checks passed
@kixelated
kixelated deleted the quest/m1/broadcast-close/rust branch September 25, 2026 00:09
@kixelated

Copy link
Copy Markdown
Collaborator Author

Merged into quest/m1/broadcast-close/README.

  • Fixed: deprecated finish now claims the end and sets finished in one locked step (53195df), so a racing abort can't leave is_finished() true beside an abort cause.
  • Declined: retargeting to dev and close(self), both contrary to the maintainer-decided quest plan.
  • Checked js/net and doc/concept; no stale broadcast end references.

(Written by Claude Opus 5.5)

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