Skip to content

moq-net: RAII broadcast announcements via OriginPublish; remove broadcast abort/close#1640

Merged
kixelated merged 2 commits into
devfrom
claude/interesting-agnesi-609ac5
Jun 7, 2026
Merged

moq-net: RAII broadcast announcements via OriginPublish; remove broadcast abort/close#1640
kixelated merged 2 commits into
devfrom
claude/interesting-agnesi-609ac5

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Summary

Makes the origin announcement lifecycle sans-io and reframes what a broadcast is.

Previously publish_broadcast spawned a task per broadcast (web_async::spawn) that awaited broadcast.closed() and then removed it from the origin tree, which required a runtime. Now publishing returns an OriginPublish guard whose Drop does the removal synchronously, so the origin no longer watches closure and no longer spawns. This is the first concrete step toward driving moq-net from poll_xxx/wakers instead of tokio.

Paired conceptual change: a broadcast is just a collection of tracks, so it can no longer be "closed" with a code. BroadcastProducer::abort / BroadcastDynamic::abort are removed. A broadcast ends only when every producer is dropped; pending dynamic track requests are rejected when the last dynamic handler goes away. There is no wire code for broadcast closure, so nothing is lost on the wire. You can now unannounce without closing (drop the guard while the producer keeps serving tracks).

