Skip to content

moq-net: auto-create Origin on connect/accept, expose via Session#1536

Merged
kixelated merged 11 commits into
devfrom
claude/client-session-redesign
May 30, 2026
Merged

moq-net: auto-create Origin on connect/accept, expose via Session#1536
kixelated merged 11 commits into
devfrom
claude/client-session-redesign

Conversation

@kixelated
Copy link
Copy Markdown
Collaborator

@kixelated kixelated commented May 29, 2026

Summary

Makes explicitly constructing an Origin optional on both client and server in moq-net and moq-ffi. The common-case setup collapses from this:

let origin = Origin::random().produce();
let client = Client::new().with_origin(origin.clone());
let session = client.connect(transport).await?;
// ... use origin to publish / consume ...

to this:

let session = Client::new().connect(transport).await?;
let pub_origin = session.publisher().unwrap();
let sub_origin = session.consumer().unwrap();
// ... use them ...

with_publish / with_consume / with_origin stay for callers that need a custom topology (relay, gst, cli, ffi); when any of those is set, session.publisher() and session.consumer() return None and the caller drives things through the origin it already holds.

Targeting dev per CLAUDE.md (breaking change to public APIs in rs/moq-net, rs/moq-ffi, and the language wrappers).

Rust layer (rs/moq-net)

Session now carries the auto-created origin sides directly:

pub struct Session {
    /* existing fields */
    publisher: Option<OriginProducer>,
    consumer: Option<OriginConsumer>,
}

impl Session {
    pub fn publisher(&self) -> Option<&OriginProducer> { ... }
    pub fn consumer(&self) -> Option<&OriginConsumer> { ... }
}

Client::connect and Server::accept both auto-create an Origin when neither publish nor consume was set, then surface it through these accessors. One type instead of a separate ClientSession wrapper, symmetric across the client and server paths.

Downstream call sites in moq-native, moq-relay, moq-cli, moq-gst, libmoq, and moq-ffi thread session.publisher() / session.consumer() where they need the auto-created sides; everything else just continues to use session as before.

One internal moq-net test was depending on "no origin → trivial close with code 0 (Cancel)" to validate SETUP framing. With auto-origin attached the Lite01 session now closes with RequiredExtension (code 1) since Lite01 can't satisfy the new origin. Loosened the assertion to "any non-Version error" since the test's purpose is the framing path, not the close code.

FFI layer (rs/moq-ffi)

Mirror at the FFI level: MoqSession gains publisher() and consumer() accessor methods (uniffi Objects, return Option<Arc<MoqOriginProducer>> / Option<Arc<MoqOriginConsumer>>). MoqClient::connect continues to return Arc<MoqSession>.

Renamed MoqOriginProducer::publishadd_broadcast so session.publisher().unwrap().add_broadcast(path, broadcast) reads cleanly instead of stuttering …publisher().publish(...).

