From 67ceb4776a5075f709df771489817002cf2ec5fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 10:58:26 -0700 Subject: [PATCH 1/9] quest: claim quest/m1/ffi-shape/json Co-Authored-By: Claude Opus 5.5 From 8e8d93d582514bf46301ffae407f572ae6e99b6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 11:07:47 -0700 Subject: [PATCH 2/9] refactor(ffi)!: construct JSON and flate tracks from the track they wrap BroadcastProducer/BroadcastConsumer lose publish_json_*, subscribe_json_*, and publish_flate_*. Each JSON and flate type gains a constructor over a track producer or consumer, so a track accepted from a request works too; producers still advertise in the broadcast's catalog. Co-Authored-By: Claude Opus 5.5 --- go/wrapper/errors.go | 2 +- rs/moq-ffi/src/consumer.rs | 26 ++++- rs/moq-ffi/src/error.rs | 14 +-- rs/moq-ffi/src/flate.rs | 85 +++++++-------- rs/moq-ffi/src/json.rs | 163 +++++++++++++--------------- rs/moq-ffi/src/producer.rs | 13 +++ rs/moq-ffi/src/test.rs | 217 +++++++++++++++++++++++-------------- 7 files changed, 301 insertions(+), 219 deletions(-) diff --git a/go/wrapper/errors.go b/go/wrapper/errors.go index ab9fd032fd..b1ba8c401d 100644 --- a/go/wrapper/errors.go +++ b/go/wrapper/errors.go @@ -74,7 +74,7 @@ var ( ErrNotFound = ffi.ErrMoqErrorNotFound // ErrUnsupported is returned when the requested operation is not supported by this build or peer, however it is asked for. ErrUnsupported = ffi.ErrMoqErrorUnsupported - // ErrAlreadyCommitted is returned when a track already reading in one group order is asked for the other; subscribe again for a second cursor. + // ErrAlreadyCommitted is returned when a track already reading one way (arrival order, sequence order, or a typed reader) is asked to read another; subscribe again for a second cursor. ErrAlreadyCommitted = ffi.ErrMoqErrorAlreadyCommitted // ErrInvalidRoute is returned when a route has an invalid hop ID or too many hops. ErrInvalidRoute = ffi.ErrMoqErrorInvalidRoute diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 84da7e0076..cf1db05cfe 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -119,8 +119,7 @@ impl MoqBroadcastConsumer { } } - /// Access the underlying `moq_net::broadcast::Consumer` for sibling - /// modules (e.g. `audio`) that need to subscribe a typed track. + #[cfg(test)] pub(crate) fn inner(&self) -> &moq_net::broadcast::Consumer { &self.inner } @@ -544,6 +543,29 @@ impl MoqTrackConsumer { } } + /// Hand the subscription to a typed reader (a JSON consumer, say), closing this handle. + /// + /// Errors with [`MoqError::AlreadyCommitted`] once a group read has started, since the reader + /// would miss whatever that read consumed. + pub(crate) fn take(&self) -> Result { + let mut guard = self.shared.inner.lock().expect("track reader"); + match guard.as_ref().map(|inner| &inner.track) { + Some(Cursor::Uncommitted(_)) => {} + Some(_) => return Err(MoqError::AlreadyCommitted), + None => return Err(MoqError::Closed), + } + let Some(TrackInner { + track: Cursor::Uncommitted(track), + }) = guard.take() + else { + unreachable!("just matched Uncommitted"); + }; + drop(guard); + // Wake any datagram read still parked on the subscriber this handle no longer owns. + self.cancel_lanes(); + Ok(track) + } + fn cancel_lanes(&self) { self.shared.close(); self.groups.cancel(); diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs index cce70fb82f..f27f14f116 100644 --- a/rs/moq-ffi/src/error.rs +++ b/rs/moq-ffi/src/error.rs @@ -246,14 +246,14 @@ pub enum MoqError { #[error("unsupported")] Unsupported, - /// This track already committed to the other delivery order. + /// This track already committed to another way of reading it. /// - /// A track is read in arrival order or in sequence order, never both, and the first - /// group read picks which. Reaching for the other one afterwards is this error rather - /// than [`Self::Unsupported`]: both orders work fine here, the track just isn't - /// reading in the one you asked for. Read the track through a second consumer if you - /// genuinely need both. - #[error("already committed to the other delivery order")] + /// A track is read in arrival order, in sequence order, or by a typed reader such as a + /// JSON consumer, never two of them, and the first group read picks the order. Reaching + /// for another one afterwards is this error rather than [`Self::Unsupported`]: each works + /// fine here, the track just isn't reading the way you asked for. Read the track through + /// a second consumer if you genuinely need both. + #[error("already committed to another way of reading")] AlreadyCommitted, /// A route carried an invalid hop id or too many hops. diff --git a/rs/moq-ffi/src/flate.rs b/rs/moq-ffi/src/flate.rs index a447306ec1..2722ae3dea 100644 --- a/rs/moq-ffi/src/flate.rs +++ b/rs/moq-ffi/src/flate.rs @@ -1,7 +1,7 @@ //! Opaque data tracks over the FFI boundary, advertised in the catalog. //! //! The `moq-flate` counterpart of [`crate::json`]: opaque payloads (for example a camera's latest -//! JPEG thumbnail) on a named track, in either mode: `snapshot` (each payload supersedes the last) +//! JPEG thumbnail) on a track, in either mode: `snapshot` (each payload supersedes the last) //! or `stream` (every payload preserved in order). The broadcast's catalog carries //! `binary.tracks.` (mode, plus `mime` and `compression` when set) for as long as the track //! lives, so a consumer discovers it without knowing the application. @@ -11,7 +11,7 @@ use std::sync::Arc; use moq_mux::catalog::hang::Extra; use crate::error::MoqError; -use crate::producer::MoqBroadcastProducer; +use crate::producer::{MoqBroadcastProducer, MoqTrackProducer}; /// Options for an opaque data track, in either mode (the mode is fixed by the constructor). #[derive(Clone, uniffi::Record)] @@ -35,49 +35,6 @@ impl From for moq_mux::binary::Config { } } -#[uniffi::export] -impl MoqBroadcastProducer { - /// Publish an opaque snapshot track (lossy latest-value) by name, advertised in the catalog. - /// - /// Errors if the catalog already carries an entry under `name`. - pub fn publish_flate_snapshot( - &self, - name: String, - config: MoqFlateConfig, - ) -> Result, MoqError> { - let _guard = crate::ffi::enter(); - self.with_state(|state| { - let track = state.broadcast.create_track(name, None)?; - let producer = state - .catalog - .binary_snapshot(track, moq_mux::binary::Config::from(config))?; - Ok(Arc::new(MoqFlateSnapshotProducer { - inner: std::sync::Mutex::new(Some(producer)), - })) - }) - } - - /// Publish an opaque stream track (lossless append-log) by name, advertised in the catalog. - /// - /// Errors if the catalog already carries an entry under `name`. - pub fn publish_flate_stream( - &self, - name: String, - config: MoqFlateConfig, - ) -> Result, MoqError> { - let _guard = crate::ffi::enter(); - self.with_state(|state| { - let track = state.broadcast.create_track(name, None)?; - let producer = state - .catalog - .binary_stream(track, moq_mux::binary::Config::from(config))?; - Ok(Arc::new(MoqFlateStreamProducer { - inner: std::sync::Mutex::new(Some(producer)), - })) - }) - } -} - /// Publishes opaque payloads that consumers see as a single latest value. #[derive(uniffi::Object)] pub struct MoqFlateSnapshotProducer { @@ -86,6 +43,25 @@ pub struct MoqFlateSnapshotProducer { #[uniffi::export] impl MoqFlateSnapshotProducer { + /// Publish `track` as an opaque snapshot track (lossy latest-value), advertised in `broadcast`'s catalog. + /// + /// Takes over `track`, whose handle is closed afterward. Errors if the catalog already + /// carries an entry under the track's name. + #[uniffi::constructor] + pub fn new( + broadcast: &MoqBroadcastProducer, + track: &MoqTrackProducer, + config: MoqFlateConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let config = moq_mux::binary::Config::from(config); + let producer = + track.adopt(|track| broadcast.with_state(|state| Ok(state.catalog.binary_snapshot(track, config)?)))?; + Ok(Arc::new(Self { + inner: std::sync::Mutex::new(Some(producer)), + })) + } + /// Publish a new payload, superseding the last. pub fn update(&self, payload: Vec) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); @@ -111,6 +87,25 @@ pub struct MoqFlateStreamProducer { #[uniffi::export] impl MoqFlateStreamProducer { + /// Publish `track` as an opaque stream track (lossless append-log), advertised in `broadcast`'s catalog. + /// + /// Takes over `track`, whose handle is closed afterward. Errors if the catalog already + /// carries an entry under the track's name. + #[uniffi::constructor] + pub fn new( + broadcast: &MoqBroadcastProducer, + track: &MoqTrackProducer, + config: MoqFlateConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let config = moq_mux::binary::Config::from(config); + let producer = + track.adopt(|track| broadcast.with_state(|state| Ok(state.catalog.binary_stream(track, config)?)))?; + Ok(Arc::new(Self { + inner: std::sync::Mutex::new(Some(producer)), + })) + } + /// Append one payload to the log. pub fn append(&self, payload: Vec) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 6b10c40e10..9e48d4522e 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -1,19 +1,20 @@ //! Generic JSON tracks over the FFI boundary. //! -//! Wraps [`moq_json`] so native callers can publish and consume JSON on an arbitrary named -//! track, in either mode: `snapshot` (lossy latest-value, RFC 7396 merge-patch deltas) or -//! `stream` (lossless append-log). Values cross the boundary as JSON strings; the caller parses -//! and serializes on its own side. +//! Wraps [`moq_json`] so native callers can publish and consume JSON on a track, in either mode: +//! `snapshot` (lossy latest-value, RFC 7396 merge-patch deltas) or `stream` (lossless +//! append-log). Each type is constructed from the track it wraps, so a track accepted from a +//! request works as well as one created by name. Values cross the boundary as JSON strings; the +//! caller parses and serializes on its own side. use std::sync::Arc; use serde_json::Value; -use crate::consumer::MoqBroadcastConsumer; +use crate::consumer::MoqTrackConsumer; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; -use crate::producer::MoqBroadcastProducer; +use crate::producer::{MoqBroadcastProducer, MoqTrackProducer}; use moq_mux::catalog::hang::Extra; /// Options for a JSON snapshot track (lossy latest-value mode). @@ -98,85 +99,6 @@ mod tests { } } -// ---- Entry points ---- - -#[uniffi::export] -impl MoqBroadcastProducer { - /// Publish a JSON snapshot track (lossy latest-value) by name, advertised in the catalog. - /// - /// The broadcast's catalog carries `json.tracks.` (`mode: snapshot`, and - /// `compression: deflate` when set) for as long as the track lives; finishing or dropping the - /// producer retires it. Errors if the catalog already carries an entry under `name`. - pub fn publish_json_snapshot( - &self, - name: String, - config: MoqJsonSnapshotConfig, - ) -> Result, MoqError> { - let _guard = crate::ffi::enter(); - self.with_state(|state| { - let track = state.broadcast.create_track(name, None)?; - let config = moq_mux::json::Config::default() - .with_compression(config.compression) - .with_delta_ratio(config.delta_ratio); - let producer = state.catalog.json_snapshot::(track, config)?; - Ok(Arc::new(MoqJsonSnapshotProducer { - inner: std::sync::Mutex::new(Some(producer)), - })) - }) - } - - /// Publish a JSON stream track (lossless append-log) by name, advertised in the catalog. - /// - /// The broadcast's catalog carries `json.tracks.` (`mode: stream`) for as long as the - /// track lives. Errors if the catalog already carries an entry under `name`. - pub fn publish_json_stream( - &self, - name: String, - config: MoqJsonStreamConfig, - ) -> Result, MoqError> { - let _guard = crate::ffi::enter(); - self.with_state(|state| { - let track = state.broadcast.create_track(name, None)?; - let config = moq_mux::json::Config::default().with_compression(config.compression); - let producer = state.catalog.json_stream::(track, config)?; - Ok(Arc::new(MoqJsonStreamProducer { - inner: std::sync::Mutex::new(Some(producer)), - })) - }) - } -} - -#[uniffi::export] -impl MoqBroadcastConsumer { - /// Subscribe to a JSON snapshot track (lossy latest-value) by name. - /// - /// Pass the same [`MoqJsonSnapshotConfig::compression`] the producer used. - pub async fn subscribe_json_snapshot( - &self, - name: String, - config: MoqJsonSnapshotConfig, - ) -> Result, MoqError> { - let track = self.inner().track(&name)?.subscribe(None).await?; - let consumer = moq_json::snapshot::Consumer::::new(track, config.into()); - Ok(Arc::new(MoqJsonSnapshotConsumer { - task: Task::new(SnapshotConsumer { inner: consumer }), - })) - } - - /// Subscribe to a JSON stream track (lossless append-log) by name. - pub async fn subscribe_json_stream( - &self, - name: String, - config: MoqJsonStreamConfig, - ) -> Result, MoqError> { - let track = self.inner().track(&name)?.subscribe(None).await?; - let consumer = moq_json::stream::Consumer::::new(track, config.into()); - Ok(Arc::new(MoqJsonStreamConsumer { - task: Task::new(StreamConsumer { inner: consumer }), - })) - } -} - // ---- Snapshot ---- /// Publishes a JSON value that consumers see as a single latest state. @@ -187,6 +109,30 @@ pub struct MoqJsonSnapshotProducer { #[uniffi::export] impl MoqJsonSnapshotProducer { + /// Publish `track` as a JSON snapshot track (lossy latest-value), advertised in `broadcast`'s catalog. + /// + /// The catalog carries `json.tracks.` (`mode: snapshot`, and `compression: deflate` when + /// set) for as long as the producer lives; finishing or dropping it retires the entry. Takes + /// over `track`, whose handle is closed afterward. Errors if the catalog already carries an + /// entry under the track's name. + #[uniffi::constructor] + pub fn new( + broadcast: &MoqBroadcastProducer, + track: &MoqTrackProducer, + config: MoqJsonSnapshotConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let config = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_delta_ratio(config.delta_ratio); + let producer = track.adopt(|track| { + broadcast.with_state(|state| Ok(state.catalog.json_snapshot::(track, config)?)) + })?; + Ok(Arc::new(Self { + inner: std::sync::Mutex::new(Some(producer)), + })) + } + /// Publish a new value, encoded as a snapshot or delta automatically. `value` is a JSON /// document. A no-op if unchanged from the previous update. pub fn update(&self, value: String) -> Result<(), MoqError> { @@ -234,6 +180,19 @@ pub struct MoqJsonSnapshotConsumer { #[uniffi::export] impl MoqJsonSnapshotConsumer { + /// Read `track` as a JSON snapshot track (lossy latest-value). + /// + /// Pass the same [`MoqJsonSnapshotConfig::compression`] the producer used. Takes over + /// `track`, whose handle is closed afterward; errors if it has already read a group. + #[uniffi::constructor] + pub fn new(track: &MoqTrackConsumer, config: MoqJsonSnapshotConfig) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let consumer = moq_json::snapshot::Consumer::::new(track.take()?, config.into()); + Ok(Arc::new(Self { + task: Task::new(SnapshotConsumer { inner: consumer }), + })) + } + /// Get the next value as a JSON string. Returns `None` once the track ends. /// /// A consumer that has fallen behind collapses the backlog and yields only the latest value. @@ -259,6 +218,27 @@ pub struct MoqJsonStreamProducer { #[uniffi::export] impl MoqJsonStreamProducer { + /// Publish `track` as a JSON stream track (lossless append-log), advertised in `broadcast`'s catalog. + /// + /// The catalog carries `json.tracks.` (`mode: stream`) for as long as the producer + /// lives. Takes over `track`, whose handle is closed afterward. Errors if the catalog already + /// carries an entry under the track's name. + #[uniffi::constructor] + pub fn new( + broadcast: &MoqBroadcastProducer, + track: &MoqTrackProducer, + config: MoqJsonStreamConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let config = moq_mux::json::Config::default().with_compression(config.compression); + let producer = track.adopt(|track| { + broadcast.with_state(|state| Ok(state.catalog.json_stream::(track, config)?)) + })?; + Ok(Arc::new(Self { + inner: std::sync::Mutex::new(Some(producer)), + })) + } + /// Append one record to the log. `value` is a JSON document. pub fn append(&self, value: String) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); @@ -305,6 +285,19 @@ pub struct MoqJsonStreamConsumer { #[uniffi::export] impl MoqJsonStreamConsumer { + /// Read `track` as a JSON stream track (lossless append-log). + /// + /// Pass the same [`MoqJsonStreamConfig::compression`] the producer used. Takes over `track`, + /// whose handle is closed afterward; errors if it has already read a group. + #[uniffi::constructor] + pub fn new(track: &MoqTrackConsumer, config: MoqJsonStreamConfig) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let consumer = moq_json::stream::Consumer::::new(track.take()?, config.into()); + Ok(Arc::new(Self { + task: Task::new(StreamConsumer { inner: consumer }), + })) + } + /// Get the next record as a JSON string. Returns `None` once the track ends. pub async fn next(&self) -> Result, MoqError> { self.task.run(|mut state| async move { state.next().await }).await diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index c471339c04..a2a7fadca5 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -675,6 +675,19 @@ impl MoqTrackProducer { let track = guard.as_ref().ok_or(MoqError::Closed)?; Ok(track.demand()) } + + /// Hand the track to a typed writer that `f` builds (a JSON producer, say), closing this + /// handle once `f` succeeds so the writer is the track's only producer. On failure the + /// handle stays open. + pub(crate) fn adopt( + &self, + f: impl FnOnce(moq_net::track::Producer) -> Result, + ) -> Result { + let mut guard = self.inner.lock().unwrap(); + let out = f(guard.as_ref().ok_or(MoqError::Closed)?.clone())?; + guard.take(); + Ok(out) + } } #[uniffi::export] diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 95744cc6a9..92453dd44d 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -7,8 +7,11 @@ use crate::consumer::MoqFetchGroupOptions; use crate::consumer::MoqSubscription; use crate::consumer::MoqTrackConsumer; use crate::error::MoqError; -use crate::flate::MoqFlateConfig; -use crate::json::{MoqJsonSnapshotConfig, MoqJsonStreamConfig}; +use crate::flate::{MoqFlateConfig, MoqFlateSnapshotProducer, MoqFlateStreamProducer}; +use crate::json::{ + MoqJsonSnapshotConfig, MoqJsonSnapshotConsumer, MoqJsonSnapshotProducer, MoqJsonStreamConfig, MoqJsonStreamConsumer, + MoqJsonStreamProducer, +}; use crate::media::{MoqAudioFormat, MoqAudioInit, MoqFrame, MoqVideoFormat, MoqVideoInit}; use crate::session::{MoqBackoff, MoqConnectionStatus}; @@ -616,6 +619,16 @@ async fn raw_track_update_does_not_wait_for_pending_read() { assert_eq!(frame.timestamp_us, 20_000); } +/// Subscribe to `name` on `broadcast` as a raw track, for a typed consumer to take over. +async fn subscribe(broadcast: &MoqBroadcastProducer, name: &str) -> Arc { + broadcast + .consume() + .unwrap() + .subscribe_track(name.into(), None) + .await + .unwrap() +} + #[tokio::test] async fn json_snapshot_roundtrip() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -623,13 +636,10 @@ async fn json_snapshot_roundtrip() { delta_ratio: 8, compression: true, }; - let producer = broadcast.publish_json_snapshot("meta".into(), config.clone()).unwrap(); - let consumer = broadcast - .consume() - .unwrap() - .subscribe_json_snapshot("meta".into(), config) - .await - .unwrap(); + let track = broadcast.publish_track("meta".into(), None).unwrap(); + let producer = MoqJsonSnapshotProducer::new(&broadcast, &track, config.clone()).unwrap(); + assert!(matches!(track.name(), Err(MoqError::Closed)), "the producer takes over the track"); + let consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "meta").await, config).unwrap(); producer.update(r#"{"a":1}"#.into()).unwrap(); let value = tokio::time::timeout(TIMEOUT, consumer.next()) @@ -667,27 +677,18 @@ async fn json_demand() { compression: true, }; let stream_config = MoqJsonStreamConfig { compression: true }; - let snapshot = broadcast - .publish_json_snapshot("status".into(), snapshot_config.clone()) - .unwrap(); - let stream = broadcast - .publish_json_stream("events".into(), stream_config.clone()) - .unwrap(); + let track = broadcast.publish_track("status".into(), None).unwrap(); + let snapshot = MoqJsonSnapshotProducer::new(&broadcast, &track, snapshot_config.clone()).unwrap(); + let track = broadcast.publish_track("events".into(), None).unwrap(); + let stream = MoqJsonStreamProducer::new(&broadcast, &track, stream_config.clone()).unwrap(); let snapshot_demand = snapshot.demand().unwrap(); let stream_demand = stream.demand().unwrap(); assert_eq!(snapshot_demand.name(), "status"); assert_eq!(stream_demand.name(), "events"); assert!(!snapshot_demand.is_used()); - let consumer = broadcast.consume().unwrap(); - let snapshot_consumer = consumer - .subscribe_json_snapshot("status".into(), snapshot_config) - .await - .unwrap(); - let stream_consumer = consumer - .subscribe_json_stream("events".into(), stream_config) - .await - .unwrap(); + let snapshot_consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "status").await, snapshot_config).unwrap(); + let stream_consumer = MoqJsonStreamConsumer::new(&*subscribe(&broadcast, "events").await, stream_config).unwrap(); tokio::time::timeout(TIMEOUT, snapshot_demand.used()) .await @@ -757,13 +758,9 @@ async fn demand_handle_outlives_finish() { async fn json_stream_roundtrip() { let broadcast = MoqBroadcastProducer::new().unwrap(); let config = MoqJsonStreamConfig { compression: true }; - let producer = broadcast.publish_json_stream("events".into(), config.clone()).unwrap(); - let consumer = broadcast - .consume() - .unwrap() - .subscribe_json_stream("events".into(), config) - .await - .unwrap(); + let track = broadcast.publish_track("events".into(), None).unwrap(); + let producer = MoqJsonStreamProducer::new(&broadcast, &track, config.clone()).unwrap(); + let consumer = MoqJsonStreamConsumer::new(&*subscribe(&broadcast, "events").await, config).unwrap(); for n in 0..3 { producer.append(format!(r#"{{"n":{n}}}"#)).unwrap(); @@ -780,6 +777,67 @@ async fn json_stream_roundtrip() { producer.finish().unwrap(); } +/// A JSON producer serves a track a subscriber requested, which the broadcast never created by name. +#[tokio::test] +async fn json_on_requested_track() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let dynamic = broadcast.dynamic().unwrap(); + let config = MoqJsonStreamConfig { compression: false }; + + // The subscribe stays pending until the request is accepted, so run it concurrently. + let subscribe = { + let consumer = broadcast.consume().unwrap(); + tokio::spawn(async move { consumer.subscribe_track("events".into(), None).await }) + }; + let request = tokio::time::timeout(TIMEOUT, dynamic.requested_track()) + .await + .expect("timed out waiting for requested track") + .unwrap(); + let producer = MoqJsonStreamProducer::new(&broadcast, &request.accept(None).unwrap(), config.clone()).unwrap(); + let track = tokio::time::timeout(TIMEOUT, subscribe) + .await + .expect("timed out waiting for the subscribe") + .unwrap() + .unwrap(); + let consumer = MoqJsonStreamConsumer::new(&track, config).unwrap(); + + producer.append(r#"{"n":1}"#.into()).unwrap(); + let value = tokio::time::timeout(TIMEOUT, consumer.next()) + .await + .expect("timed out waiting for json stream record") + .unwrap() + .expect("expected a record"); + assert_eq!(value, r#"{"n":1}"#); +} + +/// A JSON consumer refuses a track that has already read a group, and closes one it takes over. +#[tokio::test] +async fn json_consumer_takes_an_unread_track() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("events".into(), None).unwrap(); + let config = MoqJsonStreamConfig { compression: false }; + + let read = broadcast.consume().unwrap().subscribe_track("events".into(), None).await.unwrap(); + track.append_group().unwrap(); + tokio::time::timeout(TIMEOUT, read.recv_group()) + .await + .expect("timed out waiting for a group") + .unwrap() + .expect("expected a group"); + assert!(matches!( + MoqJsonStreamConsumer::new(&read, config.clone()), + Err(MoqError::AlreadyCommitted) + )); + + let unread = subscribe(&broadcast, "events").await; + MoqJsonStreamConsumer::new(&unread, config.clone()).unwrap(); + assert!(matches!(unread.recv_group().await, Err(MoqError::Cancelled))); + assert!(matches!( + MoqJsonStreamConsumer::new(&unread, config), + Err(MoqError::Closed) + )); +} + #[tokio::test] async fn dynamic_track_request() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -4537,18 +4595,21 @@ fn published_catalog( #[tokio::test] async fn json_tracks_are_advertised_in_the_catalog() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let snapshot = broadcast - .publish_json_snapshot( - "status".into(), - MoqJsonSnapshotConfig { - delta_ratio: 4, - compression: true, - }, - ) - .unwrap(); - let stream = broadcast - .publish_json_stream("events".into(), MoqJsonStreamConfig { compression: false }) - .unwrap(); + let snapshot = MoqJsonSnapshotProducer::new( + &broadcast, + &broadcast.publish_track("status".into(), None).unwrap(), + MoqJsonSnapshotConfig { + delta_ratio: 4, + compression: true, + }, + ) + .unwrap(); + let stream = MoqJsonStreamProducer::new( + &broadcast, + &broadcast.publish_track("events".into(), None).unwrap(), + MoqJsonStreamConfig { compression: false }, + ) + .unwrap(); let catalog = published_catalog(&broadcast); let entry = catalog.json.tracks.get("status").expect("snapshot track advertised"); @@ -4573,24 +4634,24 @@ async fn json_tracks_are_advertised_in_the_catalog() { #[tokio::test] async fn flate_tracks_are_advertised_in_the_catalog() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let thumb = broadcast - .publish_flate_snapshot( - "thumbnail".into(), - MoqFlateConfig { - compression: false, - mime: Some("image/jpeg".into()), - }, - ) - .unwrap(); - let log = broadcast - .publish_flate_stream( - "log".into(), - MoqFlateConfig { - compression: false, - mime: None, - }, - ) - .unwrap(); + let thumb = MoqFlateSnapshotProducer::new( + &broadcast, + &broadcast.publish_track("thumbnail".into(), None).unwrap(), + MoqFlateConfig { + compression: false, + mime: Some("image/jpeg".into()), + }, + ) + .unwrap(); + let log = MoqFlateStreamProducer::new( + &broadcast, + &broadcast.publish_track("log".into(), None).unwrap(), + MoqFlateConfig { + compression: false, + mime: None, + }, + ) + .unwrap(); thumb.update(vec![0xff, 0xd8, 0xff]).unwrap(); log.append(vec![1, 2, 3]).unwrap(); @@ -4613,30 +4674,28 @@ async fn flate_tracks_are_advertised_in_the_catalog() { } /// A second data track under a name the catalog already carries is refused, leaving the first. +/// +/// The broadcast refuses a duplicate track itself, so the collision comes from a track created on +/// another broadcast. The refused track's handle stays open for the caller. #[tokio::test] async fn data_track_names_cannot_collide() { let broadcast = MoqBroadcastProducer::new().unwrap(); - let first = broadcast - .publish_json_snapshot( - "state".into(), - MoqJsonSnapshotConfig { - delta_ratio: 0, - compression: false, - }, - ) - .unwrap(); + let first = MoqJsonSnapshotProducer::new( + &broadcast, + &broadcast.publish_track("state".into(), None).unwrap(), + MoqJsonSnapshotConfig { + delta_ratio: 0, + compression: false, + }, + ) + .unwrap(); + let other = MoqBroadcastProducer::new().unwrap(); + let track = other.publish_track("state".into(), None).unwrap(); assert!( - broadcast - .publish_flate_stream( - "state".into(), - MoqFlateConfig { - compression: false, - mime: None, - }, - ) - .is_err(), + MoqJsonStreamProducer::new(&broadcast, &track, MoqJsonStreamConfig { compression: false }).is_err(), "a duplicate data track name should fail" ); + assert_eq!(track.name().unwrap(), "state"); assert_eq!( published_catalog(&broadcast) .json From ed2745e48478cbadcdabe2de596511b0f9a59592 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 11:15:14 -0700 Subject: [PATCH 3/9] refactor(py,go)!: JSON tracks move to moq.json and moq.dev/moq/json Python gains a moq.json submodule and Go a moq.dev/moq/json subpackage, each constructing JSON producers and consumers from a track. Go's ctx bridge and the handles behind the root types move to internal/bridge so subpackages can share them without exporting anything, and package-wrapper.sh now stages subpackages. Co-Authored-By: Claude Opus 5.5 --- doc/lib/go/index.md | 6 +- doc/lib/py/index.md | 2 +- go/scripts/package-wrapper.sh | 8 +- go/wrapper/README.md | 21 +- go/wrapper/bridge.go | 15 ++ go/wrapper/client.go | 3 +- go/wrapper/errors.go | 111 --------- go/wrapper/internal/bridge/call.go | 150 ++++++++++++ .../bridge/call_test.go} | 16 +- go/wrapper/internal/bridge/handles.go | 18 ++ go/wrapper/iter.go | 37 --- go/wrapper/json.go | 205 ---------------- go/wrapper/json/json.go | 220 ++++++++++++++++++ go/wrapper/json/json_test.go | 181 ++++++++++++++ go/wrapper/moq_test.go | 135 +---------- go/wrapper/origin.go | 11 +- go/wrapper/publish.go | 9 +- go/wrapper/server.go | 7 +- go/wrapper/session.go | 5 +- go/wrapper/subscribe.go | 39 ++-- py/moq-rs/README.md | 2 +- py/moq-rs/docs/index.md | 22 +- py/moq-rs/moq/__init__.py | 10 +- py/moq-rs/moq/json.py | 154 ++++++++++++ py/moq-rs/moq/publish.py | 82 ------- py/moq-rs/moq/subscribe.py | 84 ------- py/moq-rs/tests/test_local.py | 24 +- 27 files changed, 846 insertions(+), 731 deletions(-) create mode 100644 go/wrapper/bridge.go create mode 100644 go/wrapper/internal/bridge/call.go rename go/wrapper/{errors_internal_test.go => internal/bridge/call_test.go} (89%) create mode 100644 go/wrapper/internal/bridge/handles.go delete mode 100644 go/wrapper/iter.go delete mode 100644 go/wrapper/json.go create mode 100644 go/wrapper/json/json.go create mode 100644 go/wrapper/json/json_test.go create mode 100644 py/moq-rs/moq/json.py diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 559db086a5..10a84b0f60 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -124,8 +124,10 @@ QUIC-only relay, and `moq.WithWebSocketDelay` changes the head start. returns the closed `moq.Transport` enum. `Request.SetPublish`/`SetConsume` return an error if the request is already answered, cancelled, or currently accepting; `ErrBusy` is the race with an -in-flight Accept. JSON tracks -take anything `encoding/json` handles and return `json.RawMessage`. The rest +in-flight Accept. JSON tracks live in `moq.dev/moq/json` (import it as `moqjson`): +`moqjson.NewSnapshotProducer(broadcast, track, options)` takes over a `TrackProducer` +and `moqjson.NewSnapshotConsumer(track, options)` a `TrackConsumer`, and they take +anything `encoding/json` handles and return `json.RawMessage`. The rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one: `FetchGroup`/`FetchMediaGroup`, `Dynamic()` with `Requests(ctx)`, `Session.Bandwidth()` to divide the send estimate, diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 54b0b90f64..c32d4b2269 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -64,7 +64,7 @@ async def main(): # Raw bytes and JSON events = broadcast.publish_track("events") events.write_frame(b'{"cmd": "ready"}', 0) - status = broadcast.publish_json_snapshot("status", compression=True) + status = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("status"), compression=True) status.update({"state": "live", "viewers": 42}) broadcast.announce() diff --git a/go/scripts/package-wrapper.sh b/go/scripts/package-wrapper.sh index 210d0db854..52f8d6cb16 100755 --- a/go/scripts/package-wrapper.sh +++ b/go/scripts/package-wrapper.sh @@ -103,11 +103,13 @@ mkdir -p "$PKG_STAGE" # --- 1. Copy in-tree source --- # The package sits at the module root, so the import path is the module path: -# `import "moq.dev/moq"` binds `moq` with no alias. +# `import "moq.dev/moq"` binds `moq` with no alias. Subpackages (`moq.dev/moq/json`, +# `internal/`) keep their directories. `vendor/` is local `go mod vendor` output. cp "$SOURCE_DIR/go.mod" "$PKG_STAGE/" cp "$SOURCE_DIR/VERSION" "$PKG_STAGE/" -for f in "$SOURCE_DIR"/*.go; do - cp "$f" "$PKG_STAGE/" +(cd "$SOURCE_DIR" && find . -path ./vendor -prune -o -name '*.go' -print) | while IFS= read -r f; do + mkdir -p "$PKG_STAGE/$(dirname "$f")" + cp "$SOURCE_DIR/$f" "$PKG_STAGE/$f" done # Dual-license files lifted from the workspace root. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index b3743abc41..374a42ce52 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -85,13 +85,24 @@ before the stream reveals them. deriving a unique name from the format. A duplicate name fails, and the `OnTrack` variants refuse it because the request already names the track. -JSON tracks are available in two modes. `PublishJSONSnapshot` / `SubscribeJSONSnapshot` -carry lossy latest state, while `PublishJSONStream` / `SubscribeJSONStream` carry every -record in order. Producers accept any `encoding/json` value; consumers return +JSON tracks live in the `moq.dev/moq/json` subpackage, mirroring the `moq-json` crate. +Import it under an alias next to `encoding/json`. Each type wraps a track: +`NewSnapshotProducer` / `NewStreamProducer` take over a `TrackProducer` and advertise it +in the broadcast's catalog, and `NewSnapshotConsumer` / `NewStreamConsumer` take over a +`TrackConsumer` that has not read a group yet. Snapshots carry lossy latest state; streams +carry every record in order. Producers accept any `encoding/json` value; consumers return `json.RawMessage` so callers choose their own decoded type. -Publishing takes `JSONSnapshotOptions` or `JSONStreamOptions`; both subscribe calls take -`JSONSubscribeOptions`, where only `Compression` has to match the producer. +```go +import moqjson "moq.dev/moq/json" + +track, err := broadcast.PublishTrack("status", nil) +status, err := moqjson.NewSnapshotProducer(broadcast, track, moqjson.SnapshotOptions{Compression: true}) +err = status.Update(map[string]any{"viewers": 42}) +``` + +Producers take `SnapshotOptions` or `StreamOptions`; both consumers take +`ConsumerOptions`, where only `Compression` has to match the producer. ## Errors diff --git a/go/wrapper/bridge.go b/go/wrapper/bridge.go new file mode 100644 index 0000000000..09bdf8c4b6 --- /dev/null +++ b/go/wrapper/bridge.go @@ -0,0 +1,15 @@ +package moq + +import ( + ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" +) + +// Hand the subpackages (moq.dev/moq/json) the handles behind these types +// without exporting them. +func init() { + bridge.BroadcastProducer = func(v any) *ffi.MoqBroadcastProducer { return v.(*BroadcastProducer).inner } + bridge.TrackProducer = func(v any) *ffi.MoqTrackProducer { return v.(*TrackProducer).inner } + bridge.TrackConsumer = func(v any) *ffi.MoqTrackConsumer { return v.(*TrackConsumer).inner } + bridge.TrackDemand = func(inner *ffi.MoqTrackDemand) any { return &TrackDemand{inner: inner} } +} diff --git a/go/wrapper/client.go b/go/wrapper/client.go index 475aed6c10..7640aa448f 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -7,6 +7,7 @@ import ( "time" ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" ) // ClientOption configures a client created with Dial. @@ -270,7 +271,7 @@ func Dial(ctx context.Context, url string, opts ...ClientOption) (*Client, error } c.inner = inner - session, err := runHandle(ctx, inner.Cancel, func(ctx context.Context) (*ffi.MoqSession, error) { + session, err := bridge.CallHandle(ctx, inner.Cancel, func(ctx context.Context) (*ffi.MoqSession, error) { return inner.Connect(ctx, url) }) if err != nil { diff --git a/go/wrapper/errors.go b/go/wrapper/errors.go index b1ba8c401d..519a0bd8c9 100644 --- a/go/wrapper/errors.go +++ b/go/wrapper/errors.go @@ -1,7 +1,6 @@ package moq import ( - "context" "errors" ffi "moq.dev/moq-ffi/moq" @@ -114,113 +113,3 @@ func ProtocolError(err error) (ffi.MoqProtocolError, bool) { } return proto.Details, true } - -// handle is a native object the wrapper owns. Every uniffi-generated object has -// Destroy, which drops the Rust-side Arc, and comparable so an optional one can -// be checked for nil before it is destroyed. -type handle interface { - comparable - Destroy() -} - -// run races a blocking FFI call against ctx. -// -// `cancel` is the object's own cancel() for a call that owns a stream, session, -// or listener, so a cancelled Next still ends the stream and a cancelled Connect -// still tears the client down. The generated binding is given a context that -// does not cancel with the caller (see objectCallContext); otherwise it can -// return ctx.Err() into the result channel first and skip that abort. The -// blocked goroutine then unwinds on its own; the result channel is buffered so -// its send never blocks and it can't leak. -// -// release, for a call that returns a native handle, disposes of a result nobody -// received. Cancelling is not a retraction: select picks at random when the -// deadline and the result are both ready, so a call can succeed and still lose, -// and the handle it produced is live either way. -func run[T any](ctx context.Context, cancel func(), call func() (T, error), release func(T)) (T, error) { - // A context that is already done starts no native work at all. Racing it - // instead would let a call that resolves immediately (a subscribe to a track - // already there) win the select and hand back a live handle the caller asked - // not to have, and every such call has side effects on the way. - if err := ctx.Err(); err != nil { - var zero T - return zero, err - } - - type result struct { - val T - err error - } - ch := make(chan result, 1) - go func() { - val, err := call() - ch <- result{val, err} - }() - - select { - case <-ctx.Done(): - if cancel != nil { - cancel() - } - if release != nil { - // Exactly one receiver takes the result, so it is this one once the - // caller has given up. Waiting here would put the native unwind on - // the caller's deadline, so it happens off to the side. - go func() { - if r := <-ch; r.err == nil { - release(r.val) - } - }() - } - var zero T - return zero, ctx.Err() - case r := <-ch: - return r.val, r.err - } -} - -// runCancellable runs a blocking FFI call that yields no handle of its own, so a -// result the caller never sees costs nothing to drop. -// -// cancel is the object's own cancel() for a call that owns its stream, session, -// or listener. One-shot calls pass ctx straight to the generated binding. -func runCancellable[T any](ctx context.Context, cancel func(), call func(context.Context) (T, error)) (T, error) { - return run(ctx, cancel, func() (T, error) { return call(objectCallContext(ctx, cancel)) }, nil) -} - -// runHandle is runCancellable for a call that returns a native handle, which is -// destroyed rather than abandoned when the caller has already given up. Left to -// the Go finalizer it would stay live in the meantime: a subscription still -// running on the wire, or an incoming request accepted and never answered. -func runHandle[T handle](ctx context.Context, cancel func(), call func(context.Context) (T, error)) (T, error) { - return run(ctx, cancel, func() (T, error) { return call(objectCallContext(ctx, cancel)) }, releaseHandle[T]) -} - -// objectCallContext is the context the generated binding sees. Object-owned -// calls already abort via cancel; sharing the caller's ctx lets the binding -// return ctx.Err() first and skip that abort, leaving the stream, session, or -// request active. -func objectCallContext(ctx context.Context, cancel func()) context.Context { - if cancel == nil { - return ctx - } - return context.WithoutCancel(ctx) -} - -// runErr is runCancellable for calls that return only an error. -func runErr(ctx context.Context, cancel func(), call func(context.Context) error) error { - _, err := runCancellable(ctx, cancel, func(ctx context.Context) (struct{}, error) { - return struct{}{}, call(ctx) - }) - return err -} - -// releaseHandle drops a handle the caller never received. A successful call can -// still yield none (an Accept once the server has stopped), which is the zero -// value rather than something to destroy. -func releaseHandle[T handle](val T) { - var zero T - if val != zero { - val.Destroy() - } -} diff --git a/go/wrapper/internal/bridge/call.go b/go/wrapper/internal/bridge/call.go new file mode 100644 index 0000000000..b41e133a0d --- /dev/null +++ b/go/wrapper/internal/bridge/call.go @@ -0,0 +1,150 @@ +// Package bridge is what moq.dev/moq shares with its subpackages: the context +// bridge for blocking FFI calls, and the generated handles behind the root +// package's wrapper types. +package bridge + +import ( + "context" + "iter" +) + +// Handle is a native object the wrapper owns. Every uniffi-generated object has +// Destroy, which drops the Rust-side Arc, and comparable so an optional one can +// be checked for nil before it is destroyed. +type Handle interface { + comparable + Destroy() +} + +// run races a blocking FFI call against ctx. +// +// `cancel` is the object's own cancel() for a call that owns a stream, session, +// or listener, so a cancelled Next still ends the stream and a cancelled Connect +// still tears the client down. The generated binding is given a context that +// does not cancel with the caller (see objectCallContext); otherwise it can +// return ctx.Err() into the result channel first and skip that abort. The +// blocked goroutine then unwinds on its own; the result channel is buffered so +// its send never blocks and it can't leak. +// +// release, for a call that returns a native handle, disposes of a result nobody +// received. Cancelling is not a retraction: select picks at random when the +// deadline and the result are both ready, so a call can succeed and still lose, +// and the handle it produced is live either way. +func run[T any](ctx context.Context, cancel func(), call func() (T, error), release func(T)) (T, error) { + // A context that is already done starts no native work at all. Racing it + // instead would let a call that resolves immediately (a subscribe to a track + // already there) win the select and hand back a live handle the caller asked + // not to have, and every such call has side effects on the way. + if err := ctx.Err(); err != nil { + var zero T + return zero, err + } + + type result struct { + val T + err error + } + ch := make(chan result, 1) + go func() { + val, err := call() + ch <- result{val, err} + }() + + select { + case <-ctx.Done(): + if cancel != nil { + cancel() + } + if release != nil { + // Exactly one receiver takes the result, so it is this one once the + // caller has given up. Waiting here would put the native unwind on + // the caller's deadline, so it happens off to the side. + go func() { + if r := <-ch; r.err == nil { + release(r.val) + } + }() + } + var zero T + return zero, ctx.Err() + case r := <-ch: + return r.val, r.err + } +} + +// Call runs a blocking FFI call that yields no handle of its own, so a +// result the caller never sees costs nothing to drop. +// +// cancel is the object's own cancel() for a call that owns its stream, session, +// or listener. One-shot calls pass ctx straight to the generated binding. +func Call[T any](ctx context.Context, cancel func(), call func(context.Context) (T, error)) (T, error) { + return run(ctx, cancel, func() (T, error) { return call(objectCallContext(ctx, cancel)) }, nil) +} + +// CallHandle is Call for a call that returns a native handle, which is +// destroyed rather than abandoned when the caller has already given up. Left to +// the Go finalizer it would stay live in the meantime: a subscription still +// running on the wire, or an incoming request accepted and never answered. +func CallHandle[T Handle](ctx context.Context, cancel func(), call func(context.Context) (T, error)) (T, error) { + return run(ctx, cancel, func() (T, error) { return call(objectCallContext(ctx, cancel)) }, releaseHandle[T]) +} + +// objectCallContext is the context the generated binding sees. Object-owned +// calls already abort via cancel; sharing the caller's ctx lets the binding +// return ctx.Err() first and skip that abort, leaving the stream, session, or +// request active. +func objectCallContext(ctx context.Context, cancel func()) context.Context { + if cancel == nil { + return ctx + } + return context.WithoutCancel(ctx) +} + +// CallErr is Call for calls that return only an error. +func CallErr(ctx context.Context, cancel func(), call func(context.Context) error) error { + _, err := Call(ctx, cancel, func(ctx context.Context) (struct{}, error) { + return struct{}{}, call(ctx) + }) + return err +} + +// releaseHandle drops a handle the caller never received. A successful call can +// still yield none (an Accept once the server has stopped), which is the zero +// value rather than something to destroy. +func releaseHandle[T Handle](val T) { + var zero T + if val != zero { + val.Destroy() + } +} + +// Seq turns an end-on-nil Next method (returning a pointer or interface) +// into a Go 1.23 range-over-func sequence. It yields (value, nil) for each item, +// yields (nil, err) once if a call fails, and stops cleanly when Next returns nil +// (the stream ended) or when the consumer breaks out of the range loop. +// +// for frame, err := range consumer.Frames(ctx) { +// if err != nil { +// if moq.IsShutdown(err) { break } +// return err +// } +// // use frame +// } +func Seq[T comparable](ctx context.Context, next func(context.Context) (T, error)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + var zero T + for { + val, err := next(ctx) + if err != nil { + yield(zero, err) + return + } + if val == zero { + return + } + if !yield(val, nil) { + return + } + } + } +} diff --git a/go/wrapper/errors_internal_test.go b/go/wrapper/internal/bridge/call_test.go similarity index 89% rename from go/wrapper/errors_internal_test.go rename to go/wrapper/internal/bridge/call_test.go index 23aaf48316..0d086a20c0 100644 --- a/go/wrapper/errors_internal_test.go +++ b/go/wrapper/internal/bridge/call_test.go @@ -1,4 +1,4 @@ -package moq +package bridge import ( "context" @@ -36,7 +36,7 @@ func TestRunHandleReleasesAResultNobodyReceived(t *testing.T) { cancelCtx() }() - val, err := runHandle(ctx, nil, func(context.Context) (*stubHandle, error) { + val, err := CallHandle(ctx, nil, func(context.Context) (*stubHandle, error) { close(started) <-finish return stub, nil @@ -70,7 +70,7 @@ func TestRunHandleIgnoresAResultThatIsNoHandle(t *testing.T) { cancelCtx() }() - _, err := runHandle(ctx, nil, func(context.Context) (*stubHandle, error) { + _, err := CallHandle(ctx, nil, func(context.Context) (*stubHandle, error) { close(started) <-finish close(returned) @@ -94,7 +94,7 @@ func TestRunHandleStartsNothingForADoneContext(t *testing.T) { cancelCtx() called := false - val, err := runHandle(ctx, nil, func(context.Context) (*stubHandle, error) { + val, err := CallHandle(ctx, nil, func(context.Context) (*stubHandle, error) { called = true return &stubHandle{destroyed: make(chan struct{})}, nil }) @@ -118,7 +118,7 @@ func TestRunHidesCallerCancelFromAnObjectOwnedCall(t *testing.T) { defer cancelCtx() got := make(chan context.Context, 1) - _, err := runCancellable(ctx, func() {}, func(callCtx context.Context) (int, error) { + _, err := Call(ctx, func() {}, func(callCtx context.Context) (int, error) { got <- callCtx return 1, nil }) @@ -139,7 +139,7 @@ func TestRunPassesCallerCancelWhenThereIsNoObjectCancel(t *testing.T) { defer cancelCtx() got := make(chan context.Context, 1) - _, err := runCancellable(ctx, nil, func(callCtx context.Context) (int, error) { + _, err := Call(ctx, nil, func(callCtx context.Context) (int, error) { got <- callCtx return 1, nil }) @@ -167,7 +167,7 @@ func TestRunCancelsTheObjectWhenCtxEndsDuringTheCall(t *testing.T) { cancelCtx() }() - _, err := runCancellable(ctx, func() { close(objectCancelled) }, func(callCtx context.Context) (int, error) { + _, err := Call(ctx, func() { close(objectCancelled) }, func(callCtx context.Context) (int, error) { close(started) <-objectCancelled select { @@ -198,7 +198,7 @@ func TestRunCancelsTheObjectWhenCtxEndsDuringTheCall(t *testing.T) { func TestRunHandleReturnsAResultThatWon(t *testing.T) { stub := &stubHandle{destroyed: make(chan struct{})} - val, err := runHandle(context.Background(), nil, func(context.Context) (*stubHandle, error) { + val, err := CallHandle(context.Background(), nil, func(context.Context) (*stubHandle, error) { return stub, nil }) if err != nil { diff --git a/go/wrapper/internal/bridge/handles.go b/go/wrapper/internal/bridge/handles.go new file mode 100644 index 0000000000..70f4712add --- /dev/null +++ b/go/wrapper/internal/bridge/handles.go @@ -0,0 +1,18 @@ +package bridge + +import ffi "moq.dev/moq-ffi/moq" + +// The generated handles behind the root package's wrapper types, which it keeps +// unexported. Package moq sets these from init, and every subpackage imports +// moq, so they are set before a subpackage can call them. They take and return +// `any` because this package cannot import moq's types without a cycle. +var ( + // BroadcastProducer returns the handle behind a *moq.BroadcastProducer. + BroadcastProducer func(any) *ffi.MoqBroadcastProducer + // TrackProducer returns the handle behind a *moq.TrackProducer. + TrackProducer func(any) *ffi.MoqTrackProducer + // TrackConsumer returns the handle behind a *moq.TrackConsumer. + TrackConsumer func(any) *ffi.MoqTrackConsumer + // TrackDemand wraps a handle as a *moq.TrackDemand. + TrackDemand func(*ffi.MoqTrackDemand) any +) diff --git a/go/wrapper/iter.go b/go/wrapper/iter.go deleted file mode 100644 index aaec05d077..0000000000 --- a/go/wrapper/iter.go +++ /dev/null @@ -1,37 +0,0 @@ -package moq - -import ( - "context" - "iter" -) - -// streamSeq turns an end-on-nil Next method (returning a pointer or interface) -// into a Go 1.23 range-over-func sequence. It yields (value, nil) for each item, -// yields (nil, err) once if a call fails, and stops cleanly when Next returns nil -// (the stream ended) or when the consumer breaks out of the range loop. -// -// for frame, err := range consumer.Frames(ctx) { -// if err != nil { -// if moq.IsShutdown(err) { break } -// return err -// } -// // use frame -// } -func streamSeq[T comparable](ctx context.Context, next func(context.Context) (T, error)) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - var zero T - for { - val, err := next(ctx) - if err != nil { - yield(zero, err) - return - } - if val == zero { - return - } - if !yield(val, nil) { - return - } - } - } -} diff --git a/go/wrapper/json.go b/go/wrapper/json.go deleted file mode 100644 index a1ba6f3408..0000000000 --- a/go/wrapper/json.go +++ /dev/null @@ -1,205 +0,0 @@ -package moq - -import ( - "context" - "encoding/json" - "iter" - - ffi "moq.dev/moq-ffi/moq" -) - -// DefaultDeltaRatio is used when [JSONSnapshotOptions.DeltaRatio] is nil. Go has no field -// defaults, so unlike the other bindings this restates the `#[uniffi(default = 8)]` on -// moq-ffi's MoqJsonSnapshotConfig (itself pinned to moq-json's default by a Rust test). -const DefaultDeltaRatio uint32 = 8 - -// JSONSnapshotOptions configures publishing a lossy latest-value JSON track. -type JSONSnapshotOptions struct { - // DeltaRatio controls how aggressively deltas replace full snapshots. Nil uses - // [DefaultDeltaRatio]; a pointer to zero disables deltas entirely. - DeltaRatio *uint32 - // Compression enables group-scoped DEFLATE and must match on the consumer. - Compression bool -} - -// deltaRatio resolves the configured ratio, falling back to the shared default. -func (o JSONSnapshotOptions) deltaRatio() uint32 { - if o.DeltaRatio == nil { - return DefaultDeltaRatio - } - return *o.DeltaRatio -} - -// JSONStreamOptions configures publishing a lossless append-log JSON track. -type JSONStreamOptions struct { - // Compression enables group-scoped DEFLATE and must match on the consumer. - Compression bool -} - -// JSONSubscribeOptions configures subscribing to either kind of JSON track. -// -// Delta encoding is a producer-side choice the consumer reconstructs automatically, -// so only the compression setting has to match. -type JSONSubscribeOptions struct { - // Compression enables group-scoped DEFLATE and must match the producer. - Compression bool -} - -// JSONSnapshotProducer publishes a JSON value as lossy latest state. -type JSONSnapshotProducer struct { - inner *ffi.MoqJsonSnapshotProducer -} - -// Update publishes value as a JSON snapshot or delta. It is a no-op when unchanged. -func (p *JSONSnapshotProducer) Update(value any) error { - encoded, err := json.Marshal(value) - if err != nil { - return err - } - return p.inner.Update(string(encoded)) -} - -// Demand returns a watch-only handle to whether the track has subscribers. -func (p *JSONSnapshotProducer) Demand() (*TrackDemand, error) { - inner, err := p.inner.Demand() - if err != nil { - return nil, err - } - return &TrackDemand{inner: inner}, nil -} - -// Finish closes the snapshot track. -func (p *JSONSnapshotProducer) Finish() error { - return p.inner.Finish() -} - -// JSONStreamProducer publishes an ordered lossless log of JSON records. -type JSONStreamProducer struct { - inner *ffi.MoqJsonStreamProducer -} - -// Append adds one JSON record to the log. -func (p *JSONStreamProducer) Append(value any) error { - encoded, err := json.Marshal(value) - if err != nil { - return err - } - return p.inner.Append(string(encoded)) -} - -// Demand returns a watch-only handle to whether the track has subscribers. -func (p *JSONStreamProducer) Demand() (*TrackDemand, error) { - inner, err := p.inner.Demand() - if err != nil { - return nil, err - } - return &TrackDemand{inner: inner}, nil -} - -// Finish closes the stream track. -func (p *JSONStreamProducer) Finish() error { - return p.inner.Finish() -} - -// JSONSnapshotConsumer receives the latest reconstructed JSON value. -type JSONSnapshotConsumer struct { - inner *ffi.MoqJsonSnapshotConsumer -} - -// Next returns the next JSON value, or nil when the track ends. -func (c *JSONSnapshotConsumer) Next(ctx context.Context) (*json.RawMessage, error) { - value, err := runCancellable(ctx, c.inner.Cancel, c.inner.Next) - if err != nil || value == nil { - return nil, err - } - decoded := json.RawMessage(*value) - return &decoded, nil -} - -// Values ranges over reconstructed JSON values until the track ends. -func (c *JSONSnapshotConsumer) Values(ctx context.Context) iter.Seq2[*json.RawMessage, error] { - return streamSeq(ctx, c.Next) -} - -// Cancel stops current and future reads. -func (c *JSONSnapshotConsumer) Cancel() { - c.inner.Cancel() -} - -// JSONStreamConsumer receives every JSON record in order. -type JSONStreamConsumer struct { - inner *ffi.MoqJsonStreamConsumer -} - -// Next returns the next JSON record, or nil when the track ends. -func (c *JSONStreamConsumer) Next(ctx context.Context) (*json.RawMessage, error) { - value, err := runCancellable(ctx, c.inner.Cancel, c.inner.Next) - if err != nil || value == nil { - return nil, err - } - decoded := json.RawMessage(*value) - return &decoded, nil -} - -// Values ranges over every JSON record until the track ends. -func (c *JSONStreamConsumer) Values(ctx context.Context) iter.Seq2[*json.RawMessage, error] { - return streamSeq(ctx, c.Next) -} - -// Cancel stops current and future reads. -func (c *JSONStreamConsumer) Cancel() { - c.inner.Cancel() -} - -// PublishJSONSnapshot opens a lossy latest-value JSON track. -func (b *BroadcastProducer) PublishJSONSnapshot( - name string, - options JSONSnapshotOptions, -) (*JSONSnapshotProducer, error) { - inner, err := b.inner.PublishJsonSnapshot(name, ffi.MoqJsonSnapshotConfig{ - DeltaRatio: options.deltaRatio(), - Compression: options.Compression, - }) - if err != nil { - return nil, err - } - return &JSONSnapshotProducer{inner: inner}, nil -} - -// PublishJSONStream opens a lossless append-log JSON track. -func (b *BroadcastProducer) PublishJSONStream(name string, options JSONStreamOptions) (*JSONStreamProducer, error) { - inner, err := b.inner.PublishJsonStream(name, ffi.MoqJsonStreamConfig{Compression: options.Compression}) - if err != nil { - return nil, err - } - return &JSONStreamProducer{inner: inner}, nil -} - -// SubscribeJSONSnapshot subscribes to a lossy latest-value JSON track. -func (b *BroadcastConsumer) SubscribeJSONSnapshot( - ctx context.Context, - name string, - options JSONSubscribeOptions, -) (*JSONSnapshotConsumer, error) { - inner, err := b.inner.SubscribeJsonSnapshot(ctx, name, ffi.MoqJsonSnapshotConfig{ - DeltaRatio: 0, - Compression: options.Compression, - }) - if err != nil { - return nil, err - } - return &JSONSnapshotConsumer{inner: inner}, nil -} - -// SubscribeJSONStream subscribes to a lossless append-log JSON track. -func (b *BroadcastConsumer) SubscribeJSONStream( - ctx context.Context, - name string, - options JSONSubscribeOptions, -) (*JSONStreamConsumer, error) { - inner, err := b.inner.SubscribeJsonStream(ctx, name, ffi.MoqJsonStreamConfig{Compression: options.Compression}) - if err != nil { - return nil, err - } - return &JSONStreamConsumer{inner: inner}, nil -} diff --git a/go/wrapper/json/json.go b/go/wrapper/json/json.go new file mode 100644 index 0000000000..4fd88a7e34 --- /dev/null +++ b/go/wrapper/json/json.go @@ -0,0 +1,220 @@ +// Package json publishes and consumes JSON tracks, mirroring the moq-json crate. +// +// Each type wraps a track from package moq: a producer takes over a +// [moq.TrackProducer] and advertises it in its broadcast's catalog, and a +// consumer takes over a [moq.TrackConsumer] that has not read a group yet. The +// track can come from a request as well as by name. Import it under an alias +// next to encoding/json: +// +// import moqjson "moq.dev/moq/json" +package json + +import ( + "context" + stdjson "encoding/json" + "iter" + + "moq.dev/moq" + ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" +) + +// DefaultDeltaRatio is used when [SnapshotOptions.DeltaRatio] is nil. Go has no field +// defaults, so unlike the other bindings this restates the `#[uniffi(default = 8)]` on +// moq-ffi's MoqJsonSnapshotConfig (itself pinned to moq-json's default by a Rust test). +const DefaultDeltaRatio uint32 = 8 + +// SnapshotOptions configures a lossy latest-value JSON producer. +type SnapshotOptions struct { + // DeltaRatio controls how aggressively deltas replace full snapshots. Nil uses + // [DefaultDeltaRatio]; a pointer to zero disables deltas entirely. + DeltaRatio *uint32 + // Compression enables group-scoped DEFLATE and must match on the consumer. + Compression bool +} + +// deltaRatio resolves the configured ratio, falling back to the shared default. +func (o SnapshotOptions) deltaRatio() uint32 { + if o.DeltaRatio == nil { + return DefaultDeltaRatio + } + return *o.DeltaRatio +} + +// StreamOptions configures a lossless append-log JSON producer. +type StreamOptions struct { + // Compression enables group-scoped DEFLATE and must match on the consumer. + Compression bool +} + +// ConsumerOptions configures reading either kind of JSON track. +// +// Delta encoding is a producer-side choice the consumer reconstructs automatically, +// so only the compression setting has to match. +type ConsumerOptions struct { + // Compression enables group-scoped DEFLATE and must match the producer. + Compression bool +} + +// SnapshotProducer publishes a JSON value as lossy latest state. +type SnapshotProducer struct { + inner *ffi.MoqJsonSnapshotProducer +} + +// NewSnapshotProducer publishes track as a lossy latest-value JSON track, advertised in +// broadcast's catalog until it finishes. It takes over track, whose handle is closed +// afterward, and fails if the catalog already carries the track's name. +func NewSnapshotProducer(broadcast *moq.BroadcastProducer, track *moq.TrackProducer, options SnapshotOptions) (*SnapshotProducer, error) { + inner, err := ffi.NewMoqJsonSnapshotProducer( + bridge.BroadcastProducer(broadcast), + bridge.TrackProducer(track), + ffi.MoqJsonSnapshotConfig{DeltaRatio: options.deltaRatio(), Compression: options.Compression}, + ) + if err != nil { + return nil, err + } + return &SnapshotProducer{inner: inner}, nil +} + +// Update publishes value as a JSON snapshot or delta. It is a no-op when unchanged. +func (p *SnapshotProducer) Update(value any) error { + encoded, err := stdjson.Marshal(value) + if err != nil { + return err + } + return p.inner.Update(string(encoded)) +} + +// Demand returns a watch-only handle to whether the track has subscribers. +func (p *SnapshotProducer) Demand() (*moq.TrackDemand, error) { + inner, err := p.inner.Demand() + if err != nil { + return nil, err + } + return bridge.TrackDemand(inner).(*moq.TrackDemand), nil +} + +// Finish closes the snapshot track. +func (p *SnapshotProducer) Finish() error { + return p.inner.Finish() +} + +// StreamProducer publishes an ordered lossless log of JSON records. +type StreamProducer struct { + inner *ffi.MoqJsonStreamProducer +} + +// NewStreamProducer publishes track as a lossless append-log JSON track, advertised in +// broadcast's catalog until it finishes. It takes over track, whose handle is closed +// afterward, and fails if the catalog already carries the track's name. +func NewStreamProducer(broadcast *moq.BroadcastProducer, track *moq.TrackProducer, options StreamOptions) (*StreamProducer, error) { + inner, err := ffi.NewMoqJsonStreamProducer( + bridge.BroadcastProducer(broadcast), + bridge.TrackProducer(track), + ffi.MoqJsonStreamConfig{Compression: options.Compression}, + ) + if err != nil { + return nil, err + } + return &StreamProducer{inner: inner}, nil +} + +// Append adds one JSON record to the log. +func (p *StreamProducer) Append(value any) error { + encoded, err := stdjson.Marshal(value) + if err != nil { + return err + } + return p.inner.Append(string(encoded)) +} + +// Demand returns a watch-only handle to whether the track has subscribers. +func (p *StreamProducer) Demand() (*moq.TrackDemand, error) { + inner, err := p.inner.Demand() + if err != nil { + return nil, err + } + return bridge.TrackDemand(inner).(*moq.TrackDemand), nil +} + +// Finish closes the stream track. +func (p *StreamProducer) Finish() error { + return p.inner.Finish() +} + +// SnapshotConsumer receives the latest reconstructed JSON value. +type SnapshotConsumer struct { + inner *ffi.MoqJsonSnapshotConsumer +} + +// NewSnapshotConsumer reads track as a lossy latest-value JSON track. It takes over +// track, whose handle is closed afterward, and fails if track has already read a group. +func NewSnapshotConsumer(track *moq.TrackConsumer, options ConsumerOptions) (*SnapshotConsumer, error) { + // DeltaRatio is producer-only; the consumer ignores it. + inner, err := ffi.NewMoqJsonSnapshotConsumer( + bridge.TrackConsumer(track), + ffi.MoqJsonSnapshotConfig{Compression: options.Compression}, + ) + if err != nil { + return nil, err + } + return &SnapshotConsumer{inner: inner}, nil +} + +// Next returns the next JSON value, or nil when the track ends. +func (c *SnapshotConsumer) Next(ctx context.Context) (*stdjson.RawMessage, error) { + value, err := bridge.Call(ctx, c.inner.Cancel, c.inner.Next) + if err != nil || value == nil { + return nil, err + } + decoded := stdjson.RawMessage(*value) + return &decoded, nil +} + +// Values ranges over reconstructed JSON values until the track ends. +func (c *SnapshotConsumer) Values(ctx context.Context) iter.Seq2[*stdjson.RawMessage, error] { + return bridge.Seq(ctx, c.Next) +} + +// Cancel stops current and future reads. +func (c *SnapshotConsumer) Cancel() { + c.inner.Cancel() +} + +// StreamConsumer receives every JSON record in order. +type StreamConsumer struct { + inner *ffi.MoqJsonStreamConsumer +} + +// NewStreamConsumer reads track as a lossless append-log JSON track. It takes over +// track, whose handle is closed afterward, and fails if track has already read a group. +func NewStreamConsumer(track *moq.TrackConsumer, options ConsumerOptions) (*StreamConsumer, error) { + inner, err := ffi.NewMoqJsonStreamConsumer( + bridge.TrackConsumer(track), + ffi.MoqJsonStreamConfig{Compression: options.Compression}, + ) + if err != nil { + return nil, err + } + return &StreamConsumer{inner: inner}, nil +} + +// Next returns the next JSON record, or nil when the track ends. +func (c *StreamConsumer) Next(ctx context.Context) (*stdjson.RawMessage, error) { + value, err := bridge.Call(ctx, c.inner.Cancel, c.inner.Next) + if err != nil || value == nil { + return nil, err + } + decoded := stdjson.RawMessage(*value) + return &decoded, nil +} + +// Values ranges over every JSON record until the track ends. +func (c *StreamConsumer) Values(ctx context.Context) iter.Seq2[*stdjson.RawMessage, error] { + return bridge.Seq(ctx, c.Next) +} + +// Cancel stops current and future reads. +func (c *StreamConsumer) Cancel() { + c.inner.Cancel() +} diff --git a/go/wrapper/json/json_test.go b/go/wrapper/json/json_test.go new file mode 100644 index 0000000000..dbc08b03e7 --- /dev/null +++ b/go/wrapper/json/json_test.go @@ -0,0 +1,181 @@ +package json_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "moq.dev/moq" + moqjson "moq.dev/moq/json" +) + +// testTimeout bounds the blocking stream calls so a regression fails the test +// job instead of hanging it. +const testTimeout = 10 * time.Second + +// newBroadcast returns a standalone broadcast and a consumer reading it. +func newBroadcast(t *testing.T) (*moq.BroadcastProducer, *moq.BroadcastConsumer) { + t.Helper() + broadcast, err := moq.NewBroadcastProducer() + if err != nil { + t.Fatal(err) + } + consumer, err := broadcast.Consume() + if err != nil { + t.Fatal(err) + } + return broadcast, consumer +} + +// publishTrack creates a raw track for a JSON producer to take over. +func publishTrack(t *testing.T, broadcast *moq.BroadcastProducer, name string) *moq.TrackProducer { + t.Helper() + track, err := broadcast.PublishTrack(name, nil) + if err != nil { + t.Fatal(err) + } + return track +} + +// subscribeTrack subscribes to a raw track for a JSON consumer to take over. +func subscribeTrack(ctx context.Context, t *testing.T, consumer *moq.BroadcastConsumer, name string) *moq.TrackConsumer { + t.Helper() + track, err := consumer.SubscribeTrack(ctx, name, nil) + if err != nil { + t.Fatal(err) + } + return track +} + +func TestTracks(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + broadcast, consumer := newBroadcast(t) + + snapshot, err := moqjson.NewSnapshotProducer(broadcast, publishTrack(t, broadcast, "status"), moqjson.SnapshotOptions{Compression: true}) + if err != nil { + t.Fatal(err) + } + snapshotConsumer, err := moqjson.NewSnapshotConsumer(subscribeTrack(ctx, t, consumer, "status"), moqjson.ConsumerOptions{Compression: true}) + if err != nil { + t.Fatal(err) + } + defer snapshotConsumer.Cancel() + if err := snapshot.Update(map[string]any{"viewers": 42}); err != nil { + t.Fatal(err) + } + value, err := snapshotConsumer.Next(ctx) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(*value, &decoded); err != nil { + t.Fatal(err) + } + if decoded["viewers"] != float64(42) { + t.Fatalf("snapshot = %s", *value) + } + + stream, err := moqjson.NewStreamProducer(broadcast, publishTrack(t, broadcast, "events"), moqjson.StreamOptions{Compression: true}) + if err != nil { + t.Fatal(err) + } + streamConsumer, err := moqjson.NewStreamConsumer(subscribeTrack(ctx, t, consumer, "events"), moqjson.ConsumerOptions{Compression: true}) + if err != nil { + t.Fatal(err) + } + defer streamConsumer.Cancel() + if err := stream.Append(map[string]any{"n": 1}); err != nil { + t.Fatal(err) + } + record, err := streamConsumer.Next(ctx) + if err != nil { + t.Fatal(err) + } + if string(*record) != `{"n":1}` { + t.Fatalf("record = %s", *record) + } + + if err := snapshot.Finish(); err != nil { + t.Fatal(err) + } + if err := stream.Finish(); err != nil { + t.Fatal(err) + } +} + +// A producer takes over its track, so the raw handle is closed afterward. +func TestProducerTakesTheTrack(t *testing.T) { + broadcast, _ := newBroadcast(t) + track := publishTrack(t, broadcast, "status") + snapshot, err := moqjson.NewSnapshotProducer(broadcast, track, moqjson.SnapshotOptions{}) + if err != nil { + t.Fatal(err) + } + defer snapshot.Finish() + if _, err := track.Name(); !errors.Is(err, moq.ErrClosed) { + t.Fatalf("Name after take = %v, want ErrClosed", err) + } +} + +func TestDemand(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + broadcast, consumer := newBroadcast(t) + + snapshot, err := moqjson.NewSnapshotProducer(broadcast, publishTrack(t, broadcast, "status"), moqjson.SnapshotOptions{}) + if err != nil { + t.Fatal(err) + } + stream, err := moqjson.NewStreamProducer(broadcast, publishTrack(t, broadcast, "events"), moqjson.StreamOptions{}) + if err != nil { + t.Fatal(err) + } + snapshotDemand, err := snapshot.Demand() + if err != nil { + t.Fatal(err) + } + streamDemand, err := stream.Demand() + if err != nil { + t.Fatal(err) + } + if name := snapshotDemand.Name(); name != "status" { + t.Fatalf("Name = %q, want status", name) + } + if snapshotDemand.IsUsed() { + t.Fatal("IsUsed before any subscriber") + } + + snapshotConsumer, err := moqjson.NewSnapshotConsumer(subscribeTrack(ctx, t, consumer, "status"), moqjson.ConsumerOptions{}) + if err != nil { + t.Fatal(err) + } + streamConsumer, err := moqjson.NewStreamConsumer(subscribeTrack(ctx, t, consumer, "events"), moqjson.ConsumerOptions{}) + if err != nil { + t.Fatal(err) + } + if err := snapshotDemand.Used(ctx); err != nil { + t.Fatal(err) + } + if err := streamDemand.Used(ctx); err != nil { + t.Fatal(err) + } + + snapshotConsumer.Cancel() + streamConsumer.Cancel() + if err := snapshotDemand.Unused(ctx); err != nil { + t.Fatal(err) + } + if err := streamDemand.Unused(ctx); err != nil { + t.Fatal(err) + } + + if err := snapshot.Finish(); err != nil { + t.Fatal(err) + } + if err := snapshotDemand.Used(ctx); !errors.Is(err, moq.ErrClosed) { + t.Fatalf("Used after Finish = %v, want ErrClosed", err) + } +} diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index aa8fe94287..f5c0b77226 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -3,7 +3,6 @@ package moq_test import ( "context" "encoding/binary" - "encoding/json" "errors" "fmt" "runtime" @@ -691,138 +690,6 @@ func TestTrackSparseGroupsAndKnownEnd(t *testing.T) { } } -func TestJSONTracks(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - broadcast, err := moq.NewBroadcastProducer() - if err != nil { - t.Fatal(err) - } - consumer, err := broadcast.Consume() - if err != nil { - t.Fatal(err) - } - - snapshot, err := broadcast.PublishJSONSnapshot("status", moq.JSONSnapshotOptions{Compression: true}) - if err != nil { - t.Fatal(err) - } - snapshotConsumer, err := consumer.SubscribeJSONSnapshot(ctx, "status", moq.JSONSubscribeOptions{Compression: true}) - if err != nil { - t.Fatal(err) - } - defer snapshotConsumer.Cancel() - if err := snapshot.Update(map[string]any{"viewers": 42}); err != nil { - t.Fatal(err) - } - value, err := snapshotConsumer.Next(ctx) - if err != nil { - t.Fatal(err) - } - var decoded map[string]any - if err := json.Unmarshal(*value, &decoded); err != nil { - t.Fatal(err) - } - if decoded["viewers"] != float64(42) { - t.Fatalf("snapshot = %s", *value) - } - - stream, err := broadcast.PublishJSONStream("events", moq.JSONStreamOptions{Compression: true}) - if err != nil { - t.Fatal(err) - } - streamConsumer, err := consumer.SubscribeJSONStream(ctx, "events", moq.JSONSubscribeOptions{Compression: true}) - if err != nil { - t.Fatal(err) - } - defer streamConsumer.Cancel() - if err := stream.Append(map[string]any{"n": 1}); err != nil { - t.Fatal(err) - } - record, err := streamConsumer.Next(ctx) - if err != nil { - t.Fatal(err) - } - if string(*record) != `{"n":1}` { - t.Fatalf("record = %s", *record) - } - - if err := snapshot.Finish(); err != nil { - t.Fatal(err) - } - if err := stream.Finish(); err != nil { - t.Fatal(err) - } -} - -func TestJSONDemand(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - broadcast, err := moq.NewBroadcastProducer() - if err != nil { - t.Fatal(err) - } - consumer, err := broadcast.Consume() - if err != nil { - t.Fatal(err) - } - snapshot, err := broadcast.PublishJSONSnapshot("status", moq.JSONSnapshotOptions{}) - if err != nil { - t.Fatal(err) - } - stream, err := broadcast.PublishJSONStream("events", moq.JSONStreamOptions{}) - if err != nil { - t.Fatal(err) - } - snapshotDemand, err := snapshot.Demand() - if err != nil { - t.Fatal(err) - } - streamDemand, err := stream.Demand() - if err != nil { - t.Fatal(err) - } - if name := snapshotDemand.Name(); name != "status" { - t.Fatalf("Name = %q, want status", name) - } - if snapshotDemand.IsUsed() { - t.Fatal("IsUsed before any subscriber") - } - - snapshotConsumer, err := consumer.SubscribeJSONSnapshot(ctx, "status", moq.JSONSubscribeOptions{}) - if err != nil { - t.Fatal(err) - } - streamConsumer, err := consumer.SubscribeJSONStream(ctx, "events", moq.JSONSubscribeOptions{}) - if err != nil { - t.Fatal(err) - } - if err := snapshotDemand.Used(ctx); err != nil { - t.Fatal(err) - } - if err := streamDemand.Used(ctx); err != nil { - t.Fatal(err) - } - - snapshotConsumer.Cancel() - streamConsumer.Cancel() - if err := snapshotDemand.Unused(ctx); err != nil { - t.Fatal(err) - } - if err := streamDemand.Unused(ctx); err != nil { - t.Fatal(err) - } - - if err := snapshot.Finish(); err != nil { - t.Fatal(err) - } - if err := snapshotDemand.Used(ctx); !errors.Is(err, moq.ErrClosed) { - t.Fatalf("Used after Finish = %v, want ErrClosed", err) - } -} - func TestDynamicTrackRequest(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -989,7 +856,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { } } -// TestRecvGroupCancelRace exercises the core runCancellable path under -race: +// TestRecvGroupCancelRace exercises the core bridge.Call path under -race: // the native RecvGroup runs on an internal goroutine while ctx expiry triggers a // concurrent Cancel on the same consumer. No group is ever written, so each read // blocks until its short ctx fires. The race detector flags any unsynchronized diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 1c088abe38..38bab4fe90 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -6,6 +6,7 @@ import ( "iter" ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" ) // OriginProducer publishes broadcasts under paths and hands out consumers that @@ -72,7 +73,7 @@ type OriginDynamic struct { // RequestedBroadcast blocks until a consumer requests a path with no existing // exact-path broadcast. func (d *OriginDynamic) RequestedBroadcast(ctx context.Context) (*BroadcastRequest, error) { - inner, err := runHandle(ctx, d.inner.Cancel, d.inner.RequestedBroadcast) + inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedBroadcast) if err != nil { return nil, err } @@ -82,7 +83,7 @@ func (d *OriginDynamic) RequestedBroadcast(ctx context.Context) (*BroadcastReque // Requests ranges over requested broadcasts until the stream errors or the loop // breaks. func (d *OriginDynamic) Requests(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { - return streamSeq(ctx, d.RequestedBroadcast) + return bridge.Seq(ctx, d.RequestedBroadcast) } // Update re-prices the route in place: replaces its hops and costs. @@ -177,7 +178,7 @@ type AnnounceConsumer struct { // Next returns the next announce event, or (nil, nil) when the stream ends. func (a *AnnounceConsumer) Next(ctx context.Context) (AnnounceEvent, error) { - res, err := runCancellable(ctx, a.inner.Cancel, a.inner.Next) + res, err := bridge.Call(ctx, a.inner.Cancel, a.inner.Next) if err != nil || res == nil { return nil, err } @@ -186,7 +187,7 @@ func (a *AnnounceConsumer) Next(ctx context.Context) (AnnounceEvent, error) { // All ranges over announce events until the stream ends or the loop breaks. func (a *AnnounceConsumer) All(ctx context.Context) iter.Seq2[AnnounceEvent, error] { - return streamSeq(ctx, a.Next) + return bridge.Seq(ctx, a.Next) } // Cancel stops the announcement stream. @@ -201,7 +202,7 @@ type AnnouncedBroadcast struct { // Available blocks until the broadcast is available and returns its consumer. func (a *AnnouncedBroadcast) Available(ctx context.Context) (*BroadcastConsumer, error) { - inner, err := runHandle(ctx, a.inner.Cancel, a.inner.Available) + inner, err := bridge.CallHandle(ctx, a.inner.Cancel, a.inner.Available) if err != nil { return nil, err } diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 99fcbedc3d..2d2e635e58 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -6,6 +6,7 @@ import ( "iter" ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" ) // AudioOption configures an audio publish. @@ -275,7 +276,7 @@ type BroadcastDynamic struct { // RequestedTrack waits for the next subscriber-requested track. func (d *BroadcastDynamic) RequestedTrack(ctx context.Context) (*TrackRequest, error) { - inner, err := runHandle(ctx, d.inner.Cancel, d.inner.RequestedTrack) + inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedTrack) if err != nil { return nil, err } @@ -284,7 +285,7 @@ func (d *BroadcastDynamic) RequestedTrack(ctx context.Context) (*TrackRequest, e // Requests ranges over subscriber-requested tracks until the dynamic source ends. func (d *BroadcastDynamic) Requests(ctx context.Context) iter.Seq2[*TrackRequest, error] { - return streamSeq(ctx, d.RequestedTrack) + return bridge.Seq(ctx, d.RequestedTrack) } // Cancel stops the dynamic request stream. @@ -620,7 +621,7 @@ type TrackDynamic struct { // RequestedGroup waits for the next uncached group request. func (d *TrackDynamic) RequestedGroup(ctx context.Context) (*GroupRequest, error) { - inner, err := runHandle(ctx, d.inner.Cancel, d.inner.RequestedGroup) + inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedGroup) if err != nil { return nil, err } @@ -629,7 +630,7 @@ func (d *TrackDynamic) RequestedGroup(ctx context.Context) (*GroupRequest, error // Requests ranges over uncached group requests until the dynamic source ends. func (d *TrackDynamic) Requests(ctx context.Context) iter.Seq2[*GroupRequest, error] { - return streamSeq(ctx, d.RequestedGroup) + return bridge.Seq(ctx, d.RequestedGroup) } // Cancel stops current and future requested-group waits. diff --git a/go/wrapper/server.go b/go/wrapper/server.go index 02f668f116..70938eac53 100644 --- a/go/wrapper/server.go +++ b/go/wrapper/server.go @@ -6,6 +6,7 @@ import ( "sync" ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" ) // Transport is the network transport carrying an incoming session. @@ -71,7 +72,7 @@ func (r *Request) SetConsume(o *OriginProducer) error { // Accept completes the handshake and returns the established session. Hold the // session to keep the connection alive. func (r *Request) Accept(ctx context.Context) (*Session, error) { - inner, err := runHandle(ctx, r.inner.Cancel, r.inner.Accept) + inner, err := bridge.CallHandle(ctx, r.inner.Cancel, r.inner.Accept) if err != nil { return nil, err } @@ -80,7 +81,7 @@ func (r *Request) Accept(ctx context.Context) (*Session, error) { // Reject refuses the session with an application error code; 401 and 403 map to unauthorized. func (r *Request) Reject(ctx context.Context, code uint16) error { - return runErr(ctx, r.inner.Cancel, func(ctx context.Context) error { + return bridge.CallErr(ctx, r.inner.Cancel, func(ctx context.Context) error { return r.inner.Reject(ctx, code) }) } @@ -178,7 +179,7 @@ func Listen(ctx context.Context, bind string, opts ...ServerOption) (*Server, er } s.inner = inner - addr, err := runCancellable(ctx, inner.Cancel, inner.Listen) + addr, err := bridge.Call(ctx, inner.Cancel, inner.Listen) if err != nil { inner.Cancel() return nil, err diff --git a/go/wrapper/session.go b/go/wrapper/session.go index d1a594a9d3..5bcfe03fc8 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -4,6 +4,7 @@ import ( "context" ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" ) // Session is an established MoQ connection. Hold it (or the Client/Server that @@ -18,7 +19,7 @@ type Session struct { // up waiting and shuts the session down, so a caller that no longer cares about // the connection can tear it down by cancelling. func (s *Session) Closed(ctx context.Context) error { - return runErr(ctx, s.inner.Shutdown, s.inner.Closed) + return bridge.CallErr(ctx, s.inner.Shutdown, s.inner.Closed) } // Status blocks until the connection status differs from the one this session @@ -33,7 +34,7 @@ func (s *Session) Closed(ctx context.Context) error { // before the next call is coalesced away, so the outages it hides are the ones // that already healed. Don't count outages with it. func (s *Session) Status(ctx context.Context) (ConnectionStatus, error) { - return runCancellable(ctx, s.inner.Shutdown, s.inner.Status) + return bridge.Call(ctx, s.inner.Shutdown, s.inner.Status) } // Epoch is the connection epoch: 1 for the connect that built this session, one diff --git a/go/wrapper/subscribe.go b/go/wrapper/subscribe.go index cf350a31f2..ed79ef0d17 100644 --- a/go/wrapper/subscribe.go +++ b/go/wrapper/subscribe.go @@ -5,6 +5,7 @@ import ( "iter" ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" ) // BroadcastConsumer reads tracks from a broadcast. @@ -158,12 +159,12 @@ type MediaConsumer struct { // Next returns the next frame, or (nil, nil) when the track ends. func (m *MediaConsumer) Next(ctx context.Context) (*MediaFrame, error) { - return runCancellable(ctx, m.inner.Cancel, m.inner.Next) + return bridge.Call(ctx, m.inner.Cancel, m.inner.Next) } // Frames ranges over frames until the track ends or the loop breaks. func (m *MediaConsumer) Frames(ctx context.Context) iter.Seq2[*MediaFrame, error] { - return streamSeq(ctx, m.Next) + return bridge.Seq(ctx, m.Next) } // Cancel stops the stream. @@ -184,12 +185,12 @@ func (m *MediaGroupConsumer) Sequence() uint64 { // Next returns the next decoded frame, or (nil, nil) when the group ends. func (m *MediaGroupConsumer) Next(ctx context.Context) (*MediaFrame, error) { - return runCancellable(ctx, m.inner.Cancel, m.inner.Next) + return bridge.Call(ctx, m.inner.Cancel, m.inner.Next) } // Frames ranges over decoded frames until the group ends or the loop breaks. func (m *MediaGroupConsumer) Frames(ctx context.Context) iter.Seq2[*MediaFrame, error] { - return streamSeq(ctx, m.Next) + return bridge.Seq(ctx, m.Next) } // Cancel stops the stream. @@ -209,12 +210,12 @@ func (g *GroupConsumer) Sequence() uint64 { // ReadFrame returns the next timestamped frame, or (nil, nil) when the group ends. func (g *GroupConsumer) ReadFrame(ctx context.Context) (*Frame, error) { - return runCancellable(ctx, g.inner.Cancel, g.inner.ReadFrame) + return bridge.Call(ctx, g.inner.Cancel, g.inner.ReadFrame) } // Frames ranges over timestamped frames until the group ends or the loop breaks. func (g *GroupConsumer) Frames(ctx context.Context) iter.Seq2[*Frame, error] { - return streamSeq(ctx, g.ReadFrame) + return bridge.Seq(ctx, g.ReadFrame) } // Cancel stops the stream. @@ -232,7 +233,7 @@ type TrackConsumer struct { // or (nil, nil) when the track ends. Prefer this for live, latency-sensitive // consumption. func (t *TrackConsumer) RecvGroup(ctx context.Context) (*GroupConsumer, error) { - res, err := runHandle(ctx, t.inner.Cancel, func(ctx context.Context) (*ffi.MoqGroupConsumer, error) { + res, err := bridge.CallHandle(ctx, t.inner.Cancel, func(ctx context.Context) (*ffi.MoqGroupConsumer, error) { res, err := t.inner.RecvGroup(ctx) if err != nil || res == nil { return nil, err @@ -250,7 +251,7 @@ func (t *TrackConsumer) RecvGroup(ctx context.Context) (*GroupConsumer, error) { // more than latency. Shares the sequence cursor with ReadFrame: a group one // method has already taken is not returned by the other. func (t *TrackConsumer) NextGroup(ctx context.Context) (*GroupConsumer, error) { - res, err := runHandle(ctx, t.inner.Cancel, func(ctx context.Context) (*ffi.MoqGroupConsumer, error) { + res, err := bridge.CallHandle(ctx, t.inner.Cancel, func(ctx context.Context) (*ffi.MoqGroupConsumer, error) { res, err := t.inner.NextGroup(ctx) if err != nil || res == nil { return nil, err @@ -269,13 +270,13 @@ func (t *TrackConsumer) NextGroup(ctx context.Context) (*GroupConsumer, error) { // Cancelling the context cancels this consumer, not just this call: see // package docs. func (t *TrackConsumer) ReadFrame(ctx context.Context) (*Frame, error) { - return runCancellable(ctx, t.inner.Cancel, t.inner.ReadFrame) + return bridge.Call(ctx, t.inner.Cancel, t.inner.ReadFrame) } // RecvDatagram returns the next best-effort datagram in arrival order, or // (nil, nil) when the track ends. func (t *TrackConsumer) RecvDatagram(ctx context.Context) (*Datagram, error) { - return runCancellable(ctx, t.inner.Cancel, t.inner.RecvDatagram) + return bridge.Call(ctx, t.inner.Cancel, t.inner.RecvDatagram) } // Info returns the publisher-side track properties learned during subscription. @@ -290,18 +291,18 @@ func (t *TrackConsumer) Update(subscription Subscription) { // Groups ranges over groups in sequence order. func (t *TrackConsumer) Groups(ctx context.Context) iter.Seq2[*GroupConsumer, error] { - return streamSeq(ctx, t.NextGroup) + return bridge.Seq(ctx, t.NextGroup) } // GroupsAsArrived ranges over groups in arrival order, including // out-of-sequence deliveries. func (t *TrackConsumer) GroupsAsArrived(ctx context.Context) iter.Seq2[*GroupConsumer, error] { - return streamSeq(ctx, t.RecvGroup) + return bridge.Seq(ctx, t.RecvGroup) } // Datagrams ranges over best-effort datagrams in arrival order. func (t *TrackConsumer) Datagrams(ctx context.Context) iter.Seq2[*Datagram, error] { - return streamSeq(ctx, t.RecvDatagram) + return bridge.Seq(ctx, t.RecvDatagram) } // Cancel stops the stream. @@ -316,12 +317,12 @@ type AudioConsumer struct { // Next returns the next audio frame, or (nil, nil) when the track ends. func (a *AudioConsumer) Next(ctx context.Context) (*AudioFrame, error) { - return runCancellable(ctx, a.inner.Cancel, a.inner.Next) + return bridge.Call(ctx, a.inner.Cancel, a.inner.Next) } // Frames ranges over audio frames until the track ends or the loop breaks. func (a *AudioConsumer) Frames(ctx context.Context) iter.Seq2[*AudioFrame, error] { - return streamSeq(ctx, a.Next) + return bridge.Seq(ctx, a.Next) } // Cancel stops the stream. @@ -337,7 +338,7 @@ type VideoConsumer struct { // Next returns the next decoded frame, or (nil, nil) when the track ends. // Close each frame when done with it. func (v *VideoConsumer) Next(ctx context.Context) (*VideoDecodedFrame, error) { - res, err := runHandle(ctx, v.inner.Cancel, func(ctx context.Context) (*ffi.MoqVideoDecodedFrame, error) { + res, err := bridge.CallHandle(ctx, v.inner.Cancel, func(ctx context.Context) (*ffi.MoqVideoDecodedFrame, error) { res, err := v.inner.Next(ctx) if err != nil || res == nil { return nil, err @@ -352,7 +353,7 @@ func (v *VideoConsumer) Next(ctx context.Context) (*VideoDecodedFrame, error) { // Frames ranges over decoded frames until the track ends or the loop breaks. func (v *VideoConsumer) Frames(ctx context.Context) iter.Seq2[*VideoDecodedFrame, error] { - return streamSeq(ctx, v.Next) + return bridge.Seq(ctx, v.Next) } // Cancel stops the stream. @@ -409,12 +410,12 @@ type CatalogConsumer struct { // Next returns the next catalog, or (nil, nil) when the track ends. func (c *CatalogConsumer) Next(ctx context.Context) (*Catalog, error) { - return runCancellable(ctx, c.inner.Cancel, c.inner.Next) + return bridge.Call(ctx, c.inner.Cancel, c.inner.Next) } // Updates ranges over catalog updates until the track ends or the loop breaks. func (c *CatalogConsumer) Updates(ctx context.Context) iter.Seq2[*Catalog, error] { - return streamSeq(ctx, c.Next) + return bridge.Seq(ctx, c.Next) } // Cancel stops the stream. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 819ce8f9e9..db2bd33772 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -193,7 +193,7 @@ client = moq.Client( - **`GroupConsumer`**. Async iterator of timestamped `Frame`s. - `.read_frame() -> Frame | None` returns a timestamped raw frame. -Every handle whose cleanup is `cancel()` is an async context manager, so exiting `async with` releases it: the consumers (`CatalogConsumer`, `MediaConsumer`, `MediaGroupConsumer`, `TrackConsumer`, `AudioConsumer`, `GroupConsumer`, `JsonSnapshotConsumer`, `JsonStreamConsumer`, `AnnounceConsumer`, `AnnouncedBroadcast`) and the dynamic sources (`OriginDynamic`, `BroadcastDynamic`, `TrackDynamic`). +Every handle whose cleanup is `cancel()` is an async context manager, so exiting `async with` releases it: the consumers (`CatalogConsumer`, `MediaConsumer`, `MediaGroupConsumer`, `TrackConsumer`, `AudioConsumer`, `GroupConsumer`, `json.SnapshotConsumer`, `json.StreamConsumer`, `AnnounceConsumer`, `AnnouncedBroadcast`) and the dynamic sources (`OriginDynamic`, `BroadcastDynamic`, `TrackDynamic`). ### Origin (advanced) diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index c7282ed47d..481a8382c8 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -64,8 +64,6 @@ asyncio.run(main()) MediaStreamProducer AudioProducer VideoProducer - JsonSnapshotProducer - JsonStreamProducer ``` ## Subscribing @@ -83,8 +81,24 @@ asyncio.run(main()) MediaConsumer AudioConsumer CatalogConsumer - JsonSnapshotConsumer - JsonStreamConsumer +``` + +## JSON tracks + +`moq.json` mirrors the `moq-json` crate: each type wraps a track from the +broadcast, and producers advertise it in the catalog. + +```{eval-rst} +.. currentmodule:: moq.json + +.. autosummary:: + :toctree: api + :nosignatures: + + SnapshotProducer + StreamProducer + SnapshotConsumer + StreamConsumer ``` ## Origin and announcements diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 7c4f836e0e..c04c0fc34c 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -5,6 +5,8 @@ from moq_ffi import MoqError as Error +# A submodule, not in __all__: a star import would shadow the stdlib `json`. +from . import json as json from .client import Client, connect from .errors import is_auth, is_shutdown, protocol_error from .log import log_level @@ -30,8 +32,6 @@ ContainerStreamProducer, GroupProducer, GroupRequest, - JsonSnapshotProducer, - JsonStreamProducer, MediaProducer, MediaStreamProducer, TrackDemand, @@ -47,8 +47,6 @@ BroadcastConsumer, CatalogConsumer, GroupConsumer, - JsonSnapshotConsumer, - JsonStreamConsumer, MediaConsumer, MediaGroupConsumer, TrackConsumer, @@ -141,10 +139,6 @@ "GroupConsumer", "GroupRequest", "GroupProducer", - "JsonSnapshotConsumer", - "JsonSnapshotProducer", - "JsonStreamConsumer", - "JsonStreamProducer", "MediaConsumer", "MediaGroupConsumer", "MediaProducer", diff --git a/py/moq-rs/moq/json.py b/py/moq-rs/moq/json.py new file mode 100644 index 0000000000..274914e8ea --- /dev/null +++ b/py/moq-rs/moq/json.py @@ -0,0 +1,154 @@ +"""JSON tracks, mirroring the ``moq-json`` crate. + +Each type wraps a track. A producer takes over a :class:`~moq.TrackProducer` and advertises it +in its broadcast's catalog (``json.tracks.``) for as long as it lives; a consumer takes over +a :class:`~moq.TrackConsumer` that has not read a group yet. Either track can come from a request +as well as by name. Values are any JSON-serializable Python object. +""" + +from __future__ import annotations + +import json +from typing import Any + +from moq_ffi import ( + MoqJsonSnapshotConfig, + MoqJsonSnapshotConsumer, + MoqJsonSnapshotProducer, + MoqJsonStreamConfig, + MoqJsonStreamConsumer, + MoqJsonStreamProducer, +) + +from .publish import BroadcastProducer, TrackDemand, TrackProducer +from .subscribe import TrackConsumer + +__all__ = ["SnapshotConsumer", "SnapshotProducer", "StreamConsumer", "StreamProducer"] + + +class SnapshotProducer: + """Publish a JSON value that consumers see as a single latest state (lossy). + + Each :meth:`update` supersedes the last; a late joiner only sees the newest value, encoded as + snapshots and merge-patch deltas automatically. ``delta_ratio`` controls how aggressively + deltas replace full snapshots (0 disables them); ``None`` uses the binding's default. Set + ``compression`` to DEFLATE-compress each group; the consumer must pass the same flag. A name + the catalog already carries is refused. + """ + + def __init__( + self, + broadcast: BroadcastProducer, + track: TrackProducer, + *, + delta_ratio: int | None = None, + compression: bool = False, + ) -> None: + # Let the record supply delta_ratio's default rather than restating it here. + config = ( + MoqJsonSnapshotConfig(compression=compression) + if delta_ratio is None + else MoqJsonSnapshotConfig(delta_ratio=delta_ratio, compression=compression) + ) + self._inner = MoqJsonSnapshotProducer(broadcast._inner, track._inner, config) + + def demand(self) -> TrackDemand: + """A watch-only handle to whether this track has subscribers.""" + return TrackDemand(self._inner.demand()) + + def update(self, value: Any) -> None: + """Publish a new value. A no-op if unchanged from the previous update.""" + self._inner.update(json.dumps(value)) + + def finish(self) -> None: + """Finish the track, closing any open group.""" + self._inner.finish() + + +class StreamProducer: + """Publish an ordered log of JSON records (lossless). + + Every :meth:`append` is preserved and delivered in order. Set ``compression`` to + DEFLATE-compress the group; the consumer must pass the same flag. A name the catalog already + carries is refused. + """ + + def __init__(self, broadcast: BroadcastProducer, track: TrackProducer, *, compression: bool = False) -> None: + config = MoqJsonStreamConfig(compression=compression) + self._inner = MoqJsonStreamProducer(broadcast._inner, track._inner, config) + + def demand(self) -> TrackDemand: + """A watch-only handle to whether this track has subscribers.""" + return TrackDemand(self._inner.demand()) + + def append(self, value: Any) -> None: + """Append one record to the log.""" + self._inner.append(json.dumps(value)) + + def finish(self) -> None: + """Finish the track, closing the group.""" + self._inner.finish() + + +class SnapshotConsumer: + """Async iterator over a JSON snapshot track, yielding the latest value (lossy). + + Each item is a parsed Python object. A consumer that has fallen behind collapses the backlog + and yields only the latest value. Pass the same ``compression`` the producer used. Usable as + an async context manager that cancels on exit. + """ + + def __init__(self, track: TrackConsumer, *, compression: bool = False) -> None: + # delta_ratio is producer-only, so leave it at its default here. + config = MoqJsonSnapshotConfig(compression=compression) + self._inner = MoqJsonSnapshotConsumer(track._inner, config) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc) -> None: + self.cancel() + + def __aiter__(self): + return self + + async def __anext__(self) -> Any: + value = await self._inner.next() + if value is None: + raise StopAsyncIteration + return json.loads(value) + + def cancel(self) -> None: + """Cancel all current and future next() calls.""" + self._inner.cancel() + + +class StreamConsumer: + """Async iterator over a JSON stream track, yielding every record in order (lossless). + + Each item is a parsed Python object. Pass the same ``compression`` the producer used. Usable + as an async context manager that cancels on exit. + """ + + def __init__(self, track: TrackConsumer, *, compression: bool = False) -> None: + config = MoqJsonStreamConfig(compression=compression) + self._inner = MoqJsonStreamConsumer(track._inner, config) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc) -> None: + self.cancel() + + def __aiter__(self): + return self + + async def __anext__(self) -> Any: + value = await self._inner.next() + if value is None: + raise StopAsyncIteration + return json.loads(value) + + def cancel(self) -> None: + """Cancel all current and future next() calls.""" + self._inner.cancel() diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 8138961e29..e9b359c56a 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -15,10 +15,6 @@ MoqContainerStreamProducer, MoqGroupProducer, MoqGroupRequest, - MoqJsonSnapshotConfig, - MoqJsonSnapshotProducer, - MoqJsonStreamConfig, - MoqJsonStreamProducer, MoqMediaProducer, MoqMediaStreamProducer, MoqTrackDemand, @@ -428,53 +424,6 @@ def cancel(self) -> None: self._inner.cancel() -class JsonSnapshotProducer: - """Publish a JSON value that consumers see as a single latest state (lossy). - - Built via :meth:`BroadcastProducer.publish_json_snapshot`. Each :meth:`update` supersedes the - last; a late joiner only sees the newest value. Values are any JSON-serializable Python - object, encoded as snapshots and merge-patch deltas automatically. - """ - - def __init__(self, inner: MoqJsonSnapshotProducer) -> None: - self._inner = inner - - def demand(self) -> TrackDemand: - """A watch-only handle to whether this track has subscribers.""" - return TrackDemand(self._inner.demand()) - - def update(self, value: Any) -> None: - """Publish a new value. A no-op if unchanged from the previous update.""" - self._inner.update(json.dumps(value)) - - def finish(self) -> None: - """Finish the track, closing any open group.""" - self._inner.finish() - - -class JsonStreamProducer: - """Publish an ordered log of JSON records (lossless). - - Built via :meth:`BroadcastProducer.publish_json_stream`. Every :meth:`append` is - preserved and delivered in order. Records are any JSON-serializable Python object. - """ - - def __init__(self, inner: MoqJsonStreamProducer) -> None: - self._inner = inner - - def demand(self) -> TrackDemand: - """A watch-only handle to whether this track has subscribers.""" - return TrackDemand(self._inner.demand()) - - def append(self, value: Any) -> None: - """Append one record to the log.""" - self._inner.append(json.dumps(value)) - - def finish(self) -> None: - """Finish the track, closing the group.""" - self._inner.finish() - - class AudioProducer: """Publish raw PCM and let libopus encode it on the way out. @@ -787,37 +736,6 @@ def publish_track(self, name: str, info: TrackInfo | None = None) -> TrackProduc properties (priority, cache, timescale); omit for defaults.""" return TrackProducer(self._inner.publish_track(name, info)) - def publish_json_snapshot( - self, name: str, *, delta_ratio: int | None = None, compression: bool = False - ) -> JsonSnapshotProducer: - """Publish a JSON snapshot track (lossy latest-value). - - Each update supersedes the last; a late joiner only sees the newest value. - ``delta_ratio`` controls how aggressively deltas are emitted instead of full - snapshots (0 disables deltas); ``None`` uses the binding's default. Set - ``compression`` to DEFLATE-compress each group; the consumer must pass the same - flag. The track is advertised in the broadcast's catalog (``json.tracks.``) - until it finishes; a name the catalog already carries is refused. - """ - # Let the record supply delta_ratio's default rather than restating it here. - config = ( - MoqJsonSnapshotConfig(compression=compression) - if delta_ratio is None - else MoqJsonSnapshotConfig(delta_ratio=delta_ratio, compression=compression) - ) - return JsonSnapshotProducer(self._inner.publish_json_snapshot(name, config)) - - def publish_json_stream(self, name: str, *, compression: bool = False) -> JsonStreamProducer: - """Publish a JSON stream track (lossless append-log). - - Every appended record is preserved and delivered in order. Set ``compression`` to - DEFLATE-compress the group; the consumer must pass the same flag. The track is - advertised in the broadcast's catalog (``json.tracks.``) until it finishes; a - name the catalog already carries is refused. - """ - config = MoqJsonStreamConfig(compression=compression) - return JsonStreamProducer(self._inner.publish_json_stream(name, config)) - def set_catalog_section(self, name: str, value: Any) -> None: """Set or replace an untyped application section in the catalog. diff --git a/py/moq-rs/moq/subscribe.py b/py/moq-rs/moq/subscribe.py index 477479337d..7c130cf126 100644 --- a/py/moq-rs/moq/subscribe.py +++ b/py/moq-rs/moq/subscribe.py @@ -2,19 +2,13 @@ from __future__ import annotations -import json from collections.abc import AsyncIterator -from typing import Any from moq_ffi import ( MoqAudioConsumer, MoqBroadcastConsumer, MoqCatalogConsumer, MoqGroupConsumer, - MoqJsonSnapshotConfig, - MoqJsonSnapshotConsumer, - MoqJsonStreamConfig, - MoqJsonStreamConsumer, MoqMediaConsumer, MoqMediaGroupConsumer, MoqTrackConsumer, @@ -300,67 +294,6 @@ def cancel(self) -> None: self._inner.cancel() -class JsonSnapshotConsumer: - """Async iterator over a JSON snapshot track, yielding the latest value (lossy). - - Built via :meth:`BroadcastConsumer.subscribe_json_snapshot`. Each item is a parsed Python object. - A consumer that has fallen behind collapses the backlog and yields only the latest value. - Usable as an async context manager that cancels on exit. - """ - - def __init__(self, inner: MoqJsonSnapshotConsumer) -> None: - self._inner = inner - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc) -> None: - self.cancel() - - def __aiter__(self): - return self - - async def __anext__(self) -> Any: - value = await self._inner.next() - if value is None: - raise StopAsyncIteration - return json.loads(value) - - def cancel(self) -> None: - """Cancel all current and future next() calls.""" - self._inner.cancel() - - -class JsonStreamConsumer: - """Async iterator over a JSON stream track, yielding every record in order (lossless). - - Built via :meth:`BroadcastConsumer.subscribe_json_stream`. Each item is a parsed Python object. - Usable as an async context manager that cancels on exit. - """ - - def __init__(self, inner: MoqJsonStreamConsumer) -> None: - self._inner = inner - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc) -> None: - self.cancel() - - def __aiter__(self): - return self - - async def __anext__(self) -> Any: - value = await self._inner.next() - if value is None: - raise StopAsyncIteration - return json.loads(value) - - def cancel(self) -> None: - """Cancel all current and future next() calls.""" - self._inner.cancel() - - class CatalogConsumer: """Async-iterable stream of :class:`Catalog` snapshots as the broadcast updates. @@ -412,23 +345,6 @@ async def subscribe_track(self, name: str, subscription: Subscription | None = N """ return TrackConsumer(await self._inner.subscribe_track(name, subscription)) - async def subscribe_json_snapshot(self, name: str, *, compression: bool = False) -> JsonSnapshotConsumer: - """Subscribe to a JSON snapshot track (lossy latest-value). - - Yields parsed Python objects. Pass the same ``compression`` the producer used. - """ - # delta_ratio is producer-only, so leave it at its default here. - config = MoqJsonSnapshotConfig(compression=compression) - return JsonSnapshotConsumer(await self._inner.subscribe_json_snapshot(name, config)) - - async def subscribe_json_stream(self, name: str, *, compression: bool = False) -> JsonStreamConsumer: - """Subscribe to a JSON stream track (lossless append-log). - - Yields parsed Python objects in order. Pass the same ``compression`` the producer used. - """ - config = MoqJsonStreamConfig(compression=compression) - return JsonStreamConsumer(await self._inner.subscribe_json_stream(name, config)) - async def fetch_group( self, name: str, diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 4350d97d2c..3465a8d334 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -396,8 +396,8 @@ async def test_fetch_group_and_serve_dynamic_miss(): async def test_json_snapshot_roundtrip(): broadcast = moq.BroadcastProducer() - producer = broadcast.publish_json_snapshot("status", compression=True) - consumer = await broadcast.consume().subscribe_json_snapshot("status", compression=True) + producer = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("status"), compression=True) + consumer = moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("status"), compression=True) producer.update({"state": "live", "viewers": 1}) value = await asyncio.wait_for(anext(consumer), timeout=5.0) @@ -412,8 +412,8 @@ async def test_json_snapshot_roundtrip(): async def test_json_stream_roundtrip(): broadcast = moq.BroadcastProducer() - producer = broadcast.publish_json_stream("events") - consumer = await broadcast.consume().subscribe_json_stream("events") + producer = moq.json.StreamProducer(broadcast, broadcast.publish_track("events")) + consumer = moq.json.StreamConsumer(await broadcast.consume().subscribe_track("events")) for n in range(3): producer.append({"n": n}) @@ -425,16 +425,16 @@ async def test_json_stream_roundtrip(): async def test_json_producers_report_demand(): broadcast = moq.BroadcastProducer() - snapshot = broadcast.publish_json_snapshot("status", compression=True) - stream = broadcast.publish_json_stream("events") + snapshot = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("status"), compression=True) + stream = moq.json.StreamProducer(broadcast, broadcast.publish_track("events")) snapshot_demand = snapshot.demand() stream_demand = stream.demand() assert snapshot_demand.name == "status" assert not snapshot_demand.is_used() consumer = broadcast.consume() - snapshot_consumer = await consumer.subscribe_json_snapshot("status", compression=True) - stream_consumer = await consumer.subscribe_json_stream("events") + snapshot_consumer = moq.json.SnapshotConsumer(await consumer.subscribe_track("status"), compression=True) + stream_consumer = moq.json.StreamConsumer(await consumer.subscribe_track("events")) await asyncio.wait_for(snapshot_demand.used(), timeout=5.0) await asyncio.wait_for(stream_demand.used(), timeout=5.0) assert snapshot_demand.is_used() @@ -1259,13 +1259,13 @@ async def assert_cancelled(awaitable) -> None: pass await assert_cancelled(track_dynamic.requested_group()) - snapshot = broadcast.publish_json_snapshot("state") - async with await broadcast.consume().subscribe_json_snapshot("state") as snapshot_consumer: + snapshot = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("state")) + async with moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("state")) as snapshot_consumer: pass await assert_cancelled(anext(snapshot_consumer)) - stream = broadcast.publish_json_stream("log") - async with await broadcast.consume().subscribe_json_stream("log") as stream_consumer: + stream = moq.json.StreamProducer(broadcast, broadcast.publish_track("log")) + async with moq.json.StreamConsumer(await broadcast.consume().subscribe_track("log")) as stream_consumer: pass await assert_cancelled(anext(stream_consumer)) From e8c53f2577447e60e60e7eec5d5021b784dbde5f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 11:18:42 -0700 Subject: [PATCH 4/9] refactor(kt,swift)!: JSON tracks move to dev.moq.json and the Json namespace Kotlin gains a dev.moq.json package and Swift a caseless Json enum, each constructing JSON producers and consumers from a track. rs/moq-ffi/AGENTS.md records the per-language namespace pattern for the other ffi-shape children. Co-Authored-By: Claude Opus 5.5 --- doc/lib/kt/index.md | 6 +- doc/lib/swift/index.md | 4 +- .../kotlin/dev/moq/Aliases.kt | 12 - .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 23 -- .../jvmAndAndroidMain/kotlin/dev/moq/Json.kt | 41 +-- .../kotlin/dev/moq/json/Tracks.kt | 88 ++++++ .../kotlin/dev/moq/SmokeTest.kt | 29 +- rs/moq-ffi/AGENTS.md | 6 + swift/README.md | 13 +- swift/Sources/Moq/Broadcast.swift | 51 ---- swift/Sources/Moq/Json.swift | 272 ++++++++++-------- swift/Tests/MoqTests/SmokeTests.swift | 26 +- 12 files changed, 297 insertions(+), 274 deletions(-) create mode 100644 kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index defc177d2e..6021b3bce3 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -86,8 +86,10 @@ QUIC-only relay, and a `websocketDelay` `Duration` changes the head start. `accept()`/`reject()`. Generated configuration setters, including `MoqRequest.setPublish`/`setConsume`, throw if a connect, listen, or accept is in flight, or after cancel. `MoqRequest.transport()` returns a `Transport` enum. -JSON tracks take `@Serializable` types -(`publishJsonSnapshot`, `publishJsonStream`, `valuesAs()`), and the rest of +JSON tracks live in the `dev.moq.json` package and take `@Serializable` types: +`SnapshotProducer(broadcast, track, SnapshotConfig())` takes over a track from +`publishTrack`, `SnapshotConsumer(track, SnapshotConfig())` one from +`subscribeTrack`, and `valuesAs()` decodes. The rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one: `fetchGroup`/`fetchMediaGroup`, `dynamic()` for tracks and `dynamic(prefix)` for broadcasts, `appendDatagram`/`datagrams()`, `setCatalogSection`, `demand()` for `used()`/`unused()`. `session.bandwidth()` divides the diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 0d125ac658..7dfbdaf0fb 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -90,8 +90,8 @@ QUIC-only relay, and `client.setWebsocketDelay(_:)` changes the head start, in microseconds. `Server` binds, generates or loads TLS, and hands you each request to -`accept()` or `reject(code:)`; `request.transport` is a `Transport` enum. JSON tracks take `Codable` types -(`publishJsonSnapshot(name:of:)`, `subscribeJsonStream(name:as:)`), and the +`accept()` or `reject(code:)`; `request.transport` is a `Transport` enum. JSON tracks live under `Json` and take `Codable` types +(`Json.SnapshotProducer(broadcast:track:)`, `Json.StreamConsumer(track:)`), and the rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one: `fetchGroup`/`fetchMediaGroup`, `dynamic()` for tracks and `dynamic(prefix:)` for broadcasts, `appendDatagram`/ `datagrams`, `setCatalogSection`, `demand()` for `used()`/`unused()`. `session.bandwidth()` diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 11154a920c..d0c0d55490 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -99,14 +99,6 @@ typealias VideoConsumer = uniffi.moq.MoqVideoConsumer typealias VideoProducer = uniffi.moq.MoqVideoProducer /** The read side of a broadcast's catalog: yields updates as the set of tracks changes. */ typealias CatalogConsumer = uniffi.moq.MoqCatalogConsumer -/** Publishes lossy latest-value JSON snapshots. */ -typealias JsonSnapshotProducer = uniffi.moq.MoqJsonSnapshotProducer -/** Consumes reconstructed latest-value JSON snapshots. */ -typealias JsonSnapshotConsumer = uniffi.moq.MoqJsonSnapshotConsumer -/** Publishes a lossless stream of JSON records. */ -typealias JsonStreamProducer = uniffi.moq.MoqJsonStreamProducer -/** Consumes a lossless stream of JSON records. */ -typealias JsonStreamConsumer = uniffi.moq.MoqJsonStreamConsumer // Data types. /** A broadcast's catalog: its tracks and their properties, plus any application sections. */ @@ -191,10 +183,6 @@ typealias ErrorScope = uniffi.moq.MoqErrorScope typealias ProtocolKind = uniffi.moq.MoqProtocolKind /** A protocol failure: scope, verbatim wire code, kind, and a diagnostic message. */ typealias ProtocolError = uniffi.moq.MoqProtocolError -/** Configures a lossy latest-value JSON track. */ -typealias JsonSnapshotConfig = uniffi.moq.MoqJsonSnapshotConfig -/** Configures a lossless JSON stream track. */ -typealias JsonStreamConfig = uniffi.moq.MoqJsonStreamConfig // NOTE: a few types are intentionally NOT aliased. `MoqContainer` (sealed) and // `MoqException` (sealed) need subtype access (`MoqContainer.Loc`, diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index f84127ee98..40f887e1ec 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -84,29 +84,6 @@ fun VideoConsumer.frames(): Flow = flow { if (cause is CancellationException) cancel() } -/** - * Stream of JSON values (as strings) from a snapshot track, yielding the latest reconstructed - * value. A consumer that has fallen behind collapses the backlog to the latest. - */ -fun JsonSnapshotConsumer.values(): Flow = flow { - while (true) { - currentCoroutineContext().ensureActive() - emit(next() ?: break) - } -}.onCompletion { cause -> - if (cause is CancellationException) cancel() -} - -/** Stream of JSON records (as strings) from a stream track, in order. */ -fun JsonStreamConsumer.values(): Flow = flow { - while (true) { - currentCoroutineContext().ensureActive() - emit(next() ?: break) - } -}.onCompletion { cause -> - if (cause is CancellationException) cancel() -} - /** Stream of groups in sequence order, skipping forward if the reader falls behind. */ fun TrackConsumer.groups(): Flow = flow { while (true) { diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Json.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Json.kt index aa7a857e31..66b5dfbe43 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Json.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Json.kt @@ -1,56 +1,17 @@ package dev.moq -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import uniffi.moq.MoqBroadcastProducer -import uniffi.moq.MoqJsonSnapshotConsumer -import uniffi.moq.MoqJsonSnapshotProducer -import uniffi.moq.MoqJsonStreamConsumer -import uniffi.moq.MoqJsonStreamProducer /** - * The [Json] format used by the typed helpers below. + * The [Json] format used by the typed helpers here and in `dev.moq.json`. * * Lenient about unknown keys so a producer adding a field does not break older * subscribers, matching how the catalog treats untyped sections. */ val MoqJson: Json = Json { ignoreUnknownKeys = true } -/** - * Publish [value] as a JSON snapshot, superseding the last. A no-op when unchanged. - * - * The `@Serializable` type is encoded with [MoqJson]. Pass an already-encoded - * `String` to serialize with another library: the member overload takes it - * unchanged. - */ -inline fun MoqJsonSnapshotProducer.update(value: T) { - update(MoqJson.encodeToString(serializer(), value)) -} - -/** Append [value] to a JSON stream track as one record, encoded with [MoqJson]. */ -inline fun MoqJsonStreamProducer.append(value: T) { - append(MoqJson.encodeToString(serializer(), value)) -} - -/** - * Stream of decoded snapshot values, yielding the latest reconstructed value. - * - * A consumer that has fallen behind collapses the backlog to the latest. Use - * [MoqJsonSnapshotConsumer.values] for the undecoded JSON strings. - */ -inline fun MoqJsonSnapshotConsumer.valuesAs(): Flow = - values().map { MoqJson.decodeFromString(serializer(), it) } - -/** - * Stream of decoded stream records, in order. - * - * Use [MoqJsonStreamConsumer.values] for the undecoded JSON strings. - */ -inline fun MoqJsonStreamConsumer.valuesAs(): Flow = - values().map { MoqJson.decodeFromString(serializer(), it) } - /** * Set or replace an untyped application section in the catalog. * diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt new file mode 100644 index 0000000000..9550f7600a --- /dev/null +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt @@ -0,0 +1,88 @@ +package dev.moq.json + +// JSON tracks, mirroring the moq-json crate. Each type wraps a track: a +// producer takes over a `dev.moq.TrackProducer` and advertises it in the +// broadcast's catalog, and a consumer takes over a `dev.moq.TrackConsumer` +// that has not read a group yet: +// +// val status = SnapshotProducer(broadcast, broadcast.publishTrack("status", null), SnapshotConfig()) +// val reader = SnapshotConsumer(consumer.subscribeTrack("status", null), SnapshotConfig()) + +import dev.moq.MoqJson +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.serialization.serializer + +/** Publishes lossy latest-value JSON snapshots on a track it takes over. */ +typealias SnapshotProducer = uniffi.moq.MoqJsonSnapshotProducer +/** Consumes reconstructed latest-value JSON snapshots from a track it takes over. */ +typealias SnapshotConsumer = uniffi.moq.MoqJsonSnapshotConsumer +/** Publishes a lossless stream of JSON records on a track it takes over. */ +typealias StreamProducer = uniffi.moq.MoqJsonStreamProducer +/** Consumes a lossless stream of JSON records from a track it takes over. */ +typealias StreamConsumer = uniffi.moq.MoqJsonStreamConsumer +/** Configures a lossy latest-value JSON track. */ +typealias SnapshotConfig = uniffi.moq.MoqJsonSnapshotConfig +/** Configures a lossless JSON stream track. */ +typealias StreamConfig = uniffi.moq.MoqJsonStreamConfig + +/** + * Publish [value] as a JSON snapshot, superseding the last. A no-op when unchanged. + * + * The `@Serializable` type is encoded with [MoqJson]. Pass an already-encoded + * `String` to serialize with another library: the member overload takes it + * unchanged. + */ +inline fun SnapshotProducer.update(value: T) { + update(MoqJson.encodeToString(serializer(), value)) +} + +/** Append [value] to a JSON stream track as one record, encoded with [MoqJson]. */ +inline fun StreamProducer.append(value: T) { + append(MoqJson.encodeToString(serializer(), value)) +} + +/** + * Stream of JSON values (as strings) from a snapshot track, yielding the latest reconstructed + * value. A consumer that has fallen behind collapses the backlog to the latest. + */ +fun SnapshotConsumer.values(): Flow = flow { + while (true) { + currentCoroutineContext().ensureActive() + emit(next() ?: break) + } +}.onCompletion { cause -> + if (cause is CancellationException) cancel() +} + +/** Stream of JSON records (as strings) from a stream track, in order. */ +fun StreamConsumer.values(): Flow = flow { + while (true) { + currentCoroutineContext().ensureActive() + emit(next() ?: break) + } +}.onCompletion { cause -> + if (cause is CancellationException) cancel() +} + +/** + * Stream of decoded snapshot values, yielding the latest reconstructed value. + * + * A consumer that has fallen behind collapses the backlog to the latest. Use + * [SnapshotConsumer.values] for the undecoded JSON strings. + */ +inline fun SnapshotConsumer.valuesAs(): Flow = + values().map { MoqJson.decodeFromString(serializer(), it) } + +/** + * Stream of decoded stream records, in order. + * + * Use [StreamConsumer.values] for the undecoded JSON strings. + */ +inline fun StreamConsumer.valuesAs(): Flow = + values().map { MoqJson.decodeFromString(serializer(), it) } diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index e4a090b4f7..f0b1274068 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -8,6 +8,12 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield +import dev.moq.json.SnapshotConfig +import dev.moq.json.SnapshotConsumer +import dev.moq.json.SnapshotProducer +import dev.moq.json.StreamConfig +import dev.moq.json.update +import dev.moq.json.valuesAs import kotlinx.serialization.Serializable import uniffi.moq.MoqException import kotlin.test.Test @@ -124,8 +130,8 @@ class SmokeTest { framerate = 60.0, optimizeForLatency = true, ) - val snapshot: JsonSnapshotConfig = JsonSnapshotConfig(deltaRatio = 8u, compression = false) - val stream: JsonStreamConfig = JsonStreamConfig(compression = false) + val snapshot: SnapshotConfig = SnapshotConfig(deltaRatio = 8u, compression = false) + val stream: StreamConfig = StreamConfig(compression = false) val properties: VideoProperties = VideoProperties(rotation = 315.0) val backoff: Backoff = Backoff( initialUs = 500_000uL, @@ -284,11 +290,11 @@ class SmokeTest { @Test fun `typed json snapshot round-trips a serializable value`() = runTest { BroadcastProducer().use { broadcast -> - val config = JsonSnapshotConfig(deltaRatio = 0u, compression = false) - val producer = broadcast.publishJsonSnapshot("status", config) + val config = SnapshotConfig(deltaRatio = 0u, compression = false) + val producer = SnapshotProducer(broadcast, broadcast.publishTrack("status", null), config) producer.update(Status(state = "live")) - val consumer = broadcast.consume().subscribeJsonSnapshot("status", config) + val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null), config) assertEquals(Status(state = "live"), consumer.valuesAs().first()) } } @@ -296,12 +302,13 @@ class SmokeTest { @Test fun `json producer demand follows subscribers`() = runTest { BroadcastProducer().use { broadcast -> - val config = JsonSnapshotConfig(deltaRatio = 0u, compression = false) - val demand: TrackDemand = broadcast.publishJsonSnapshot("status", config).demand() + val config = SnapshotConfig(deltaRatio = 0u, compression = false) + val producer = SnapshotProducer(broadcast, broadcast.publishTrack("status", null), config) + val demand: TrackDemand = producer.demand() assertEquals("status", demand.name()) assertEquals(false, demand.isUsed()) - val consumer = broadcast.consume().subscribeJsonSnapshot("status", config) + val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null), config) demand.used() consumer.cancel() demand.unused() @@ -316,11 +323,11 @@ class SmokeTest { @Test fun `raw json string passes through unencoded`() = runTest { BroadcastProducer().use { broadcast -> - val config = JsonSnapshotConfig(deltaRatio = 0u, compression = false) - val producer = broadcast.publishJsonSnapshot("status", config) + val config = SnapshotConfig(deltaRatio = 0u, compression = false) + val producer = SnapshotProducer(broadcast, broadcast.publishTrack("status", null), config) producer.update("""{"state":"raw"}""") - val consumer = broadcast.consume().subscribeJsonSnapshot("status", config) + val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null), config) assertEquals(Status(state = "raw"), consumer.valuesAs().first()) } } diff --git a/rs/moq-ffi/AGENTS.md b/rs/moq-ffi/AGENTS.md index 7d3a51b5e7..58ffdacfdb 100644 --- a/rs/moq-ffi/AGENTS.md +++ b/rs/moq-ffi/AGENTS.md @@ -9,6 +9,12 @@ Mirror every change in the same PR: - Docs under `doc/lib/{py,go,dart,swift,kt,c}`. - Then `just test interop --all` for the interop matrix. +# Layers + +UniFFI allows one namespace per crate, so moq-ffi names by type (`MoqJsonSnapshotProducer`) and gives each layer above moq-net a constructor over the handle it wraps, not a verb on `MoqBroadcastProducer`. A constructor that takes over a handle closes it. + +The wrappers give each layer its own namespace, following `json`: a Python submodule (`moq.json`), a Go subpackage (`moq.dev/moq/json`, reaching root handles through `internal/bridge`), a Kotlin package (`dev.moq.json`), a Dart library (`package:moq/json.dart`), and a Swift caseless enum (`Json`). + Keep the wrappers thin and their names aligned with the Rust API. Swift and Python extend additively through labeled/keyword args with defaults; Go, Kotlin, and Dart take an options struct like Rust. # Gotchas diff --git a/swift/README.md b/swift/README.md index e309f3a526..ccd5f4ea09 100644 --- a/swift/README.md +++ b/swift/README.md @@ -78,11 +78,14 @@ and `TrackConsumer.datagrams` streams them in arrival order. Payloads are capped Datagrams require a datagram-capable transport and lite-05 or newer moq-lite; IETF moq-transport, pre-lite-05, WebSocket, and TCP paths do not deliver them, and there is no stream fallback. -JSON tracks carry your own `Codable` types with the framing handled for you. You opt into one of two -distinct modes: `publishJsonSnapshot`/`subscribeJsonSnapshot` (lossy latest-value, a subscriber only -sees the newest value) or `publishJsonStream`/`subscribeJsonStream` (lossless append-log, every record -preserved in order). Values encode/decode at the boundary with `JSONEncoder`/`JSONDecoder`; the consumer -is an `AsyncSequence` of the decoded type. Pass matching `compression` on both sides. +JSON tracks live under the `Json` namespace, mirroring the `moq-json` crate, and carry your own +`Codable` types with the framing handled for you. Each type wraps a track: a producer takes over a +`TrackProducer` and advertises it in the broadcast's catalog, and a consumer takes over a +`TrackConsumer`. You opt into one of two distinct modes: `Json.SnapshotProducer`/`Json.SnapshotConsumer` +(lossy latest-value, a subscriber only sees the newest value) or `Json.StreamProducer`/`Json.StreamConsumer` +(lossless append-log, every record preserved in order). Values encode/decode at the boundary with +`JSONEncoder`/`JSONDecoder`; the consumer is an `AsyncSequence` of the decoded type. Pass matching +`compression` on both sides. ## Local development diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift index 528f3401e1..70d2d36a2f 100644 --- a/swift/Sources/Moq/Broadcast.swift +++ b/swift/Sources/Moq/Broadcast.swift @@ -95,30 +95,6 @@ public final class BroadcastConsumer: Sendable { ) async throws -> VideoConsumer { VideoConsumer(try await ffi.decodeVideo(name: name, catalogVideo: catalogVideo, output: output)) } - - /// Subscribe to a JSON snapshot track (lossy latest-value), decoding each value as `Value`. - /// - /// Yields only the newest value, collapsing the backlog for a reader that has fallen behind. - /// `compression` must match the flag the producer used. - public func subscribeJsonSnapshot( - name: String, - as _: Value.Type, - compression: Bool = false - ) async throws -> JsonSnapshotConsumer { - // deltaRatio is producer-only, so leave it at its default here. - JsonSnapshotConsumer(try await ffi.subscribeJsonSnapshot(name: name, config: MoqJsonSnapshotConfig(compression: compression))) - } - - /// Subscribe to a JSON stream track (lossless append-log), decoding each record as `Value`. - /// - /// Yields every record in order. `compression` must match the flag the producer used. - public func subscribeJsonStream( - name: String, - as _: Value.Type, - compression: Bool = false - ) async throws -> JsonStreamConsumer { - JsonStreamConsumer(try await ffi.subscribeJsonStream(name: name, config: MoqJsonStreamConfig(compression: compression))) - } } /// Write side of a broadcast: open tracks and publish frames. @@ -303,33 +279,6 @@ public final class BroadcastProducer: Sendable { VideoProducer(try ffi.encodeVideo(input: input, output: output, bandwidth: bandwidth?.ffi)) } - /// Open a JSON snapshot track (lossy latest-value), encoding each value from `Value`. - /// - /// Each `update` supersedes the last; a late joiner only sees the newest value. `deltaRatio` - /// controls how aggressively merge-patch deltas replace full snapshots (`0` disables deltas). - /// Set `compression` to DEFLATE each group; the consumer must pass the same flag. Advertise - /// the track with `setCatalogSection` if consumers should discover it. - public func publishJsonSnapshot( - name: String, - of _: Value.Type, - deltaRatio: UInt32 = MoqJsonSnapshotConfig().deltaRatio, - compression: Bool = false - ) throws -> JsonSnapshotProducer { - JsonSnapshotProducer(try ffi.publishJsonSnapshot(name: name, config: MoqJsonSnapshotConfig(deltaRatio: deltaRatio, compression: compression))) - } - - /// Open a JSON stream track (lossless append-log), encoding each record from `Value`. - /// - /// Every appended record is preserved and delivered in order. Set `compression` to DEFLATE - /// the group; the consumer must pass the same flag. - public func publishJsonStream( - name: String, - of _: Value.Type, - compression: Bool = false - ) throws -> JsonStreamProducer { - JsonStreamProducer(try ffi.publishJsonStream(name: name, config: MoqJsonStreamConfig(compression: compression))) - } - /// Set (or replace) an untyped application catalog section by name. /// /// `json` is any JSON document as a string; it rides alongside `video`/`audio` and reaches diff --git a/swift/Sources/Moq/Json.swift b/swift/Sources/Moq/Json.swift index 21b215e606..222fa7c677 100644 --- a/swift/Sources/Moq/Json.swift +++ b/swift/Sources/Moq/Json.swift @@ -1,125 +1,161 @@ import Foundation import MoqFFI -/// Write side of a JSON snapshot track (lossy latest-value). +/// JSON tracks, mirroring the `moq-json` crate. /// -/// Built via `BroadcastProducer.publishJsonSnapshot`. Each `update` supersedes the last, so a -/// late joiner only sees the newest value. Values cross the FFI boundary as JSON, encoded from -/// `Value` with `JSONEncoder`, so `Value` must encode to a JSON object or array: `JSONEncoder` -/// rejects a top-level scalar (a bare number/string/bool) on Foundation before the swift-foundation -/// rewrite (iOS < 18 / macOS < 15). -public final class JsonSnapshotProducer: Sendable { - let ffi: MoqJsonSnapshotProducer - - init(_ ffi: MoqJsonSnapshotProducer) { - self.ffi = ffi - } - - /// Publish a new value, encoded as a snapshot or merge-patch delta automatically. A no-op - /// if unchanged from the previous update. - public func update(_ value: Value) throws { - try ffi.update(value: encodeJson(value)) - } - - /// A watch-only handle to whether the track has subscribers. - public func demand() throws -> TrackDemand { - TrackDemand(try ffi.demand()) - } - - /// Finish the track, closing any open group. - public func finish() throws { - try ffi.finish() - } -} - -/// Read side of a JSON snapshot track (lossy latest-value). Iterating yields the latest value, -/// collapsing the backlog for a reader that has fallen behind: `for try await value in json { ... }`. -public final class JsonSnapshotConsumer: AsyncSequence, Sendable { - /// The decoded snapshot value emitted by this sequence. - public typealias Element = Value - - let ffi: MoqJsonSnapshotConsumer - - init(_ ffi: MoqJsonSnapshotConsumer) { - self.ffi = ffi - } - - /// The next value, decoded as `Value`, or `nil` once the track ends or is closed. - public func next() async throws -> Value? { - guard let json = try await ffi.next() else { return nil } - return try decodeJson(json) as Value - } - - /// Cancel all current and future reads. - public func cancel() { - ffi.cancel() - } - - /// Create an iterator that cancels native reads when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { - moqStream(cancel: { [ffi] in ffi.cancel() }) { [self] in - try await next() - }.makeAsyncIterator() - } -} - -/// Write side of a JSON stream track (lossless append-log). -/// -/// Built via `BroadcastProducer.publishJsonStream`. Every `append` is preserved and delivered -/// in order. Values cross the FFI boundary as JSON, encoded from `Value` with `JSONEncoder`, so -/// `Value` must encode to a JSON object or array: `JSONEncoder` rejects a top-level scalar (a bare -/// number/string/bool) on Foundation before the swift-foundation rewrite (iOS < 18 / macOS < 15). -public final class JsonStreamProducer: Sendable { - let ffi: MoqJsonStreamProducer - - init(_ ffi: MoqJsonStreamProducer) { - self.ffi = ffi - } - - /// Append one record to the log. - public func append(_ value: Value) throws { - try ffi.append(value: encodeJson(value)) - } - - /// A watch-only handle to whether the track has subscribers. - public func demand() throws -> TrackDemand { - TrackDemand(try ffi.demand()) - } - - /// Finish the track, closing the group. - public func finish() throws { - try ffi.finish() - } -} - -/// Read side of a JSON stream track (lossless append-log). Iterating yields every record in -/// order: `for try await record in json { ... }`. -public final class JsonStreamConsumer: AsyncSequence, Sendable { - /// The decoded stream record emitted by this sequence. - public typealias Element = Value - - let ffi: MoqJsonStreamConsumer - - init(_ ffi: MoqJsonStreamConsumer) { - self.ffi = ffi - } - - /// The next record, decoded as `Value`, or `nil` once the track ends or is closed. - public func next() async throws -> Value? { - guard let json = try await ffi.next() else { return nil } - return try decodeJson(json) as Value - } - - /// Cancel all current and future reads. - public func cancel() { - ffi.cancel() - } - - /// Create an iterator that cancels native reads when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { - moqStream(cancel: { [ffi] in ffi.cancel() }) { [self] in - try await next() - }.makeAsyncIterator() +/// Each type wraps a track: a producer takes over a `TrackProducer` and advertises it in the +/// broadcast's catalog, and a consumer takes over a `TrackConsumer` that has not read a group +/// yet. Values cross the FFI boundary as JSON, encoded with `JSONEncoder` and decoded with +/// `JSONDecoder`. `JSONEncoder` rejects a top-level scalar (a bare number/string/bool) on +/// Foundation before the swift-foundation rewrite (iOS < 18 / macOS < 15), so encode an object +/// or array there. +public enum Json {} + +extension Json { + /// Write side of a JSON snapshot track (lossy latest-value). + /// + /// Each `update` supersedes the last, so a late joiner only sees the newest value. + public final class SnapshotProducer: Sendable { + let ffi: MoqJsonSnapshotProducer + + /// Publish `track` as a JSON snapshot track, advertised in `broadcast`'s catalog until it + /// finishes. Takes over `track`, whose handle is closed afterward; throws if the catalog + /// already carries the track's name. + /// + /// `deltaRatio` controls how aggressively merge-patch deltas replace full snapshots (`0` + /// disables deltas). Set `compression` to DEFLATE each group; the consumer must pass the + /// same flag. + public init( + broadcast: BroadcastProducer, + track: TrackProducer, + deltaRatio: UInt32 = MoqJsonSnapshotConfig().deltaRatio, + compression: Bool = false + ) throws { + ffi = try MoqJsonSnapshotProducer( + broadcast: broadcast.ffi, + track: track.ffi, + config: MoqJsonSnapshotConfig(deltaRatio: deltaRatio, compression: compression) + ) + } + + /// Publish a new value, encoded as a snapshot or merge-patch delta automatically. A no-op + /// if unchanged from the previous update. + public func update(_ value: Value) throws { + try ffi.update(value: encodeJson(value)) + } + + /// A watch-only handle to whether the track has subscribers. + public func demand() throws -> TrackDemand { + TrackDemand(try ffi.demand()) + } + + /// Finish the track, closing any open group. + public func finish() throws { + try ffi.finish() + } + } + + /// Read side of a JSON snapshot track (lossy latest-value). Iterating yields the latest value, + /// collapsing the backlog for a reader that has fallen behind: `for try await value in json { ... }`. + public final class SnapshotConsumer: AsyncSequence, Sendable { + /// The decoded snapshot value emitted by this sequence. + public typealias Element = Value + + let ffi: MoqJsonSnapshotConsumer + + /// Read `track` as a JSON snapshot track. Takes over `track`, whose handle is closed + /// afterward; throws if it has already read a group. `compression` must match the flag + /// the producer used. + public init(track: TrackConsumer, compression: Bool = false) throws { + // deltaRatio is producer-only, so leave it at its default here. + ffi = try MoqJsonSnapshotConsumer(track: track.ffi, config: MoqJsonSnapshotConfig(compression: compression)) + } + + /// The next value, decoded as `Value`, or `nil` once the track ends or is closed. + public func next() async throws -> Value? { + guard let json = try await ffi.next() else { return nil } + return try decodeJson(json) as Value + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + /// Create an iterator that cancels native reads when iteration ends. + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [self] in + try await next() + }.makeAsyncIterator() + } + } + + /// Write side of a JSON stream track (lossless append-log). + /// + /// Every `append` is preserved and delivered in order. + public final class StreamProducer: Sendable { + let ffi: MoqJsonStreamProducer + + /// Publish `track` as a JSON stream track, advertised in `broadcast`'s catalog until it + /// finishes. Takes over `track`, whose handle is closed afterward; throws if the catalog + /// already carries the track's name. Set `compression` to DEFLATE the group; the consumer + /// must pass the same flag. + public init(broadcast: BroadcastProducer, track: TrackProducer, compression: Bool = false) throws { + ffi = try MoqJsonStreamProducer( + broadcast: broadcast.ffi, + track: track.ffi, + config: MoqJsonStreamConfig(compression: compression) + ) + } + + /// Append one record to the log. + public func append(_ value: Value) throws { + try ffi.append(value: encodeJson(value)) + } + + /// A watch-only handle to whether the track has subscribers. + public func demand() throws -> TrackDemand { + TrackDemand(try ffi.demand()) + } + + /// Finish the track, closing the group. + public func finish() throws { + try ffi.finish() + } + } + + /// Read side of a JSON stream track (lossless append-log). Iterating yields every record in + /// order: `for try await record in json { ... }`. + public final class StreamConsumer: AsyncSequence, Sendable { + /// The decoded stream record emitted by this sequence. + public typealias Element = Value + + let ffi: MoqJsonStreamConsumer + + /// Read `track` as a JSON stream track. Takes over `track`, whose handle is closed + /// afterward; throws if it has already read a group. `compression` must match the flag + /// the producer used. + public init(track: TrackConsumer, compression: Bool = false) throws { + ffi = try MoqJsonStreamConsumer(track: track.ffi, config: MoqJsonStreamConfig(compression: compression)) + } + + /// The next record, decoded as `Value`, or `nil` once the track ends or is closed. + public func next() async throws -> Value? { + guard let json = try await ffi.next() else { return nil } + return try decodeJson(json) as Value + } + + /// Cancel all current and future reads. + public func cancel() { + ffi.cancel() + } + + /// Create an iterator that cancels native reads when iteration ends. + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + moqStream(cancel: { [ffi] in ffi.cancel() }) { [self] in + try await next() + }.makeAsyncIterator() + } } } diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 866f05105b..c77d862e9f 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -231,9 +231,10 @@ final class SmokeTests: XCTestCase { } let broadcast = try BroadcastProducer() - let producer = try broadcast.publishJsonSnapshot(name: "status", of: Status.self, compression: true) - let consumer = try await broadcast.consume().subscribeJsonSnapshot( - name: "status", as: Status.self, compression: true) + let producer = try Json.SnapshotProducer( + broadcast: broadcast, track: try broadcast.publishTrack(name: "status"), compression: true) + let consumer = try Json.SnapshotConsumer( + track: try await broadcast.consume().subscribeTrack(name: "status"), compression: true) try producer.update(Status(state: "live", viewers: 42)) let first = try await consumer.next() @@ -255,9 +256,10 @@ final class SmokeTests: XCTestCase { } let broadcast = try BroadcastProducer() - let producer = try broadcast.publishJsonStream(name: "events", of: Event.self) - let consumer = try await broadcast.consume().subscribeJsonStream( - name: "events", as: Event.self) + let producer = try Json.StreamProducer( + broadcast: broadcast, track: try broadcast.publishTrack(name: "events")) + let consumer = try Json.StreamConsumer( + track: try await broadcast.consume().subscribeTrack(name: "events")) for n in 0..<3 { try producer.append(Event(n: n)) @@ -272,16 +274,20 @@ final class SmokeTests: XCTestCase { func testJsonProducersReportDemand() async throws { let broadcast = try BroadcastProducer() - let snapshot = try broadcast.publishJsonSnapshot(name: "status", of: [String: Int].self) - let stream = try broadcast.publishJsonStream(name: "events", of: [String: Int].self) + let snapshot = try Json.SnapshotProducer<[String: Int]>( + broadcast: broadcast, track: try broadcast.publishTrack(name: "status")) + let stream = try Json.StreamProducer<[String: Int]>( + broadcast: broadcast, track: try broadcast.publishTrack(name: "events")) let snapshotDemand = try snapshot.demand() let streamDemand = try stream.demand() XCTAssertEqual(snapshotDemand.name, "status") XCTAssertFalse(snapshotDemand.isUsed) let consumer = try broadcast.consume() - let snapshotConsumer = try await consumer.subscribeJsonSnapshot(name: "status", as: [String: Int].self) - let streamConsumer = try await consumer.subscribeJsonStream(name: "events", as: [String: Int].self) + let snapshotConsumer = try Json.SnapshotConsumer<[String: Int]>( + track: try await consumer.subscribeTrack(name: "status")) + let streamConsumer = try Json.StreamConsumer<[String: Int]>( + track: try await consumer.subscribeTrack(name: "events")) try await snapshotDemand.used() try await streamDemand.used() XCTAssertTrue(snapshotDemand.isUsed) From 662a849c45fb071f7f7dabc1c26e396bc8e4bf92 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 11:22:01 -0700 Subject: [PATCH 5/9] refactor(dart)!: JSON tracks move to package:moq/json.dart; finish the json quest Dart gains a json library over the regenerated constructors, with a round-trip test. The upgrade page and the sibling quests name the constructors, and quest/m1/ffi-shape/json.md is done. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/json.dart | 37 +++ dart/moq/lib/src/aliases.dart | 18 -- dart/moq/test/moq_test.dart | 26 ++ dart/moq_ffi/lib/src/moq.dart | 473 +++++++++++++----------------- doc/setup/upgrade.md | 14 +- quest/m1/data-capture-bindings.md | 10 +- quest/m1/ffi-shape/README.md | 10 +- quest/m1/ffi-shape/codec.md | 1 - quest/m1/ffi-shape/json.md | 33 --- quest/m1/ffi-shape/media.md | 4 - quest/m1/ffi-shape/net.md | 4 - quest/m2/flate/bindings.md | 14 +- 12 files changed, 297 insertions(+), 347 deletions(-) create mode 100644 dart/moq/lib/json.dart delete mode 100644 quest/m1/ffi-shape/json.md diff --git a/dart/moq/lib/json.dart b/dart/moq/lib/json.dart new file mode 100644 index 0000000000..539fb5f634 --- /dev/null +++ b/dart/moq/lib/json.dart @@ -0,0 +1,37 @@ +/// JSON tracks, mirroring the `moq-json` crate. +/// +/// Each type wraps a track: a producer takes over a `TrackProducer` and +/// advertises it in the broadcast's catalog, and a consumer takes over a +/// `TrackConsumer` that has not read a group yet. Values cross as JSON +/// strings. Import it with a prefix, since `dart:convert` has a `json` too: +/// +/// ```dart +/// import 'package:moq/json.dart' as moq_json; +/// +/// final status = moq_json.SnapshotProducer( +/// broadcast: broadcast, +/// track: broadcast.publishTrack(name: 'status', info: null), +/// config: moq_json.SnapshotConfig(), +/// ); +/// ``` +library; + +import 'package:moq_ffi/moq_ffi.dart'; + +/// Publishes lossy latest-value JSON snapshots on a track it takes over. +typedef SnapshotProducer = MoqJsonSnapshotProducer; + +/// Consumes reconstructed latest-value JSON snapshots from a track it takes over. +typedef SnapshotConsumer = MoqJsonSnapshotConsumer; + +/// Configures a lossy latest-value JSON track. +typedef SnapshotConfig = MoqJsonSnapshotConfig; + +/// Publishes a lossless stream of JSON records on a track it takes over. +typedef StreamProducer = MoqJsonStreamProducer; + +/// Consumes a lossless stream of JSON records from a track it takes over. +typedef StreamConsumer = MoqJsonStreamConsumer; + +/// Configures a lossless JSON stream track. +typedef StreamConfig = MoqJsonStreamConfig; diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 742fbe54f4..f846ebc54e 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -122,24 +122,6 @@ typedef MediaGroupConsumer = MoqMediaGroupConsumer; /// The read side of a broadcast's catalog: yields updates as the set of tracks changes. typedef CatalogConsumer = MoqCatalogConsumer; -/// Publishes lossy latest-value JSON snapshots. -typedef JsonSnapshotProducer = MoqJsonSnapshotProducer; - -/// Consumes reconstructed latest-value JSON snapshots. -typedef JsonSnapshotConsumer = MoqJsonSnapshotConsumer; - -/// Configures a lossy latest-value JSON track. -typedef JsonSnapshotConfig = MoqJsonSnapshotConfig; - -/// Publishes a lossless stream of JSON records. -typedef JsonStreamProducer = MoqJsonStreamProducer; - -/// Consumes a lossless stream of JSON records. -typedef JsonStreamConsumer = MoqJsonStreamConsumer; - -/// Configures a lossless JSON stream track. -typedef JsonStreamConfig = MoqJsonStreamConfig; - /// A broadcast's catalog: its tracks and their properties, plus any application sections. typedef Catalog = MoqCatalog; diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index b3a5ff4e83..039fe63203 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:moq/json.dart' as moq_json; import 'package:moq/moq.dart'; import 'package:test/test.dart'; @@ -249,4 +250,29 @@ void main() { dynamic.dispose(); served.dispose(); }); + + test('JSON tracks take over a raw track', () async { + final broadcast = MoqBroadcastProducer(); + final producer = moq_json.SnapshotProducer( + broadcast: broadcast, + track: broadcast.publishTrack(name: 'status', info: null), + config: moq_json.SnapshotConfig(), + ); + final track = await broadcast + .consume() + .subscribeTrack(name: 'status', subscription: null) + .timeout(timeout); + final consumer = moq_json.SnapshotConsumer( + track: track, + config: moq_json.SnapshotConfig(), + ); + + producer.update(value: jsonEncode({'state': 'live'})); + final value = await consumer.next().timeout(timeout); + expect(jsonDecode(value!), {'state': 'live'}); + + consumer.cancel(); + producer.finish(); + broadcast.close(); + }); } diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 785297f956..646efc8e0b 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -4357,14 +4357,6 @@ abstract class MoqBroadcastConsumerInterface { required String name, required MoqSubscription? subscription, }); - Future subscribeJsonSnapshot({ - required String name, - required MoqJsonSnapshotConfig config, - }); - Future subscribeJsonStream({ - required String name, - required MoqJsonStreamConfig config, - }); } final _MoqBroadcastConsumerFinalizer = Finalizer>((ptr) { @@ -4507,47 +4499,6 @@ class MoqBroadcastConsumer implements MoqBroadcastConsumerInterface { moqExceptionErrorHandler, ); } - - Future subscribeJsonSnapshot({ - required String name, - required MoqJsonSnapshotConfig config, - }) { - return uniffiRustCallAsync( - () => - uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_snapshot( - uniffiClonePointer(), - FfiConverterString.lower(name), - FfiConverterMoqJsonSnapshotConfig.lower(config), - ), - ffi_moq_ffi_rust_future_poll_u64, - ffi_moq_ffi_rust_future_complete_u64, - ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqJsonSnapshotConsumer.lift( - Pointer.fromAddress(ptr), - ), - moqExceptionErrorHandler, - ); - } - - Future subscribeJsonStream({ - required String name, - required MoqJsonStreamConfig config, - }) { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_stream( - uniffiClonePointer(), - FfiConverterString.lower(name), - FfiConverterMoqJsonStreamConfig.lower(config), - ), - ffi_moq_ffi_rust_future_poll_u64, - ffi_moq_ffi_rust_future_complete_u64, - ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqJsonStreamConsumer.lift( - Pointer.fromAddress(ptr), - ), - moqExceptionErrorHandler, - ); - } } class FfiConverterMoqBroadcastConsumer { @@ -5173,6 +5124,21 @@ class MoqFlateSnapshotProducer implements MoqFlateSnapshotProducerInterface { MoqFlateSnapshotProducer._(this._ptr) { _MoqFlateSnapshotProducerFinalizer.attach(this, _ptr, detach: this); } + MoqFlateSnapshotProducer({ + required MoqBroadcastProducer broadcast, + required MoqTrackProducer track, + required MoqFlateConfig config, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqflatesnapshotproducer_new( + FfiConverterMoqBroadcastProducer.lower(broadcast), + FfiConverterMoqTrackProducer.lower(track), + FfiConverterMoqFlateConfig.lower(config), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqFlateSnapshotProducerFinalizer.attach(this, _ptr, detach: this); + } factory MoqFlateSnapshotProducer.lift(Pointer ptr) { return MoqFlateSnapshotProducer._(ptr); } @@ -5252,6 +5218,21 @@ class MoqFlateStreamProducer implements MoqFlateStreamProducerInterface { MoqFlateStreamProducer._(this._ptr) { _MoqFlateStreamProducerFinalizer.attach(this, _ptr, detach: this); } + MoqFlateStreamProducer({ + required MoqBroadcastProducer broadcast, + required MoqTrackProducer track, + required MoqFlateConfig config, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqflatestreamproducer_new( + FfiConverterMoqBroadcastProducer.lower(broadcast), + FfiConverterMoqTrackProducer.lower(track), + FfiConverterMoqFlateConfig.lower(config), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqFlateStreamProducerFinalizer.attach(this, _ptr, detach: this); + } factory MoqFlateStreamProducer.lift(Pointer ptr) { return MoqFlateStreamProducer._(ptr); } @@ -5330,6 +5311,19 @@ class MoqJsonSnapshotConsumer implements MoqJsonSnapshotConsumerInterface { MoqJsonSnapshotConsumer._(this._ptr) { _MoqJsonSnapshotConsumerFinalizer.attach(this, _ptr, detach: this); } + MoqJsonSnapshotConsumer({ + required MoqTrackConsumer track, + required MoqJsonSnapshotConfig config, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqjsonsnapshotconsumer_new( + FfiConverterMoqTrackConsumer.lower(track), + FfiConverterMoqJsonSnapshotConfig.lower(config), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqJsonSnapshotConsumerFinalizer.attach(this, _ptr, detach: this); + } factory MoqJsonSnapshotConsumer.lift(Pointer ptr) { return MoqJsonSnapshotConsumer._(ptr); } @@ -5412,6 +5406,21 @@ class MoqJsonSnapshotProducer implements MoqJsonSnapshotProducerInterface { MoqJsonSnapshotProducer._(this._ptr) { _MoqJsonSnapshotProducerFinalizer.attach(this, _ptr, detach: this); } + MoqJsonSnapshotProducer({ + required MoqBroadcastProducer broadcast, + required MoqTrackProducer track, + required MoqJsonSnapshotConfig config, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqjsonsnapshotproducer_new( + FfiConverterMoqBroadcastProducer.lower(broadcast), + FfiConverterMoqTrackProducer.lower(track), + FfiConverterMoqJsonSnapshotConfig.lower(config), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqJsonSnapshotProducerFinalizer.attach(this, _ptr, detach: this); + } factory MoqJsonSnapshotProducer.lift(Pointer ptr) { return MoqJsonSnapshotProducer._(ptr); } @@ -5501,6 +5510,19 @@ class MoqJsonStreamConsumer implements MoqJsonStreamConsumerInterface { MoqJsonStreamConsumer._(this._ptr) { _MoqJsonStreamConsumerFinalizer.attach(this, _ptr, detach: this); } + MoqJsonStreamConsumer({ + required MoqTrackConsumer track, + required MoqJsonStreamConfig config, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqjsonstreamconsumer_new( + FfiConverterMoqTrackConsumer.lower(track), + FfiConverterMoqJsonStreamConfig.lower(config), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqJsonStreamConsumerFinalizer.attach(this, _ptr, detach: this); + } factory MoqJsonStreamConsumer.lift(Pointer ptr) { return MoqJsonStreamConsumer._(ptr); } @@ -5583,6 +5605,21 @@ class MoqJsonStreamProducer implements MoqJsonStreamProducerInterface { MoqJsonStreamProducer._(this._ptr) { _MoqJsonStreamProducerFinalizer.attach(this, _ptr, detach: this); } + MoqJsonStreamProducer({ + required MoqBroadcastProducer broadcast, + required MoqTrackProducer track, + required MoqJsonStreamConfig config, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqjsonstreamproducer_new( + FfiConverterMoqBroadcastProducer.lower(broadcast), + FfiConverterMoqTrackProducer.lower(track), + FfiConverterMoqJsonStreamConfig.lower(config), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqJsonStreamProducerFinalizer.attach(this, _ptr, detach: this); + } factory MoqJsonStreamProducer.lift(Pointer ptr) { return MoqJsonStreamProducer._(ptr); } @@ -6277,22 +6314,6 @@ class FfiConverterMoqBroadcastDynamic { } abstract class MoqBroadcastProducerInterface { - MoqFlateSnapshotProducer publishFlateSnapshot({ - required String name, - required MoqFlateConfig config, - }); - MoqFlateStreamProducer publishFlateStream({ - required String name, - required MoqFlateConfig config, - }); - MoqJsonSnapshotProducer publishJsonSnapshot({ - required String name, - required MoqJsonSnapshotConfig config, - }); - MoqJsonStreamProducer publishJsonStream({ - required String name, - required MoqJsonStreamConfig config, - }); void announce({required MoqRoute route}); void close(); MoqBroadcastConsumer consume(); @@ -6357,74 +6378,6 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { ); } - MoqFlateSnapshotProducer publishFlateSnapshot({ - required String name, - required MoqFlateConfig config, - }) { - return rustCallWithLifter( - (status) => - uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_flate_snapshot( - uniffiClonePointer(), - FfiConverterString.lower(name), - FfiConverterMoqFlateConfig.lower(config), - status, - ), - FfiConverterMoqFlateSnapshotProducer.lift, - moqExceptionErrorHandler, - ); - } - - MoqFlateStreamProducer publishFlateStream({ - required String name, - required MoqFlateConfig config, - }) { - return rustCallWithLifter( - (status) => - uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_flate_stream( - uniffiClonePointer(), - FfiConverterString.lower(name), - FfiConverterMoqFlateConfig.lower(config), - status, - ), - FfiConverterMoqFlateStreamProducer.lift, - moqExceptionErrorHandler, - ); - } - - MoqJsonSnapshotProducer publishJsonSnapshot({ - required String name, - required MoqJsonSnapshotConfig config, - }) { - return rustCallWithLifter( - (status) => - uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_snapshot( - uniffiClonePointer(), - FfiConverterString.lower(name), - FfiConverterMoqJsonSnapshotConfig.lower(config), - status, - ), - FfiConverterMoqJsonSnapshotProducer.lift, - moqExceptionErrorHandler, - ); - } - - MoqJsonStreamProducer publishJsonStream({ - required String name, - required MoqJsonStreamConfig config, - }) { - return rustCallWithLifter( - (status) => - uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_stream( - uniffiClonePointer(), - FfiConverterString.lower(name), - FfiConverterMoqJsonStreamConfig.lower(config), - status, - ), - FfiConverterMoqJsonStreamProducer.lift, - moqExceptionErrorHandler, - ); - } - void announce({required MoqRoute route}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqbroadcastproducer_announce( @@ -9836,26 +9789,6 @@ uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_track( RustBuffer subscription, ); -@Native Function(Pointer, RustBuffer, RustBuffer)>( - assetId: _uniffiAssetId, -) -external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_snapshot( - Pointer ptr, - RustBuffer name, - RustBuffer config, -); - -@Native Function(Pointer, RustBuffer, RustBuffer)>( - assetId: _uniffiAssetId, -) -external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_stream( - Pointer ptr, - RustBuffer name, - RustBuffer config, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10107,6 +10040,22 @@ external void uniffi_moq_ffi_fn_free_moqflatesnapshotproducer( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + Pointer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_constructor_moqflatesnapshotproducer_new( + Pointer broadcast, + Pointer track, + RustBuffer config, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -10140,6 +10089,21 @@ external void uniffi_moq_ffi_fn_free_moqflatestreamproducer( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + Pointer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_constructor_moqflatestreamproducer_new( + Pointer broadcast, + Pointer track, + RustBuffer config, + Pointer uniffiStatus, +); + @Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10173,6 +10137,16 @@ external void uniffi_moq_ffi_fn_free_moqjsonsnapshotconsumer( Pointer uniffiStatus, ); +@Native< + Pointer Function(Pointer, RustBuffer, Pointer) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_constructor_moqjsonsnapshotconsumer_new( + Pointer track, + RustBuffer config, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -10202,6 +10176,22 @@ external void uniffi_moq_ffi_fn_free_moqjsonsnapshotproducer( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + Pointer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_constructor_moqjsonsnapshotproducer_new( + Pointer broadcast, + Pointer track, + RustBuffer config, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10243,6 +10233,15 @@ external void uniffi_moq_ffi_fn_free_moqjsonstreamconsumer( Pointer uniffiStatus, ); +@Native< + Pointer Function(Pointer, RustBuffer, Pointer) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_constructor_moqjsonstreamconsumer_new( + Pointer track, + RustBuffer config, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -10272,6 +10271,21 @@ external void uniffi_moq_ffi_fn_free_moqjsonstreamproducer( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + Pointer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_constructor_moqjsonstreamproducer_new( + Pointer broadcast, + Pointer track, + RustBuffer config, + Pointer uniffiStatus, +); + @Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10588,70 +10602,6 @@ external Pointer uniffi_moq_ffi_fn_constructor_moqbroadcastproducer_new( Pointer uniffiStatus, ); -@Native< - Pointer Function( - Pointer, - RustBuffer, - RustBuffer, - Pointer, - ) ->(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_flate_snapshot( - Pointer ptr, - RustBuffer name, - RustBuffer config, - Pointer uniffiStatus, -); - -@Native< - Pointer Function( - Pointer, - RustBuffer, - RustBuffer, - Pointer, - ) ->(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_flate_stream( - Pointer ptr, - RustBuffer name, - RustBuffer config, - Pointer uniffiStatus, -); - -@Native< - Pointer Function( - Pointer, - RustBuffer, - RustBuffer, - Pointer, - ) ->(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_snapshot( - Pointer ptr, - RustBuffer name, - RustBuffer config, - Pointer uniffiStatus, -); - -@Native< - Pointer Function( - Pointer, - RustBuffer, - RustBuffer, - Pointer, - ) ->(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_stream( - Pointer ptr, - RustBuffer name, - RustBuffer config, - Pointer uniffiStatus, -); - @Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) @@ -12138,14 +12088,6 @@ uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_media(); external int uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_track(); -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_snapshot(); - -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_stream(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqcatalogconsumer_cancel(); @@ -12310,22 +12252,6 @@ external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel(); external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track(); -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_flate_snapshot(); - -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_flate_stream(); - -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot(); - -@Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce(); @@ -12655,6 +12581,24 @@ external int uniffi_moq_ffi_checksum_method_moqsession_stats(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_status(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqflatesnapshotproducer_new(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqflatestreamproducer_new(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotconsumer_new(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotproducer_new(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqjsonstreamconsumer_new(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqjsonstreamproducer_new(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqoriginproducer_new(); @@ -12716,14 +12660,6 @@ void _checkApiChecksums() { 2348) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_snapshot() != - 46473) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_stream() != - 3028) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqcatalogconsumer_cancel() != 65421) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -12897,22 +12833,6 @@ void _checkApiChecksums() { 24118) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_flate_snapshot() != - 8575) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_flate_stream() != - 56158) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot() != - 64276) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream() != - 54975) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -13252,6 +13172,29 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqsession_status() != 49725) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_constructor_moqflatesnapshotproducer_new() != + 47430) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_constructor_moqflatestreamproducer_new() != 469) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotconsumer_new() != + 62847) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotproducer_new() != + 2801) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_constructor_moqjsonstreamconsumer_new() != + 44851) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_constructor_moqjsonstreamproducer_new() != + 10341) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_constructor_moqoriginproducer_new() != 48126) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index aea4de7e0a..6cdeb84504 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -27,10 +27,16 @@ These land with the next breaking release, not the 2026-09-23 train. now carries `moq_net::Error`, so it is no longer `PartialEq`. moq-mux's `Error::Binary` is `Error::Flate`. In TypeScript, import `Snapshot` and `Stream` from `@moq/flate`. -- **moq-ffi flate tracks.** `publish_binary_snapshot` / `publish_binary_stream` - are `publish_flate_snapshot` / `publish_flate_stream`, taking - `MoqFlateConfig` and returning `MoqFlateSnapshotProducer` / - `MoqFlateStreamProducer`. The C `moq_publish_binary_*` calls are unchanged. +- **moq-ffi data tracks wrap a track.** The broadcast's + `publish_binary_*`, `publish_json_*`, and `subscribe_json_*` are gone. + Create the track with `publish_track` or `subscribe_track`, then construct + `MoqFlateSnapshotProducer` / `MoqFlateStreamProducer` (taking + `MoqFlateConfig`) or `MoqJsonSnapshotProducer` / `MoqJsonStreamProducer` + from the broadcast and the track, or `MoqJsonSnapshotConsumer` / + `MoqJsonStreamConsumer` from the track. The wrappers keep JSON under its own + namespace: `moq.json` in Python, `moq.dev/moq/json` in Go, `dev.moq.json` in + Kotlin, `package:moq/json.dart` in Dart, and `Json` in Swift. The C + `moq_publish_binary_*` calls are unchanged. ## Wire diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md index 90f1f008ea..39ccfa7c12 100644 --- a/quest/m1/data-capture-bindings.md +++ b/quest/m1/data-capture-bindings.md @@ -34,8 +34,10 @@ not moq-c. past capture time is accepted and a future one refused. - Go (`go/wrapper/moq`) and Python (`py/moq-rs`) wrap only the JSON producers; [#4137](https://github.com/moq-dev/moq/pull/4137) added - `publish_binary_snapshot` / `publish_binary_stream` (now - `publish_flate_*`) to moq-ffi without them. Add hand-written flate wrappers there, capture time included, so + `publish_binary_snapshot` / `publish_binary_stream` (now the + `MoqFlateSnapshotProducer` / `MoqFlateStreamProducer` constructors) to + moq-ffi without them. Put them in a `flate` namespace beside each wrapper's + `json` one (see `rs/moq-ffi/AGENTS.md`). Add hand-written flate wrappers there, capture time included, so the capture tests cover binary too. The maintainer asked for this. Update `doc/lib/{py,swift,kt,go,dart}`. @@ -46,10 +48,6 @@ no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock `window::Producer::push` accepts `Timed`, source-compatible. Wire: none. -## Required - -- [JSON and flate namespaces](/quest/m1/ffi-shape/json.md) - moves the data producers this changes, so the two breaks land in order rather than colliding - ## Related - [Generated C bindings](/quest/m1/c/README.md) - replaces libmoq, so C inherits this from moq-ffi diff --git a/quest/m1/ffi-shape/README.md b/quest/m1/ffi-shape/README.md index 5f10244392..f18042a408 100644 --- a/quest/m1/ffi-shape/README.md +++ b/quest/m1/ffi-shape/README.md @@ -19,13 +19,14 @@ Settled shape: origin, broadcast, track, group); `media` merges hang and moq-mux, since a binding never sees that split (catalog, import producers, container consumers); `json`, `flate`, `audio`, and `video` own their producers and - consumers. `flate` holds the opaque snapshot and stream tracks moq-ffi - publishes as `publish_flate_*` today, named after the `moq-flate` crate. + consumers. `flate` holds the opaque snapshot and stream tracks, named after + the `moq-flate` crate. - A layer's type is constructed from the handles its Rust constructor takes, not reached through an accessor on the broadcast: JSON wraps a track (`moq_json::snapshot::Producer::new(track, config)`), so it also works on a track accepted from a request; the codecs take the broadcast and its - catalog. Sketch, not a contract: `json.SnapshotProducer(track, config)`, + catalog. JSON and flate producers take the broadcast too, whose catalog + advertises the track, as `moq_mux::catalog::Producer::json_snapshot` does. Sketch, not a contract: `json.SnapshotProducer(track, config)`, `video.Encoder(broadcast, catalog, config)`. - UniFFI 0.32 allows one namespace per crate, so moq-ffi groups by type and the wrappers supply real namespaces in each language's idiom: Python @@ -38,6 +39,8 @@ Settled shape: [generated C](/quest/m1/c/README.md) and [C++](/quest/m1/cpp/README.md) bindings inherit this shape from moq-ffi, so reshaping the hand-written C ABI would break C users twice. +- `rs/moq-ffi/AGENTS.md` records the per-language namespace pattern `json` + set. Each child reshapes one group end to end: moq-ffi, all five wrappers, and the `doc/lib` samples, per the cross-package table. This README owns the @@ -51,7 +54,6 @@ work no child does: ## Required -- [JSON](/quest/m1/ffi-shape/json.md) - the pilot: json and flate become their own namespaces wrapping a track in every binding and set the per-language pattern - [Net](/quest/m1/ffi-shape/net.md) - client and server take config records, snapshots are records, and the verbs match moq-net - [Media](/quest/m1/ffi-shape/media.md) - catalog, import, and container consume move under `media` - [Codecs](/quest/m1/ffi-shape/codec.md) - audio and video encoders and decoders move under their own namespaces with one constructor shape diff --git a/quest/m1/ffi-shape/codec.md b/quest/m1/ffi-shape/codec.md index a5668cfc19..b68efe5906 100644 --- a/quest/m1/ffi-shape/codec.md +++ b/quest/m1/ffi-shape/codec.md @@ -30,5 +30,4 @@ Public API: breaking in every binding. Wire: none. ## Required -- [JSON](/quest/m1/ffi-shape/json.md) - sets the per-language namespace pattern - [Media](/quest/m1/ffi-shape/media.md) - the catalog handle the encoders register into diff --git a/quest/m1/ffi-shape/json.md b/quest/m1/ffi-shape/json.md deleted file mode 100644 index 1475f6217a..0000000000 --- a/quest/m1/ffi-shape/json.md +++ /dev/null @@ -1,33 +0,0 @@ -# [M] JSON and flate get their own namespaces in every binding - -## Goal - -JSON tracks live under `json` and opaque tracks under `flate` in moq-ffi and -every wrapper, constructed from a track producer or consumer as in `moq-json` -and `moq-flate`, and `BroadcastProducer`/`BroadcastConsumer` lose -`publish_json_*`/`subscribe_json_*` and `publish_flate_*`. The per-language -namespace pattern this sets is what the other children copy. - -## Plan - -JSON is the smallest group, so it carries the setup cost: a Python submodule, -a Go subpackage, a Kotlin package, a Dart library, and a Swift namespace, each -wired into its package build, tests, and docs. Settle the pattern here and -write it down where the next child will find it (`rs/moq-ffi/AGENTS.md` if it -is a convention, after reading `PROMPTING.md`). - -Mirror `moq-json`'s names (`snapshot`, `stream`) and constructors, which -take a track: that also covers a track accepted from a request, which the -broadcast methods cannot reach. Snapshot and -stream producers keep `demand()`. Payloads stay `String` at the FFI; wrappers -that already type them (Swift's generic producer, Kotlin's reified `update`) -keep doing so, and the rest may follow. - -Watch for Go import cycles: a subpackage takes the root's broadcast handle, so -the root must not import it back. - -`flate` is the same shape over opaque bytes: moq-ffi's `flate.rs` -(`publish_flate_snapshot`, `publish_flate_stream`) moves under it, mirroring -`moq_flate::{snapshot, stream}`. - -Public API: breaking in every binding. Wire: none. diff --git a/quest/m1/ffi-shape/media.md b/quest/m1/ffi-shape/media.md index e2a0a53714..e515c63d4e 100644 --- a/quest/m1/ffi-shape/media.md +++ b/quest/m1/ffi-shape/media.md @@ -25,7 +25,3 @@ once the shape is in front of you, and prefer one path. Go's through `demand()` only. Public API: breaking in every binding. Wire: none. - -## Required - -- [JSON](/quest/m1/ffi-shape/json.md) - sets the per-language namespace pattern diff --git a/quest/m1/ffi-shape/net.md b/quest/m1/ffi-shape/net.md index 83f99cc8af..8cf902b076 100644 --- a/quest/m1/ffi-shape/net.md +++ b/quest/m1/ffi-shape/net.md @@ -44,7 +44,3 @@ are renamed. #3959). Public API: breaking in every binding. Wire: none. - -## Required - -- [JSON](/quest/m1/ffi-shape/json.md) - sets the per-language namespace pattern diff --git a/quest/m2/flate/bindings.md b/quest/m2/flate/bindings.md index 13c3c21db8..2c4770b075 100644 --- a/quest/m2/flate/bindings.md +++ b/quest/m2/flate/bindings.md @@ -9,19 +9,17 @@ decodes in the browser with `@moq/flate` and vice versa. ## Plan -moq-ffi publishes opaque tracks today (`publish_flate_snapshot` and -`publish_flate_stream`). Only the generated bindings reach them; no wrapper does. This quest binds the existing +moq-ffi publishes opaque tracks today (`MoqFlateSnapshotProducer` and +`MoqFlateStreamProducer`, constructed from a broadcast and a track). Only the generated bindings reach them; no wrapper does. This quest binds the existing track modes, not the bare codec: a `frame()` call across the FFI boundary invites the window desync the track modes exist to prevent. - moq-ffi has no consume side for these tracks. Add it next to the JSON consumers so each wrapper can read what it writes. -- Wrappers per the Cross-Package Sync table: `go/wrapper/json.go`, - `py/moq-rs/moq/{publish,subscribe}.py`, `swift/Sources/Moq/Json.swift`, - `kt/moq`'s `Json.kt` with its `Aliases.kt` re-exports, and - `dart/moq/lib/src/aliases.dart` each gain a flate sibling. If - [FFI shape](/quest/m1/ffi-shape/README.md) has landed, follow its `flate` - namespace instead. +- Wrappers per the Cross-Package Sync table: each gains a `flate` namespace + beside its `json` one (`py/moq-rs/moq/json.py`, `go/wrapper/json/`, + `swift/Sources/Moq/Json.swift`, `kt/moq`'s `dev.moq.json`, and + `dart/moq/lib/json.dart`), per `rs/moq-ffi/AGENTS.md`. - Document in `doc/lib/{py,swift,kt,go,dart}` beside the JSON entry. - Tests: a round trip in each wrapper that has tests, and one cross-language check that a wrapper-published group decodes with `@moq/flate`. Run From d727b2bb76025e512b97b1e7bdfea2fe94283246 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 11:24:02 -0700 Subject: [PATCH 6/9] style(ffi): cargo fmt Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/json.rs | 10 ++++------ rs/moq-ffi/src/test.rs | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 9e48d4522e..151dfdc853 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -125,9 +125,8 @@ impl MoqJsonSnapshotProducer { let config = moq_mux::json::Config::default() .with_compression(config.compression) .with_delta_ratio(config.delta_ratio); - let producer = track.adopt(|track| { - broadcast.with_state(|state| Ok(state.catalog.json_snapshot::(track, config)?)) - })?; + let producer = track + .adopt(|track| broadcast.with_state(|state| Ok(state.catalog.json_snapshot::(track, config)?)))?; Ok(Arc::new(Self { inner: std::sync::Mutex::new(Some(producer)), })) @@ -231,9 +230,8 @@ impl MoqJsonStreamProducer { ) -> Result, MoqError> { let _guard = crate::ffi::enter(); let config = moq_mux::json::Config::default().with_compression(config.compression); - let producer = track.adopt(|track| { - broadcast.with_state(|state| Ok(state.catalog.json_stream::(track, config)?)) - })?; + let producer = track + .adopt(|track| broadcast.with_state(|state| Ok(state.catalog.json_stream::(track, config)?)))?; Ok(Arc::new(Self { inner: std::sync::Mutex::new(Some(producer)), })) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 92453dd44d..dea17922d9 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -9,8 +9,8 @@ use crate::consumer::MoqTrackConsumer; use crate::error::MoqError; use crate::flate::{MoqFlateConfig, MoqFlateSnapshotProducer, MoqFlateStreamProducer}; use crate::json::{ - MoqJsonSnapshotConfig, MoqJsonSnapshotConsumer, MoqJsonSnapshotProducer, MoqJsonStreamConfig, MoqJsonStreamConsumer, - MoqJsonStreamProducer, + MoqJsonSnapshotConfig, MoqJsonSnapshotConsumer, MoqJsonSnapshotProducer, MoqJsonStreamConfig, + MoqJsonStreamConsumer, MoqJsonStreamProducer, }; use crate::media::{MoqAudioFormat, MoqAudioInit, MoqFrame, MoqVideoFormat, MoqVideoInit}; use crate::session::{MoqBackoff, MoqConnectionStatus}; @@ -638,7 +638,10 @@ async fn json_snapshot_roundtrip() { }; let track = broadcast.publish_track("meta".into(), None).unwrap(); let producer = MoqJsonSnapshotProducer::new(&broadcast, &track, config.clone()).unwrap(); - assert!(matches!(track.name(), Err(MoqError::Closed)), "the producer takes over the track"); + assert!( + matches!(track.name(), Err(MoqError::Closed)), + "the producer takes over the track" + ); let consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "meta").await, config).unwrap(); producer.update(r#"{"a":1}"#.into()).unwrap(); @@ -687,7 +690,8 @@ async fn json_demand() { assert_eq!(stream_demand.name(), "events"); assert!(!snapshot_demand.is_used()); - let snapshot_consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "status").await, snapshot_config).unwrap(); + let snapshot_consumer = + MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "status").await, snapshot_config).unwrap(); let stream_consumer = MoqJsonStreamConsumer::new(&*subscribe(&broadcast, "events").await, stream_config).unwrap(); tokio::time::timeout(TIMEOUT, snapshot_demand.used()) @@ -817,7 +821,12 @@ async fn json_consumer_takes_an_unread_track() { let track = broadcast.publish_track("events".into(), None).unwrap(); let config = MoqJsonStreamConfig { compression: false }; - let read = broadcast.consume().unwrap().subscribe_track("events".into(), None).await.unwrap(); + let read = broadcast + .consume() + .unwrap() + .subscribe_track("events".into(), None) + .await + .unwrap(); track.append_group().unwrap(); tokio::time::timeout(TIMEOUT, read.recv_group()) .await From 812f5d3f1aae945f76f16b6db191749e39c03386 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 11:47:30 -0700 Subject: [PATCH 7/9] docs(py): layer submodules are public surface too Co-Authored-By: Claude Opus 5.5 --- py/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py/AGENTS.md b/py/AGENTS.md index 58bd95b8f0..a52f46b862 100644 --- a/py/AGENTS.md +++ b/py/AGENTS.md @@ -7,7 +7,7 @@ The `/py` uv workspace. Extends the root `AGENTS.md` and `rs/moq-ffi/AGENTS.md`. # moq-rs -`moq/__init__.py` is the single public surface and defines `__all__`. Modules map to roles; keep names aligned with `rs/moq-net`. +`moq/__init__.py` is the public surface for the moq-net root and defines `__all__`; each layer above it is a public submodule (`moq.json`) with its own `__all__`. Modules map to roles; keep names aligned with the Rust crate they mirror. # Conventions From 8518eef6be1ffea6952a4e424b1724fadca9457d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 21:36:16 -0700 Subject: [PATCH 8/9] docs(moq-ffi): JSON and flate producers need the track's own broadcast Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/flate.rs | 6 ++++++ rs/moq-ffi/src/json.rs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/rs/moq-ffi/src/flate.rs b/rs/moq-ffi/src/flate.rs index 2722ae3dea..a694986d84 100644 --- a/rs/moq-ffi/src/flate.rs +++ b/rs/moq-ffi/src/flate.rs @@ -45,6 +45,9 @@ pub struct MoqFlateSnapshotProducer { impl MoqFlateSnapshotProducer { /// Publish `track` as an opaque snapshot track (lossy latest-value), advertised in `broadcast`'s catalog. /// + /// `track` must come from `broadcast`; otherwise the catalog advertises a track the broadcast + /// does not carry. + /// /// Takes over `track`, whose handle is closed afterward. Errors if the catalog already /// carries an entry under the track's name. #[uniffi::constructor] @@ -89,6 +92,9 @@ pub struct MoqFlateStreamProducer { impl MoqFlateStreamProducer { /// Publish `track` as an opaque stream track (lossless append-log), advertised in `broadcast`'s catalog. /// + /// `track` must come from `broadcast`; otherwise the catalog advertises a track the broadcast + /// does not carry. + /// /// Takes over `track`, whose handle is closed afterward. Errors if the catalog already /// carries an entry under the track's name. #[uniffi::constructor] diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 151dfdc853..5a8ec3f30e 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -111,6 +111,9 @@ pub struct MoqJsonSnapshotProducer { impl MoqJsonSnapshotProducer { /// Publish `track` as a JSON snapshot track (lossy latest-value), advertised in `broadcast`'s catalog. /// + /// `track` must come from `broadcast`; otherwise the catalog advertises a track the broadcast + /// does not carry. + /// /// The catalog carries `json.tracks.` (`mode: snapshot`, and `compression: deflate` when /// set) for as long as the producer lives; finishing or dropping it retires the entry. Takes /// over `track`, whose handle is closed afterward. Errors if the catalog already carries an @@ -219,6 +222,9 @@ pub struct MoqJsonStreamProducer { impl MoqJsonStreamProducer { /// Publish `track` as a JSON stream track (lossless append-log), advertised in `broadcast`'s catalog. /// + /// `track` must come from `broadcast`; otherwise the catalog advertises a track the broadcast + /// does not carry. + /// /// The catalog carries `json.tracks.` (`mode: stream`) for as long as the producer /// lives. Takes over `track`, whose handle is closed afterward. Errors if the catalog already /// carries an entry under the track's name. From 169caf637fe479dd9bd2122cb57e5362be4580a3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 22:41:27 -0700 Subject: [PATCH 9/9] chore(dart): regenerate moq_ffi bindings Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 646efc8e0b..f34c91bad2 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -13173,10 +13173,11 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqflatesnapshotproducer_new() != - 47430) { + 40377) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_constructor_moqflatestreamproducer_new() != 469) { + if (uniffi_moq_ffi_checksum_constructor_moqflatestreamproducer_new() != + 13731) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotconsumer_new() != @@ -13184,15 +13185,14 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotproducer_new() != - 2801) { + 4988) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqjsonstreamconsumer_new() != 44851) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_constructor_moqjsonstreamproducer_new() != - 10341) { + if (uniffi_moq_ffi_checksum_constructor_moqjsonstreamproducer_new() != 9785) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqoriginproducer_new() != 48126) {