API changes (moq-net, breaking → targeting dev)

  • publish_broadcast returns Result<OriginPublish, Error> (was bool). Hold the guard to stay announced; drop it (or call OriginPublish::unannounce) to remove it.
  • create_broadcast returns Result<BroadcastPublish, Error>; BroadcastPublish derefs to BroadcastProducer and unannounces on drop.
  • New Error::Loop (broadcast's hop chain already contains this origin) and reused Error::Unauthorized (path outside allowed prefixes) distinguish the two reject reasons that were previously a bare false/None.
  • Removed BroadcastProducer::abort and BroadcastDynamic::abort.

The Lock-based origin tree is unchanged — connections stay scoped to their place in the tree so they don't contend on a global lock.

Callers

  • lite/ietf subscribers: store the guard in the per-broadcast map so Ended / restart / reflected-loop drop it. Side benefit: unannounce-on-Ended is now prompt instead of waiting for the broadcast to fully close.
  • stats / relay / cli / gst / rtc / boy / examples: hold the guard for the broadcast's lifetime.
  • FFI edges (libmoq, moq-ffi): keep fire-and-forget by spawning a small boundary watcher that drops the guard when the broadcast closes. Public signatures are unchanged, so the py/swift/kt/go wrappers and doc/lib need no changes.

Cross-package sync

Skipped js/net and doc/concept: this is a Rust-internal lifecycle refactor (RAII guard + removing abort); the wire protocol is unchanged, and the JS @moq/net API has its own idioms. Worth a follow-up if we want JS to mirror the guard/unannounce model.

Test plan

  • cargo test -p moq-net — 366 pass (origin tests now exercise OriginPublish::drop via a #[cfg(test)] helper that reproduces the old fire-and-forget behavior)
  • cargo test -p moq-ffi — 18 pass (exercises the relocated announce watcher)
  • cargo check --all-targets across every changed crate — clean
  • cargo clippy -p moq-net --all-targets (via nix) — clean
  • cargo fmt (via nix) applied
  • full just check — run once any concurrent local moq-cli demo (shared target dir) has exited

🤖 Generated with Claude Code

…st close

Make the origin announcement lifecycle sans-io. Previously `publish_broadcast`
spawned a task per broadcast that awaited `broadcast.closed()` and then removed
it from the tree, which required a runtime. Now publishing returns a guard whose
`Drop` does the removal synchronously, so the origin no longer watches closure
and no longer spawns.

This is paired with a conceptual change: a broadcast is just a collection of
tracks, so it can no longer be "closed" with a code. Removed
`BroadcastProducer::abort` / `BroadcastDynamic::abort`; a broadcast ends only
when every producer is dropped, and pending dynamic track requests are rejected
when the last dynamic handler goes away. There is no wire code for broadcast
closure, so nothing is lost.

API changes (moq-net, breaking):
- `publish_broadcast` returns `Result<OriginPublish, Error>` (was `bool`). The
  guard keeps the broadcast announced; drop it (or call `OriginPublish::unannounce`)
  to remove it. The announcement is independent of the broadcast's own lifetime,
  so you can now unannounce without closing the broadcast.
- `create_broadcast` returns `Result<BroadcastPublish, Error>`, which derefs to
  `BroadcastProducer` and unannounces on drop.
- New `Error::Loop` (hop chain already contains this origin) and reused
  `Error::Unauthorized` (path out of scope) distinguish the two reject reasons,
  instead of a bare `false`.
- Removed `BroadcastProducer::abort` and `BroadcastDynamic::abort`.

The Lock-based origin tree is unchanged: connections stay scoped to their place
in the tree so they don't contend on a global lock.

Callers updated to hold the guard for the broadcast's lifetime. The lite/ietf
subscribers store it in their per-broadcast maps so Ended/restart/reflected drop
it (which also makes unannounce-on-Ended prompt instead of waiting for the
broadcast to fully close). The runtime-bound FFI edges (libmoq, moq-ffi) keep
fire-and-forget by spawning a small boundary watcher that drops the guard on
close, so their public signatures and the language wrappers are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread rs/libmoq/src/origin.rs Outdated
// Errors only if the origin rejects the publish (loop / out of scope). libmoq origins
// are full-scope and broadcasts carry empty hop chains, so this is unreachable; ignore
// it to match the prior fire-and-forget contract.
if let Ok(publish) = origin.publish_broadcast(path, broadcast.clone()) {

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.

I think we need to store it in the slab? And add an unpublish method.

Also please return an error instead of assuming it will always be impossible.

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.

Done. publish now stores the OriginPublish guard in a new published slab and returns a publish handle; added moq_origin_unpublish (C: moq_origin_unpublish(handle)) which removes the entry and drops the guard to unannounce. It now propagates the origin error via ? instead of swallowing it. Note this drops the old auto-unannounce-on-close behavior: the announcement lives until unpublish, independent of the broadcast closing. (Written by Claude)

Comment thread rs/moq-cli/src/client.rs Outdated
origin.publish_broadcast(&name, publish.consume());
let _publish = origin
.publish_broadcast(&name, publish.consume())
.expect("origin should allow publishing");

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.

.context instead of .expect for all of these that use anyhow.

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.

Switched all the anyhow call sites (moq-cli client/server, clock/chat/video examples, moq-boy, moq-rtc) to .context("failed to publish broadcast")?. (Written by Claude)

Comment thread rs/moq-gst/src/sink/imp.rs Outdated
// Held for the lifetime of this task; dropping it (on return) unannounces the broadcast.
let _publish = origin
.publish_broadcast(&settings.broadcast, broadcast_consumer)
.map_err(|err| anyhow::anyhow!("failed to publish broadcast {}: {}", settings.broadcast, err))?;

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.

Please just use .context, I don't need to know the name.

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.

Done, now .context("failed to publish broadcast")? with no name. (Written by Claude)

…publish

- Remove the loop check (and `Error::Loop`) from `publish_broadcast`. Relays
  already filter reflections on the announce path, so the origin check was pure
  redundancy; keep a `debug_assert` as a dev tripwire. `publish_broadcast` now
  fails only with `Error::Unauthorized` (path out of scope).
- libmoq: store the `OriginPublish` guard in a slab and return a publish handle;
  add `moq_origin_unpublish` to drop it. Propagate the origin error instead of
  assuming the publish always succeeds.
- Use `anyhow::Context` instead of `.expect` / `anyhow!` for the anyhow callers
  (moq-cli, examples, moq-boy, moq-gst, moq-rtc), dropping the broadcast name
  from the message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kixelated kixelated enabled auto-merge (squash) June 7, 2026 01:48
@kixelated kixelated merged commit 623a36a into dev Jun 7, 2026
2 checks passed
@kixelated kixelated deleted the claude/interesting-agnesi-609ac5 branch June 7, 2026 01:53
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