New test server_client_roundtrip_auto_origin validates the no-config path end-to-end (client doesn't call set_publish/set_consume; session.publisher() and session.consumer() are Some and drive a server → client broadcast).

Per-language wrapper sync (CLAUDE.md cross-package table)

  • py/moq-rs/moq/client.py: no extraction step needed, just stores the returned MoqSession directly.
  • py/moq-rs/moq/origin.py: Python OriginProducer.publish wrapper kept (no Python API churn); now calls add_broadcast on the FFI underneath.
  • swift/README.md, doc/lib/{swift,kt}/{index,moq}.md: show the new shape — no MoqOriginProducer() construction in the happy path; session.publisher() / session.consumer() return the auto-created sides.
  • go/: pure binding shim, no Go-side wrapper to update; consumers get the regenerated MoqClient.Connect() returning *MoqSession with the new accessors.
  • Swift Moq.swift / Kotlin Moq.kt: already deleted in the prior PR swift+kt: re-export FFI, session.shutdown(); explicit Origin wiring #1526, so no entry-point shims to update here.

Why fold into Session instead of keeping ClientSession

The earlier version of this PR added a separate ClientSession { session, publisher, consumer } wrapper. Two reasons to fold:

  1. Symmetry: the same auto-origin behavior applies to servers too. Keeping ClientSession would force either a parallel ServerSession (more types) or asymmetric APIs. Folding the fields onto Session lets Server::accept use the same path with zero new naming.
  2. Naming: there's no obvious neutral name for the wrapper (Endpoint and Connection are overloaded), and Session already represents "the connected thing." Adding optional fields fits cleanly without inventing a new concept.

Test plan

  • cargo test --workspace --tests passes (all suites, including the new server_client_roundtrip_auto_origin).
  • cargo fmt --check clean; cargo clippy --workspace --all-targets clean.
  • just swift check passes (2/2 smoke tests).
  • Kotlin: gradle toolchain not available locally, so just kt check only ran the binding regeneration step. CI exercises the full Kotlin build.
  • Python: smoke tests not run locally (no uv environment set up in this worktree). CI runs just py check.

(Written by Claude)

kixelated and others added 3 commits May 28, 2026 20:02
Make explicitly constructing an Origin optional at the Rust layer to
match the FFI redesign that follows. When neither publish nor consume
is set on the Client builder, connect() now creates a fresh Origin and
wires it as both sides; the returned ClientSession exposes the producer
and consumer so the caller can publish broadcasts and read announcements
without ever touching Origin::random() themselves.

Client::connect's return type changes from Session to ClientSession:

  pub struct ClientSession {
      pub session: Session,
      pub publisher: Option<OriginProducer>,
      pub consumer: Option<OriginConsumer>,
  }

The Option fields are populated only on the no-config path so callers
that wire their own origin(s) (relay, cli, gst, boy, hang, ffi) keep
managing them as before. They just access cs.session instead of the
session directly.

Downstream call-site updates spread across moq-native (client,
reconnect, examples, tests), moq-relay (cluster, smoke tests), moq-gst,
libmoq, and moq-ffi. Each one renames its local binding from `session`
to `cs` and threads `cs.session` where it used to use `session`. None
of them currently exercise the auto-origin path (they all pre-set at
least one side), so the new fields are always None for them.

One internal moq-net test (no_alpn_falls_back_to_draft14_and_switches_
version_post_setup) depended on "no origin → trivial close with code 0
(Cancel)" to validate SETUP framing. With the auto-origin attached the
Lite01 session now closes with RequiredExtension (code 1) since Lite01
can't satisfy the new origin. Loosened the assertion to "any non-Version
error" since the test's purpose is the framing path, not the close code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace MoqClient::connect's return type with a richer MoqClientSession
that exposes the auto-created origin sides via accessor methods, mirroring
the moq-net change in the prior commit.

  pub struct MoqClientSession {
      session: Arc<MoqSession>,
      publisher: Option<Arc<MoqOriginProducer>>,
      consumer: Option<Arc<MoqOriginConsumer>>,
  }

  #[uniffi::export]
  impl MoqClientSession {
      pub fn session(&self) -> Arc<MoqSession>;
      pub fn publisher(&self) -> Option<Arc<MoqOriginProducer>>;
      pub fn consumer(&self) -> Option<Arc<MoqOriginConsumer>>;
  }

publisher/consumer are Some only on the no-config path (caller didn't call
setPublish or setConsume). Callers that wire their own origin keep doing
so and ignore the new accessors.

Rename `MoqOriginProducer::publish` to `add_broadcast` per the redesign
plan: `cs.publisher().add_broadcast(...)` reads cleanly where
`cs.publisher().publish(...)` would stutter. The internal Python wrapper
keeps its own `publish` method name (no public Python API churn) but now
calls `add_broadcast` on the FFI underneath.

Cross-package sync per CLAUDE.md:
- py/moq-rs/moq/client.py: extract `.session()` from the new return type
  so the public `client.session` property still hands back a MoqSession.
- py/moq-rs/moq/origin.py: wrapper.publish calls inner.add_broadcast.
- swift/README.md, doc/lib/{swift,kt}/{index,moq}.md: show the new
  pattern (no MoqOriginProducer() construction needed in the happy path;
  cs.publisher() / cs.consumer() return the auto-created sides).

New test `server_client_roundtrip_auto_origin` validates the no-config
path end-to-end. Existing 17 tests still pass after migrating each
`origin.publish(...)` and `session.shutdown()` call to `add_broadcast`
and `cs.session().shutdown()` respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session now carries the auto-created origin sides directly:

  pub struct Session {
      /* existing fields */
      publisher: Option<OriginProducer>,
      consumer: Option<OriginConsumer>,
  }
  impl Session {
      pub fn publisher(&self) -> Option<&OriginProducer> { ... }
      pub fn consumer(&self) -> Option<&OriginConsumer> { ... }
  }

The ClientSession wrapper from the previous commit was client-only, but
the auto-origin pattern is symmetric: a server with no publish/consume
configured can also have one wired in. Server::accept now auto-creates
identically to Client::connect, and both surface the result via the
same Session accessors. One type instead of two; no name to bikeshed.

Downstream callers unwind: `let cs = client.connect(...)` → `let session
= client.connect(...)`, `cs.session.closed()` → `session.closed()`, etc.
The unwind is mostly mechanical (sed `cs.session.` → `cs.`) across
moq-native, moq-relay, moq-cli, moq-gst, libmoq, moq-ffi.

FFI mirror in the same shape: MoqClientSession deleted, publisher() and
consumer() accessors added directly to MoqSession. MoqClient::connect
goes back to returning Arc<MoqSession>. Python wrapper drops the
.session() extraction. Swift README + doc/lib/{swift,kt}/{index,moq}.md
updated to call `cs.shutdown()` / `cs.publisher()` / `cs.consumer()`
directly on the returned session.

The existing FFI test server_client_roundtrip_auto_origin still
exercises the no-config path end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kixelated kixelated changed the title moq-ffi: auto-create Origin on connect, expose via MoqClientSession moq-net: auto-create Origin on connect/accept, expose via Session May 29, 2026
kixelated and others added 4 commits May 29, 2026 18:08
…kes Producer

Drop the Option<> around publisher/consumer on Session and at the FFI
MoqSession level. Each accessor is now infallibly Some: whatever the
caller wired via with_publish/with_consume, or a fresh auto-created
Origin for any side they left unset. The duplex no-config case still
defaults to one shared Origin (the typical client).

Unifying on always-Some required picking a consistent type for the
publish-side wiring. with_publish (on both Client and Server) now takes
an OriginProducer instead of an OriginConsumer; internally moq-net
calls .consume() on it just like every caller used to do at the call
site. with_consume already took a Producer, so that side is unchanged.

Cascading updates:
- Drop trailing `.consume()` from every `with_publish(x.consume())`
  call across moq-cli, moq-gst, moq-boy, moq-native tests/examples,
  moq-relay, libmoq, moq-ffi.
- Relay's `Cluster::subscriber()` returns Option<OriginProducer> now
  (the underlying `.with_root(...).scope(...)` already yields a
  Producer; we just stop calling `.consume()` at the end).
- relay/web.rs `serve_announced` / `serve_fetch` derive their read
  handle via `origin.consume()` since they now hold a Producer.
- FFI MoqSession.publisher() / .consumer() return Arc<...> directly
  (no Option). The auto-origin smoke test drops its .expect().
- Swift README + per-language docs no longer mention nil/null fallback
  for the accessors.

Behavioral note: a client/server that wires only one side now also
gets a fresh auto-created Origin on the other side, where previously
the unset side was simply absent. Allocations are cheap; the trade is
"always have something to call .publisher() / .consumer() on" for
"slightly more state per one-sided session".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picked up #1528 (moq-rtc) and #1531 (compress field on Track) by
merging origin/dev. The new moq-rtc binary still passes
publisher.consume() to with_publish, which used to take an
OriginConsumer but now takes an OriginProducer. Drop the .consume().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The auto-origin work removed `Origin` from server.rs's imports because
the construction logic moved into the shared `client::resolve_origins`
helper. The accept() docstring still referenced [`Origin`] as an
intra-doc link, which now resolves to nothing. cargo doc with
-D warnings (CI) flags this; locally it only shows up when running
`just check` which has the same flag.

Switch to the fully-qualified [`Origin`](crate::Origin) form so the
link works without depending on what's currently in scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread doc/lib/swift/index.md Outdated
Comment thread doc/lib/swift/moq.md Outdated
Comment thread doc/lib/swift/moq.md Outdated
Comment thread rs/hang/examples/video.rs Outdated
Comment thread rs/libmoq/src/session.rs
kixelated and others added 3 commits May 29, 2026 20:03
…unce

Per inline review on rs/hang/examples/video.rs and rs/libmoq/src/session.rs:
with_publish (and the matching libmoq Session::connect publish param)
takes an OriginConsumer again. The user's downstream code was already
written against that shape and the asymmetry actually fits the relay's
scoped-subscriber use case better than the symmetric Producer form.

Session.publisher and Session.consumer stay always-Some. When the
caller overrides either side via with_publish / with_consume, the
session's accessor for that side becomes a standalone auto-created
no-op (the caller drives publishing/consuming through whatever they
passed in). The duplex no-config path is unchanged: one shared origin
powers Session.publisher, Session.consumer, and both wire directions.

resolve_origins picks the four values (Session.publisher,
Session.consumer, wire-publish, wire-consume) from the user's
overrides in one place, so both Client::connect and Server::accept
funnel through the same logic.

Renamed MoqOriginProducer::add_broadcast to announce per the inline
review on doc/lib/swift/moq.md (cleaner: cs.publisher().announce(...)
vs publisher().add_broadcast). Python's OriginProducer.publish wrapper
keeps its name and now calls inner.announce underneath.

Doc nits per review:
- doc/lib/swift/index.md: inline `let consumer = cs.consumer()` into
  the same line as `.announced(prefix:)`.
- doc/lib/swift/moq.md + doc/lib/swift/index.md: use cdn.moq.dev/anon
  as the example URL.

Cascading caller-side cleanup: re-add .consume() to every
with_publish(producer) call across moq-cli, moq-native (tests +
examples), moq-boy, moq-gst, moq-relay, moq-rtc, libmoq, FFI, and
the relay's cluster.subscriber() goes back to returning OriginConsumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolve_origins collapses from a 4-tuple to a 2-tuple. The asymmetric
Consumer-vs-Producer API was forcing Session.publisher / Session.consumer
to decouple from the wire whenever the caller overrode either side
(no-op fallback). Unifying with_publish on OriginProducer removes the
asymmetry: the same origin powers Session.publisher AND the wire's
read side (via .consume() internally).

Inverse of the previous commit:
- with_publish (moq-net Client + Server + Request, moq-native, libmoq,
  FFI) takes OriginProducer again.
- resolve_origins picks (publisher, consumer) with at most two fresh
  origins and the duplex sharing for the no-config case. No more
  4-tuple, no no-op fallback.
- All `with_publish(x.consume())` callers across moq-cli, moq-native
  (tests + examples), moq-boy, moq-gst, moq-rtc, libmoq, FFI, and
  moq-relay/tests/smoke.rs drop the .consume() and pass the Producer
  directly.
- moq-relay's cluster.subscriber() returns Option<OriginProducer> again
  (the underlying .scope() yields a Producer; we just stop calling
  .consume() at the end).
- moq-relay/src/web.rs serve_announced / serve_fetch derive the
  consumer locally via origin.consume() since they now hold a Producer.
- chat.rs and video.rs examples flip their run_session signature back
  to OriginProducer.

This is the API kixelated@ ended up endorsing on the PR: I had thrashed
between the two shapes; the 4-tuple was the artifact of trying to
satisfy "always-Some + with_publish(Consumer)" simultaneously. With
with_publish(Producer), the always-Some property falls out naturally
and the function is two lines per arm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the accessor names on Session (publisher / consumer) and reads
better with the new symmetric Producer-taking shape: with_publisher(p)
sets the publisher that Session::publisher returns. Rename is purely
mechanical across the workspace; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread rs/moq-net/src/server.rs Outdated
Comment thread doc/lib/kt/index.md Outdated
Comment thread doc/lib/kt/moq.md Outdated
Comment thread doc/lib/rs/env/native.md Outdated
Comment thread doc/lib/swift/moq.md Outdated
Comment thread rs/moq-net/src/client.rs Outdated
Comment thread rs/moq-net/src/client.rs Outdated
…n from inner libs

Two structural simplifications:

1) Client and Server fields go from Option<OriginProducer> to plain
   OriginProducer. Default::default() initializes both sides with one
   shared Origin::random().produce() — the typical full-duplex client
   needs no setup. with_publisher/with_consumer overwrite the defaults.
   resolve_origins is gone; connect/accept just clone the fields.

2) lite::start, ietf::start, PublisherConfig, SubscriberConfig, and
   the ietf Subscriber field all drop Option. There's always an origin
   now, so the no-op fallbacks (Origin::random().produce().consume(),
   the has_origin/is_none guards, the .as_ref().expect()/.unwrap()
   chains) disappear.

Callers that hold an Option (FFI MoqClient, MoqServer, libmoq's
Session::connect, moq-relay's connection/websocket handlers) move
their None-handling to the call site — `if let Some(p) = ... { client
= client.with_publisher(p) }`. moq-net itself defaults the unset side
to a fresh no-op origin, which matches the previous behavior for
publish-only or subscribe-only configurations.

doc/lib/rs/env/native.md publishing+subscribing examples updated to
the new pattern (no manual Origin::new().produce(); use
session.publisher() / session.consumer() directly).

doc/lib/{swift,kt}/{index,moq}.md drop the `let consumer = cs.consumer()`
temporary per review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kixelated kixelated enabled auto-merge (squash) May 30, 2026 04:16
@kixelated kixelated merged commit e7701ed into dev May 30, 2026
2 checks passed
@kixelated kixelated deleted the claude/client-session-redesign branch May 30, 2026 04:28
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