From c990c3d372e893a83f58709c9ab189df75981ad5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 16:44:52 -0700 Subject: [PATCH 1/9] quest: claim quest/m1/ffi-shape/net Co-Authored-By: Claude Opus 5.5 From c0a66db191d9672625c0775a1de4d3dcf4120c27 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 16:57:34 -0700 Subject: [PATCH 2/9] refactor(ffi)!: client and server take config records; TrackProducer watches through demand() Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/error.rs | 4 + rs/moq-ffi/src/ffi.rs | 4 + rs/moq-ffi/src/producer.rs | 26 +- rs/moq-ffi/src/server.rs | 174 +++++----- rs/moq-ffi/src/session.rs | 643 +++++++++++++++++-------------------- rs/moq-ffi/src/test.rs | 511 ++++++++++++++++------------- 6 files changed, 663 insertions(+), 699 deletions(-) diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs index f27f14f116..1278cc8b25 100644 --- a/rs/moq-ffi/src/error.rs +++ b/rs/moq-ffi/src/error.rs @@ -271,6 +271,10 @@ pub enum MoqError { #[error("log: {0}")] Log(String), + + /// A configuration record held a value the constructor cannot use. + #[error("config: {0}")] + Config(String), } impl From for MoqError { diff --git a/rs/moq-ffi/src/ffi.rs b/rs/moq-ffi/src/ffi.rs index 16019d1bb0..184b09b158 100644 --- a/rs/moq-ffi/src/ffi.rs +++ b/rs/moq-ffi/src/ffi.rs @@ -175,6 +175,8 @@ impl Task { } /// Try to lock the state synchronously. Returns `None` if a task is running or it was cancelled. + // Only the native server and its requests read state synchronously. + #[cfg(not(target_arch = "wasm32"))] pub fn lock(&self) -> Option> { let guard = self.state.clone().try_lock_owned().ok()?; @@ -191,6 +193,7 @@ impl Task { /// Whether [Self::cancel] has run, which [Self::lock] folds into the same `None` as a busy /// state. The state may still be a moment away from being dropped. + #[cfg(not(target_arch = "wasm32"))] pub fn is_cancelled(&self) -> bool { *self.cancel.borrow() } @@ -201,6 +204,7 @@ impl Task { /// Configuration must apply or fail, so this splits them: [`MoqError::Busy`] /// while a [`Self::run`] owns the state, [`MoqError::Cancelled`] after /// [`Self::cancel`]. + #[cfg(not(target_arch = "wasm32"))] pub fn configure(&self) -> Result, MoqError> { match self.lock() { Some(guard) => Ok(guard), diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index a2a7fadca5..c3839be93f 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -692,14 +692,6 @@ impl MoqTrackProducer { #[uniffi::export] impl MoqTrackProducer { - /// Return the name of this track. - pub fn name(&self) -> Result { - let _guard = crate::ffi::enter(); - let guard = self.inner.lock().unwrap(); - let track = guard.as_ref().ok_or(MoqError::Closed)?; - Ok(track.name().to_string()) - } - /// Create a handler for uncached group fetches on this track. /// /// Hold the returned object for as long as cache misses should wait to be @@ -711,27 +703,11 @@ impl MoqTrackProducer { Ok(Arc::new(MoqTrackDynamic::new(track.dynamic()))) } - /// A watch-only handle to whether this track has subscribers. + /// A watch-only handle to this track's name and whether it has subscribers. pub fn demand(&self) -> Result, MoqError> { Ok(MoqTrackDemand::new(self.track_demand()?)) } - /// Wait until this track has at least one active consumer. - /// - /// Prefer [`demand`](Self::demand), a handle that can wait without borrowing this producer. - pub async fn used(&self) -> Result<(), MoqError> { - let track = self.inner.lock().unwrap().as_ref().ok_or(MoqError::Closed)?.clone(); - crate::ffi::detached(async move { track.used().await }).await - } - - /// Wait until this track has no active consumers. - /// - /// Prefer [`demand`](Self::demand), a handle that can wait without borrowing this producer. - pub async fn unused(&self) -> Result<(), MoqError> { - let track = self.inner.lock().unwrap().as_ref().ok_or(MoqError::Closed)?.clone(); - crate::ffi::detached(async move { track.unused().await }).await - } - /// Create a consumer that reads from this producer's track. /// /// Useful for local pub/sub without going through an origin/broadcast. `subscription` diff --git a/rs/moq-ffi/src/server.rs b/rs/moq-ffi/src/server.rs index daae44f026..5e2a1c4c9b 100644 --- a/rs/moq-ffi/src/server.rs +++ b/rs/moq-ffi/src/server.rs @@ -4,16 +4,89 @@ use std::sync::Arc; use crate::error::MoqError; use crate::ffi::Task; use crate::origin::MoqOriginProducer; -use crate::session::MoqSession; +use crate::session::{MoqQuicConfig, MoqSession}; + +/// Configuration for [`MoqServer::new`], mirroring moq-tokio's server config. +/// +/// Every field has a default, so set only what you need. The TLS identity needs one of +/// `tls.generate` or a `tls.cert`/`tls.key` pair. +#[derive(Clone, Default, uniffi::Record)] +pub struct MoqServerConfig { + /// Address to bind, e.g. `127.0.0.1:4443`, `[::]:443`, or `localhost:0`. Null binds `[::]:443`. + /// + /// DNS hostnames are resolved when [`MoqServer::listen`] binds. + #[uniffi(default = None)] + pub bind: Option, + /// Protocol versions to accept, spelled like `moq-lite-03`. Empty accepts every supported version. + #[uniffi(default = [])] + pub versions: Vec, + /// The served TLS identity. + #[uniffi(default)] + pub tls: MoqServerTls, + /// QUIC transport tuning. + #[uniffi(default)] + pub quic: MoqQuicConfig, + /// The origin whose broadcasts are served to incoming sessions. + /// + /// With neither `publish` nor `consume` set, each session's two sides share one fresh + /// origin. A [`MoqRequest`] can override either side before it is accepted. + #[uniffi(default = None)] + pub publish: Option>, + /// The origin that receives broadcasts published by incoming sessions. See `publish`. + #[uniffi(default = None)] + pub consume: Option>, +} + +/// The served TLS identity for a [`MoqServerConfig`]. +#[derive(Clone, Debug, Default, uniffi::Record)] +pub struct MoqServerTls { + /// PEM certificate chain files, one per identity. + #[uniffi(default = [])] + pub cert: Vec, + /// PEM private key files, paired with `cert` in order. + #[uniffi(default = [])] + pub key: Vec, + /// Hostnames to generate a self-signed certificate for. + /// + /// Clients must either pin the certificate fingerprint or disable verification. + #[uniffi(default = [])] + pub generate: Vec, +} struct ServerState { config: moq_tokio::listen::Config, + quic: moq_tokio::quic::Config, publish: Option>, consume: Option>, server: Option, } impl ServerState { + fn new(config: MoqServerConfig) -> Result { + let mut listen = moq_tokio::listen::Config::default(); + if let Some(bind) = config.bind { + let parsed = bind + .parse() + .map_err(|_| MoqError::Config(format!("invalid bind address: {bind}")))?; + listen.bind = Some(parsed); + } + listen.version = crate::session::parse_versions(&config.versions)?; + listen.tls.cert = config.tls.cert.into_iter().map(PathBuf::from).collect(); + listen.tls.key = config.tls.key.into_iter().map(PathBuf::from).collect(); + listen.tls.generate = config.tls.generate; + + let mut quic = moq_tokio::quic::Config::default(); + quic.max_streams = config.quic.max_streams; + + Ok(Self { + config: listen, + quic, + publish: config.publish, + consume: config.consume, + server: None, + }) + } + async fn listen(&mut self) -> Result { if self.server.is_some() { return Err(MoqError::Bind("already listening".into())); @@ -21,7 +94,7 @@ impl ServerState { let server = self .config .clone() - .init(Default::default()) + .init(self.quic.clone()) .map_err(|err| MoqError::Bind(format!("{err}")))? .listen() .await @@ -49,103 +122,22 @@ impl ServerState { } /// A MoQ server that accepts incoming QUIC/WebTransport sessions. -/// -/// Bind and TLS are captured at [`listen`](Self::listen); those setters fail -/// afterwards. Origins are captured at each [`accept`](Self::accept). Every setter -/// fails with [`MoqError::Busy`] while listen/accept is in flight and -/// [`MoqError::Cancelled`] after [`cancel`](Self::cancel). #[derive(uniffi::Object)] pub struct MoqServer { task: Task, } -impl MoqServer { - fn configure(&self, f: impl FnOnce(&mut ServerState) -> R) -> Result { - Ok(f(&mut *self.task.configure()?)) - } - - fn configure_listen(&self, f: impl FnOnce(&mut ServerState) -> R) -> Result { - let mut state = self.task.configure()?; - if state.server.is_some() { - return Err(MoqError::Bind("already listening".into())); - } - Ok(f(&mut state)) - } -} - #[uniffi::export] impl MoqServer { - /// Create a new MoQ server with default configuration. + /// Create a server from `config`, failing on any value it cannot use. + /// + /// Nothing is bound until [`listen`](Self::listen). #[uniffi::constructor] - pub fn new() -> Arc { + pub fn new(config: MoqServerConfig) -> Result, MoqError> { let _guard = crate::ffi::runtime().enter(); - Arc::new(Self { - task: Task::new(ServerState { - config: moq_tokio::listen::Config::default(), - publish: None, - consume: None, - server: None, - }), - }) - } - - /// Set the address to bind, e.g. `127.0.0.1:4443`, `[::]:443`, or `localhost:0`. - /// - /// Validated syntactically up-front. DNS hostnames are accepted and resolved - /// at `listen()` time. Captured at [`listen`](Self::listen); fails afterwards. - pub fn set_bind(&self, addr: String) -> Result<(), MoqError> { - let bind = addr - .parse() - .map_err(|_| MoqError::Bind(format!("invalid bind address: {addr}")))?; - self.configure_listen(|state| { - state.config.bind = Some(bind); - }) - } - - /// Load TLS certificate chains from PEM files on disk. - /// - /// Captured at [`listen`](Self::listen); fails afterwards. - pub fn set_tls_cert(&self, paths: Vec) -> Result<(), MoqError> { - self.configure_listen(|state| { - state.config.tls.cert = paths.into_iter().map(PathBuf::from).collect(); - }) - } - - /// Load TLS private keys from PEM files on disk. - /// - /// Captured at [`listen`](Self::listen); fails afterwards. - pub fn set_tls_key(&self, paths: Vec) -> Result<(), MoqError> { - self.configure_listen(|state| { - state.config.tls.key = paths.into_iter().map(PathBuf::from).collect(); - }) - } - - /// Generate self-signed TLS certificates for the given hostnames. - /// - /// Clients must either pin the certificate fingerprint or disable verification. - /// Captured at [`listen`](Self::listen); fails afterwards. - pub fn set_tls_generate(&self, hostnames: Vec) -> Result<(), MoqError> { - self.configure_listen(|state| { - state.config.tls.generate = hostnames; - }) - } - - /// Set the origin to publish broadcasts to incoming sessions. - /// - /// Captured at each [`accept`](Self::accept). - pub fn set_publish(&self, origin: Option>) -> Result<(), MoqError> { - self.configure(|state| { - state.publish = origin; - }) - } - - /// Set the origin to consume broadcasts from incoming sessions. - /// - /// Captured at each [`accept`](Self::accept). - pub fn set_consume(&self, origin: Option>) -> Result<(), MoqError> { - self.configure(|state| { - state.consume = origin; - }) + Ok(Arc::new(Self { + task: Task::new(ServerState::new(config)?), + })) } /// Bind the listening socket. Returns the bound local address as a string, @@ -166,7 +158,7 @@ impl MoqServer { /// /// Useful for pinning a generated self-signed certificate in a browser via /// WebTransport's `serverCertificateHashes`. Returns an error if called - /// before `listen()`. + /// before `listen()`, and [`MoqError::Busy`] while `listen()` or `accept()` is in flight. pub fn cert_fingerprints(&self) -> Result, MoqError> { let state = self.task.configure()?; let server = state diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 504a6f41ff..52b0d5a987 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -7,26 +7,207 @@ use crate::error::MoqError; use crate::ffi::Task; use crate::origin::{MoqOriginConsumer, MoqOriginProducer}; -/// Native QUIC/WebTransport client configuration. +/// Configuration for [`MoqClient::new`], mirroring moq-tokio's client config. +/// +/// Every field has a default, so set only what you need. The browser owns the socket and the +/// trust store, so a wasm build honors only `versions`, `tls.fingerprints`, and the origins, +/// always dials once, and fails `new` with `Unsupported` when anything else is set. +#[derive(Clone, Default, uniffi::Record)] +pub struct MoqClientConfig { + /// Local UDP address to bind, e.g. `0.0.0.0:0`. Null binds an ephemeral dual-stack port. + #[uniffi(default = None)] + pub bind: Option, + /// Protocol versions to offer, most preferred first, spelled like `moq-lite-03`. + /// Empty offers every supported version. + #[uniffi(default = [])] + pub versions: Vec, + /// Certificate trust and the mTLS identity. + #[uniffi(default)] + pub tls: MoqClientTls, + /// QUIC transport tuning. + #[uniffi(default)] + pub quic: MoqQuicConfig, + /// The WebSocket fallback, raced against QUIC for networks that block UDP. + #[uniffi(default)] + pub websocket: MoqWebSocketConfig, + /// Dial once instead of redialing with backoff whenever the transport drops. + /// + /// With this set, the transport's close ends the session (surfaced via + /// [`MoqSession::closed`]). + #[uniffi(default = false)] + pub once: bool, + /// Retry pacing for the automatic reconnect. + #[uniffi(default)] + pub backoff: MoqBackoff, + /// The origin whose broadcasts are published to the remote. + /// + /// With neither `publish` nor `consume` set, each session's two sides share one fresh + /// origin, so a broadcast announced on it is also discoverable through it. Setting either + /// opts out of that and gives the other side its own fresh origin. + #[uniffi(default = None)] + pub publish: Option>, + /// The origin that receives broadcasts consumed from the remote. See `publish`. + #[uniffi(default = None)] + pub consume: Option>, +} + +/// Certificate trust and the mTLS identity for a [`MoqClientConfig`]. +#[derive(Clone, Debug, Default, PartialEq, Eq, uniffi::Record)] +pub struct MoqClientTls { + /// Skip certificate verification. Local development only. + #[uniffi(default = false)] + pub insecure: bool, + /// PEM root certificate files to trust instead of the platform roots. + #[uniffi(default = [])] + pub roots: Vec, + /// Whether to also trust the platform roots. Null trusts them only when `roots` is empty. + #[uniffi(default = None)] + pub system_roots: Option, + /// SHA-256 certificate fingerprints, hex-encoded, to pin the peer to. + /// + /// The native equivalent of WebTransport's `serverCertificateHashes`, accepting what + /// `MoqServer.cert_fingerprints` reports, so a self-signed certificate is trusted without + /// disabling verification. + #[uniffi(default = [])] + pub fingerprints: Vec, + /// PEM certificate chain to present when the relay requires mTLS. Pair with `key`. + #[uniffi(default = None)] + pub cert: Option, + /// PEM private key to present when the relay requires mTLS. Pair with `cert`. + #[uniffi(default = None)] + pub key: Option, +} + +/// QUIC transport tuning, mirroring moq-tokio's QUIC config. +#[derive(Clone, Debug, Default, PartialEq, Eq, uniffi::Record)] +pub struct MoqQuicConfig { + /// Cap on the concurrent QUIC streams the peer may open toward this endpoint. Null uses 1024. + /// + /// MoQ opens a stream per group, and for a subscriber those arrive from the peer, so an + /// endpoint subscribing to many tracks wants this raised. Ignored by the WebSocket fallback. + #[uniffi(default = None)] + pub max_streams: Option, +} + +/// The WebSocket fallback for a [`MoqClientConfig`], raced against QUIC for `http(s)` URLs. +#[derive(Clone, Debug, Default, PartialEq, Eq, uniffi::Record)] +pub struct MoqWebSocketConfig { + /// Whether the fallback runs. Null enables it; disable it for a relay that only serves + /// QUIC, so a failed QUIC dial reports its own error. + #[uniffi(default = None)] + pub enabled: Option, + /// Head start QUIC gets before the fallback joins the race, in microseconds. Null uses + /// 200ms, and 0 races both at once. + #[uniffi(default = None)] + pub delay_us: Option, +} + +/// Retry pacing for the automatic reconnect. +/// +/// The delay starts at `initial_us`, multiplies by `multiplier` after each failed attempt, +/// and caps at `max_us`. After `timeout_us` of consecutive failures the connection gives up +/// for good; the window resets whenever a session stays up past `initial_us`. Each null +/// field uses moq-tokio's default: 1s, x2, 5s, and a 10s window. +#[derive(Clone, Debug, Default, PartialEq, Eq, uniffi::Record)] +pub struct MoqBackoff { + /// Delay before the first reconnect attempt, in microseconds. + #[uniffi(default = None)] + pub initial_us: Option, + /// Multiplier applied to the delay after each failure. + #[uniffi(default = None)] + pub multiplier: Option, + /// Maximum delay between reconnect attempts, in microseconds. + #[uniffi(default = None)] + pub max_us: Option, + /// Time spent retrying before giving up, in microseconds. 0 retries forever. + #[uniffi(default = None)] + pub timeout_us: Option, +} + +/// Parse protocol version names, as `MoqClientConfig::versions` and +/// `MoqServerConfig::versions` spell them. +pub(crate) fn parse_versions(versions: &[String]) -> Result, MoqError> { + versions + .iter() + .map(|version| version.parse().map_err(MoqError::Config)) + .collect() +} + +/// Native QUIC/WebTransport client state: the configured endpoint and the wired origins. #[cfg(not(target_arch = "wasm32"))] struct Client { - config: moq_tokio::connect::Config, - /// QUIC transport tuning the dial applies, e.g. `quic_max_streams`. - quic: moq_tokio::quic::Config, + client: moq_tokio::Client, publish: Option>, consume: Option>, } #[cfg(not(target_arch = "wasm32"))] impl Client { - async fn connect(&self, url: Url) -> Result, MoqError> { - let client = self.config.clone().init(self.quic.clone()).map_err(map_connect_error)?; + fn new(config: MoqClientConfig) -> Result { + let _guard = crate::ffi::enter(); + + let mut connect = moq_tokio::connect::Config::default(); + if let Some(bind) = config.bind { + let bind = bind + .parse() + .map_err(|err| MoqError::Config(format!("invalid bind address {bind}: {err}")))?; + connect.bind = Some(bind); + } + connect.version = parse_versions(&config.versions)?; + + let tls = config.tls; + connect.tls.insecure = Some(tls.insecure); + connect.tls.root = tls.roots.into_iter().map(Into::into).collect(); + connect.tls.system_roots = tls.system_roots; + connect.tls.fingerprint = tls.fingerprints; + connect.tls.cert = tls.cert.map(Into::into); + connect.tls.key = tls.key.map(Into::into); + + connect.websocket.enabled = config.websocket.enabled; + if let Some(delay) = config.websocket.delay_us { + connect.websocket.delay = std::time::Duration::from_micros(delay); + } + + connect.once = Some(config.once); + + let backoff = config.backoff; + if let Some(initial) = backoff.initial_us { + connect.backoff.initial = std::time::Duration::from_micros(initial); + } + if let Some(multiplier) = backoff.multiplier { + connect.backoff.multiplier = multiplier; + } + if let Some(max) = backoff.max_us { + connect.backoff.max = std::time::Duration::from_micros(max); + } + if let Some(timeout) = backoff.timeout_us { + connect.backoff.timeout = std::time::Duration::from_micros(timeout); + } + + let mut quic = moq_tokio::quic::Config::default(); + quic.max_streams = config.quic.max_streams; + // Building the endpoint here, not at connect, is what surfaces an unreadable + // certificate or a half-configured mTLS identity from `new`. + let client = connect + .init(quic) + .map_err(|err| MoqError::Config(format!("{err}")))?; + + Ok(Self { + client, + publish: config.publish, + consume: config.consume, + }) + } + + async fn connect(&self, url: Url) -> Result, MoqError> { // Materialize both origin sides so the session can publish/subscribe and the FFI can // always hand back a publish/consume origin. let (publish, subscribe) = crate::origin::resolve_pair(self.publish.as_ref(), self.consume.as_ref()); - let connection = client + let connection = self + .client + .clone() .with_publisher(&publish) .with_subscriber(subscribe.clone()) .connect(url); @@ -79,6 +260,7 @@ fn map_closed_error(err: moq_tokio::Error) -> MoqError { } } + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -106,7 +288,7 @@ mod tests { #[test] fn rejects_non_ascii_instead_of_panicking() { for input in ["aéa", "é", "ééééééééééééééééééééééééééééééé"] { - assert!(matches!(decode_hex(input), Err(MoqError::Connect(_))), "{input}"); + assert!(matches!(decode_hex(input), Err(MoqError::Config(_))), "{input}"); } } @@ -128,7 +310,7 @@ mod tests { format!("{}+0", &VALID[..62]), format!(" 0{}", &VALID[2..]), ] { - assert!(matches!(decode_hex(&bad), Err(MoqError::Connect(_))), "{bad}"); + assert!(matches!(decode_hex(&bad), Err(MoqError::Config(_))), "{bad}"); } } @@ -186,109 +368,49 @@ mod tests { )); } + /// `new` is where a bad value surfaces, rather than at the first connect. #[test] - fn sets_tls_system_roots() { - let client = MoqClient::new(); - - client.set_tls_system_roots(true).unwrap(); - { - let state = client.task.lock().expect("client state should be available"); - assert_eq!(state.config.tls.system_roots, Some(true)); - } - - client.set_tls_system_roots(false).unwrap(); - let state = client.task.lock().expect("client state should be available"); - assert_eq!(state.config.tls.system_roots, Some(false)); - } - - #[test] - fn sets_tls_client_cert_and_key() { - let client = MoqClient::new(); - - client.set_tls_cert(Some("cert.pem".into())).unwrap(); - client.set_tls_key(Some("key.pem".into())).unwrap(); - { - let state = client.task.lock().expect("client state should be available"); - assert_eq!(state.config.tls.cert.as_deref(), Some(std::path::Path::new("cert.pem"))); - assert_eq!(state.config.tls.key.as_deref(), Some(std::path::Path::new("key.pem"))); - } - - client.set_tls_cert(None).unwrap(); - client.set_tls_key(None).unwrap(); - let state = client.task.lock().expect("client state should be available"); - assert_eq!(state.config.tls.cert, None); - assert_eq!(state.config.tls.key, None); - } - - #[test] - fn sets_the_quic_stream_cap() { - let client = MoqClient::new(); - assert_eq!(client.task.lock().unwrap().quic.max_streams, None); - - client.set_quic_max_streams(4096).unwrap(); - assert_eq!(client.task.lock().unwrap().quic.max_streams, Some(4096)); + fn new_rejects_invalid_config() { + let bind = MoqClientConfig { + bind: Some("not-an-address".into()), + ..Default::default() + }; + assert!(matches!(MoqClient::new(bind), Err(MoqError::Config(_)))); + + let version = MoqClientConfig { + versions: vec!["moq-lite-99".into()], + ..Default::default() + }; + assert!(matches!(MoqClient::new(version), Err(MoqError::Config(_)))); + + let missing = MoqClientConfig { + tls: MoqClientTls { + roots: vec!["/nonexistent/root.pem".into()], + ..Default::default() + }, + ..Default::default() + }; + assert!(matches!(MoqClient::new(missing), Err(MoqError::Config(_)))); } #[test] - fn sets_the_websocket_fallback() { - let client = MoqClient::new(); - let resolved = client.task.lock().unwrap().config.websocket.resolve(); - assert!(resolved.enabled); - assert_eq!(resolved.delay, std::time::Duration::from_millis(200)); - - client.set_websocket_enabled(false).unwrap(); - client.set_websocket_delay(0).unwrap(); - let resolved = client.task.lock().unwrap().config.websocket.resolve(); - assert!(!resolved.enabled); - assert_eq!(resolved.delay, std::time::Duration::ZERO); - - client.set_websocket_enabled(true).unwrap(); - client.set_websocket_delay(1_500).unwrap(); - let resolved = client.task.lock().unwrap().config.websocket.resolve(); - assert!(resolved.enabled); - assert_eq!(resolved.delay, std::time::Duration::from_micros(1_500)); - } - - #[test] - fn setters_fail_after_cancel() { - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.cancel(); - assert!(matches!(client.set_tls_verify(true), Err(MoqError::Cancelled))); - assert!(matches!( - client.set_bind("127.0.0.1:0".into()), - Err(MoqError::Cancelled) - )); - assert!(matches!(client.set_publish(None), Err(MoqError::Cancelled))); - assert!(matches!(client.set_consume(None), Err(MoqError::Cancelled))); + fn new_accepts_the_defaults_and_known_versions() { + MoqClient::new(MoqClientConfig::default()).unwrap(); + MoqClient::new(MoqClientConfig { + bind: Some("127.0.0.1:0".into()), + versions: vec!["moq-lite-03".into()], + once: true, + backoff: MoqBackoff { + timeout_us: Some(0), + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); } } -/// Retry pacing for the automatic reconnect (see [`MoqClient::set_backoff`]). -/// -/// The delay starts at `initial_us`, multiplies by `multiplier` after each failed -/// attempt, and caps at `max_us`. After `timeout_us` of consecutive failures the -/// connection gives up for good (0 retries forever); the window resets whenever a -/// session stays up past `initial_us`. The defaults mirror the native -/// [`moq_tokio::Backoff`]: 1s, x2, 5s, and a 10s window. -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug, uniffi::Record)] -pub struct MoqBackoff { - /// Delay before the first reconnect attempt, in microseconds. - #[uniffi(default = 1000000)] - pub initial_us: u64, - /// Multiplier applied to the delay after each failure. - #[uniffi(default = 2)] - pub multiplier: u32, - /// Maximum delay between reconnect attempts, in microseconds. - #[uniffi(default = 5000000)] - pub max_us: u64, - /// Time spent retrying before giving up, in microseconds. 0 retries forever. - #[uniffi(default = 10000000)] - pub timeout_us: u64, -} - -/// Browser WebTransport client configuration. +/// Browser WebTransport client state. /// /// The browser owns the socket and the trust store, so none of the native TLS knobs /// (roots, mTLS, bind address) have an equivalent. Certificate hashes are the one @@ -296,12 +418,52 @@ pub struct MoqBackoff { #[cfg(target_arch = "wasm32")] struct Client { fingerprints: Vec>, + versions: Vec, publish: Option>, consume: Option>, } #[cfg(target_arch = "wasm32")] impl Client { + fn new(config: MoqClientConfig) -> Result { + let MoqClientConfig { + bind, + versions, + tls, + quic, + websocket, + // A browser session never redials, so it dials once either way. + once: _, + backoff, + publish, + consume, + } = config; + + let native_tls = MoqClientTls { + fingerprints: Vec::new(), + ..tls.clone() + }; + if bind.is_some() + || native_tls != MoqClientTls::default() + || quic != MoqQuicConfig::default() + || websocket != MoqWebSocketConfig::default() + || backoff != MoqBackoff::default() + { + return Err(MoqError::Unsupported); + } + + Ok(Self { + fingerprints: tls + .fingerprints + .iter() + .map(|hex| decode_hex(hex)) + .collect::>()?, + versions: parse_versions(&versions)?, + publish, + consume, + }) + } + async fn connect(&self, url: Url) -> Result, MoqError> { let (publish, subscribe) = crate::origin::resolve_pair(self.publish.as_ref(), self.consume.as_ref()); @@ -311,10 +473,15 @@ impl Client { } .map_err(|err| MoqError::Connect(format!("{err}")))?; + let mut client = moq_net::Client::new(); + if !self.versions.is_empty() { + client = client.with_versions(self.versions.clone().into()); + } + // Run the driver on the microtask queue. The driver // holds no session clone, so dropping the last handle still closes the // transport and ends that task. - let (session, driver) = moq_net::Client::new() + let (session, driver) = client .with_publisher(&publish) .with_subscriber(subscribe.clone()) .connect(web_async::time::Instant::now(), transport) @@ -343,14 +510,14 @@ fn decode_hex(hex: &str) -> Result, MoqError> { // non-ASCII would let a 2-byte chunk split a character, and `from_str_radix` accepts // a leading sign, so an unchecked "+a" would decode to 10 rather than erroring. if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(MoqError::Connect(format!("fingerprint is not hex: {hex}"))); + return Err(MoqError::Config(format!("fingerprint is not hex: {hex}"))); } // WebTransport's `serverCertificateHashes` only accepts a 32-byte sha-256, so a // different length can never match a certificate. Rejecting here beats failing // opaquely inside the browser. if hex.len() != LEN { - return Err(MoqError::Connect(format!( + return Err(MoqError::Config(format!( "expected a {LEN}-character sha-256 fingerprint, got {}", hex.len() ))); @@ -360,272 +527,45 @@ fn decode_hex(hex: &str) -> Result, MoqError> { .chunks(2) .map(|pair| { let pair = std::str::from_utf8(pair).expect("checked ascii above"); - u8::from_str_radix(pair, 16).map_err(|err| MoqError::Connect(format!("{err}"))) + u8::from_str_radix(pair, 16).map_err(|err| MoqError::Config(format!("{err}"))) }) .collect() } -/// Builds a [`MoqSession`]: configure it, then [`connect`](Self::connect). +/// Dials a [`MoqSession`] with the configuration it was built from. /// -/// The configuration differs by target, because the transport does. Native builds expose -/// the QUIC socket and TLS trust store; the browser owns both, so a wasm build exposes -/// only the certificate hashes WebTransport accepts. -/// -/// Setters write the configuration [`connect`](Self::connect) will snapshot. They fail -/// with [`MoqError::Busy`] while a connect is in flight and [`MoqError::Cancelled`] -/// after [`cancel`](Self::cancel). A finished connect does not freeze the handle: later -/// setters apply to the next dial until cancel. +/// The configuration differs by what the target honors, because the transport does; see +/// [`MoqClientConfig`]. [`connect`](Self::connect) may be called again for another session +/// until [`cancel`](Self::cancel). #[derive(uniffi::Object)] pub struct MoqClient { task: Task, } -impl MoqClient { - fn configure(&self, f: impl FnOnce(&mut Client) -> R) -> Result { - Ok(f(&mut *self.task.configure()?)) - } -} - -#[cfg(target_arch = "wasm32")] #[uniffi::export] impl MoqClient { - /// Create a new MoQ client with default configuration. + /// Create a client from `config`, failing on any value it cannot use. #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self { - task: Task::new(Client { - fingerprints: Vec::new(), - publish: None, - consume: None, - }), - }) - } - - /// Pin the peer to a certificate with one of these SHA-256 fingerprints, encoded as hex. - /// - /// Passed through to WebTransport's `serverCertificateHashes`. An empty list restores - /// the browser's normal certificate verification. - pub fn set_tls_fingerprints(&self, fingerprints: Vec) -> Result<(), MoqError> { - let parsed = fingerprints - .iter() - .map(|hex| decode_hex(hex)) - .collect::, _>>()?; - self.configure(|state| { - state.fingerprints = parsed; - }) - } - - /// Set the origin to publish local broadcasts to the remote. - pub fn set_publish(&self, origin: Option>) -> Result<(), MoqError> { - self.configure(|state| { - state.publish = origin; - }) - } - - /// Set the origin to consume remote broadcasts from the remote. - pub fn set_consume(&self, origin: Option>) -> Result<(), MoqError> { - self.configure(|state| { - state.consume = origin; - }) + pub fn new(config: MoqClientConfig) -> Result, MoqError> { + Ok(Arc::new(Self { + task: Task::new(Client::new(config)?), + })) } /// Connect to a MoQ server and wait for the session to be established. /// - /// Can be cancelled by calling `cancel()`. - pub async fn connect(&self, url: String) -> Result, MoqError> { - let url = Url::parse(&url)?; - self.task.run(|state| async move { state.connect(url).await }).await - } - - /// Cancel all current and future `connect()` calls. - pub fn cancel(&self) { - self.task.cancel(); - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[uniffi::export] -impl MoqClient { - /// Create a new MoQ client with default configuration. - #[uniffi::constructor] - pub fn new() -> Arc { - let _guard = crate::ffi::enter(); - Arc::new(Self { - task: Task::new(Client { - config: moq_tokio::connect::Config::default(), - quic: moq_tokio::quic::Config::default(), - publish: None, - consume: None, - }), - }) - } - - /// Enable or disable TLS certificate verification. - pub fn set_tls_verify(&self, verify: bool) -> Result<(), MoqError> { - self.configure(|state| { - state.config.tls.insecure = Some(!verify); - }) - } - - /// Trust these PEM root certificate file(s) instead of the system roots. - /// - /// Pass the paths to PEM-encoded CA certificates. An empty list restores the - /// default behavior of using the platform's native root store. - pub fn set_tls_roots(&self, paths: Vec) -> Result<(), MoqError> { - self.configure(|state| { - state.config.tls.root = paths.into_iter().map(Into::into).collect(); - }) - } - - /// Configure whether to also trust the platform's native root certificates. - /// - /// By default, system roots are trusted only when no custom roots are configured. - /// Set this to `true` to trust system roots in addition to roots from - /// `set_tls_roots`, or `false` to trust only custom roots. - pub fn set_tls_system_roots(&self, system_roots: bool) -> Result<(), MoqError> { - self.configure(|state| { - state.config.tls.system_roots = Some(system_roots); - }) - } - - /// Pin the peer to a certificate with one of these SHA-256 fingerprints, encoded as hex. - /// - /// This is the native equivalent of the browser's WebTransport `serverCertificateHashes` - /// and accepts the same values a server reports (see `MoqServer.cert_fingerprints`). Use it - /// to trust a self-signed certificate without disabling verification. An empty list clears - /// any pinned fingerprints. - pub fn set_tls_fingerprints(&self, fingerprints: Vec) -> Result<(), MoqError> { - self.configure(|state| { - state.config.tls.fingerprint = fingerprints; - }) - } - - /// Present this PEM certificate chain when the relay requires mTLS. - /// - /// Only certificates are read from the file; any private keys are ignored. Must be - /// paired with `set_tls_key`, otherwise `connect` fails with an incomplete-auth error. - /// Pass `None` to clear a previously set path. - pub fn set_tls_cert(&self, path: Option) -> Result<(), MoqError> { - self.configure(|state| { - state.config.tls.cert = path.map(Into::into); - }) - } - - /// Present this PEM private key when the relay requires mTLS. - /// - /// Only the private key is read from the file; any certificates are ignored. Must be - /// paired with `set_tls_cert`, otherwise `connect` fails with an incomplete-auth error. - /// Pass `None` to clear a previously set path. - pub fn set_tls_key(&self, path: Option) -> Result<(), MoqError> { - self.configure(|state| { - state.config.tls.key = path.map(Into::into); - }) - } - - /// Set the local UDP socket bind address. Defaults to `[::]:0`. - /// - /// Returns an error if the address cannot be parsed, if a connect is in flight, - /// or after [`cancel`](Self::cancel). - pub fn set_bind(&self, addr: String) -> Result<(), MoqError> { - let parsed: std::net::SocketAddr = addr - .parse() - .map_err(|err| MoqError::Bind(format!("invalid bind address: {err}")))?; - self.configure(|state| { - state.config.bind = Some(parsed); - }) - } - - /// Cap the concurrent QUIC streams the peer may open toward this connection. - /// Defaults to 1024. - /// - /// MoQ opens a stream per group, and for a subscriber those arrive from the relay, - /// so a client subscribing to many tracks wants this raised. A publisher's own - /// streams are bounded by the peer's advertised limit, not this one. Ignored by - /// the WebSocket fallback. - pub fn set_quic_max_streams(&self, max_streams: u64) -> Result<(), MoqError> { - self.configure(|state| { - state.quic.max_streams = Some(max_streams); - }) - } - - /// Enable or disable the WebSocket fallback. Enabled by default. - /// - /// The fallback races a WebSocket dial against QUIC for `http(s)` URLs, for networks - /// that block UDP. Disable it for a relay that only serves QUIC, so a failed QUIC dial - /// reports its own error instead of the fallback's. - pub fn set_websocket_enabled(&self, enabled: bool) -> Result<(), MoqError> { - self.configure(|state| { - state.config.websocket.enabled = Some(enabled); - }) - } - - /// Set the head start, in microseconds, QUIC gets before the WebSocket fallback joins - /// the race. Defaults to 200ms. - /// - /// Zero races both at once. A server where WebSocket already won skips the head start. - pub fn set_websocket_delay(&self, delay_us: u64) -> Result<(), MoqError> { - self.configure(|state| { - state.config.websocket.delay = std::time::Duration::from_micros(delay_us); - }) - } - - /// Enable or disable automatic reconnecting. Enabled by default. - /// - /// When enabled, the session returned by [`connect`](Self::connect) redials with - /// backoff whenever the transport drops, and broadcasts consumed through it survive - /// the gap. Disable for a one-shot dial: the transport's close then ends the session - /// (surfaced via [`MoqSession::closed`]). - pub fn set_reconnect(&self, enabled: bool) -> Result<(), MoqError> { - self.configure(|state| { - state.config.once = Some(!enabled); - }) - } - - /// Configure retry pacing for the automatic reconnect (see [`MoqBackoff`]). - pub fn set_backoff(&self, backoff: MoqBackoff) -> Result<(), MoqError> { - self.configure(|state| { - let mut out = moq_tokio::Backoff::default(); - out.initial = std::time::Duration::from_micros(backoff.initial_us); - out.multiplier = backoff.multiplier; - out.max = std::time::Duration::from_micros(backoff.max_us); - out.timeout = std::time::Duration::from_micros(backoff.timeout_us); - state.config.backoff = out; - }) - } - - /// Set the origin to publish local broadcasts to the remote. - pub fn set_publish(&self, origin: Option>) -> Result<(), MoqError> { - self.configure(|state| { - state.publish = origin; - }) - } - - /// Set the origin to consume remote broadcasts from the remote. - pub fn set_consume(&self, origin: Option>) -> Result<(), MoqError> { - self.configure(|state| { - state.consume = origin; - }) - } - - /// Connect to a MoQ server and wait for the session to be established. - /// - /// The returned session automatically reconnects with backoff when the transport - /// drops (unless disabled via [`set_reconnect`](Self::set_reconnect)), and broadcasts - /// consumed through it ride out the gap. Watch [`MoqSession::status`] for the - /// connect/disconnect transitions, [`MoqSession::epoch`] for the reconnect count, - /// and [`MoqSession::closed`] for the connection giving up for good. + /// A native session automatically reconnects with backoff when the transport drops + /// (unless [`MoqClientConfig::once`] is set), and broadcasts consumed through it ride out + /// the gap. Watch [`MoqSession::status`] for the connect/disconnect transitions, + /// [`MoqSession::epoch`] for the reconnect count, and [`MoqSession::closed`] for the + /// connection giving up for good. /// /// Both origin sides are always accessible via [`MoqSession::publish`] and - /// [`MoqSession::consume`], without the caller constructing a [`MoqOriginProducer`] - /// themselves. With neither [`set_publish`](Self::set_publish) nor - /// [`set_consume`](Self::set_consume) wired, the two sides share one origin, so a broadcast - /// announced on this session is also discoverable through it. Wiring either side opts out of - /// that and gives the other side its own fresh origin. + /// [`MoqSession::consume`], without the caller constructing a [`MoqOriginProducer`]. /// /// Can be cancelled by calling `cancel()`, including while the initial dial is retrying. pub async fn connect(&self, url: String) -> Result, MoqError> { let url = Url::parse(&url)?; - self.task.run(|state| async move { state.connect(url).await }).await } @@ -638,6 +578,7 @@ impl MoqClient { } } + /// A snapshot of connection statistics for a [`MoqSession`]. /// /// Each field is `None` when the transport backend doesn't report that metric (native QUIC diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index dea17922d9..e1f53f7b96 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -1,7 +1,7 @@ use super::origin::*; use super::producer::*; -use super::server::MoqServer; -use super::session::{MoqClient, MoqSession}; +use super::server::{MoqServer, MoqServerConfig, MoqServerTls}; +use super::session::{MoqClient, MoqClientConfig, MoqClientTls, MoqSession}; use crate::consumer::MoqBroadcastConsumer; use crate::consumer::MoqFetchGroupOptions; use crate::consumer::MoqSubscription; @@ -63,6 +63,32 @@ where handle } +/// Trust any certificate, for dialing a server with a generated one. +fn insecure_tls() -> MoqClientTls { + MoqClientTls { + insecure: true, + ..Default::default() + } +} + +/// A self-signed identity for `localhost`. +fn localhost_tls() -> MoqServerTls { + MoqServerTls { + generate: vec!["localhost".into()], + ..Default::default() + } +} + +/// Retry pacing fast enough for a test, retrying forever. +fn fast_backoff() -> MoqBackoff { + MoqBackoff { + initial_us: Some(50_000), + multiplier: Some(2), + max_us: Some(200_000), + timeout_us: Some(0), + } +} + async fn wait_for_config_error( mut op: impl FnMut() -> Result<(), MoqError>, want: impl Fn(&MoqError) -> bool, @@ -375,16 +401,17 @@ fn publish_media_lifecycle() { async fn raw_track_activity() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("status".into(), None).unwrap(); - assert_eq!(track.name().unwrap(), "status"); + let demand = track.demand().unwrap(); + assert_eq!(demand.name(), "status"); let consumer = track.consume(None).unwrap(); - tokio::time::timeout(TIMEOUT, track.used()) + tokio::time::timeout(TIMEOUT, demand.used()) .await .expect("timed out waiting for raw track to become used") .unwrap(); drop(consumer); - tokio::time::timeout(TIMEOUT, track.unused()) + tokio::time::timeout(TIMEOUT, demand.unused()) .await .expect("timed out waiting for raw track to become unused") .unwrap(); @@ -639,7 +666,7 @@ 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)), + matches!(track.demand(), Err(MoqError::Closed)), "the producer takes over the track" ); let consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "meta").await, config).unwrap(); @@ -3261,8 +3288,9 @@ async fn raw_read_frame_terminal_cancel_releases_demand() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("status".into(), None).unwrap(); let consumer = track.consume(None).unwrap(); + let demand = track.demand().unwrap(); - tokio::time::timeout(TIMEOUT, track.used()) + tokio::time::timeout(TIMEOUT, demand.used()) .await .expect("timed out waiting for the subscriber") .unwrap(); @@ -3286,7 +3314,7 @@ async fn raw_read_frame_terminal_cancel_releases_demand() { Ok(None) => panic!("cancelled read returned EOF"), } - tokio::time::timeout(TIMEOUT, track.unused()) + tokio::time::timeout(TIMEOUT, demand.unused()) .await .expect("timed out waiting for demand to drop") .unwrap(); @@ -3371,9 +3399,12 @@ fn without_runtime() { assert_eq!(announcement.prefix, "test"); let _bc = pollster::block_on(consumer.request_broadcast("test".into())).unwrap(); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_consume(Some(origin)).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + consume: Some(origin), + ..Default::default() + }) + .unwrap(); announced.cancel(); client.cancel(); @@ -3392,10 +3423,13 @@ fn without_runtime() { async fn server_client_roundtrip() { // Server side: bind, set a publish origin, accept incoming sessions. let server_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); - server.set_publish(Some(server_origin.clone())).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + publish: Some(server_origin.clone()), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -3417,10 +3451,13 @@ async fn server_client_roundtrip() { // Client side: connect, subscribe via a consume origin. let client_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_consume(Some(client_origin.clone())).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + consume: Some(client_origin.clone()), + ..Default::default() + }) + .unwrap(); let cs = tokio::time::timeout(TIMEOUT, client.connect(url)) .await .expect("connect timed out") @@ -3487,10 +3524,13 @@ async fn server_client_roundtrip_auto_origin() { // `set_publish` / `set_consume`: the auto-created origin sides on // `MoqClientSession` are what drive publishing and subscribing. let server_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); - server.set_publish(Some(server_origin.clone())).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + publish: Some(server_origin.clone()), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -3509,9 +3549,12 @@ async fn server_client_roundtrip_auto_origin() { }); // No set_publish / set_consume, so this uses the auto-origin path. - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + ..Default::default() + }) + .unwrap(); let cs = tokio::time::timeout(TIMEOUT, client.connect(url)) .await .expect("connect timed out") @@ -3553,26 +3596,34 @@ async fn server_client_roundtrip_auto_origin() { } #[tokio::test] -async fn server_set_bind_validates() { - let server = MoqServer::new(); - assert!(server.set_bind("127.0.0.1:0".into()).is_ok()); - assert!(server.set_bind("[::]:443".into()).is_ok()); - assert!(server.set_bind("localhost:4443".into()).is_ok()); - assert!(matches!( - server.set_bind("localhost:443:8443".into()), - Err(crate::error::MoqError::Bind(_)) - )); - assert!(matches!( - server.set_bind("not-an-address".into()), - Err(crate::error::MoqError::Bind(_)) - )); +async fn server_new_validates_the_config() { + let server = |bind: &str| { + MoqServer::new(MoqServerConfig { + bind: Some(bind.into()), + ..Default::default() + }) + }; + assert!(server("127.0.0.1:0").is_ok()); + assert!(server("[::]:443").is_ok()); + assert!(server("localhost:4443").is_ok()); + assert!(matches!(server("localhost:443:8443"), Err(MoqError::Config(_)))); + assert!(matches!(server("not-an-address"), Err(MoqError::Config(_)))); + + let version = MoqServer::new(MoqServerConfig { + versions: vec!["moq-lite-99".into()], + ..Default::default() + }); + assert!(matches!(version, Err(MoqError::Config(_)))); } #[tokio::test] async fn server_cert_fingerprints_available_after_listen() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); // Not available before listen(). assert!(matches!( @@ -3594,9 +3645,12 @@ async fn server_cert_fingerprints_available_after_listen() { #[tokio::test] async fn server_cert_fingerprints_rejected_after_cancel() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -3618,15 +3672,18 @@ async fn server_cert_fingerprints_rejected_after_cancel() { /// cancel has to unwind an in-flight run rather than an idle state. #[tokio::test] async fn server_cancel_releases_the_bound_port() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = server.listen().await.expect("listen failed"); // Park an accept on the server lock, the state a live server is closed in. let accepting = server.clone(); let accept = tokio::spawn(async move { accepting.accept().await }); - wait_for_config_error(|| server.set_publish(None), |err| matches!(err, MoqError::Busy)).await; + wait_for_config_error(|| server.cert_fingerprints().map(drop), |err| matches!(err, MoqError::Busy)).await; server.cancel(); @@ -3636,9 +3693,12 @@ async fn server_cancel_releases_the_bound_port() { std::net::UdpSocket::bind(&addr).expect("cancel should release the socket before it returns"); // No retry: the socket is already closed, so this must succeed on the first try. - let rebound = MoqServer::new(); - rebound.set_bind(addr.clone()).unwrap(); - rebound.set_tls_generate(vec!["localhost".into()]).unwrap(); + let rebound = MoqServer::new(MoqServerConfig { + bind: Some(addr.clone()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); rebound.listen().await.expect("the port should rebind immediately"); let accept = tokio::time::timeout(TIMEOUT, accept) @@ -3654,9 +3714,12 @@ async fn server_cancel_releases_the_bound_port() { async fn request_double_respond_returns_already_responded() { use crate::error::MoqError; - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = server.listen().await.expect("listen failed"); let url = format!("https://{addr}"); @@ -3683,9 +3746,12 @@ async fn request_double_respond_returns_already_responded() { session }); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + ..Default::default() + }) + .unwrap(); let _session = tokio::time::timeout(TIMEOUT, client.connect(url)) .await .expect("connect timed out") @@ -3703,9 +3769,12 @@ async fn request_double_respond_returns_already_responded() { #[tokio::test] async fn request_per_session_publish_override() { // The server's publish origin is empty; a per-request override is used instead. - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = server.listen().await.expect("listen failed"); let url = format!("https://{addr}"); @@ -3726,10 +3795,13 @@ async fn request_per_session_publish_override() { }); let client_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_consume(Some(client_origin.clone())).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + consume: Some(client_origin.clone()), + ..Default::default() + }) + .unwrap(); let cs = tokio::time::timeout(TIMEOUT, client.connect(url)) .await .expect("connect timed out") @@ -3761,10 +3833,13 @@ async fn request_per_session_publish_override() { #[tokio::test] async fn client_reconnects_and_resumes_announcements() { let server_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); - server.set_publish(Some(server_origin.clone())).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + publish: Some(server_origin.clone()), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -3800,19 +3875,15 @@ async fn client_reconnects_and_resumes_announcements() { }); let client_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_consume(Some(client_origin.clone())).unwrap(); - // Fast retries so the test doesn't wait out the default 1s backoff. - client - .set_backoff(MoqBackoff { - initial_us: 50_000, - multiplier: 2, - max_us: 200_000, - timeout_us: 0, - }) - .unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + consume: Some(client_origin.clone()), + // Fast retries so the test doesn't wait out the default 1s backoff. + backoff: fast_backoff(), + ..Default::default() + }) + .unwrap(); let cs = tokio::time::timeout(TIMEOUT, client.connect(url)) .await @@ -3882,9 +3953,12 @@ async fn client_reconnects_and_resumes_announcements() { /// the session, surfacing through `closed()` instead of a redial. #[tokio::test] async fn one_shot_client_close_surfaces_through_closed() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -3902,10 +3976,13 @@ async fn one_shot_client_close_surfaces_through_closed() { request.accept().await.expect("handshake failed") }); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_reconnect(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); let cs = tokio::time::timeout(TIMEOUT, client.connect(url)) .await @@ -3932,9 +4009,12 @@ async fn one_shot_client_close_surfaces_through_closed() { /// test_server_request_close, which drives the same path through the bindings. #[tokio::test] async fn rejected_session_surfaces_through_closed() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -3952,10 +4032,13 @@ async fn rejected_session_surfaces_through_closed() { } }); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_reconnect(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); // Either the dial fails outright, or the optimistic connect resolves and the // rejection lands as the session's terminal close. Both must surface within @@ -3978,8 +4061,11 @@ async fn rejected_session_surfaces_through_closed() { /// loop or not; the kt BindingsSmokeTest relies on this to fail fast. #[tokio::test] async fn cancel_before_connect_fails_fast() { - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + ..Default::default() + }) + .unwrap(); client.cancel(); let result = tokio::time::timeout( Duration::from_secs(5), @@ -4001,9 +4087,12 @@ async fn cancel_before_connect_fails_fast() { /// `recv_datagram`) sits on that path; `status` is just the easiest to drive. #[tokio::test] async fn cancelled_status_does_not_swallow_the_next_transition() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await @@ -4023,17 +4112,13 @@ async fn cancelled_status_does_not_swallow_the_next_transition() { request.accept().await.expect("handshake failed") }); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client - .set_backoff(MoqBackoff { - initial_us: 50_000, - multiplier: 2, - max_us: 200_000, - timeout_us: 0, - }) - .unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + backoff: fast_backoff(), + ..Default::default() + }) + .unwrap(); let cs = tokio::time::timeout(TIMEOUT, client.connect(url)) .await @@ -4217,9 +4302,12 @@ async fn set_bitrate_caps_a_later_bandwidth_grant() { } async fn one_shot_peers() -> (Arc, Arc, Arc) { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await .expect("listen timed out") @@ -4236,10 +4324,13 @@ async fn one_shot_peers() -> (Arc, Arc, Arc) request.accept().await.expect("handshake failed") }); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_reconnect(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); let client_session = tokio::time::timeout(TIMEOUT, client.connect(url)) .await .expect("connect timed out") @@ -4284,33 +4375,29 @@ async fn session_protocol_codes_cross_the_ffi() { server.cancel(); } } - #[tokio::test] -async fn client_setters_busy_during_connect_and_cancelled_after() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); +async fn client_cancel_aborts_a_pending_connect() { + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = server.listen().await.expect("listen failed"); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_reconnect(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); - // The server never accepts, so connect holds the client lock in established(). + // The server never accepts, so connect parks in established(). let connecting = client.clone(); - let connect = tokio::spawn(async move { connecting.connect(format!("https://{addr}")).await }); - - assert!(matches!( - wait_for_config_error(|| client.set_tls_verify(false), |err| matches!(err, MoqError::Busy)).await, - MoqError::Busy - )); - assert!(matches!(client.set_publish(None), Err(MoqError::Busy))); - assert!(matches!(client.set_bind("127.0.0.1:0".into()), Err(MoqError::Busy))); + let connect = spawn_parked(async move { connecting.connect(format!("https://{addr}")).await }).await; client.cancel(); - assert!(matches!(client.set_tls_verify(true), Err(MoqError::Cancelled))); - assert!(matches!(client.set_publish(None), Err(MoqError::Cancelled))); let connect_err = tokio::time::timeout(TIMEOUT, connect) .await @@ -4321,84 +4408,26 @@ async fn client_setters_busy_during_connect_and_cancelled_after() { } #[tokio::test] -async fn client_setters_apply_after_connect_returns() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); - let addr = server.listen().await.expect("listen failed"); - - let accept_server = server.clone(); - let accept = tokio::spawn(async move { - let request = accept_server - .accept() - .await - .expect("accept errored") - .expect("accept returned None"); - request.accept().await.expect("handshake failed") - }); - - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - let session = tokio::time::timeout(TIMEOUT, client.connect(format!("https://{addr}"))) - .await - .expect("connect timed out") - .expect("connect failed"); - - // A finished connect releases the lock; later setters apply to the next dial. - client.set_quic_max_streams(2048).unwrap(); - client.set_reconnect(false).unwrap(); - - let server_session = tokio::time::timeout(TIMEOUT, accept) - .await - .expect("accept timed out") - .expect("accept task panicked"); - session.shutdown(); - server_session.cancel(0); - server.cancel(); -} - -#[tokio::test] -async fn server_setters_busy_during_accept_cancelled_after_and_frozen_after_listen() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); +async fn server_cert_fingerprints_busy_during_accept_and_cancelled_after() { + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); server.listen().await.expect("listen failed"); - - assert!(matches!(server.set_bind("127.0.0.1:1".into()), Err(MoqError::Bind(_)))); - assert!(matches!( - server.set_tls_generate(vec!["other".into()]), - Err(MoqError::Bind(_)) - )); - assert!(matches!( - server.set_tls_cert(vec!["cert.pem".into()]), - Err(MoqError::Bind(_)) - )); - assert!(matches!( - server.set_tls_key(vec!["key.pem".into()]), - Err(MoqError::Bind(_)) - )); - - // Origins are captured at accept, so they still apply between accepts. - server.set_publish(None).unwrap(); - server.set_consume(None).unwrap(); + server.cert_fingerprints().expect("fingerprints available between accepts"); let accepting = server.clone(); let accept = tokio::spawn(async move { accepting.accept().await }); - assert!(matches!( - wait_for_config_error(|| server.set_publish(None), |err| matches!(err, MoqError::Busy)).await, - MoqError::Busy - )); - assert!(matches!(server.set_consume(None), Err(MoqError::Busy))); - assert!(matches!(server.cert_fingerprints(), Err(MoqError::Busy))); + wait_for_config_error( + || server.cert_fingerprints().map(drop), + |err| matches!(err, MoqError::Busy), + ) + .await; server.cancel(); - assert!(matches!(server.set_publish(None), Err(MoqError::Cancelled))); - assert!(matches!( - server.set_bind("127.0.0.1:0".into()), - Err(MoqError::Cancelled) - )); assert!(matches!(server.cert_fingerprints(), Err(MoqError::Cancelled))); let accept_err = tokio::time::timeout(TIMEOUT, accept) @@ -4410,15 +4439,21 @@ async fn server_setters_busy_during_accept_cancelled_after_and_frozen_after_list #[tokio::test] async fn request_origin_setters_apply_or_error() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = server.listen().await.expect("listen failed"); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_reconnect(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); let connecting = client.clone(); let connect = tokio::spawn(async move { connecting.connect(format!("https://{addr}")).await }); @@ -4477,15 +4512,21 @@ async fn request_origin_setters_apply_or_error() { #[tokio::test] async fn request_origin_setters_cancelled_after_cancel() { - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + ..Default::default() + }) + .unwrap(); let addr = server.listen().await.expect("listen failed"); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_bind("127.0.0.1:0".into()).unwrap(); - client.set_reconnect(false).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); let connecting = client.clone(); let connect = tokio::spawn(async move { connecting.connect(format!("https://{addr}")).await }); @@ -4522,10 +4563,13 @@ async fn shutdown_cancels_and_drops_cleanly() { } let server_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let server = MoqServer::new(); - server.set_bind("127.0.0.1:0".into()).unwrap(); - server.set_tls_generate(vec!["localhost".into()]).unwrap(); - server.set_publish(Some(server_origin.clone())).unwrap(); + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + publish: Some(server_origin.clone()), + ..Default::default() + }) + .unwrap(); let addr = tokio::time::timeout(TIMEOUT, server.listen()) .await .expect("listen timed out") @@ -4538,9 +4582,12 @@ async fn shutdown_cancels_and_drops_cleanly() { }); let client_origin = MoqOriginProducer::new(MoqOriginConfig::default()); - let client = MoqClient::new(); - client.set_tls_verify(false).unwrap(); - client.set_consume(Some(client_origin.clone())).unwrap(); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + consume: Some(client_origin.clone()), + ..Default::default() + }) + .unwrap(); let session = tokio::time::timeout(TIMEOUT, client.connect(format!("https://{addr}"))) .await .expect("connect timed out") @@ -4704,7 +4751,7 @@ async fn data_track_names_cannot_collide() { MoqJsonStreamProducer::new(&broadcast, &track, MoqJsonStreamConfig { compression: false }).is_err(), "a duplicate data track name should fail" ); - assert_eq!(track.name().unwrap(), "state"); + assert_eq!(track.demand().unwrap().name(), "state"); assert_eq!( published_catalog(&broadcast) .json From 7adc2173c012ba048e26c56ed7dcf65114128c27 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:07:34 -0700 Subject: [PATCH 3/9] refactor(py)!: config-record client and server, consume origin, timedelta root records Co-Authored-By: Claude Opus 5.5 --- doc/lib/py/index.md | 12 +- py/moq-ffi/tests/test_smoke.py | 2 +- py/moq-rs/README.md | 17 +-- py/moq-rs/examples/clock.py | 10 +- py/moq-rs/examples/serve_clock.py | 10 +- py/moq-rs/moq/_records.py | 180 +++++++++++++++++++++++++ py/moq-rs/moq/client.py | 112 +++++++-------- py/moq-rs/moq/publish.py | 42 +++--- py/moq-rs/moq/server.py | 54 ++++---- py/moq-rs/moq/session.py | 2 +- py/moq-rs/moq/subscribe.py | 25 ++-- py/moq-rs/moq/types.py | 25 +--- py/moq-rs/tests/test_local.py | 102 +++++++------- py/moq-rs/tests/test_server.py | 39 +++--- rs/moq-ffi/examples/server_smoke.py | 10 +- test/interop/clients/python/interop.py | 5 +- 16 files changed, 403 insertions(+), 244 deletions(-) create mode 100644 py/moq-rs/moq/_records.py diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index c32d4b2269..bed64a8afb 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -63,7 +63,7 @@ async def main(): # Raw bytes and JSON events = broadcast.publish_track("events") - events.write_frame(b'{"cmd": "ready"}', 0) + events.write_frame(b'{"cmd": "ready"}') status = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("status"), compression=True) status.update({"state": "live", "viewers": 42}) @@ -97,8 +97,10 @@ Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lit Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `session.epoch()` counts the connections, 1 on the first, pairing with `session.status()` to log each reconnect; `moq.Backoff` tunes the pacing -(`timeout_us=0` retries forever); and `moq.connect(..., max_streams=...)` -raises the peer's inbound stream cap. +(`timeout=timedelta(0)` retries forever); `moq.connect(..., max_streams=...)` +raises the peer's inbound stream cap; and `versions=["moq-lite-03"]` pins the +protocol versions offered. A value the native side cannot use raises +`moq.Error.Config` on entering the client or server. The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after a 200 ms head start. Pass `websocket_enabled=False` to `moq.connect` for a @@ -112,7 +114,7 @@ and a producer's `demand()`, a `TrackDemand` whose `used()`/`unused()` let capture idle when nobody is subscribed. `request.set_publish`/`set_consume` raise if the request is already answered, cancelled, or currently accepting. `session.bandwidth()` divides the connection's send estimate; pass it to `encode_video` / `encode_audio` or `reserve` a share for an -app-owned track. `moq.is_auth(err)` and `moq.is_shutdown(err)` classify errors. `moq.protocol_error(err)` is the structured protocol failure (scope, verbatim code, kind) when the peer sent one. Catch `moq.Error.Busy` when a setter races an in-flight connect, listen, or accept. +app-owned track. `moq.is_auth(err)` and `moq.is_shutdown(err)` classify errors. `moq.protocol_error(err)` is the structured protocol failure (scope, verbatim code, kind) when the peer sent one. Catch `moq.Error.Busy` when a request setter races its in-flight accept. Each server request reports a `moq.Transport` enum, including QUIC, Iroh, WebSocket, TCP, and Unix sockets. @@ -138,7 +140,7 @@ not the same as zero. | Field | Unit | Meaning | | --- | --- | --- | -| `rtt_us` | microseconds | Smoothed round-trip time. | +| `rtt` | `timedelta` | Smoothed round-trip time. | | `estimated_send_rate_bps` | bits per second | Send bandwidth from the congestion controller. | | `estimated_recv_rate_bps` | bits per second | Receive bandwidth from MoQ PROBE. | | `bytes_sent` | bytes | Total sent, including retransmissions and overhead. | diff --git a/py/moq-ffi/tests/test_smoke.py b/py/moq-ffi/tests/test_smoke.py index df2547f253..1eb210a8f1 100644 --- a/py/moq-ffi/tests/test_smoke.py +++ b/py/moq-ffi/tests/test_smoke.py @@ -11,5 +11,5 @@ def test_exports_core_classes(): def test_client_constructs(): - client = moq_ffi.MoqClient() + client = moq_ffi.MoqClient(moq_ffi.MoqClientConfig()) assert client is not None diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index db2bd33772..24c47858d7 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -114,7 +114,7 @@ origin = moq.OriginProducer() client = moq.Client( "https://cdn.moq.dev/anon", publish=origin, - subscribe=origin, + consume=origin, ) ``` @@ -122,16 +122,17 @@ client = moq.Client( ### Connection -- **`connect(url, *, tls_verify=True, tls_roots=None, tls_system_roots=None, tls_fingerprints=None, tls_cert=None, tls_key=None, bind=None, max_streams=None, reconnect=True, backoff=None, publish=None, subscribe=None)`**. Shorthand for `Client(...)`; use as `async with moq.connect(url) as client:`. -- **`Client(url, *, tls_verify=True, tls_roots=None, tls_system_roots=None, tls_fingerprints=None, tls_cert=None, tls_key=None, bind=None, max_streams=None, reconnect=True, backoff=None, publish=None, subscribe=None)`**. Async context manager for connecting to a relay. +- **`connect(url, *, tls_verify=True, tls_roots=(), tls_system_roots=None, tls_fingerprints=(), tls_cert=None, tls_key=None, bind=None, versions=(), max_streams=None, websocket_enabled=None, websocket_delay=None, reconnect=True, backoff=None, publish=None, consume=None)`**. Shorthand for `Client(...)`; use as `async with moq.connect(url) as client:`. +- **`Client(url, *, ...)`**, taking the same arguments. Async context manager for connecting to a relay. - `tls_roots`. PEM root certificate file path(s) to trust instead of the system roots. - `tls_system_roots`. Whether to trust platform roots in addition to custom roots. - `tls_fingerprints`. Hex SHA-256 fingerprint(s) to pin the peer's certificate to, the native equivalent of `serverCertificateHashes`. Accepts the values a server reports via `cert_fingerprints()`, so you can trust a self-signed certificate without `tls_verify=False`. - `tls_cert`, `tls_key`. Paired PEM certificate chain and private key paths for mTLS. - `max_streams`. Raise the peer's inbound stream cap. + - `versions`. Protocol versions to offer, most preferred first (e.g. `"moq-lite-03"`); empty offers all. - `reconnect`, `backoff`. Redial with a `Backoff` when the transport drops; `reconnect=False` dials once. - `.session`. The established `Session` (or `None` before connecting / after exit). -- **`Server(bind="[::]:443", *, tls_cert=(), tls_key=(), tls_generate=(), publish=None, subscribe=None)`**. Async context manager + async iterator of incoming `Request`s. +- **`Server(bind="[::]:443", *, tls_cert=(), tls_key=(), tls_generate=(), versions=(), max_streams=None, publish=None, consume=None)`**. Async context manager + async iterator of incoming `Request`s. - `.local_addr`. The bound address (useful when binding to port `0`). - `.cert_fingerprints()`. SHA-256 fingerprints of the configured TLS certificates, for `serverCertificateHashes` browser cert pinning. - `.create_broadcast(path) → BroadcastProducer`. Create an unannounced broadcast, invisible to everyone; `announce()` makes it discoverable and reachable; `close()` ends it. @@ -166,12 +167,12 @@ client = moq.Client( - `.cut()` / `.seek(sequence)` draw a group boundary (audio has none of its own) - `.finish()` - **`TrackProducer` / `GroupProducer`**. Write raw payloads with no codec parsing. - - `.write_frame(payload, timestamp_us=0)` writes a payload with a presentation timestamp in microseconds. + - `.write_frame(payload, timestamp=timedelta(0))` writes a payload with its presentation timestamp. - `.create_group(sequence)` creates a sparse or replayed group at an explicit sequence. - `.finish()` ends at the live edge; the handle remains so `.abort(error_code)` can still run. - `.finish_at(final_sequence)` declares the first group that will never be produced while leaving lower groups writable. - `.abort(error_code)` terminates the track or group with an application error. - - `.append_datagram(payload, timestamp_us=0) -> sequence` (`TrackProducer`) sends a best-effort datagram. Payloads are capped at 1200 bytes and there is no stream fallback. + - `.append_datagram(payload, timestamp=timedelta(0)) -> sequence` (`TrackProducer`) sends a best-effort datagram. Payloads are capped at 1200 bytes and there is no stream fallback. ### Subscribing @@ -212,9 +213,9 @@ Every handle whose cleanup is `cancel()` is an async context manager, so exiting ### Types - **`Catalog`**. `.audio: dict[str, Audio]`, `.video: dict[str, Video]`, `.display`, `.rotation`, `.flip`. -- **`Frame`**. `.payload: bytes`, `.timestamp_us: int`. The unit of every write and every raw read. +- **`Frame`**. `.payload: bytes`, `.timestamp: timedelta`. The unit of every write and every raw read. - **`MediaFrame`**. `.payload: bytes`, `.timestamp_us: int`, `.keyframe: bool`. Returned by media subscriptions. `keyframe` marks a group start or video keyframe; for audio it is true only at a group start. -- **`Datagram`**. `.sequence: int`, `.timestamp_us: int`, `.payload: bytes`. Delivered only on datagram-capable transports and lite-05 or newer moq-lite. +- **`Datagram`**. `.sequence: int`, `.timestamp: timedelta`, `.payload: bytes`. Delivered only on datagram-capable transports and lite-05 or newer moq-lite. - **`Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.description`. - **`Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.stalled`, `.framerate`, `.description`. A true `.stalled` recommends temporarily avoiding the rendition without making it unavailable. - **`Subscription`**. Subscriber delivery preferences: priority, staleness, and optional group range. diff --git a/py/moq-rs/examples/clock.py b/py/moq-rs/examples/clock.py index c3f73ac74b..6d53edb906 100644 --- a/py/moq-rs/examples/clock.py +++ b/py/moq-rs/examples/clock.py @@ -10,7 +10,7 @@ import argparse import asyncio -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import moq @@ -24,14 +24,14 @@ async def publish(url: str, broadcast_name: str, track_name: str, tls_verify: bo while True: now = datetime.now(timezone.utc).replace(microsecond=0) - timestamp_us = int(now.timestamp()) * 1_000_000 + timestamp = timedelta(seconds=int(now.timestamp())) group = track.append_group() - group.write_frame(now.strftime("%Y-%m-%d %H:%M:").encode(), timestamp_us) + group.write_frame(now.strftime("%Y-%m-%d %H:%M:").encode(), timestamp) current_minute = now.minute while now.minute == current_minute: - timestamp_us = int(now.timestamp()) * 1_000_000 - group.write_frame(now.strftime("%S").encode(), timestamp_us) + timestamp = timedelta(seconds=int(now.timestamp())) + group.write_frame(now.strftime("%S").encode(), timestamp) await asyncio.sleep(1 - datetime.now(timezone.utc).microsecond / 1_000_000) now = datetime.now(timezone.utc).replace(microsecond=0) diff --git a/py/moq-rs/examples/serve_clock.py b/py/moq-rs/examples/serve_clock.py index 5375aa7057..3fe7c8f521 100644 --- a/py/moq-rs/examples/serve_clock.py +++ b/py/moq-rs/examples/serve_clock.py @@ -13,7 +13,7 @@ import argparse import asyncio -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import moq @@ -31,14 +31,14 @@ async def run(bind: str, broadcast_name: str, track_name: str, host: str) -> Non try: while True: now = datetime.now(timezone.utc).replace(microsecond=0) - timestamp_us = int(now.timestamp()) * 1_000_000 + timestamp = timedelta(seconds=int(now.timestamp())) group = track.append_group() - group.write_frame(now.strftime("%Y-%m-%d %H:%M:").encode(), timestamp_us) + group.write_frame(now.strftime("%Y-%m-%d %H:%M:").encode(), timestamp) current_minute = now.minute while now.minute == current_minute: - timestamp_us = int(now.timestamp()) * 1_000_000 - group.write_frame(now.strftime("%S").encode(), timestamp_us) + timestamp = timedelta(seconds=int(now.timestamp())) + group.write_frame(now.strftime("%S").encode(), timestamp) await asyncio.sleep(1 - datetime.now(timezone.utc).microsecond / 1_000_000) now = datetime.now(timezone.utc).replace(microsecond=0) diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py new file mode 100644 index 0000000000..bc26f06747 --- /dev/null +++ b/py/moq-rs/moq/_records.py @@ -0,0 +1,180 @@ +"""Root records that carry durations, owned here so they read as ``timedelta``. + +moq-ffi spells every duration as integer microseconds, because not every target +language has a duration type. Python does, so these mirror the generated records +with ``timedelta`` fields and convert at the boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from moq_ffi import ( + MoqBackoff, + MoqConnectionStats, + MoqDatagram, + MoqFrame, + MoqSubscription, + MoqTrackInfo, +) + +_MICROSECOND = timedelta(microseconds=1) + + +def _to_us(value: timedelta, name: str) -> int: + if value < timedelta(0): + raise ValueError(f"{name} must not be negative: {value}") + return value // _MICROSECOND + + +def _opt_us(value: timedelta | None, name: str) -> int | None: + return None if value is None else _to_us(value, name) + + +def _from_us(us: int) -> timedelta: + return timedelta(microseconds=us) + + +@dataclass(frozen=True) +class Frame: + """A raw track frame: a payload and its presentation timestamp.""" + + payload: bytes + timestamp: timedelta = timedelta(0) + + def _ffi(self) -> MoqFrame: + return MoqFrame(payload=self.payload, timestamp_us=_to_us(self.timestamp, "timestamp")) + + @staticmethod + def _from_ffi(frame: MoqFrame) -> Frame: + return Frame(payload=frame.payload, timestamp=_from_us(frame.timestamp_us)) + + +@dataclass(frozen=True) +class Datagram: + """A best-effort track datagram as received: sequence number, timestamp, and payload.""" + + sequence: int + timestamp: timedelta + payload: bytes + + @staticmethod + def _from_ffi(datagram: MoqDatagram) -> Datagram: + return Datagram( + sequence=datagram.sequence, + timestamp=_from_us(datagram.timestamp_us), + payload=datagram.payload, + ) + + +@dataclass(frozen=True) +class Subscription: + """Subscriber-side delivery preferences, mirroring moq-net's ``track::Subscription``. + + ``max_age`` is how old a non-latest group may get before it is skipped; zero skips + at once. ``group_start`` is a floor and ``group_end`` an exclusive end, ``None`` for + no bound. + """ + + priority: int = 0 + max_age: timedelta = timedelta(0) + group_start: int | None = None + group_end: int | None = None + + def _ffi(self) -> MoqSubscription: + return MoqSubscription( + priority=self.priority, + max_age_us=_to_us(self.max_age, "max_age"), + group_start=self.group_start, + group_end=self.group_end, + ) + + +def _subscription(subscription: Subscription | None) -> MoqSubscription | None: + return None if subscription is None else subscription._ffi() + + +@dataclass(frozen=True) +class TrackInfo: + """Publisher-side track properties, mirroring moq-net's ``track::Info``. + + ``max_age`` is how long the publisher caches a non-latest group, ``None`` for no + limit. ``timescale`` is ticks per second, ``None`` for microseconds. + """ + + priority: int = 0 + max_age: timedelta | None = None + timescale: int | None = None + + def _ffi(self) -> MoqTrackInfo: + return MoqTrackInfo( + priority=self.priority, + max_age_us=_opt_us(self.max_age, "max_age"), + timescale=self.timescale, + ) + + @staticmethod + def _from_ffi(info: MoqTrackInfo) -> TrackInfo: + return TrackInfo( + priority=info.priority, + max_age=None if info.max_age_us is None else _from_us(info.max_age_us), + timescale=info.timescale, + ) + + +def _track_info(info: TrackInfo | None) -> MoqTrackInfo | None: + return None if info is None else info._ffi() + + +@dataclass(frozen=True) +class Backoff: + """Retry pacing for the automatic reconnect. + + The delay starts at ``initial``, multiplies by ``multiplier`` after each failed + attempt, and caps at ``max``. After ``timeout`` of consecutive failures the + connection gives up; ``timedelta(0)`` retries forever. ``None`` keeps each + default: 1s, x2, 5s, and a 10s window. + """ + + initial: timedelta | None = None + multiplier: int | None = None + max: timedelta | None = None + timeout: timedelta | None = None + + def _ffi(self) -> MoqBackoff: + return MoqBackoff( + initial_us=_opt_us(self.initial, "initial"), + multiplier=self.multiplier, + max_us=_opt_us(self.max, "max"), + timeout_us=_opt_us(self.timeout, "timeout"), + ) + + +@dataclass(frozen=True) +class ConnectionStats: + """Transport metrics for a session; each field is ``None`` when unreported.""" + + rtt: timedelta | None + estimated_send_rate_bps: int | None + estimated_recv_rate_bps: int | None + bytes_sent: int | None + bytes_received: int | None + bytes_lost: int | None + packets_sent: int | None + packets_received: int | None + packets_lost: int | None + + @staticmethod + def _from_ffi(stats: MoqConnectionStats) -> ConnectionStats: + return ConnectionStats( + rtt=None if stats.rtt_us is None else _from_us(stats.rtt_us), + estimated_send_rate_bps=stats.estimated_send_rate_bps, + estimated_recv_rate_bps=stats.estimated_recv_rate_bps, + bytes_sent=stats.bytes_sent, + bytes_received=stats.bytes_received, + bytes_lost=stats.bytes_lost, + packets_sent=stats.packets_sent, + packets_received=stats.packets_received, + packets_lost=stats.packets_lost, + ) diff --git a/py/moq-rs/moq/client.py b/py/moq-rs/moq/client.py index d9b353d25c..c249b12d86 100644 --- a/py/moq-rs/moq/client.py +++ b/py/moq-rs/moq/client.py @@ -2,15 +2,22 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import timedelta -from moq_ffi import MoqClient +from moq_ffi import ( + MoqClient, + MoqClientConfig, + MoqClientTls, + MoqQuicConfig, + MoqWebSocketConfig, +) +from ._records import Backoff, _opt_us from .origin import AnnounceConsumer, AnnouncedBroadcast, OriginConsumer, OriginProducer from .publish import BroadcastProducer from .session import Session from .subscribe import BroadcastConsumer -from .types import Backoff class Client: @@ -26,7 +33,7 @@ class Client: In advanced mode, provide your own origin for full control: origin = OriginProducer() - client = Client("https://relay.example.com", publish=origin, subscribe=origin) + client = Client("https://relay.example.com", publish=origin, consume=origin) The WebSocket fallback races QUIC for ``http(s)`` URLs after a 200 ms head start. Pass ``websocket_enabled=False`` against a QUIC-only relay, or a ``websocket_delay`` @@ -42,6 +49,9 @@ class Client: broadcasts consumed through it ride out the gap. Pass ``reconnect=False`` for a one-shot dial, or a :class:`Backoff` to tune the retry pacing; watch :meth:`Session.status` for the connect/disconnect transitions. + + ``versions`` restricts the protocol versions offered, most preferred first, spelled + like ``"moq-lite-03"``; empty offers every supported version. """ def __init__( @@ -49,40 +59,45 @@ def __init__( url: str, *, tls_verify: bool = True, - tls_roots: list[str] | None = None, + tls_roots: Sequence[str] = (), tls_system_roots: bool | None = None, - tls_fingerprints: list[str] | None = None, + tls_fingerprints: Sequence[str] = (), tls_cert: str | None = None, tls_key: str | None = None, bind: str | None = None, + versions: Sequence[str] = (), max_streams: int | None = None, websocket_enabled: bool | None = None, websocket_delay: timedelta | None = None, reconnect: bool = True, backoff: Backoff | None = None, publish: OriginProducer | None = None, - subscribe: OriginProducer | None = None, + consume: OriginProducer | None = None, ) -> None: self._url = url - self._tls_verify = tls_verify - self._tls_roots = tls_roots - self._tls_system_roots = tls_system_roots - self._tls_fingerprints = tls_fingerprints - self._tls_cert = tls_cert - self._tls_key = tls_key - self._bind = bind - self._max_streams = max_streams - self._websocket_enabled = websocket_enabled - if websocket_delay is not None and websocket_delay < timedelta(0): - raise ValueError(f"websocket_delay must not be negative: {websocket_delay}") - self._websocket_delay = websocket_delay - self._reconnect = reconnect - self._backoff = backoff - # With neither side given, moq-ffi wires one shared origin to both, so a broadcast # announced here is discoverable via announced() (loopback). - self._publish_origin = publish - self._consume_origin = subscribe + self._config = MoqClientConfig( + bind=bind, + versions=list(versions), + tls=MoqClientTls( + insecure=not tls_verify, + roots=list(tls_roots), + system_roots=tls_system_roots, + fingerprints=list(tls_fingerprints), + cert=tls_cert, + key=tls_key, + ), + quic=MoqQuicConfig(max_streams=max_streams), + websocket=MoqWebSocketConfig( + enabled=websocket_enabled, + delay_us=_opt_us(websocket_delay, "websocket_delay"), + ), + once=not reconnect, + backoff=(backoff or Backoff())._ffi(), + publish=None if publish is None else publish._inner, + consume=None if consume is None else consume._inner, + ) self._publisher: OriginProducer | None = None self._consumer: OriginConsumer | None = None @@ -90,39 +105,13 @@ def __init__( self._session: Session | None = None async def __aenter__(self): - self._inner = MoqClient() - - if not self._tls_verify: - self._inner.set_tls_verify(False) - if self._tls_roots: - self._inner.set_tls_roots(self._tls_roots) - if self._tls_system_roots is not None: - self._inner.set_tls_system_roots(self._tls_system_roots) - if self._tls_fingerprints: - self._inner.set_tls_fingerprints(self._tls_fingerprints) - if self._tls_cert is not None: - self._inner.set_tls_cert(self._tls_cert) - if self._tls_key is not None: - self._inner.set_tls_key(self._tls_key) - if self._bind is not None: - self._inner.set_bind(self._bind) - if self._max_streams is not None: - self._inner.set_quic_max_streams(self._max_streams) - if self._websocket_enabled is not None: - self._inner.set_websocket_enabled(self._websocket_enabled) - if self._websocket_delay is not None: - self._inner.set_websocket_delay(self._websocket_delay // timedelta(microseconds=1)) - if not self._reconnect: - self._inner.set_reconnect(False) - if self._backoff is not None: - self._inner.set_backoff(self._backoff) - - if self._publish_origin is not None: - self._inner.set_publish(self._publish_origin._inner) - if self._consume_origin is not None: - self._inner.set_consume(self._consume_origin._inner) - - self._session = Session(await self._inner.connect(self._url)) + self._inner = MoqClient(self._config) + try: + self._session = Session(await self._inner.connect(self._url)) + except BaseException: + self._inner.cancel() + self._inner = None + raise # The session always exposes both sides, wired from the origins above or # auto-created, so publishing and discovery always have somewhere to go. @@ -140,7 +129,6 @@ async def __aexit__(self, *exc) -> None: if self._inner is not None: self._inner.cancel() self._inner = None - self._session = None def create_broadcast(self, path: str) -> BroadcastProducer: """Create an unannounced broadcast at ``path``, invisible until announced. Announce it after populating tracks. @@ -187,19 +175,20 @@ def connect( url: str, *, tls_verify: bool = True, - tls_roots: list[str] | None = None, + tls_roots: Sequence[str] = (), tls_system_roots: bool | None = None, - tls_fingerprints: list[str] | None = None, + tls_fingerprints: Sequence[str] = (), tls_cert: str | None = None, tls_key: str | None = None, bind: str | None = None, + versions: Sequence[str] = (), max_streams: int | None = None, websocket_enabled: bool | None = None, websocket_delay: timedelta | None = None, reconnect: bool = True, backoff: Backoff | None = None, publish: OriginProducer | None = None, - subscribe: OriginProducer | None = None, + consume: OriginProducer | None = None, ) -> Client: """Shorthand for constructing a :class:`Client`. @@ -217,11 +206,12 @@ def connect( tls_cert=tls_cert, tls_key=tls_key, bind=bind, + versions=versions, max_streams=max_streams, websocket_enabled=websocket_enabled, websocket_delay=websocket_delay, reconnect=reconnect, backoff=backoff, publish=publish, - subscribe=subscribe, + consume=consume, ) diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index e9b359c56a..c1d3b78ab3 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import timedelta from typing import TYPE_CHECKING, Any from moq_ffi import ( @@ -13,6 +14,7 @@ MoqContainerInit, MoqContainerProducer, MoqContainerStreamProducer, + MoqFrame, MoqGroupProducer, MoqGroupRequest, MoqMediaProducer, @@ -25,6 +27,7 @@ MoqVideoProducer, ) +from ._records import _subscription, _track_info from .types import ( AudioEncoderInput, AudioEncoderOutput, @@ -88,7 +91,7 @@ async def unused(self) -> None: def write_frame(self, payload: bytes, timestamp_us: int = 0) -> None: """Write one encoded frame with a presentation timestamp in microseconds.""" - self._inner.write_frame(Frame(payload=payload, timestamp_us=timestamp_us)) + self._inner.write_frame(MoqFrame(payload=payload, timestamp_us=timestamp_us)) def flush(self, timestamp_us: int) -> None: """Record a local encoder's frame handoff on the broadcast media clock. @@ -216,9 +219,9 @@ def consume(self) -> GroupConsumer: return GroupConsumer(self._inner.consume()) - def write_frame(self, payload: bytes, timestamp_us: int = 0) -> None: - """Write a frame with a presentation timestamp in microseconds.""" - self._inner.write_frame(Frame(payload=payload, timestamp_us=timestamp_us)) + def write_frame(self, payload: bytes, timestamp: timedelta = timedelta(0)) -> None: + """Write a frame with its presentation timestamp.""" + self._inner.write_frame(Frame(payload, timestamp)._ffi()) def finish(self) -> None: """Close this group cleanly, marking it complete for subscribers. @@ -271,23 +274,10 @@ class TrackProducer: def __init__(self, inner: MoqTrackProducer) -> None: self._inner = inner - @property - def name(self) -> str: - """The track name.""" - return self._inner.name() - def demand(self) -> TrackDemand: - """A watch-only handle to whether this track has subscribers.""" + """A watch-only handle to this track's name and whether it has subscribers.""" return TrackDemand(self._inner.demand()) - async def used(self) -> None: - """Wait until this track has at least one active subscriber. Prefer :meth:`demand`.""" - await self._inner.used() - - async def unused(self) -> None: - """Wait until this track has no active subscribers. Prefer :meth:`demand`.""" - await self._inner.unused() - def dynamic(self) -> TrackDynamic: """Serve fetches for groups that are not currently cached.""" return TrackDynamic(self._inner.dynamic()) @@ -300,17 +290,17 @@ def create_group(self, sequence: int) -> GroupProducer: """Create a group with an explicit sequence number.""" return GroupProducer(self._inner.create_group(sequence)) - def write_frame(self, payload: bytes, timestamp_us: int = 0) -> None: - """Write a single-frame group with a timestamp in microseconds.""" - self._inner.write_frame(Frame(payload=payload, timestamp_us=timestamp_us)) + def write_frame(self, payload: bytes, timestamp: timedelta = timedelta(0)) -> None: + """Write a single-frame group with its presentation timestamp.""" + self._inner.write_frame(Frame(payload, timestamp)._ffi()) - def append_datagram(self, payload: bytes, timestamp_us: int = 0) -> int: + def append_datagram(self, payload: bytes, timestamp: timedelta = timedelta(0)) -> int: """Send a best-effort datagram and return its sequence number. Payloads are capped at 1200 bytes. Datagram delivery requires a datagram-capable transport and wire version; there is no stream fallback. """ - return self._inner.append_datagram(Frame(payload=payload, timestamp_us=timestamp_us)) + return self._inner.append_datagram(Frame(payload, timestamp)._ffi()) def consume(self, subscription: Subscription | None = None) -> TrackConsumer: """Create a consumer that reads directly from this producer's track. @@ -319,7 +309,7 @@ def consume(self, subscription: Subscription | None = None) -> TrackConsumer: """ from .subscribe import TrackConsumer - return TrackConsumer(self._inner.consume(subscription)) + return TrackConsumer(self._inner.consume(_subscription(subscription))) def abort(self, error_code: int) -> None: """Abort this track with an application error code.""" @@ -358,7 +348,7 @@ def accept(self, info: TrackInfo | None = None) -> TrackProducer: ``info`` fixes the track's timescale, priority, and cache; omit for defaults. """ - return TrackProducer(self._inner.accept(info)) + return TrackProducer(self._inner.accept(_track_info(info))) def dynamic(self) -> TrackDynamic: """Create a fetch handler before accepting this requested track.""" @@ -734,7 +724,7 @@ def encode_video( def publish_track(self, name: str, info: TrackInfo | None = None) -> TrackProducer: """Create a track. Send any bytes, no codec validation. ``info`` sets track properties (priority, cache, timescale); omit for defaults.""" - return TrackProducer(self._inner.publish_track(name, info)) + return TrackProducer(self._inner.publish_track(name, _track_info(info))) 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/server.py b/py/moq-rs/moq/server.py index 6b3db8b04b..3a24a0bcfa 100644 --- a/py/moq-rs/moq/server.py +++ b/py/moq-rs/moq/server.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Sequence -from moq_ffi import MoqRequest, MoqServer, MoqTransport +from moq_ffi import MoqQuicConfig, MoqRequest, MoqServer, MoqServerConfig, MoqServerTls, MoqTransport from .origin import OriginProducer from .publish import BroadcastProducer @@ -119,7 +119,7 @@ class Server: "127.0.0.1:4443", tls_generate=["localhost"], publish=origin, - subscribe=origin, + consume=origin, ) """ @@ -130,42 +130,36 @@ def __init__( tls_cert: Sequence[str] = (), tls_key: Sequence[str] = (), tls_generate: Sequence[str] = (), + versions: Sequence[str] = (), + max_streams: int | None = None, publish: OriginProducer | None = None, - subscribe: OriginProducer | None = None, + consume: OriginProducer | None = None, ) -> None: - self._bind = bind - self._tls_cert = list(tls_cert) - self._tls_key = list(tls_key) - self._tls_generate = list(tls_generate) - # If neither origin is provided, create a shared internal one. - if publish is None and subscribe is None: - self._origin: OriginProducer | None = OriginProducer() - self._publish_origin: OriginProducer | None = self._origin - self._consume_origin: OriginProducer | None = self._origin - else: - self._origin = None - self._publish_origin = publish - self._consume_origin = subscribe + if publish is None and consume is None: + publish = consume = OriginProducer() + self._publish_origin = publish + + self._config = MoqServerConfig( + bind=bind, + versions=list(versions), + tls=MoqServerTls(cert=list(tls_cert), key=list(tls_key), generate=list(tls_generate)), + quic=MoqQuicConfig(max_streams=max_streams), + publish=None if publish is None else publish._inner, + consume=None if consume is None else consume._inner, + ) self._inner: MoqServer | None = None self._local_addr: str | None = None async def __aenter__(self): - self._inner = MoqServer() - self._inner.set_bind(self._bind) - if self._tls_cert: - self._inner.set_tls_cert(self._tls_cert) - if self._tls_key: - self._inner.set_tls_key(self._tls_key) - if self._tls_generate: - self._inner.set_tls_generate(self._tls_generate) - if self._publish_origin is not None: - self._inner.set_publish(self._publish_origin._inner) - if self._consume_origin is not None: - self._inner.set_consume(self._consume_origin._inner) - - self._local_addr = await self._inner.listen() + self._inner = MoqServer(self._config) + try: + self._local_addr = await self._inner.listen() + except BaseException: + self._inner.cancel() + self._inner = None + raise return self async def __aexit__(self, *exc) -> None: diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 3001df09dc..4727246826 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -85,7 +85,7 @@ def stats(self) -> ConnectionStats: byte/packet counters). Cheap to call; intended for periodic polling. Individual fields are ``None`` when the transport backend doesn't report them.""" - return self._inner.stats() + return ConnectionStats._from_ffi(self._inner.stats()) def bandwidth(self) -> Bandwidth: """The session's bandwidth allocator. diff --git a/py/moq-rs/moq/subscribe.py b/py/moq-rs/moq/subscribe.py index 7c130cf126..8b9e34254d 100644 --- a/py/moq-rs/moq/subscribe.py +++ b/py/moq-rs/moq/subscribe.py @@ -8,6 +8,7 @@ MoqAudioConsumer, MoqBroadcastConsumer, MoqCatalogConsumer, + MoqFrame, MoqGroupConsumer, MoqMediaConsumer, MoqMediaGroupConsumer, @@ -15,6 +16,7 @@ MoqVideoConsumer, ) +from ._records import _subscription from .types import ( Audio, AudioDecoderOutput, @@ -33,6 +35,10 @@ ) +def _frame(frame: MoqFrame | None) -> Frame | None: + return None if frame is None else Frame._from_ffi(frame) + + class MediaConsumer: """Async-iterable stream of decoded :class:`MediaFrame` in decode order. @@ -123,11 +129,11 @@ async def __anext__(self) -> Frame: frame = await self._inner.read_frame() if frame is None: raise StopAsyncIteration - return frame + return Frame._from_ffi(frame) async def read_frame(self) -> Frame | None: """Read the next timestamped frame. Returns `None` when the group ends.""" - return await self._inner.read_frame() + return _frame(await self._inner.read_frame()) def cancel(self) -> None: """Cancel reading this group and stop delivering frames.""" @@ -207,7 +213,7 @@ async def read_frame(self) -> Frame | None: `None` only when the track ends. Cancelling one call keeps the current group so a later :meth:`read_frame` or :meth:`next_group` still sees it. """ - return await self._inner.read_frame() + return _frame(await self._inner.read_frame()) async def recv_datagram(self) -> Datagram | None: """Receive the next best-effort datagram in arrival order. @@ -215,15 +221,16 @@ async def recv_datagram(self) -> Datagram | None: Returns ``None`` when the track ends. Datagrams are unavailable over stream-only transports and older wire versions. """ - return await self._inner.recv_datagram() + datagram = await self._inner.recv_datagram() + return None if datagram is None else Datagram._from_ffi(datagram) def info(self) -> TrackInfo: """Return the publisher-side track properties.""" - return self._inner.info() + return TrackInfo._from_ffi(self._inner.info()) def update(self, subscription: Subscription) -> None: """Change this subscriber's delivery preferences.""" - self._inner.update(subscription) + self._inner.update(subscription._ffi()) def cancel(self) -> None: """Cancel the subscription and stop delivering groups.""" @@ -343,7 +350,7 @@ async def subscribe_track(self, name: str, subscription: Subscription | None = N ``subscription`` tunes delivery priority, group range, and staleness; omit for defaults. """ - return TrackConsumer(await self._inner.subscribe_track(name, subscription)) + return TrackConsumer(await self._inner.subscribe_track(name, _subscription(subscription))) async def fetch_group( self, @@ -389,11 +396,11 @@ async def subscribe_media( dynamic flow, where you subscribe before the catalog exists. ``subscription`` tunes delivery priority, group range, and the max age; omit for defaults. Raise - :attr:`Subscription.max_age_us` to buffer instead of skipping a + :attr:`Subscription.max_age` to buffer instead of skipping a stalled group. """ container = track if isinstance(track, Container) else track.container - return MediaConsumer(await self._inner.subscribe_media(name, container, subscription)) + return MediaConsumer(await self._inner.subscribe_media(name, container, _subscription(subscription))) async def resolve(self, reference: str | None) -> "BroadcastConsumer": """Resolve a catalog rendition's ``broadcast`` reference to the broadcast diff --git a/py/moq-rs/moq/types.py b/py/moq-rs/moq/types.py index 49123f14fd..614ae1809a 100644 --- a/py/moq-rs/moq/types.py +++ b/py/moq-rs/moq/types.py @@ -1,4 +1,7 @@ -"""Re-export moq-ffi record types without the Moq prefix.""" +"""Re-export moq-ffi record types without the Moq prefix. + +Records with duration fields are owned in `_records` instead, so they read as `timedelta`. +""" from moq_ffi import ( MoqAudio as Audio, @@ -24,15 +27,9 @@ from moq_ffi import ( MoqAudioSampleFormat as AudioSampleFormat, ) -from moq_ffi import ( - MoqBackoff as Backoff, -) from moq_ffi import ( MoqCatalog as Catalog, ) -from moq_ffi import ( - MoqConnectionStats as ConnectionStats, -) from moq_ffi import ( MoqConnectionStatus as ConnectionStatus, ) @@ -42,9 +39,6 @@ from moq_ffi import ( MoqContainerFormat as ContainerFormat, ) -from moq_ffi import ( - MoqDatagram as Datagram, -) from moq_ffi import ( MoqDimensions as Dimensions, ) @@ -54,9 +48,6 @@ from moq_ffi import ( MoqFetchGroupOptions as FetchGroupOptions, ) -from moq_ffi import ( - MoqFrame as Frame, -) from moq_ffi import ( MoqMediaFrame as MediaFrame, ) @@ -69,12 +60,6 @@ from moq_ffi import ( MoqRoute as Route, ) -from moq_ffi import ( - MoqSubscription as Subscription, -) -from moq_ffi import ( - MoqTrackInfo as TrackInfo, -) from moq_ffi import ( MoqVideo as Video, ) @@ -112,6 +97,8 @@ MoqVideoProperties, ) +from ._records import Backoff, ConnectionStats, Datagram, Frame, Subscription, TrackInfo + VideoProperties = MoqVideoProperties """Video catalog properties shared by every rendition; ``None`` fields clear them.""" diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 3465a8d334..c1409e169c 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -2,6 +2,7 @@ import asyncio import struct +from datetime import timedelta from typing import cast import moq @@ -346,7 +347,7 @@ async def test_announced_broadcast(): def test_publish_lifecycle(): broadcast = moq.BroadcastProducer() track = broadcast.publish_track("status") - track.write_frame(b'{"cmd": "ready"}', 0) + track.write_frame(b'{"cmd": "ready"}') track.finish() broadcast.close() @@ -354,11 +355,11 @@ def test_publish_lifecycle(): async def test_publish_track_info_and_subscription(): """Raw track published with explicit TrackInfo, consumed with a Subscription.""" broadcast = moq.BroadcastProducer() - info = moq.TrackInfo(priority=5, max_age_us=2_000_000) + info = moq.TrackInfo(priority=5, max_age=timedelta(seconds=2)) track = broadcast.publish_track("status", info) consumer = track.consume(moq.Subscription(priority=3)) - track.write_frame(b"ready", 0) + track.write_frame(b"ready") frame = await asyncio.wait_for(consumer.read_frame(), timeout=5.0) assert frame is not None @@ -373,7 +374,7 @@ async def test_fetch_group_and_serve_dynamic_miss(): consumer = broadcast.consume() cached = track.append_group() - cached.write_frame(b"cached", 0) + cached.write_frame(b"cached") cached.finish() fetched = await consumer.fetch_group("events", 0, moq.FetchGroupOptions(priority=3)) @@ -387,7 +388,7 @@ async def test_fetch_group_and_serve_dynamic_miss(): assert request.priority == 11 produced = request.accept() - produced.write_frame(b"archive", 140_000) + produced.write_frame(b"archive", timedelta(microseconds=140_000)) produced.finish() fetched = await asyncio.wait_for(pending, timeout=5.0) @@ -463,7 +464,7 @@ async def test_dynamic_track_request(): # Accept the request as a raw track (which unblocks the subscribe), then write. track = request.accept() payload = b"hello dynamic track" - track.write_frame(payload, 0) + track.write_frame(payload) track_consumer = await asyncio.wait_for(subscribe, timeout=5.0) frame = await asyncio.wait_for(track_consumer.read_frame(), timeout=5.0) @@ -531,12 +532,12 @@ async def test_dynamic_broadcast_request(): broadcast = await asyncio.wait_for(request_broadcast, timeout=5.0) track_consumer = await broadcast.subscribe_track("status") payload = b"served dynamically" - track.write_frame(payload, 20_000) + track.write_frame(payload, timedelta(microseconds=20_000)) frame = await asyncio.wait_for(track_consumer.read_frame(), timeout=5.0) assert frame is not None assert frame.payload == payload - assert frame.timestamp_us == 20_000 + assert frame.timestamp == timedelta(microseconds=20_000) track.finish() served.close() @@ -579,7 +580,7 @@ def test_raw_group_write_multiple_frames(): group = track.append_group() for i in range(10): - group.write_frame(f"frame-{i}".encode(), i) + group.write_frame(f"frame-{i}".encode(), timedelta(microseconds=i)) group.finish() @@ -589,7 +590,7 @@ def test_raw_group_empty_payload(): track = broadcast.publish_track("empty") group = track.append_group() - group.write_frame(b"", 0) + group.write_frame(b"") group.finish() @@ -600,7 +601,7 @@ def test_raw_group_write_after_finish_fails(): group.finish() with pytest.raises(Exception): - group.write_frame(b"too late", 0) + group.write_frame(b"too late") def test_raw_group_abort_after_finish(): @@ -624,7 +625,7 @@ def test_raw_track_write_after_finish_fails(): track.finish() with pytest.raises(Exception): - track.write_frame(b"late", 0) + track.write_frame(b"late") def test_raw_sparse_groups_and_known_end(): @@ -656,9 +657,9 @@ def test_raw_parallel_groups(): assert g0.sequence == 0 assert g1.sequence == 1 - g0.write_frame(b"a0", 0) - g1.write_frame(b"b0", 0) - g0.write_frame(b"a1", 1) + g0.write_frame(b"a0") + g1.write_frame(b"b0") + g0.write_frame(b"a1", timedelta(microseconds=1)) g0.finish() g1.finish() @@ -678,8 +679,8 @@ def test_public_api_exports(): tls_roots=["root.pem"], tls_fingerprints=["abc123"], ) - assert client._tls_roots == ["root.pem"] - assert client._tls_fingerprints == ["abc123"] + assert client._config.tls.roots == ["root.pem"] + assert client._config.tls.fingerprints == ["abc123"] async def test_subscribe_media_default_latency_and_context_manager(): @@ -722,7 +723,7 @@ async def test_raw_publish_consume(): raw_consumer = await broadcast_consumer.subscribe_track("events") payload = b'{"cmd": "button_changed", "arm": "left", "button": "THUMB", "state": "PRESSED"}' - raw.write_frame(payload, 0) + raw.write_frame(payload) async for group in raw_consumer: async for frame in group: @@ -742,7 +743,8 @@ async def test_raw_multiple_frames(): async for announcement in routes(consumer.announced()): broadcast_consumer = await consumer.request_broadcast(announcement.prefix) - raw_consumer = await broadcast_consumer.subscribe_track("commands", moq.Subscription(max_age_us=1_000_000)) + subscription = moq.Subscription(max_age=timedelta(seconds=1)) + raw_consumer = await broadcast_consumer.subscribe_track("commands", subscription) messages = [ b'{"cmd": "led", "arm": "left", "led": "THUMB", "state": 1}', @@ -750,7 +752,7 @@ async def test_raw_multiple_frames(): b'{"cmd": "tone_stop", "arm": "right"}', ] for msg in messages: - raw.write_frame(msg, 0) + raw.write_frame(msg) received = [] async for group in raw_consumer: @@ -767,10 +769,10 @@ async def test_raw_producer_consume_direct(): """Consume a raw track directly from the producer, no origin/broadcast plumbing.""" broadcast = moq.BroadcastProducer() track = broadcast.publish_track("direct") - consumer = track.consume(moq.Subscription(max_age_us=1_000_000)) + consumer = track.consume(moq.Subscription(max_age=timedelta(seconds=1))) - track.write_frame(b"hello", 0) - track.write_frame(b"world", 0) + track.write_frame(b"hello") + track.write_frame(b"world") received = [] async for group in consumer: @@ -790,8 +792,8 @@ async def test_raw_group_producer_consume_direct(): group_consumer = group.consume() assert group_consumer.sequence == group.sequence - group.write_frame(b"a", 0) - group.write_frame(b"b", 0) + group.write_frame(b"a") + group.write_frame(b"b") group.finish() received = [frame.payload async for frame in group_consumer] @@ -805,7 +807,7 @@ async def test_broadcast_producer_consume_direct(): consumer = broadcast.consume() raw_consumer = await consumer.subscribe_track("events") - raw.write_frame(b"event-0", 0) + raw.write_frame(b"event-0") async for group in raw_consumer: async for frame in group: @@ -824,13 +826,13 @@ async def test_raw_group_sequence(): async for announcement in routes(consumer.announced()): broadcast_consumer = await consumer.request_broadcast(announcement.prefix) - raw_consumer = await broadcast_consumer.subscribe_track("seq", moq.Subscription(max_age_us=1_000_000)) + raw_consumer = await broadcast_consumer.subscribe_track("seq", moq.Subscription(max_age=timedelta(seconds=1))) sent_sequences = [] for i in range(3): group = raw.append_group() sent_sequences.append(group.sequence) - group.write_frame(f"msg-{i}".encode(), i) + group.write_frame(f"msg-{i}".encode(), timedelta(microseconds=i)) group.finish() received_sequences = [] @@ -855,13 +857,13 @@ async def test_default_iteration_is_sequence_order(): broadcast = create_announced(origin, "track/ordering") raw = broadcast.publish_track("ordering") - subscription = moq.Subscription(max_age_us=1_000_000) + subscription = moq.Subscription(max_age=timedelta(seconds=1)) seq_consumer = raw.consume(subscription) arr_consumer = raw.consume(subscription) for sequence in (5, 3): group = raw.create_group(sequence) - group.write_frame(f"group-{sequence}".encode(), 0) + group.write_frame(f"group-{sequence}".encode()) group.finish() # Arrival order sees them as produced, newest sequence first. @@ -896,7 +898,7 @@ async def test_raw_multi_frame_group(): group_producer = raw.append_group() chunks = [b"chunk-0", b"chunk-1", b"chunk-2"] for chunk in chunks: - group_producer.write_frame(chunk, 0) + group_producer.write_frame(chunk) group_producer.finish() async for group in raw_consumer: @@ -911,11 +913,11 @@ async def test_read_frame_one_per_group(): """read_frame() returns the first frame of each successive group.""" broadcast = moq.BroadcastProducer() track = broadcast.publish_track("status") - consumer = track.consume(moq.Subscription(max_age_us=1_000_000)) + consumer = track.consume(moq.Subscription(max_age=timedelta(seconds=1))) - track.write_frame(b"ready", 0) - track.write_frame(b"running", 0) - track.write_frame(b"done", 0) + track.write_frame(b"ready") + track.write_frame(b"running") + track.write_frame(b"done") frame = await consumer.read_frame() assert frame is not None @@ -934,35 +936,35 @@ async def test_raw_read_frame_preserves_timestamp(): track = broadcast.publish_track("status") consumer = track.consume() - track.write_frame(b"ready", 12_345) + track.write_frame(b"ready", timedelta(microseconds=12_345)) frame = await consumer.read_frame() assert frame is not None assert frame.payload == b"ready" - assert frame.timestamp_us == 12_345 + assert frame.timestamp == timedelta(microseconds=12_345) group = track.append_group() group_consumer = group.consume() - group.write_frame(b"group", 23_456) + group.write_frame(b"group", timedelta(microseconds=23_456)) group.finish() frame = await group_consumer.read_frame() assert frame is not None assert frame.payload == b"group" - assert frame.timestamp_us == 23_456 + assert frame.timestamp == timedelta(microseconds=23_456) async def test_read_frame_skips_remaining_frames_in_group(): """read_frame() only returns the first frame of a multi-frame group.""" broadcast = moq.BroadcastProducer() track = broadcast.publish_track("mixed") - consumer = track.consume(moq.Subscription(max_age_us=1_000_000)) + consumer = track.consume(moq.Subscription(max_age=timedelta(seconds=1))) group = track.append_group() - group.write_frame(b"first", 0) - group.write_frame(b"second-ignored", 0) + group.write_frame(b"first") + group.write_frame(b"second-ignored") group.finish() - track.write_frame(b"next-group-first", 0) + track.write_frame(b"next-group-first") frame = await consumer.read_frame() assert frame is not None @@ -978,7 +980,7 @@ async def test_read_frame_returns_none_when_track_finished(): track = broadcast.publish_track("done") consumer = track.consume() - track.write_frame(b"only", 0) + track.write_frame(b"only") track.finish() frame = await consumer.read_frame() @@ -999,11 +1001,11 @@ async def test_read_frame_skips_empty_group_on_open_track(): await asyncio.sleep(0.05) assert not read.done(), "empty group must not end an open track" - track.write_frame(b"after-empty", 1_000) + track.write_frame(b"after-empty", timedelta(microseconds=1_000)) frame = await asyncio.wait_for(read, timeout=5.0) assert frame is not None assert frame.payload == b"after-empty" - assert frame.timestamp_us == 1_000 + assert frame.timestamp == timedelta(microseconds=1_000) async def test_read_frame_skips_empty_then_populated_groups(): @@ -1014,12 +1016,12 @@ async def test_read_frame_skips_empty_then_populated_groups(): track.append_group().finish() track.append_group().finish() - track.write_frame(b"populated", 2_000) + track.write_frame(b"populated", timedelta(microseconds=2_000)) frame = await asyncio.wait_for(consumer.read_frame(), timeout=5.0) assert frame is not None assert frame.payload == b"populated" - assert frame.timestamp_us == 2_000 + assert frame.timestamp == timedelta(microseconds=2_000) async def test_read_frame_keeps_group_across_cancelled_call(): @@ -1036,14 +1038,14 @@ async def test_read_frame_keeps_group_across_cancelled_call(): with pytest.raises(asyncio.CancelledError): await read - group.write_frame(b"kept", 3_000) + group.write_frame(b"kept", timedelta(microseconds=3_000)) group.finish() track.finish() frame = await asyncio.wait_for(consumer.read_frame(), timeout=5.0) assert frame is not None assert frame.payload == b"kept" - assert frame.timestamp_us == 3_000 + assert frame.timestamp == timedelta(microseconds=3_000) assert await asyncio.wait_for(consumer.read_frame(), timeout=5.0) is None diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index 1b4596a1d3..1c04bf3486 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -117,7 +117,12 @@ async def accept_loop() -> None: tls_verify=False, bind="127.0.0.1:0", # Fast retries so the test doesn't wait out the default 1s backoff. - backoff=moq.Backoff(initial_us=50_000, multiplier=2, max_us=200_000, timeout_us=0), + backoff=moq.Backoff( + initial=timedelta(milliseconds=50), + multiplier=2, + max=timedelta(milliseconds=200), + timeout=timedelta(0), + ), ) as client: session = client.session assert session is not None @@ -172,12 +177,15 @@ async def reject_loop() -> None: reject_task = asyncio.create_task(reject_loop()) try: - client = moq_ffi.MoqClient() - client.set_tls_verify(False) - client.set_bind("127.0.0.1:0") - # One-shot, so this dial's outcome is what surfaces here rather than - # whatever the reconnect loop eventually reports. - client.set_reconnect(False) + client = moq_ffi.MoqClient( + moq_ffi.MoqClientConfig( + bind="127.0.0.1:0", + tls=moq_ffi.MoqClientTls(insecure=True), + # One-shot, so this dial's outcome is what surfaces here rather than + # whatever the reconnect loop eventually reports. + once=True, + ) + ) # The rejection races the optimistic connect: it surfaces either as a # connect error or as the session's terminal close. MoqError is an # Exception subclass at runtime; UniFFI's generated code rebinds the @@ -227,15 +235,14 @@ async def accept_loop() -> None: moq.Client("https://localhost", websocket_delay=timedelta(milliseconds=-1)) -async def test_client_setters_fail_after_cancel(): - """A cancelled client refuses further configuration rather than ignoring it.""" - client = moq_ffi.MoqClient() - client.set_tls_verify(False) - client.cancel() - with pytest.raises(moq_ffi.MoqError.Cancelled): # type: ignore[misc] - client.set_tls_verify(True) - with pytest.raises(moq_ffi.MoqError.Cancelled): # type: ignore[misc] - client.set_bind("127.0.0.1:0") +async def test_invalid_config_fails_on_enter(): + """A value the native side cannot use fails the connect or listen, not a later call.""" + with pytest.raises(moq.Error.Config): # type: ignore[attr-defined] + async with moq.Client("https://localhost", versions=["moq-lite-99"]): + pass + with pytest.raises(moq.Error.Config): # type: ignore[attr-defined] + async with moq.Server("not-an-address", tls_generate=["localhost"]): + pass async def test_cert_fingerprints_after_listen(): diff --git a/rs/moq-ffi/examples/server_smoke.py b/rs/moq-ffi/examples/server_smoke.py index aa627891e0..6d9703f3c5 100644 --- a/rs/moq-ffi/examples/server_smoke.py +++ b/rs/moq-ffi/examples/server_smoke.py @@ -26,9 +26,9 @@ async def main() -> int: - server = moq.MoqServer() - server.set_bind("127.0.0.1:0") - server.set_tls_generate(["localhost"]) + server = moq.MoqServer( + moq.MoqServerConfig(bind="127.0.0.1:0", tls=moq.MoqServerTls(generate=["localhost"])) + ) addr = await server.listen() print(f"server bound on {addr}") @@ -40,9 +40,7 @@ async def accept_one() -> moq.MoqSession: accept_task = asyncio.create_task(accept_one()) - client = moq.MoqClient() - client.set_tls_verify(False) - client.set_bind("127.0.0.1:0") + client = moq.MoqClient(moq.MoqClientConfig(bind="127.0.0.1:0", tls=moq.MoqClientTls(insecure=True))) client_session = await client.connect(f"https://{addr}") server_session = await accept_task diff --git a/test/interop/clients/python/interop.py b/test/interop/clients/python/interop.py index ef4b7134c4..e96511f1ba 100644 --- a/test/interop/clients/python/interop.py +++ b/test/interop/clients/python/interop.py @@ -17,11 +17,12 @@ import math import struct import sys +from datetime import timedelta import moq READ_CHUNK = 64 * 1024 -MAX_AGE_US = 1_000_000 # subscribe_media congestion-control / lookahead window +MAX_AGE = timedelta(seconds=1) # subscribe_media congestion-control / lookahead window # Synthetic audio: a 48 kHz mono tone, encoded as Opus. AUDIO_TRACK = "tone" @@ -105,7 +106,7 @@ async def subscribe(url: str, broadcast: str, timeout: float) -> None: video = catalog.video[track_name] media = await consumer.subscribe_media( - track_name, video.container, moq.Subscription(max_age_us=MAX_AGE_US) + track_name, video.container, moq.Subscription(max_age=MAX_AGE) ) total = 0 From 2b41a6c366198b81fdec017cb7a7edab7c3fb3c6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:13:40 -0700 Subject: [PATCH 4/9] refactor(go)!: config-record client and server, consume origin, Duration root records, All iterators Co-Authored-By: Claude Opus 5.5 --- doc/lib/go/index.md | 15 +- go/wrapper/backoff_internal_test.go | 46 ++++-- go/wrapper/client.go | 247 +++++++++++++--------------- go/wrapper/doc.go | 5 +- go/wrapper/errors.go | 4 +- go/wrapper/errors_test.go | 1 + go/wrapper/example_test.go | 6 +- go/wrapper/internal/bridge/call.go | 2 +- go/wrapper/json/json.go | 8 +- go/wrapper/json/json_test.go | 4 +- go/wrapper/moq_test.go | 42 ++--- go/wrapper/origin.go | 4 +- go/wrapper/publish.go | 67 +++++--- go/wrapper/reconnect_test.go | 2 +- go/wrapper/records.go | 150 +++++++++++++++++ go/wrapper/server.go | 84 +++++----- go/wrapper/session.go | 10 +- go/wrapper/subscribe.go | 65 +++++--- go/wrapper/types.go | 22 +-- 19 files changed, 485 insertions(+), 299 deletions(-) create mode 100644 go/wrapper/records.go diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 10a84b0f60..92506cea6c 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -60,7 +60,7 @@ for event, err := range announced.All(ctx) { // opusInit, packet, pts, and rgba come from your encoder or capture source. broadcast, _ := client.CreateBroadcast("my-stream.hang") audio, _ := broadcast.PublishAudio(moq.AudioFormatOpus, opusInit) -_ = audio.WriteFrame(moq.Frame{Payload: packet, TimestampUs: 20_000}) +_ = audio.WriteFrame(moq.Frame{Payload: packet, Timestamp: 20 * time.Millisecond}) track := "camera" video, _ := broadcast.EncodeVideo( @@ -113,7 +113,8 @@ Sessions reconnect with backoff when the transport drops and re-announce local broadcasts, so a worker rides out a relay restart. `Session().Epoch()` counts the connections, 1 on the first, pairing with `Session().Status(ctx)` to log each reconnect by number; `moq.WithBackoff` tunes the pacing, with -`moq.RetryForever` as the timeout; and `moq.WithQUICMaxStreams` raises the +`moq.RetryForever` as the timeout; `moq.WithVersions` pins the protocol versions +offered; and `moq.WithQUICMaxStreams` raises the peer's inbound stream cap for a subscriber to many tracks. The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after @@ -124,14 +125,16 @@ 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 live in `moq.dev/moq/json` (import it as `moqjson`): +in-flight Accept. An invalid option fails `Dial` or `Listen` with `moq.ErrConfig`. +Every live stream ranges with `All(ctx)`; a `TrackConsumer` also offers +`Groups`, `GroupsAsArrived`, and `Datagrams`. 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)`, +one: `FetchGroup`/`FetchMediaGroup`, `Dynamic()` with `All(ctx)`, `Session.Bandwidth()` to divide the send estimate, -`AppendDatagram`/`Datagrams(ctx)`, `SetCatalogSection`, `Demand()` for `Used`/`Unused`, +`AppendDatagram`/`Datagrams(ctx)`, `SetCatalogSection`, `Demand()` for `Name`, `Used`, and `Unused`, `Session().Stats()`. `moq.IsAuthError` and `moq.IsShutdown` classify errors. `moq.ProtocolError(err)` is the structured protocol failure (scope, verbatim code, kind) when the peer sent one. Each `VideoDecodedFrame` from `DecodeVideo` owns its decoded picture until @@ -153,7 +156,7 @@ available, which is not the same as zero. | Field | Unit | Meaning | | --- | --- | --- | -| `RttUs` | microseconds | Smoothed round-trip time. | +| `RTT` | `time.Duration` | Smoothed round-trip time. | | `EstimatedSendRateBps` | bits per second | Send bandwidth from the congestion controller. | | `EstimatedRecvRateBps` | bits per second | Receive bandwidth from MoQ PROBE. | | `BytesSent` | bytes | Total sent, including retransmissions and overhead. | diff --git a/go/wrapper/backoff_internal_test.go b/go/wrapper/backoff_internal_test.go index 67185c5281..23f121c5e6 100644 --- a/go/wrapper/backoff_internal_test.go +++ b/go/wrapper/backoff_internal_test.go @@ -1,61 +1,75 @@ package moq import ( + "reflect" "testing" "time" ffi "moq.dev/moq-ffi/moq" ) +func ptr[T any](v T) *T { return &v } + // The conversion is where Go's zero value meets the native encoding, and the two // disagree: zero means "unset" to a Go caller but "no delay" and "retry forever" -// to the reconnect loop. Passing it through unresolved turns the most natural +// to the reconnect loop. Passing it through as zero turns the most natural // literal a caller writes, Backoff{}, into an unthrottled dial loop. -func TestBackoffFfiResolvesUnsetFields(t *testing.T) { - defaults := ffi.MoqBackoff{InitialUs: 1_000_000, Multiplier: 2, MaxUs: 5_000_000, TimeoutUs: 10_000_000} - +func TestBackoffFfiLeavesUnsetFieldsToTheNativeDefaults(t *testing.T) { cases := []struct { name string in Backoff want ffi.MoqBackoff }{ { - name: "zero value is the documented default, never an unpaced loop", + name: "zero value is the native default, never an unpaced loop", in: Backoff{}, - want: defaults, + want: ffi.MoqBackoff{}, }, { name: "a partial override keeps the defaults for everything else", in: Backoff{Max: time.Second}, - want: ffi.MoqBackoff{InitialUs: 1_000_000, Multiplier: 2, MaxUs: 1_000_000, TimeoutUs: 10_000_000}, + want: ffi.MoqBackoff{MaxUs: ptr[uint64](1_000_000)}, }, { name: "RetryForever is the only way to reach the native zero timeout", in: Backoff{Timeout: RetryForever}, - want: ffi.MoqBackoff{InitialUs: 1_000_000, Multiplier: 2, MaxUs: 5_000_000, TimeoutUs: 0}, - }, - { - name: "negatives fall back instead of wrapping to ~1.8e19 us", - in: Backoff{Initial: -time.Second, Max: -time.Hour}, - want: defaults, + want: ffi.MoqBackoff{TimeoutUs: ptr[uint64](0)}, }, { name: "a sub-microsecond delay floors at 1us instead of truncating to zero", in: Backoff{Initial: time.Nanosecond}, - want: ffi.MoqBackoff{InitialUs: 1, Multiplier: 2, MaxUs: 5_000_000, TimeoutUs: 10_000_000}, + want: ffi.MoqBackoff{InitialUs: ptr[uint64](1)}, }, { name: "explicit values pass through", in: Backoff{Initial: 500 * time.Millisecond, Multiplier: 3, Max: 10 * time.Second, Timeout: time.Minute}, - want: ffi.MoqBackoff{InitialUs: 500_000, Multiplier: 3, MaxUs: 10_000_000, TimeoutUs: 60_000_000}, + want: ffi.MoqBackoff{ + InitialUs: ptr[uint64](500_000), + Multiplier: ptr[uint32](3), + MaxUs: ptr[uint64](10_000_000), + TimeoutUs: ptr[uint64](60_000_000), + }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := tc.in.ffi(); got != tc.want { + got, err := tc.in.ffi() + if err != nil { + t.Fatalf("Backoff%+v.ffi() failed: %v", tc.in, err) + } + if !reflect.DeepEqual(got, tc.want) { t.Errorf("Backoff%+v.ffi() = %+v, want %+v", tc.in, got, tc.want) } }) } } + +// A negative duration would wrap to ~1.8e19 us when cast, so it is refused. +func TestBackoffFfiRejectsNegatives(t *testing.T) { + for _, in := range []Backoff{{Initial: -time.Second}, {Max: -time.Hour}, {Timeout: -2 * time.Second}} { + if _, err := in.ffi(); err == nil { + t.Errorf("Backoff%+v.ffi() succeeded, want an error", in) + } + } +} diff --git a/go/wrapper/client.go b/go/wrapper/client.go index 7640aa448f..c36297d6c9 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -2,7 +2,6 @@ package moq import ( "context" - "fmt" "sync" "time" @@ -13,122 +12,142 @@ import ( // ClientOption configures a client created with Dial. type ClientOption func(*clientConfig) +// clientConfig collects the options; Dial validates it into the native config, +// whose unset fields keep moq-ffi's defaults. type clientConfig struct { - tlsVerify bool - tlsRoots []string - tlsRootsSet bool - tlsSystemRoots bool - tlsSystemRootsSet bool - tlsFingerprints []string - tlsFingerprintsSet bool - tlsCert *string - tlsKey *string - bind *string - quicMaxStreams *uint64 - websocketEnabled *bool - websocketDelay *time.Duration - reconnect *bool - backoff *Backoff - publish *OriginProducer - subscribe *OriginProducer + tlsInsecure bool + tlsRoots []string + tlsSystemRoots *bool + tlsFingerprints []string + tlsCert *string + tlsKey *string + bind *string + versions []string + quicMaxStreams *uint64 + websocketEnabled *bool + websocketDelay *time.Duration + once bool + backoff Backoff + publish *OriginProducer + consume *OriginProducer +} + +func (c *clientConfig) ffi() (ffi.MoqClientConfig, error) { + delay, err := optMicros("websocket delay", c.websocketDelay) + if err != nil { + return ffi.MoqClientConfig{}, err + } + backoff, err := c.backoff.ffi() + if err != nil { + return ffi.MoqClientConfig{}, err + } + cfg := ffi.MoqClientConfig{ + Bind: c.bind, + Versions: c.versions, + Tls: ffi.MoqClientTls{ + Insecure: c.tlsInsecure, + Roots: c.tlsRoots, + SystemRoots: c.tlsSystemRoots, + Fingerprints: c.tlsFingerprints, + Cert: c.tlsCert, + Key: c.tlsKey, + }, + Quic: ffi.MoqQuicConfig{MaxStreams: c.quicMaxStreams}, + Websocket: ffi.MoqWebSocketConfig{Enabled: c.websocketEnabled, DelayUs: delay}, + Once: c.once, + Backoff: backoff, + } + if c.publish != nil { + cfg.Publish = &c.publish.inner + } + if c.consume != nil { + cfg.Consume = &c.consume.inner + } + return cfg, nil } // Backoff is the retry pacing for automatic reconnects: the delay starts at // Initial, multiplies by Multiplier after each failed attempt, and caps at Max. // After Timeout of consecutive failures the connection gives up for good. // -// Every field is optional: the zero value means the default beside it, so a -// partial Backoff overrides only what it sets. Pass RetryForever as Timeout to -// keep retrying indefinitely. +// Every field is optional: the zero value means the native default (1s, x2, 5s, +// and a 10s window), so a partial Backoff overrides only what it sets. Pass +// RetryForever as Timeout to keep retrying indefinitely. type Backoff struct { - Initial time.Duration // delay before the first retry (default 1s) - Multiplier uint32 // applied to the delay after each failure (default 2) - Max time.Duration // ceiling on the delay (default 5s) - Timeout time.Duration // give up after this long (default 10s) + Initial time.Duration // delay before the first retry + Multiplier uint32 // applied to the delay after each failure + Max time.Duration // ceiling on the delay + Timeout time.Duration // give up after this long } // RetryForever, passed as Backoff.Timeout, keeps a reconnecting session retrying // indefinitely instead of giving up. const RetryForever time.Duration = -1 -const ( - defaultBackoffInitial = time.Second - defaultBackoffMultiplier = 2 - defaultBackoffMax = 5 * time.Second - defaultBackoffTimeout = 10 * time.Second -) - -// ffi resolves the unset fields, which is load-bearing rather than cosmetic: -// the native side reads a zero timeout as "retry forever" and a zero delay as -// no pacing at all, so passing Go's zero value straight through would turn +// ffi leaves the zero fields unset, which is load-bearing rather than cosmetic: +// the native side reads a zero timeout as "retry forever" and a zero delay as no +// pacing at all, so passing Go's zero value straight through would turn // Backoff{} into an unthrottled dial loop. -func (b Backoff) ffi() ffi.MoqBackoff { - multiplier := b.Multiplier - if multiplier == 0 { - multiplier = defaultBackoffMultiplier +func (b Backoff) ffi() (ffi.MoqBackoff, error) { + var out ffi.MoqBackoff + var err error + if out.InitialUs, err = backoffMicros("backoff initial", b.Initial); err != nil { + return out, err + } + if out.MaxUs, err = backoffMicros("backoff max", b.Max); err != nil { + return out, err + } + if b.Multiplier != 0 { + out.Multiplier = &b.Multiplier } - // Zero is the native encoding of "forever" and also Go's zero value, so the - // two are spelled apart here: Backoff{} keeps the documented default and - // forever is explicit at the call site. - timeoutUs := backoffUs(b.Timeout, defaultBackoffTimeout) + // two are spelled apart here: Backoff{} keeps the default and forever is + // explicit at the call site. if b.Timeout == RetryForever { - timeoutUs = 0 - } - - return ffi.MoqBackoff{ - InitialUs: backoffUs(b.Initial, defaultBackoffInitial), - Multiplier: multiplier, - MaxUs: backoffUs(b.Max, defaultBackoffMax), - TimeoutUs: timeoutUs, + forever := uint64(0) + out.TimeoutUs = &forever + } else if out.TimeoutUs, err = backoffMicros("backoff timeout", b.Timeout); err != nil { + return out, err } + return out, nil } -// backoffUs converts d to microseconds, substituting def when it is unset or -// negative (a negative would wrap when cast to uint64) and flooring at 1us so a -// sub-microsecond duration doesn't truncate to an unpaced zero. -func backoffUs(d, def time.Duration) uint64 { - if d <= 0 { - d = def +// backoffMicros leaves a zero duration unset and floors a positive one at 1us, +// so a sub-microsecond duration doesn't truncate to an unpaced zero. +func backoffMicros(name string, d time.Duration) (*uint64, error) { + if d == 0 { + return nil, nil } - us := d.Microseconds() - if us < 1 { - us = 1 + us, err := micros(name, d) + if err != nil { + return nil, err } - return uint64(us) + us = max(us, 1) + return &us, nil } // WithTLSVerify toggles TLS certificate verification. Verification is on by // default; pass false only against a relay with a self-signed certificate // during development. func WithTLSVerify(verify bool) ClientOption { - return func(c *clientConfig) { c.tlsVerify = verify } + return func(c *clientConfig) { c.tlsInsecure = !verify } } // WithTLSRoots trusts PEM root certificate files instead of the system roots. func WithTLSRoots(paths ...string) ClientOption { roots := append([]string(nil), paths...) - return func(c *clientConfig) { - c.tlsRoots = roots - c.tlsRootsSet = true - } + return func(c *clientConfig) { c.tlsRoots = roots } } // WithTLSSystemRoots controls whether platform roots are trusted with custom roots. func WithTLSSystemRoots(systemRoots bool) ClientOption { - return func(c *clientConfig) { - c.tlsSystemRoots = systemRoots - c.tlsSystemRootsSet = true - } + return func(c *clientConfig) { c.tlsSystemRoots = &systemRoots } } // WithTLSFingerprints pins the peer to one of these SHA-256 certificate fingerprints. func WithTLSFingerprints(fingerprints ...string) ClientOption { pins := append([]string(nil), fingerprints...) - return func(c *clientConfig) { - c.tlsFingerprints = pins - c.tlsFingerprintsSet = true - } + return func(c *clientConfig) { c.tlsFingerprints = pins } } // WithClientTLSCert sets the path to a PEM certificate chain for mTLS. @@ -146,6 +165,13 @@ func WithBind(addr string) ClientOption { return func(c *clientConfig) { c.bind = &addr } } +// WithVersions restricts the protocol versions offered, most preferred first, +// spelled like "moq-lite-03". By default every supported version is offered. +func WithVersions(versions ...string) ClientOption { + offered := append([]string(nil), versions...) + return func(c *clientConfig) { c.versions = offered } +} + // WithQUICMaxStreams caps the concurrent QUIC streams the peer may open toward // this connection (default 1024). MoQ opens a stream per group, and for a // subscriber those arrive from the relay, so a client subscribing to many tracks @@ -174,25 +200,25 @@ func WithWebSocketDelay(delay time.Duration) ClientOption { // consumed through it ride out the gap. Pass false for a one-shot dial whose // transport close ends the session. func WithReconnect(enabled bool) ClientOption { - return func(c *clientConfig) { c.reconnect = &enabled } + return func(c *clientConfig) { c.once = !enabled } } // WithBackoff sets retry pacing for the automatic reconnect. func WithBackoff(backoff Backoff) ClientOption { - return func(c *clientConfig) { c.backoff = &backoff } + return func(c *clientConfig) { c.backoff = backoff } } // WithPublishOrigin sets the origin whose broadcasts are published to the -// remote. Pair with WithSubscribeOrigin for full control; omit both to get a +// remote. Pair with WithConsumeOrigin for full control; omit both to get a // shared internal origin. func WithPublishOrigin(o *OriginProducer) ClientOption { return func(c *clientConfig) { c.publish = o } } -// WithSubscribeOrigin sets the origin that receives broadcasts consumed from +// WithConsumeOrigin sets the origin that receives broadcasts consumed from // the remote. -func WithSubscribeOrigin(o *OriginProducer) ClientOption { - return func(c *clientConfig) { c.subscribe = o } +func WithConsumeOrigin(o *OriginProducer) ClientOption { + return func(c *clientConfig) { c.consume = o } } // Client is a connected MoQ client with automatic origin wiring. When no origin @@ -209,67 +235,20 @@ type Client struct { // Dial connects to a MoQ server and returns the established client. Cancel ctx // to abort an in-flight connect. func Dial(ctx context.Context, url string, opts ...ClientOption) (*Client, error) { - // Verification is on unless WithTLSVerify(false) says otherwise; the zero - // value would mean the opposite. - cfg := clientConfig{tlsVerify: true} + var cfg clientConfig for _, opt := range opts { opt(&cfg) } - - c := &Client{} - inner := ffi.NewMoqClient() - var err error - if !cfg.tlsVerify { - err = inner.SetTlsVerify(false) - } - if err == nil && cfg.tlsRootsSet { - err = inner.SetTlsRoots(cfg.tlsRoots) - } - if err == nil && cfg.tlsSystemRootsSet { - err = inner.SetTlsSystemRoots(cfg.tlsSystemRoots) - } - if err == nil && cfg.tlsFingerprintsSet { - err = inner.SetTlsFingerprints(cfg.tlsFingerprints) - } - if err == nil && cfg.tlsCert != nil { - err = inner.SetTlsCert(cfg.tlsCert) - } - if err == nil && cfg.tlsKey != nil { - err = inner.SetTlsKey(cfg.tlsKey) - } - if err == nil && cfg.bind != nil { - err = inner.SetBind(*cfg.bind) - } - if err == nil && cfg.quicMaxStreams != nil { - err = inner.SetQuicMaxStreams(*cfg.quicMaxStreams) - } - if err == nil && cfg.websocketEnabled != nil { - err = inner.SetWebsocketEnabled(*cfg.websocketEnabled) - } - if err == nil && cfg.websocketDelay != nil { - if *cfg.websocketDelay < 0 { - err = fmt.Errorf("negative websocket delay: %v", *cfg.websocketDelay) - } else { - err = inner.SetWebsocketDelay(uint64(cfg.websocketDelay.Microseconds())) - } - } - if err == nil && cfg.reconnect != nil { - err = inner.SetReconnect(*cfg.reconnect) - } - if err == nil && cfg.backoff != nil { - err = inner.SetBackoff(cfg.backoff.ffi()) - } - if err == nil && cfg.publish != nil { - err = inner.SetPublish(&cfg.publish.inner) - } - if err == nil && cfg.subscribe != nil { - err = inner.SetConsume(&cfg.subscribe.inner) + config, err := cfg.ffi() + if err != nil { + return nil, err } + + inner, err := ffi.NewMoqClient(config) if err != nil { - inner.Cancel() return nil, err } - c.inner = inner + c := &Client{inner: inner} session, err := bridge.CallHandle(ctx, inner.Cancel, func(ctx context.Context) (*ffi.MoqSession, error) { return inner.Connect(ctx, url) diff --git a/go/wrapper/doc.go b/go/wrapper/doc.go index 18f04b1c18..867586ccbb 100644 --- a/go/wrapper/doc.go +++ b/go/wrapper/doc.go @@ -4,7 +4,8 @@ // It wraps the raw UniFFI bindings in moq.dev/moq-ffi with // idiomatic Go: context.Context cancellation, Go error returns, and Go 1.23 // range-over-func iterators (iter.Seq2) for live streams. The raw record, -// enum, and small object types are re-exported here without the Moq prefix (see types.go), so +// enum, and small object types are re-exported here without the Moq prefix (see types.go), +// records with durations carry time.Duration (see records.go), so // most programs never need to import the ffi package directly. // // A typical full-duplex client wires a single origin as both publish source @@ -23,7 +24,7 @@ // owner. // // What a cancel tears down depends on the call. A one-shot call (a subscribe, a -// fetch, RequestBroadcast, Resolve, a producer's Used/Unused, Server.Accept) +// fetch, RequestBroadcast, Resolve, a TrackDemand's Used/Unused, Server.Accept) // aborts on its own and leaves the object it was made on usable, so the same // broadcast, producer, or server takes the next call. A stream read (any Next, // RecvGroup, ReadFrame, or the iterators over them) instead cancels the stream diff --git a/go/wrapper/errors.go b/go/wrapper/errors.go index 519a0bd8c9..caaf606d6a 100644 --- a/go/wrapper/errors.go +++ b/go/wrapper/errors.go @@ -53,7 +53,7 @@ var ( ErrCancelled = ffi.ErrMoqErrorCancelled // ErrClosed is returned when the session or stream has closed; IsShutdown treats it as a graceful stop. ErrClosed = ffi.ErrMoqErrorClosed - // ErrBusy is returned when a configuration setter races an in-flight connect, listen, or accept. + // ErrBusy is returned when a request setter or CertFingerprints races an in-flight accept. ErrBusy = ffi.ErrMoqErrorBusy // ErrConnect is returned when establishing a client session fails. ErrConnect = ffi.ErrMoqErrorConnect @@ -83,6 +83,8 @@ var ( ErrUnresolvableBroadcast = ffi.ErrMoqErrorUnresolvableBroadcast // ErrLog is returned when installing or configuring the native log subscriber fails. ErrLog = ffi.ErrMoqErrorLog + // ErrConfig is returned when Dial or Listen is given a value the native side cannot use. + ErrConfig = ffi.ErrMoqErrorConfig ) // IsShutdown reports whether err is the expected result of a graceful shutdown diff --git a/go/wrapper/errors_test.go b/go/wrapper/errors_test.go index aa5fe25d66..0a384a2732 100644 --- a/go/wrapper/errors_test.go +++ b/go/wrapper/errors_test.go @@ -66,6 +66,7 @@ func TestErrorSentinels(t *testing.T) { {"UnresolvableBroadcast", ffi.NewMoqErrorUnresolvableBroadcast(""), moq.ErrUnresolvableBroadcast}, {"AlreadyCommitted", ffi.NewMoqErrorAlreadyCommitted(), moq.ErrAlreadyCommitted}, {"Log", ffi.NewMoqErrorLog(""), moq.ErrLog}, + {"Config", ffi.NewMoqErrorConfig(""), moq.ErrConfig}, } for _, test := range tests { diff --git a/go/wrapper/example_test.go b/go/wrapper/example_test.go index 2c1987856b..bda50aff4f 100644 --- a/go/wrapper/example_test.go +++ b/go/wrapper/example_test.go @@ -85,7 +85,7 @@ func ExampleClient_Session_stats() { defer client.Close() stats := client.Session().Stats() - fmt.Println("rtt:", stats.RttUs) + fmt.Println("rtt:", stats.RTT) } // Publish a video track with catalog hints known before the first keyframe. @@ -119,7 +119,7 @@ func ExampleListen() { } // Drive the accept loop directly to decide which sessions to admit. -func ExampleServer_Requests() { +func ExampleServer_All() { ctx := context.Background() server, err := moq.Listen(ctx, "127.0.0.1:4443", moq.WithTLSGenerate("localhost")) @@ -128,7 +128,7 @@ func ExampleServer_Requests() { } defer server.Close() - for req, err := range server.Requests(ctx) { + for req, err := range server.All(ctx) { if err != nil { if moq.IsShutdown(err) { break diff --git a/go/wrapper/internal/bridge/call.go b/go/wrapper/internal/bridge/call.go index b41e133a0d..7e1f9a848e 100644 --- a/go/wrapper/internal/bridge/call.go +++ b/go/wrapper/internal/bridge/call.go @@ -123,7 +123,7 @@ func releaseHandle[T Handle](val T) { // 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) { +// for frame, err := range consumer.All(ctx) { // if err != nil { // if moq.IsShutdown(err) { break } // return err diff --git a/go/wrapper/json/json.go b/go/wrapper/json/json.go index 4fd88a7e34..ff217590ca 100644 --- a/go/wrapper/json/json.go +++ b/go/wrapper/json/json.go @@ -171,8 +171,8 @@ func (c *SnapshotConsumer) Next(ctx context.Context) (*stdjson.RawMessage, error 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] { +// All ranges over reconstructed JSON values until the track ends. +func (c *SnapshotConsumer) All(ctx context.Context) iter.Seq2[*stdjson.RawMessage, error] { return bridge.Seq(ctx, c.Next) } @@ -209,8 +209,8 @@ func (c *StreamConsumer) Next(ctx context.Context) (*stdjson.RawMessage, error) 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] { +// All ranges over every JSON record until the track ends. +func (c *StreamConsumer) All(ctx context.Context) iter.Seq2[*stdjson.RawMessage, error] { return bridge.Seq(ctx, c.Next) } diff --git a/go/wrapper/json/json_test.go b/go/wrapper/json/json_test.go index dbc08b03e7..f5105f420f 100644 --- a/go/wrapper/json/json_test.go +++ b/go/wrapper/json/json_test.go @@ -115,8 +115,8 @@ func TestProducerTakesTheTrack(t *testing.T) { t.Fatal(err) } defer snapshot.Finish() - if _, err := track.Name(); !errors.Is(err, moq.ErrClosed) { - t.Fatalf("Name after take = %v, want ErrClosed", err) + if _, err := track.Demand(); !errors.Is(err, moq.ErrClosed) { + t.Fatalf("Demand after take = %v, want ErrClosed", err) } } diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index f5c0b77226..93e7f359a6 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -109,14 +109,14 @@ func TestDynamicBroadcastRequest(t *testing.T) { defer trackConsumer.Cancel() payload := []byte("served dynamically") - if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 0}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: payload}); err != nil { t.Fatal(err) } frame, err := trackConsumer.ReadFrame(ctx) if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || frame.TimestampUs != 0 { + if frame == nil || string(frame.Payload) != string(payload) || frame.Timestamp != 0 { t.Fatalf("frame = %+v, want payload=%q ts=0", frame, payload) } @@ -137,7 +137,7 @@ func TestPublishAudioLifecycle(t *testing.T) { if err != nil { t.Fatal(err) } - if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), TimestampUs: 1000}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), Timestamp: 1000 * time.Microsecond}); err != nil { t.Fatal(err) } if err := media.Finish(); err != nil { @@ -409,7 +409,7 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if err != nil { t.Fatal(err) } - if err := cached.WriteFrame(moq.Frame{Payload: []byte("cached"), TimestampUs: 0}); err != nil { + if err := cached.WriteFrame(moq.Frame{Payload: []byte("cached")}); err != nil { t.Fatal(err) } if err := cached.Finish(); err != nil { @@ -450,7 +450,7 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if err != nil { t.Fatal(err) } - if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), TimestampUs: request.Sequence() * 20_000}); err != nil { + if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: time.Duration(request.Sequence() * 20_000) * time.Microsecond}); err != nil { t.Fatal(err) } if err := produced.Finish(); err != nil { @@ -539,7 +539,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { defer mediaConsumer.Cancel() payload := []byte("opus audio payload data") - if err := media.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 1_000_000}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: payload, Timestamp: 1_000_000 * time.Microsecond}); err != nil { t.Fatal(err) } @@ -573,7 +573,7 @@ func TestTrackPublishConsume(t *testing.T) { } defer consumer.Cancel() - if err := track.WriteFrame(moq.Frame{Payload: []byte("hello"), TimestampUs: 12_345}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: []byte("hello"), Timestamp: 12_345 * time.Microsecond}); err != nil { t.Fatal(err) } @@ -584,7 +584,7 @@ func TestTrackPublishConsume(t *testing.T) { if frame == nil { t.Fatal("expected a frame") } - if string(frame.Payload) != "hello" || frame.TimestampUs != 12_345 { + if string(frame.Payload) != "hello" || frame.Timestamp != 12_345*time.Microsecond { t.Fatalf("frame = %+v, want payload=hello ts=12345", frame) } @@ -597,7 +597,7 @@ func TestTrackPublishConsume(t *testing.T) { t.Fatal(err) } defer groupConsumer.Cancel() - if err := group.WriteFrame(moq.Frame{Payload: []byte("group"), TimestampUs: 23_456}); err != nil { + if err := group.WriteFrame(moq.Frame{Payload: []byte("group"), Timestamp: 23_456 * time.Microsecond}); err != nil { t.Fatal(err) } if err := group.Finish(); err != nil { @@ -610,7 +610,7 @@ func TestTrackPublishConsume(t *testing.T) { if frame == nil { t.Fatal("expected a group frame") } - if string(frame.Payload) != "group" || frame.TimestampUs != 23_456 { + if string(frame.Payload) != "group" || frame.Timestamp != 23_456*time.Microsecond { t.Fatalf("frame = %+v, want payload=group ts=23456", frame) } } @@ -640,7 +640,7 @@ func TestReadFrameSkipsEmptyThenPopulatedGroups(t *testing.T) { if err := empty.Finish(); err != nil { t.Fatal(err) } - if err := track.WriteFrame(moq.Frame{Payload: []byte("populated"), TimestampUs: 2_000}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: []byte("populated"), Timestamp: 2_000 * time.Microsecond}); err != nil { t.Fatal(err) } @@ -648,7 +648,7 @@ func TestReadFrameSkipsEmptyThenPopulatedGroups(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != "populated" || frame.TimestampUs != 2_000 { + if frame == nil || string(frame.Payload) != "populated" || frame.Timestamp != 2_000*time.Microsecond { t.Fatalf("frame = %+v, want payload=populated ts=2000", frame) } } @@ -738,7 +738,7 @@ func TestDynamicTrackRequest(t *testing.T) { t.Fatal(err) } payload := []byte("hello dynamic track") - if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 0}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: payload}); err != nil { t.Fatal(err) } @@ -758,7 +758,7 @@ func TestDynamicTrackRequest(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || frame.TimestampUs != 0 { + if frame == nil || string(frame.Payload) != string(payload) || frame.Timestamp != 0 { t.Fatalf("frame = %+v, want payload=%q ts=0", frame, payload) } if err := track.Finish(); err != nil { @@ -837,7 +837,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { defer mediaConsumer.Cancel() payload := []byte("dynamic opus frame") - if err := media.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 20_000}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: payload, Timestamp: 20_000 * time.Microsecond}); err != nil { t.Fatal(err) } @@ -1062,7 +1062,7 @@ func TestSubscribeTrackCancelKeepsTheBroadcast(t *testing.T) { } } -// TestUsedCancelKeepsTheTrack cancels a producer-side Used wait, which has no +// TestUsedCancelKeepsTheTrack cancels a demand Used wait, which has no // object-wide cancel to fall back on, and confirms the track still publishes. func TestUsedCancelKeepsTheTrack(t *testing.T) { broadcast, err := moq.NewBroadcastProducer() @@ -1075,10 +1075,14 @@ func TestUsedCancelKeepsTheTrack(t *testing.T) { if err != nil { t.Fatal(err) } + demand, err := track.Demand() + if err != nil { + t.Fatal(err) + } ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() - if err := track.Used(ctx); !errors.Is(err, context.DeadlineExceeded) { + if err := demand.Used(ctx); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Used error = %v, want context.DeadlineExceeded", err) } @@ -1090,12 +1094,12 @@ func TestUsedCancelKeepsTheTrack(t *testing.T) { t.Fatal(err) } defer consumer.Cancel() - if err := track.Used(readCtx); err != nil { + if err := demand.Used(readCtx); err != nil { t.Fatal(err) } payload := []byte("still publishing") - if err := track.WriteFrame(moq.Frame{Payload: payload, TimestampUs: 0}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: payload}); err != nil { t.Fatal(err) } frame, err := consumer.ReadFrame(readCtx) diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 38bab4fe90..73bd7815be 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -80,9 +80,9 @@ func (d *OriginDynamic) RequestedBroadcast(ctx context.Context) (*BroadcastReque return &BroadcastRequest{inner: inner}, nil } -// Requests ranges over requested broadcasts until the stream errors or the loop +// All ranges over requested broadcasts until the stream errors or the loop // breaks. -func (d *OriginDynamic) Requests(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { +func (d *OriginDynamic) All(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { return bridge.Seq(ctx, d.RequestedBroadcast) } diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 2d2e635e58..80edbcc2d1 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -233,7 +233,11 @@ func (b *BroadcastProducer) EncodeVideo(input VideoEncoderInput, output VideoEnc // codec validation. info sets track properties (priority, cache, timescale); // pass nil for defaults. func (b *BroadcastProducer) PublishTrack(name string, info *TrackInfo) (*TrackProducer, error) { - inner, err := b.inner.PublishTrack(name, info) + ffiInfo, err := trackInfoFFI(info) + if err != nil { + return nil, err + } + inner, err := b.inner.PublishTrack(name, ffiInfo) if err != nil { return nil, err } @@ -283,8 +287,8 @@ func (d *BroadcastDynamic) RequestedTrack(ctx context.Context) (*TrackRequest, e return &TrackRequest{inner: inner}, nil } -// Requests ranges over subscriber-requested tracks until the dynamic source ends. -func (d *BroadcastDynamic) Requests(ctx context.Context) iter.Seq2[*TrackRequest, error] { +// All ranges over subscriber-requested tracks until the dynamic source ends. +func (d *BroadcastDynamic) All(ctx context.Context) iter.Seq2[*TrackRequest, error] { return bridge.Seq(ctx, d.RequestedTrack) } @@ -314,7 +318,11 @@ func (r *TrackRequest) Dynamic() (*TrackDynamic, error) { // Accept accepts the request as a raw track. For media, use PublishAudioOnTrack or PublishVideoOnTrack. func (r *TrackRequest) Accept(info *TrackInfo) (*TrackProducer, error) { - inner, err := r.inner.Accept(info) + ffiInfo, err := trackInfoFFI(info) + if err != nil { + return nil, err + } + inner, err := r.inner.Accept(ffiInfo) if err != nil { return nil, err } @@ -358,7 +366,11 @@ func (m *MediaProducer) Unused(ctx context.Context) error { // WriteFrame appends frame to the media track. The importer derives keyframe status from // the bitstream, so a Frame carries only the payload and its timestamp. func (m *MediaProducer) WriteFrame(frame Frame) error { - return m.inner.WriteFrame(frame) + f, err := frame.ffi() + if err != nil { + return err + } + return m.inner.WriteFrame(f) } // Flush records a local encoder's frame handoff on the broadcast media clock. @@ -492,12 +504,7 @@ type TrackProducer struct { inner *ffi.MoqTrackProducer } -// Name is the track name. -func (t *TrackProducer) Name() (string, error) { - return t.inner.Name() -} - -// Demand returns a watch-only handle to whether the track has subscribers. +// Demand returns a watch-only handle to the track's name and whether it has subscribers. func (t *TrackProducer) Demand() (*TrackDemand, error) { inner, err := t.inner.Demand() if err != nil { @@ -506,16 +513,6 @@ func (t *TrackProducer) Demand() (*TrackDemand, error) { return &TrackDemand{inner: inner}, nil } -// Used blocks until the track has at least one active subscriber. Prefer Demand. -func (t *TrackProducer) Used(ctx context.Context) error { - return t.inner.Used(ctx) -} - -// Unused blocks until the track has no active subscribers. Prefer Demand. -func (t *TrackProducer) Unused(ctx context.Context) error { - return t.inner.Unused(ctx) -} - // Dynamic serves fetches for groups that are not currently cached. func (t *TrackProducer) Dynamic() (*TrackDynamic, error) { inner, err := t.inner.Dynamic() @@ -545,13 +542,21 @@ func (t *TrackProducer) CreateGroup(sequence uint64) (*GroupProducer, error) { // WriteFrame writes frame as a single-frame group. func (t *TrackProducer) WriteFrame(frame Frame) error { - return t.inner.WriteFrame(frame) + f, err := frame.ffi() + if err != nil { + return err + } + return t.inner.WriteFrame(f) } // AppendDatagram sends frame as a best-effort datagram and returns the sequence number // assigned to it. Payloads are capped at 1200 bytes. There is no stream fallback. func (t *TrackProducer) AppendDatagram(frame Frame) (uint64, error) { - return t.inner.AppendDatagram(frame) + f, err := frame.ffi() + if err != nil { + return 0, err + } + return t.inner.AppendDatagram(f) } // Abort closes the track with an application error code. @@ -562,7 +567,11 @@ func (t *TrackProducer) Abort(errorCode uint16) error { // Consume reads directly from this producer's track. subscription tunes delivery // (delivery priority, group range); pass nil for defaults. func (t *TrackProducer) Consume(subscription *Subscription) (*TrackConsumer, error) { - inner, err := t.inner.Consume(subscription) + sub, err := subscriptionFFI(subscription) + if err != nil { + return nil, err + } + inner, err := t.inner.Consume(sub) if err != nil { return nil, err } @@ -601,7 +610,11 @@ func (g *GroupProducer) Consume() (*GroupConsumer, error) { // WriteFrame appends frame to the group. func (g *GroupProducer) WriteFrame(frame Frame) error { - return g.inner.WriteFrame(frame) + f, err := frame.ffi() + if err != nil { + return err + } + return g.inner.WriteFrame(f) } // Finish marks the group complete. The handle remains so Abort can still run. @@ -628,8 +641,8 @@ func (d *TrackDynamic) RequestedGroup(ctx context.Context) (*GroupRequest, error return &GroupRequest{inner: inner}, nil } -// Requests ranges over uncached group requests until the dynamic source ends. -func (d *TrackDynamic) Requests(ctx context.Context) iter.Seq2[*GroupRequest, error] { +// All ranges over uncached group requests until the dynamic source ends. +func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*GroupRequest, error] { return bridge.Seq(ctx, d.RequestedGroup) } diff --git a/go/wrapper/reconnect_test.go b/go/wrapper/reconnect_test.go index 89378ebda3..36d6d9fb10 100644 --- a/go/wrapper/reconnect_test.go +++ b/go/wrapper/reconnect_test.go @@ -37,7 +37,7 @@ func startRelay(t *testing.T, ctx context.Context, addr string) *relay { r := &relay{server: server, addr: server.LocalAddr()} go func() { - for req, err := range server.Requests(ctx) { + for req, err := range server.All(ctx) { if err != nil { return } diff --git a/go/wrapper/records.go b/go/wrapper/records.go new file mode 100644 index 0000000000..de2e47caed --- /dev/null +++ b/go/wrapper/records.go @@ -0,0 +1,150 @@ +package moq + +import ( + "fmt" + "time" + + ffi "moq.dev/moq-ffi/moq" +) + +// moq-ffi spells every duration as integer microseconds, because not every +// target language has a duration type. These records mirror the generated ones +// with time.Duration fields and convert at the boundary. + +// micros converts d to microseconds, refusing a negative duration rather than +// letting it wrap when cast to uint64. +func micros(name string, d time.Duration) (uint64, error) { + if d < 0 { + return 0, fmt.Errorf("negative %s: %v", name, d) + } + return uint64(d.Microseconds()), nil +} + +func optMicros(name string, d *time.Duration) (*uint64, error) { + if d == nil { + return nil, nil + } + us, err := micros(name, *d) + return &us, err +} + +func fromMicros(us uint64) time.Duration { + return time.Duration(us) * time.Microsecond +} + +func optFromMicros(us *uint64) *time.Duration { + if us == nil { + return nil + } + d := fromMicros(*us) + return &d +} + +// Frame is a raw track frame: a payload and its presentation timestamp. +type Frame struct { + Payload []byte + Timestamp time.Duration +} + +func (f Frame) ffi() (ffi.MoqFrame, error) { + us, err := micros("timestamp", f.Timestamp) + return ffi.MoqFrame{Payload: f.Payload, TimestampUs: us}, err +} + +func frameFromFFI(f *ffi.MoqFrame) *Frame { + if f == nil { + return nil + } + return &Frame{Payload: f.Payload, Timestamp: fromMicros(f.TimestampUs)} +} + +// Datagram is a best-effort track datagram as received: sequence number, timestamp, and payload. +type Datagram struct { + Sequence uint64 + Timestamp time.Duration + Payload []byte +} + +func datagramFromFFI(d *ffi.MoqDatagram) *Datagram { + if d == nil { + return nil + } + return &Datagram{Sequence: d.Sequence, Timestamp: fromMicros(d.TimestampUs), Payload: d.Payload} +} + +// Subscription holds subscriber-side delivery preferences: priority, the max age +// of a non-latest group before it is skipped (zero skips at once), and an +// optional group range (GroupStart a floor, GroupEnd exclusive). +type Subscription struct { + Priority uint8 + MaxAge time.Duration + GroupStart *uint64 + GroupEnd *uint64 +} + +func (s Subscription) ffi() (ffi.MoqSubscription, error) { + maxAge, err := micros("max age", s.MaxAge) + return ffi.MoqSubscription{ + Priority: s.Priority, + MaxAgeUs: maxAge, + GroupStart: s.GroupStart, + GroupEnd: s.GroupEnd, + }, err +} + +func subscriptionFFI(s *Subscription) (*ffi.MoqSubscription, error) { + if s == nil { + return nil, nil + } + out, err := s.ffi() + return &out, err +} + +// TrackInfo holds publisher-side track properties: priority, how long a +// non-latest group is cached (nil for no limit), and the timescale in ticks per +// second (nil for microseconds). +type TrackInfo struct { + Priority uint8 + MaxAge *time.Duration + Timescale *uint64 +} + +func trackInfoFFI(info *TrackInfo) (*ffi.MoqTrackInfo, error) { + if info == nil { + return nil, nil + } + maxAge, err := optMicros("max age", info.MaxAge) + return &ffi.MoqTrackInfo{Priority: info.Priority, MaxAgeUs: maxAge, Timescale: info.Timescale}, err +} + +func trackInfoFromFFI(info ffi.MoqTrackInfo) TrackInfo { + return TrackInfo{Priority: info.Priority, MaxAge: optFromMicros(info.MaxAgeUs), Timescale: info.Timescale} +} + +// ConnectionStats holds transport metrics for a session (RTT, bandwidth, byte +// and packet counters); each field is nil when unreported. +type ConnectionStats struct { + RTT *time.Duration + EstimatedSendRateBps *uint64 + EstimatedRecvRateBps *uint64 + BytesSent *uint64 + BytesReceived *uint64 + BytesLost *uint64 + PacketsSent *uint64 + PacketsReceived *uint64 + PacketsLost *uint64 +} + +func connectionStatsFromFFI(s ffi.MoqConnectionStats) ConnectionStats { + return ConnectionStats{ + RTT: optFromMicros(s.RttUs), + EstimatedSendRateBps: s.EstimatedSendRateBps, + EstimatedRecvRateBps: s.EstimatedRecvRateBps, + BytesSent: s.BytesSent, + BytesReceived: s.BytesReceived, + BytesLost: s.BytesLost, + PacketsSent: s.PacketsSent, + PacketsReceived: s.PacketsReceived, + PacketsLost: s.PacketsLost, + } +} diff --git a/go/wrapper/server.go b/go/wrapper/server.go index 70938eac53..c39fc2aa02 100644 --- a/go/wrapper/server.go +++ b/go/wrapper/server.go @@ -95,11 +95,13 @@ func (r *Request) Cancel() { type ServerOption func(*serverConfig) type serverConfig struct { - tlsCert []string - tlsKey []string - tlsGenerate []string - publish *OriginProducer - subscribe *OriginProducer + tlsCert []string + tlsKey []string + tlsGenerate []string + versions []string + quicMaxStreams *uint64 + publish *OriginProducer + consume *OriginProducer } // WithTLSCert sets paths to TLS certificate chains. @@ -117,24 +119,35 @@ func WithTLSGenerate(hostnames ...string) ServerOption { return func(c *serverConfig) { c.tlsGenerate = hostnames } } +// WithServerVersions restricts the protocol versions accepted, spelled like +// "moq-lite-03". By default every supported version is accepted. +func WithServerVersions(versions ...string) ServerOption { + accepted := append([]string(nil), versions...) + return func(c *serverConfig) { c.versions = accepted } +} + +// WithServerQUICMaxStreams caps the concurrent QUIC streams each peer may open +// toward this server (default 1024). +func WithServerQUICMaxStreams(maxStreams uint64) ServerOption { + return func(c *serverConfig) { c.quicMaxStreams = &maxStreams } +} + // WithServerPublishOrigin sets the origin whose broadcasts are served to // incoming sessions. Omit both origin options to get a shared internal origin. func WithServerPublishOrigin(o *OriginProducer) ServerOption { return func(c *serverConfig) { c.publish = o } } -// WithServerSubscribeOrigin sets the origin that receives broadcasts published +// WithServerConsumeOrigin sets the origin that receives broadcasts published // by incoming sessions. -func WithServerSubscribeOrigin(o *OriginProducer) ServerOption { - return func(c *serverConfig) { c.subscribe = o } +func WithServerConsumeOrigin(o *OriginProducer) ServerOption { + return func(c *serverConfig) { c.consume = o } } // Server accepts incoming sessions with automatic origin wiring. type Server struct { inner *ffi.MoqServer - origin *OriginProducer publishOrigin *OriginProducer - consumeOrigin *OriginProducer localAddr string closeOnce sync.Once } @@ -145,39 +158,34 @@ func Listen(ctx context.Context, bind string, opts ...ServerOption) (*Server, er for _, opt := range opts { opt(&cfg) } - - s := &Server{} - if cfg.publish == nil && cfg.subscribe == nil { - s.origin = NewOriginProducer() - s.publishOrigin = s.origin - s.consumeOrigin = s.origin - } else { - s.publishOrigin = cfg.publish - s.consumeOrigin = cfg.subscribe + if cfg.publish == nil && cfg.consume == nil { + shared := NewOriginProducer() + cfg.publish = shared + cfg.consume = shared } - inner := ffi.NewMoqServer() - err := inner.SetBind(bind) - if err == nil && len(cfg.tlsCert) > 0 { - err = inner.SetTlsCert(cfg.tlsCert) + config := ffi.MoqServerConfig{ + Bind: &bind, + Versions: cfg.versions, + Tls: ffi.MoqServerTls{ + Cert: cfg.tlsCert, + Key: cfg.tlsKey, + Generate: cfg.tlsGenerate, + }, + Quic: ffi.MoqQuicConfig{MaxStreams: cfg.quicMaxStreams}, } - if err == nil && len(cfg.tlsKey) > 0 { - err = inner.SetTlsKey(cfg.tlsKey) + if cfg.publish != nil { + config.Publish = &cfg.publish.inner } - if err == nil && len(cfg.tlsGenerate) > 0 { - err = inner.SetTlsGenerate(cfg.tlsGenerate) - } - if err == nil && s.publishOrigin != nil { - err = inner.SetPublish(&s.publishOrigin.inner) - } - if err == nil && s.consumeOrigin != nil { - err = inner.SetConsume(&s.consumeOrigin.inner) + if cfg.consume != nil { + config.Consume = &cfg.consume.inner } + + inner, err := ffi.NewMoqServer(config) if err != nil { - inner.Cancel() return nil, err } - s.inner = inner + s := &Server{inner: inner, publishOrigin: cfg.publish} addr, err := bridge.Call(ctx, inner.Cancel, inner.Listen) if err != nil { @@ -222,9 +230,9 @@ func (s *Server) Accept(ctx context.Context) (*Request, error) { return &Request{inner: *res}, nil } -// Requests ranges over incoming requests until the server stops or the loop +// All ranges over incoming requests until the server stops or the loop // breaks. Each request must be answered with Accept or Reject. -func (s *Server) Requests(ctx context.Context) iter.Seq2[*Request, error] { +func (s *Server) All(ctx context.Context) iter.Seq2[*Request, error] { return func(yield func(*Request, error) bool) { for { req, err := s.Accept(ctx) @@ -249,7 +257,7 @@ func (s *Server) Requests(ctx context.Context) iter.Seq2[*Request, error] { // // To inspect or reject requests, range over Requests instead: // -// for req, err := range server.Requests(ctx) { +// for req, err := range server.All(ctx) { // if err != nil { // return err // } diff --git a/go/wrapper/session.go b/go/wrapper/session.go index 5bcfe03fc8..2ca1a0d0ff 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -23,9 +23,9 @@ func (s *Session) Closed(ctx context.Context) error { } // Status blocks until the connection status differs from the one this session -// last reported. A client session reports StatusConnected first (the connect it -// was built from), then follows the reconnect loop: StatusDisconnected while -// redialing, StatusMigrating during a GOAWAY handover. It returns an error once +// last reported. A client session reports ConnectionStatusConnected first (the connect it +// was built from), then follows the reconnect loop: ConnectionStatusDisconnected while +// redialing, ConnectionStatusMigrating during a GOAWAY handover. It returns an error once // the connection stops for good. A server-accepted session's only transition is // terminal, so Status waits for the close and returns its reason. Cancelling ctx // gives up waiting and shuts the session down. @@ -40,7 +40,7 @@ func (s *Session) Status(ctx context.Context) (ConnectionStatus, error) { // Epoch is the connection epoch: 1 for the connect that built this session, one // more on each reconnect. A server-accepted session stays at 1. // -// Pair it with Status to log each reconnect by number: a StatusConnected whose +// Pair it with Status to log each reconnect by number: a ConnectionStatusConnected whose // Epoch grew is a reconnect. Like Status, it reports the current state, so a // drop that reconnects between reads is coalesced away. func (s *Session) Epoch() uint64 { @@ -49,7 +49,7 @@ func (s *Session) Epoch() uint64 { // Stats snapshots the current connection statistics. func (s *Session) Stats() ConnectionStats { - return s.inner.Stats() + return connectionStatsFromFFI(s.inner.Stats()) } // Bandwidth is the session's bandwidth allocator. Every call returns a handle diff --git a/go/wrapper/subscribe.go b/go/wrapper/subscribe.go index ed79ef0d17..ec00d8ba98 100644 --- a/go/wrapper/subscribe.go +++ b/go/wrapper/subscribe.go @@ -29,7 +29,11 @@ func (b *BroadcastConsumer) SubscribeTrack( name string, subscription *Subscription, ) (*TrackConsumer, error) { - inner, err := b.inner.SubscribeTrack(ctx, name, subscription) + sub, err := subscriptionFFI(subscription) + if err != nil { + return nil, err + } + inner, err := b.inner.SubscribeTrack(ctx, name, sub) if err != nil { return nil, err } @@ -70,7 +74,7 @@ func (b *BroadcastConsumer) FetchMediaGroup( // SubscribeMedia subscribes to a media track, decoded with the given container. // subscription tunes delivery priority, group range, and -// the max age; pass nil for defaults. Raise Subscription.MaxAgeUs to +// the max age; pass nil for defaults. Raise Subscription.MaxAge to // buffer instead of skipping a stalled group. func (b *BroadcastConsumer) SubscribeMedia( ctx context.Context, @@ -78,7 +82,11 @@ func (b *BroadcastConsumer) SubscribeMedia( container Container, subscription *Subscription, ) (*MediaConsumer, error) { - inner, err := b.inner.SubscribeMedia(ctx, name, container, subscription) + sub, err := subscriptionFFI(subscription) + if err != nil { + return nil, err + } + inner, err := b.inner.SubscribeMedia(ctx, name, container, sub) if err != nil { return nil, err } @@ -162,8 +170,8 @@ func (m *MediaConsumer) Next(ctx context.Context) (*MediaFrame, error) { 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] { +// All ranges over frames until the track ends or the loop breaks. +func (m *MediaConsumer) All(ctx context.Context) iter.Seq2[*MediaFrame, error] { return bridge.Seq(ctx, m.Next) } @@ -188,8 +196,8 @@ func (m *MediaGroupConsumer) Next(ctx context.Context) (*MediaFrame, error) { 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] { +// All ranges over decoded frames until the group ends or the loop breaks. +func (m *MediaGroupConsumer) All(ctx context.Context) iter.Seq2[*MediaFrame, error] { return bridge.Seq(ctx, m.Next) } @@ -210,11 +218,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 bridge.Call(ctx, g.inner.Cancel, g.inner.ReadFrame) + frame, err := bridge.Call(ctx, g.inner.Cancel, g.inner.ReadFrame) + return frameFromFFI(frame), err } -// Frames ranges over timestamped frames until the group ends or the loop breaks. -func (g *GroupConsumer) Frames(ctx context.Context) iter.Seq2[*Frame, error] { +// All ranges over timestamped frames until the group ends or the loop breaks. +func (g *GroupConsumer) All(ctx context.Context) iter.Seq2[*Frame, error] { return bridge.Seq(ctx, g.ReadFrame) } @@ -270,23 +279,35 @@ 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 bridge.Call(ctx, t.inner.Cancel, t.inner.ReadFrame) + frame, err := bridge.Call(ctx, t.inner.Cancel, t.inner.ReadFrame) + return frameFromFFI(frame), err } // 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 bridge.Call(ctx, t.inner.Cancel, t.inner.RecvDatagram) + datagram, err := bridge.Call(ctx, t.inner.Cancel, t.inner.RecvDatagram) + return datagramFromFFI(datagram), err } // Info returns the publisher-side track properties learned during subscription. func (t *TrackConsumer) Info() (TrackInfo, error) { - return t.inner.Info() + info, err := t.inner.Info() + if err != nil { + return TrackInfo{}, err + } + return trackInfoFromFFI(info), nil } -// Update changes this subscriber's delivery preferences. -func (t *TrackConsumer) Update(subscription Subscription) { - t.inner.Update(subscription) +// Update changes this subscriber's delivery preferences. It fails only on a +// negative MaxAge. +func (t *TrackConsumer) Update(subscription Subscription) error { + sub, err := subscription.ffi() + if err != nil { + return err + } + t.inner.Update(sub) + return nil } // Groups ranges over groups in sequence order. @@ -320,8 +341,8 @@ func (a *AudioConsumer) Next(ctx context.Context) (*AudioFrame, error) { 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] { +// All ranges over audio frames until the track ends or the loop breaks. +func (a *AudioConsumer) All(ctx context.Context) iter.Seq2[*AudioFrame, error] { return bridge.Seq(ctx, a.Next) } @@ -351,8 +372,8 @@ func (v *VideoConsumer) Next(ctx context.Context) (*VideoDecodedFrame, error) { return &VideoDecodedFrame{inner: res}, nil } -// Frames ranges over decoded frames until the track ends or the loop breaks. -func (v *VideoConsumer) Frames(ctx context.Context) iter.Seq2[*VideoDecodedFrame, error] { +// All ranges over decoded frames until the track ends or the loop breaks. +func (v *VideoConsumer) All(ctx context.Context) iter.Seq2[*VideoDecodedFrame, error] { return bridge.Seq(ctx, v.Next) } @@ -413,8 +434,8 @@ func (c *CatalogConsumer) Next(ctx context.Context) (*Catalog, error) { 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] { +// All ranges over catalog updates until the track ends or the loop breaks. +func (c *CatalogConsumer) All(ctx context.Context) iter.Seq2[*Catalog, error] { return bridge.Seq(ctx, c.Next) } diff --git a/go/wrapper/types.go b/go/wrapper/types.go index f227a8da59..821feb8cde 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -23,16 +23,10 @@ type ( AudioFrame = ffi.MoqAudioFrame // Catalog is a broadcast's manifest: its video and audio renditions plus display metadata. Catalog = ffi.MoqCatalog - // ConnectionStats holds transport metrics for a session (RTT, bandwidth, byte and packet counters); each field is nil when unreported. - ConnectionStats = ffi.MoqConnectionStats // ConnectionStatus is a connection lifecycle transition reported by Session.Status. ConnectionStatus = ffi.MoqConnectionStatus - // Datagram is a best-effort track datagram as received: sequence number, timestamp, and payload. - Datagram = ffi.MoqDatagram // Dimensions is a width and height in pixels. Dimensions = ffi.MoqDimensions - // Frame is a raw track frame: a payload and its presentation timestamp in microseconds. - Frame = ffi.MoqFrame // MediaFrame is a Frame plus a flag for group starts or video keyframes; audio flags only group starts. MediaFrame = ffi.MoqMediaFrame // ErrorScope is whether a protocol code is from the session or stream registry. @@ -62,10 +56,6 @@ type ( // AnnounceEventLive reports that every route live at subscribe time has been // delivered; what follows is live changes. Yielded once. AnnounceEventLive = ffi.MoqAnnounceEventLive - // Subscription holds subscriber-side delivery preferences: priority, ordering, max age, and group range. - Subscription = ffi.MoqSubscription - // TrackInfo holds publisher-side track properties: priority, ordering, max age, and timescale. - TrackInfo = ffi.MoqTrackInfo // Video describes one catalog rendition, including whether the publisher recommends temporarily avoiding it. Video = ffi.MoqVideo // VideoHint supplies catalog fields a video stream can't reveal itself, such as bitrate, filling only the gaps. @@ -246,12 +236,12 @@ func NamedEncoder(name string) VideoEncoderKind { // ConnectionStatus values: the lifecycle of a client session's connection. const ( - // StatusConnected means a session connected (the first connect, or a reconnect after a drop). - StatusConnected = ffi.MoqConnectionStatusConnected - // StatusDisconnected means the session dropped; a reconnect attempt follows. - StatusDisconnected = ffi.MoqConnectionStatusDisconnected - // StatusMigrating means the peer sent a GOAWAY; the replacement is being dialed while the old session keeps serving. - StatusMigrating = ffi.MoqConnectionStatusMigrating + // ConnectionStatusConnected means a session connected (the first connect, or a reconnect after a drop). + ConnectionStatusConnected = ffi.MoqConnectionStatusConnected + // ConnectionStatusDisconnected means the session dropped; a reconnect attempt follows. + ConnectionStatusDisconnected = ffi.MoqConnectionStatusDisconnected + // ConnectionStatusMigrating means the peer sent a GOAWAY; the replacement is being dialed while the old session keeps serving. + ConnectionStatusMigrating = ffi.MoqConnectionStatusMigrating ) // LogLevel configures the native tracing log level (e.g. "info", "debug"). From 4f348dc68c1e47c0a7689439d53008018e093973 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:17:46 -0700 Subject: [PATCH 5/9] refactor(kt)!: connect and listen take config records; announced(config).updates() replaces announcements Co-Authored-By: Claude Opus 5.5 --- doc/lib/kt/index.md | 27 +++--- kt/README.md | 6 +- .../kotlin/dev/moq/ffi/BindingsSmokeTest.kt | 3 +- .../kotlin/dev/moq/Aliases.kt | 14 ++- .../kotlin/dev/moq/Durations.kt | 22 +++-- .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 27 ++---- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 90 ++++--------------- .../kotlin/dev/moq/Server.kt | 47 ++++------ .../kotlin/dev/moq/SmokeTest.kt | 69 ++++++-------- 9 files changed, 115 insertions(+), 190 deletions(-) diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 6021b3bce3..d32ee1e2e3 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -24,8 +24,8 @@ dependencies { import dev.moq.* // Subscribe. The Flow is live, so run it in its own coroutine. -Moq.connect("https://relay.example.com", tlsRoots = listOf("ca.pem")).use { moq -> - moq.announcements(AnnounceConfig(prefix = "live/", filter = "*/camera")).collect { event -> +Moq.connect("https://relay.example.com", ClientConfig(tls = ClientTls(roots = listOf("ca.pem")))).use { moq -> + moq.announced(AnnounceConfig(prefix = "live/", filter = "*/camera")).updates().collect { event -> if (event !is AnnounceEventStart) return@collect // Update, End, or Live // Prefixes stay origin-relative; captures reports what each wildcard matched. println(event.announce.captures) @@ -64,7 +64,7 @@ is a no-op; Kotlin spells it `end` because `close()`, or `use { }`, releases the handle, which ends the broadcast only once no `dynamic()` handle remains); `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not -serve. A route is a capability, not an inventory. `announcements(config)` takes +serve. A route is a capability, not an inventory. `announced(config).updates()` takes a literal prefix plus an optional relative pattern and yields `AnnounceEvent`s: `AnnounceEventStart`, `AnnounceEventUpdate`, or `AnnounceEventEnd` carrying an `Announce`, whose `prefix` stays origin-relative and whose @@ -74,25 +74,26 @@ a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-br Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `moq.epoch()` counts the connections, 1 on the first, pairing with -`MoqSession.status` to log each reconnect; the `backoff` argument tunes the -pacing (`timeoutUs = 0` retries forever); and `maxStreams` raises the peer's -inbound stream cap. +`MoqSession.status` to log each reconnect. `Moq.connect(url, config)` takes a +`ClientConfig`: `backoff` tunes the pacing (`timeoutUs = 0uL` retries forever), +`quic = QuicConfig(maxStreams = ...)` raises the peer's inbound stream cap, +`versions` pins the protocol versions offered, and `once = true` dials once. A +value the native side cannot use throws `MoqException.Config`. The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after -a 200 ms head start. `Moq.connect(websocketEnabled = false)` turns it off for a -QUIC-only relay, and a `websocketDelay` `Duration` changes the head start. +a 200 ms head start. `WebSocketConfig(enabled = false)` turns it off for a +QUIC-only relay, and `delayUs` changes the head start. -`Server.listen(bind, tlsGenerate = ...)` accepts sessions with per-request -`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. +`Server.listen(ServerConfig(bind = ..., tls = ServerTls(generate = ...)))` accepts +sessions with per-request `accept()`/`reject()`. `MoqRequest.setPublish`/`setConsume` +throw if an accept is in flight, after a response, or after cancel. `MoqRequest.transport()` returns a `Transport` enum. 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 +`setCatalogSection`, `demand()` for `name()`, `used()`, and `unused()`. `session.bandwidth()` divides the connection's send estimate; pass it to `encodeVideo` / `encodeAudio` or `reserve` a share for an app-owned track. `MoqException.isAuth` and `isShutdown` classify errors. Microsecond fields read back as a diff --git a/kt/README.md b/kt/README.md index af610b0b08..771362e128 100644 --- a/kt/README.md +++ b/kt/README.md @@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.collect // connect() wires up an internal origin and returns a live connection. Moq.connect("https://relay.example.com").use { moq -> - moq.announcements(AnnounceConfig(prefix = "demos/", filter = "*/camera")).collect { event -> + moq.announced(AnnounceConfig(prefix = "demos/", filter = "*/camera")).updates().collect { event -> // AnnounceEventLive follows the routes live at subscribe time. if (event !is AnnounceEventStart) return@collect // Prefix stays origin-relative; captures reports what * matched. @@ -42,7 +42,7 @@ Moq.connect("https://relay.example.com").use { moq -> } ``` -`Moq.connect` builds the `MoqClient`, applies TLS / bind options, wires the publish + subscribe origins, and hands back a `Moq` you can `use {}`. Cancelling the surrounding coroutine scope propagates through the Flow extensions to the native consumer's `cancel()` via their `onCompletion` hook. +`Moq.connect(url, ClientConfig(...))` builds the `MoqClient` from the config record, wires the publish + consume origins, and hands back a `Moq` you can `use {}`. Cancelling the surrounding coroutine scope propagates through the Flow extensions to the native consumer's `cancel()` via their `onCompletion` hook. ### What the wrapper adds @@ -50,7 +50,7 @@ The `dev.moq` package is intentionally thin: Kotlin has extension functions, so - **`Moq.connect(...)`**: a connection facade (`Moq.kt`), so you never hand-wire a `MoqClient`. - **Typealiases** (`Aliases.kt`): re-export the `Moq*`-prefixed FFI types under clean `dev.moq` names (`OriginProducer`, `BroadcastConsumer`, `Catalog`, `Frame`, ...), so you import `dev.moq.*` only. A couple of sealed types (`Container`, `MoqException`) are not aliased because Kotlin can't resolve their subtypes through a typealias; use `uniffi.moq.*` for those. -- **Flow extensions** (`Flows.kt`): `updates()`, `groups()`, `frames()`, `announcements()`, `catalog()` turn the pull-based consumers into coroutine `Flow`s with cancellation wired through. `frames()` covers the media, audio, and video consumers alike. +- **Flow extensions** (`Flows.kt`): `updates()`, `groups()`, `frames()`, `catalog()` turn the pull-based consumers into coroutine `Flow`s with cancellation wired through. `frames()` covers the media, audio, and video consumers alike. - **Fetched media**: `fetchMediaGroup(...).frames()` streams the decoded frames of one retained group, then completes. - **Duration extensions** (`Durations.kt`): the FFI carries microseconds as integers, so `stats.rtt`, `backoff.initial`, `frame.timestamp`, and their siblings read back as a `kotlin.time.Duration`. - **`logLevel(...)`**: configures native Rust tracing without importing the raw bindings package. diff --git a/kt/moq-ffi/src/jvmAndAndroidTest/kotlin/dev/moq/ffi/BindingsSmokeTest.kt b/kt/moq-ffi/src/jvmAndAndroidTest/kotlin/dev/moq/ffi/BindingsSmokeTest.kt index 08603e68bc..f7d12fa704 100644 --- a/kt/moq-ffi/src/jvmAndAndroidTest/kotlin/dev/moq/ffi/BindingsSmokeTest.kt +++ b/kt/moq-ffi/src/jvmAndAndroidTest/kotlin/dev/moq/ffi/BindingsSmokeTest.kt @@ -2,6 +2,7 @@ package dev.moq.ffi import kotlinx.coroutines.test.runTest import uniffi.moq.MoqClient +import uniffi.moq.MoqClientConfig import uniffi.moq.MoqException import uniffi.moq.MoqOriginConfig import uniffi.moq.MoqOriginProducer @@ -16,7 +17,7 @@ import kotlin.test.assertFailsWith class BindingsSmokeTest { @Test fun `client constructs and connect fails fast on a bad url`() = runTest { - MoqClient().use { client -> + MoqClient(MoqClientConfig()).use { client -> client.cancel() assertFailsWith { client.connect("https://localhost:0/test") diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index d0c0d55490..80316ca56b 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -9,8 +9,20 @@ package dev.moq // Session + connection handles. `Server` is not aliased: `dev.moq.Server` is the // listen facade (see Server.kt), which exposes the raw handle as `server`. -/** A MoQ client: configure the TLS/bind knobs, then connect to a relay. */ +/** A MoQ client built from a [ClientConfig]: connect it to a relay. */ typealias Client = uniffi.moq.MoqClient +/** Client configuration: bind address, versions, TLS, QUIC, WebSocket, reconnect pacing, and origins. */ +typealias ClientConfig = uniffi.moq.MoqClientConfig +/** Certificate trust and the mTLS identity for a [ClientConfig]. */ +typealias ClientTls = uniffi.moq.MoqClientTls +/** Server configuration: bind address, versions, TLS identity, QUIC, and origins. */ +typealias ServerConfig = uniffi.moq.MoqServerConfig +/** The served TLS identity for a [ServerConfig]: PEM files or generated hostnames. */ +typealias ServerTls = uniffi.moq.MoqServerTls +/** QUIC transport tuning, such as the peer's inbound stream cap. */ +typealias QuicConfig = uniffi.moq.MoqQuicConfig +/** The WebSocket fallback raced against QUIC: whether it runs and QUIC's head start. */ +typealias WebSocketConfig = uniffi.moq.MoqWebSocketConfig /** A live pub/sub session with a relay, exposing publish and consume origins. */ typealias Session = uniffi.moq.MoqSession /** An incoming session awaiting a decision: accept it to handshake, or reject it. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 6631d166e1..5484d85f77 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -13,17 +13,21 @@ import kotlin.time.Duration.Companion.microseconds val ConnectionStats.rtt: Duration? get() = rttUs?.toLong()?.microseconds -/** Delay before the first reconnect attempt. */ -val Backoff.initial: Duration - get() = initialUs.toLong().microseconds +/** Delay before the first reconnect attempt, or null for the default. */ +val Backoff.initial: Duration? + get() = initialUs?.toLong()?.microseconds -/** Maximum delay between reconnect attempts. */ -val Backoff.max: Duration - get() = maxUs.toLong().microseconds +/** Maximum delay between reconnect attempts, or null for the default. */ +val Backoff.max: Duration? + get() = maxUs?.toLong()?.microseconds -/** Time spent retrying before giving up. [Duration.ZERO] retries forever. */ -val Backoff.timeout: Duration - get() = timeoutUs.toLong().microseconds +/** Time spent retrying before giving up, or null for the default. [Duration.ZERO] retries forever. */ +val Backoff.timeout: Duration? + get() = timeoutUs?.toLong()?.microseconds + +/** Head start QUIC gets before the WebSocket fallback joins, or null for the default. */ +val WebSocketConfig.delay: Duration? + get() = delayUs?.toLong()?.microseconds /** Upper bound on buffering before a stalled group is skipped. */ val Subscription.maxAge: Duration diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 40f887e1ec..19cb98fc6f 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -170,24 +170,15 @@ fun GroupConsumer.frames(): Flow = flow { } /** - * Stream of announce events matching [config]. An [AnnounceEventLive] follows the - * routes live at subscribe time, so a collector can gather what is live and stop. + * Stream of announce events, as `announced(config).updates()`. An + * [AnnounceEventLive] follows the routes live at subscribe time, so a collector + * can gather what is live and stop. * - * Acquires the subscription on first collection and cancels it when collection - * ends, so callers never touch the underlying handle. Use the raw - * `announced(config)` if you need to hold and cancel the handle yourself. + * Collect it once: the handle is cancelled when collection ends, however it ends. */ -fun OriginConsumer.announcements(config: AnnounceConfig = AnnounceConfig()): Flow { - val consumer = this - return flow { - val announced = consumer.announced(config) - try { - while (true) { - currentCoroutineContext().ensureActive() - emit(announced.next() ?: break) - } - } finally { - announced.cancel() - } +fun AnnounceConsumer.updates(): Flow = flow { + while (true) { + currentCoroutineContext().ensureActive() + emit(next() ?: break) } -} +}.onCompletion { cancel() } diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index d34973f5b3..11538ff062 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -1,14 +1,11 @@ package dev.moq -import kotlinx.coroutines.flow.Flow -import kotlin.time.Duration - /** * A connected MoQ session with publish/subscribe conveniences. * * Build one with [Moq.connect]. The underlying [session] always exposes a - * publisher and a subscriber (wired from the origins you pass to [connect], or - * auto-created), so you can [createBroadcast] and iterate [announcements] + * publisher and a subscriber (wired from the origins in its [ClientConfig], or + * auto-created), so you can [createBroadcast] and collect [announced] updates * without touching the raw [Client] handle. * * [Moq] is [AutoCloseable]; `use { ... }` (or [close]) gracefully shuts down @@ -28,14 +25,10 @@ class Moq internal constructor( fun createBroadcast(path: String): BroadcastProducer = session.publish().createBroadcast(path) /** - * Discover routes matching [config] as a [Flow] of [AnnounceEvent]. Prefixes stay - * relative to the origin. The subscription is acquired on - * collection and cancelled when collection ends. Use [announced] for the raw handle. + * Discover routes matching [config]; prefixes stay relative to the origin. + * Collect `announced(config).updates()` for a [Flow] of [AnnounceEvent] that + * cancels the handle when collection ends. */ - fun announcements(config: AnnounceConfig = AnnounceConfig()): Flow = - session.consume().announcements(config) - - /** Raw announcement handle for [config]; prefixes stay relative to the origin. */ fun announced(config: AnnounceConfig = AnnounceConfig()): AnnounceConsumer = session.consume().announced(config) @@ -84,72 +77,19 @@ class Moq internal constructor( /** * Connect to a relay at [url] and return the live [Moq] connection. * - * @param tlsVerify set false to skip certificate verification (local dev only). - * @param tlsRoots PEM root certificate paths to trust instead of platform roots. - * @param tlsSystemRoots whether to also trust platform roots when custom roots are set. - * @param tlsFingerprints peer certificate SHA-256 fingerprints to pin. - * @param tlsCert path to a PEM certificate chain to present for mTLS. - * @param tlsKey path to a PEM private key to present for mTLS. - * @param bind local socket address to bind, e.g. "0.0.0.0:0". - * @param maxStreams cap on the concurrent QUIC streams the peer may open toward - * this connection; MoQ opens one stream per group, and for a subscriber those - * arrive from the relay, so subscribing to many tracks may want this raised. - * @param reconnect set false for a one-shot dial. By default the session redials - * with backoff whenever the transport drops; watch [Session.status] for the - * transitions. - * @param backoff retry pacing for the automatic reconnect. - * @param websocketEnabled set false to stop the WebSocket fallback racing QUIC, - * e.g. against a relay that only serves QUIC. On by default. - * @param websocketDelay head start QUIC gets before the WebSocket fallback joins - * the race; 200ms by default, and zero races both at once. - * @param publish origin to announce broadcasts through; auto-created when null. - * @param subscribe origin to discover broadcasts through; auto-created when null. + * [config] carries the TLS trust, bind address, protocol versions, QUIC and + * WebSocket tuning, reconnect pacing, and origins; every field has a default. + * A value the native side cannot use throws `MoqException.Config`. * - * With neither [publish] nor [subscribe] given, both sides share one origin, so a - * broadcast announced on this connection is discoverable via its own [announcements] - * (loopback). Wiring either side opts out and isolates the two directions. + * With neither [ClientConfig.publish] nor [ClientConfig.consume] set, both sides + * share one origin, so a broadcast announced on this connection is discoverable + * via its own [announced] (loopback). Wiring either side opts out and isolates the + * two directions. */ - suspend fun connect( - url: String, - tlsVerify: Boolean = true, - tlsRoots: List? = null, - tlsSystemRoots: Boolean? = null, - tlsFingerprints: List? = null, - tlsCert: String? = null, - tlsKey: String? = null, - bind: String? = null, - reconnect: Boolean? = null, - backoff: Backoff? = null, - publish: OriginProducer? = null, - subscribe: OriginProducer? = null, - maxStreams: ULong? = null, - websocketEnabled: Boolean? = null, - websocketDelay: Duration? = null, - ): Moq { - require(websocketDelay == null || !websocketDelay.isNegative()) { - "websocketDelay must not be negative: $websocketDelay" - } - val client = Client() + suspend fun connect(url: String, config: ClientConfig = ClientConfig()): Moq { + val client = Client(config) try { - if (!tlsVerify) client.setTlsVerify(false) - if (tlsRoots != null) client.setTlsRoots(tlsRoots) - if (tlsSystemRoots != null) client.setTlsSystemRoots(tlsSystemRoots) - if (tlsFingerprints != null) client.setTlsFingerprints(tlsFingerprints) - if (tlsCert != null) client.setTlsCert(tlsCert) - if (tlsKey != null) client.setTlsKey(tlsKey) - if (bind != null) client.setBind(bind) - if (maxStreams != null) client.setQuicMaxStreams(maxStreams) - if (websocketEnabled != null) client.setWebsocketEnabled(websocketEnabled) - if (websocketDelay != null) { - client.setWebsocketDelay(websocketDelay.inWholeMicroseconds.toULong()) - } - if (reconnect != null) client.setReconnect(reconnect) - if (backoff != null) client.setBackoff(backoff) - if (publish != null) client.setPublish(publish) - if (subscribe != null) client.setConsume(subscribe) - - val session = client.connect(url) - return Moq(session, client) + return Moq(client.connect(url), client) } catch (e: Throwable) { // connect() failed: don't leak the client handle. client.cancel() diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index e30c3aebbb..a79f6465c5 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -98,41 +98,28 @@ class Server internal constructor( companion object { /** - * Bind a server at [bind] and start accepting. + * Bind a server described by [config] and start accepting. * - * @param bind local socket address to listen on, e.g. "127.0.0.1:4443" or "[::]:443". - * @param tlsCert PEM certificate chain paths to serve. - * @param tlsKey PEM private key paths to serve. - * @param tlsGenerate hostnames to generate a self-signed certificate for. - * @param publish origin whose broadcasts are served to incoming sessions; auto-created when null. - * @param subscribe origin that receives broadcasts published by incoming sessions; auto-created when null. + * [config] carries the bind address (`[::]:443` when null), the TLS identity, + * protocol versions, QUIC tuning, and origins. A value the native side cannot + * use throws `MoqException.Config`. + * + * With neither [ServerConfig.publish] nor [ServerConfig.consume] set, one shared + * origin is wired to both, so a broadcast announced on this server is also + * visible to sessions publishing into it. Mirrors [Moq.connect]. */ - suspend fun listen( - bind: String = "[::]:443", - tlsCert: List? = null, - tlsKey: List? = null, - tlsGenerate: List? = null, - publish: OriginProducer? = null, - subscribe: OriginProducer? = null, - ): Server { - // With neither side specified, wire ONE shared origin to both so a - // broadcast announced on this server is also visible to sessions - // publishing into it. Mirrors Moq.connect. - val shared = if (publish == null && subscribe == null) OriginProducer(OriginConfig()) else null - val publishOrigin = publish ?: shared - val subscribeOrigin = subscribe ?: shared + suspend fun listen(config: ServerConfig): Server { + val wired = if (config.publish == null && config.consume == null) { + val shared = OriginProducer(OriginConfig()) + config.copy(publish = shared, consume = shared) + } else { + config + } - val server = MoqServer() + val server = MoqServer(wired) try { - server.setBind(bind) - if (tlsCert != null) server.setTlsCert(tlsCert) - if (tlsKey != null) server.setTlsKey(tlsKey) - if (tlsGenerate != null) server.setTlsGenerate(tlsGenerate) - if (publishOrigin != null) server.setPublish(publishOrigin) - if (subscribeOrigin != null) server.setConsume(subscribeOrigin) - val localAddr = server.listen() - return Server(server, localAddr, publishOrigin) + return Server(server, localAddr, wired.publish) } catch (e: Throwable) { // listen() failed: don't leak the server handle. server.cancel() diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index f0b1274068..7fe527cd89 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -52,9 +52,6 @@ private fun opusHead(): ByteArray = 0, ) -/** Wall-clock bound on polling for a configuration race, so a regression fails instead of hanging. */ -private const val CONFIG_RACE_TIMEOUT_NS = 10_000_000_000L - class SmokeTest { @Test fun `stream abort preserves protocol details`() = runTest { @@ -80,7 +77,7 @@ class SmokeTest { @Test fun `connect fails fast and surfaces a MoqException`() = runTest { val ex = assertFailsWith { - Moq.connect("https://localhost:0/test", tlsVerify = false, reconnect = false) + Moq.connect("https://localhost:0/test", ClientConfig(tls = ClientTls(insecure = true), once = true)) } assertTrue( ex.isShutdown || ex is MoqException.Connect || ex is MoqException.Url, @@ -90,22 +87,23 @@ class SmokeTest { /** * The WebSocket fallback knobs reach the native client: a QUIC-only dial with - * no head start still fails fast, and a negative delay is refused up front - * rather than wrapping into an enormous one. + * no head start still fails fast, and an invalid value is refused up front + * as a config error rather than at the dial. */ @Test fun `connect accepts the websocket fallback knobs`() = runTest { assertFailsWith { Moq.connect( "https://localhost:0/test", - tlsVerify = false, - reconnect = false, - websocketEnabled = false, - websocketDelay = 0.milliseconds, + ClientConfig( + tls = ClientTls(insecure = true), + once = true, + websocket = WebSocketConfig(enabled = false, delayUs = 0uL), + ), ) } - assertFailsWith { - Moq.connect("https://localhost:0/test", websocketDelay = (-1).milliseconds) + assertFailsWith { + Moq.connect("https://localhost:0/test", ClientConfig(versions = listOf("moq-lite-99"))) } } @@ -139,6 +137,15 @@ class SmokeTest { maxUs = 10_000_000uL, timeoutUs = 0uL, ) + val client: ClientConfig = ClientConfig( + tls = ClientTls(insecure = true), + quic = QuicConfig(maxStreams = 4096uL), + websocket = WebSocketConfig(enabled = false), + backoff = backoff, + ) + val server: ServerConfig = ServerConfig(tls = ServerTls(generate = listOf("localhost"))) + assertEquals(4096uL, client.quic.maxStreams) + assertEquals(listOf("localhost"), server.tls.generate) val status: ConnectionStatus = ConnectionStatus.CONNECTED assertEquals(4_000_000uL, hint.bitrate) assertEquals(8u, snapshot.deltaRatio) @@ -334,7 +341,7 @@ class SmokeTest { @Test fun `server listens, publishes, and streams requests`() = runTest { - Server.listen("127.0.0.1:0", tlsGenerate = listOf("localhost")).use { server -> + Server.listen(ServerConfig(bind = "127.0.0.1:0", tls = ServerTls(generate = listOf("localhost")))).use { server -> assertTrue(server.localAddr.startsWith("127.0.0.1:"), "bound: ${server.localAddr}") val fingerprints = server.certFingerprints() @@ -350,12 +357,12 @@ class SmokeTest { @Test fun `closing a server releases its port`() = runTest { - val first = Server.listen("127.0.0.1:0", tlsGenerate = listOf("localhost")) + val first = Server.listen(ServerConfig(bind = "127.0.0.1:0", tls = ServerTls(generate = listOf("localhost")))) val addr = first.localAddr first.close() // No retry: close() released the listening socket before returning. - Server.listen(addr, tlsGenerate = listOf("localhost")).use { rebound -> + Server.listen(ServerConfig(bind = addr, tls = ServerTls(generate = listOf("localhost")))).use { rebound -> assertEquals(addr, rebound.localAddr) } } @@ -410,7 +417,7 @@ class SmokeTest { broadcast.announce(Route()) consumer.announcedBroadcast("cam").available() - val listed = consumer.announcements() + val listed = consumer.announced(AnnounceConfig()).updates() .takeWhile { it !is AnnounceEventLive } .map { (it as AnnounceEventStart).announce.prefix } .toList() @@ -478,38 +485,20 @@ class SmokeTest { } /** - * Configuration must apply or fail: a setter racing an in-flight connect - * throws [MoqException.Busy], and one after [Client.cancel] throws - * [MoqException.Cancelled]. Mirrors `test_client_setters_fail_after_cancel` - * in `py/moq-rs/tests/test_server.py`. + * Cancelling the client aborts a connect parked on a server that never + * accepts, and the connect reports it as [MoqException.Cancelled]. */ @Test - fun `client setters are busy during connect and cancelled after`() = runTest { - Server.listen("127.0.0.1:0", tlsGenerate = listOf("localhost")).use { server -> - val client = Client() - client.setTlsVerify(false) - client.setBind("127.0.0.1:0") + fun `client cancel aborts a pending connect`() = runTest { + Server.listen(ServerConfig(bind = "127.0.0.1:0", tls = ServerTls(generate = listOf("localhost")))).use { server -> // A reconnecting client would redial instead of failing the connect. - client.setReconnect(false) + val client = Client(ClientConfig(bind = "127.0.0.1:0", tls = ClientTls(insecure = true), once = true)) - // Nothing accepts the request, so connect parks holding the client lock. // runCatching, because a failed `async` would cancel the test scope // before `await` ever reported it. val connect = async { runCatching { client.connect("https://${server.localAddr}") } } - - // The lock is taken on the ffi runtime thread, so poll until it is. - val deadline = System.nanoTime() + CONFIG_RACE_TIMEOUT_NS - var busy: Throwable? = null - while (busy == null && System.nanoTime() < deadline) { - busy = runCatching { client.setTlsVerify(false) }.exceptionOrNull() - yield() - } - assertTrue(busy is MoqException.Busy, "expected Busy while connecting, got: $busy") - assertFailsWith { client.setBind("127.0.0.1:0") } - + yield() client.cancel() - assertFailsWith { client.setTlsVerify(true) } - assertFailsWith { client.setBind("127.0.0.1:0") } val connected = connect.await().exceptionOrNull() assertTrue(connected is MoqException.Cancelled, "expected a cancelled connect, got: $connected") From 64bae0d2df99a23b744f185428237bd7904783a0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:19:23 -0700 Subject: [PATCH 6/9] refactor(swift)!: Client and Server take their config as labeled init arguments Co-Authored-By: Claude Opus 5.5 --- doc/lib/swift/index.md | 23 +++-- swift/README.md | 4 +- swift/Sources/Moq/Aliases.swift | 15 ++- swift/Sources/Moq/Client.swift | 140 ++++++++------------------ swift/Sources/Moq/Server.swift | 70 ++++++------- swift/Sources/Moq/Track.swift | 17 +--- swift/Tests/MoqTests/SmokeTests.swift | 31 +++--- 7 files changed, 117 insertions(+), 183 deletions(-) diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 7dfbdaf0fb..099e37ab61 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -25,7 +25,7 @@ targets: [ import Moq // Subscribe. The sequence is live, so run it in its own Task. -let client = Client() +let client = try Client() let session = try await client.connect(to: "https://relay.example.com") for try await event in try session.consume.announced(prefix: "live/", filter: "*/camera") { @@ -75,26 +75,27 @@ matched, or `.live` once every route live at subscribe time has been delivered. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `hidden: true`. -For a self-signed relay on your own test network, `try client.setTlsVerify(false)` -accepts any certificate; prefer `setTlsRoots` or a fingerprint anywhere else. -Setters throw if a connect is in flight or after `cancel()`. +`Client(...)` takes its configuration as labeled arguments, each with a default, +and throws `MoqError.Config` for a value the native side cannot use. For a +self-signed relay on your own test network, `Client(tls: ClientTls(insecure: true))` +accepts any certificate; prefer `ClientTls(roots:)` or `fingerprints` anywhere else. +`versions:` pins the protocol versions offered, and `once: true` dials once. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `session.epoch()` counts the connections, 1 on the first, pairing -with `session.status()` to log each reconnect; `client.setBackoff` tunes the -pacing; and `client.setQuicMaxStreams` raises the peer's inbound stream cap. +with `session.status()` to log each reconnect; `Client(backoff:)` tunes the +pacing; and `Client(quic: QuicConfig(maxStreams:))` raises the peer's inbound stream cap. The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after -a 200 ms head start. `client.setWebsocketEnabled(false)` turns it off for a -QUIC-only relay, and `client.setWebsocketDelay(_:)` changes the head start, in -microseconds. +a 200 ms head start. `WebSocketConfig(enabled: false)` turns it off for a +QUIC-only relay, and `delayUs` changes the head start, in microseconds. -`Server` binds, generates or loads TLS, and hands you each request to +`Server(bind:tls:)` binds, generates or loads TLS, and hands you each request to `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()` +`datagrams`, `setCatalogSection`, `demand()` for `name`, `used()`, and `unused()`. `session.bandwidth()` divides the connection's send estimate; pass it to `encodeVideo` / `encodeAudio` or `reserve` a share for an app-owned track. `MoqError.isAuth` and `isShutdown` classify errors. `protocolError` is the structured protocol failure diff --git a/swift/README.md b/swift/README.md index ccd5f4ea09..8bfb516441 100644 --- a/swift/README.md +++ b/swift/README.md @@ -26,11 +26,11 @@ SPM resolves `MoqFFI` (and its prebuilt `MoqFFI.xcframework`, attached to the ma ```swift import Moq -let client = Client() +let client = try Client() let session = try await client.connect(to: "https://relay.example.com") // session.publish and session.consume are always populated: by whatever -// origin you wired via setPublish / setConsume before connect, or by a fresh +// origin you passed as Client(publish:consume:), or by a fresh // auto-created one. The duplex no-config path (the typical client) shares one // origin between both sides. let announced = try session.consume.announced(prefix: "demos/", filter: "*/camera") diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index c0cf266018..2f84d0d113 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -100,9 +100,22 @@ public typealias TrackInfo = MoqFFI.MoqTrackInfo /// counters). Fields are `nil` when the transport backend doesn't report them. public typealias ConnectionStats = MoqFFI.MoqConnectionStats -/// Retry pacing for the automatic reconnect; see `Client.setBackoff`. +/// Retry pacing for the automatic reconnect; see `Client(backoff:)`. A `nil` +/// field keeps its default. public typealias Backoff = MoqFFI.MoqBackoff +/// Certificate trust and the mTLS identity for a `Client`. +public typealias ClientTls = MoqFFI.MoqClientTls + +/// The served TLS identity for a `Server`: PEM files or generated hostnames. +public typealias ServerTls = MoqFFI.MoqServerTls + +/// QUIC transport tuning, such as the peer's inbound stream cap. +public typealias QuicConfig = MoqFFI.MoqQuicConfig + +/// The WebSocket fallback raced against QUIC: whether it runs and QUIC's head start. +public typealias WebSocketConfig = MoqFFI.MoqWebSocketConfig + /// A connection lifecycle transition reported by `Session.status()`. public typealias ConnectionStatus = MoqFFI.MoqConnectionStatus diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index f77fe17729..1708bffdf8 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -1,108 +1,50 @@ import MoqFFI -/// A MoQ client. Configure the optional knobs, then `connect(to:)`. -/// -/// Setters write the configuration `connect` snapshots. They throw `.Busy` while -/// a connect is in flight and `.Cancelled` after `cancel()`. A finished connect -/// does not freeze the handle: later setters apply to the next dial until cancel. +/// A MoQ client, built from its configuration and then dialed with `connect(to:)`. public final class Client: Sendable { let ffi: MoqClient - /// Create a client with default settings, ready to configure and `connect(to:)`. - public init() { - ffi = MoqClient() - } - - /// Toggle TLS certificate verification. Defaults to on; pass `false` only - /// against a relay with a self-signed certificate during development. - public func setTlsVerify(_ verify: Bool) throws { - try ffi.setTlsVerify(verify: verify) - } - - /// Trust these PEM root certificate file path(s) instead of the system roots. - public func setTlsRoots(_ paths: [String]) throws { - try ffi.setTlsRoots(paths: paths) - } - - /// Configure whether platform roots are trusted in addition to custom roots. - public func setTlsSystemRoots(_ enabled: Bool) throws { - try ffi.setTlsSystemRoots(systemRoots: enabled) - } - - /// Pin the peer certificate to these hex SHA-256 fingerprints, the native - /// equivalent of `serverCertificateHashes`. Accepts the values a server - /// reports via `Server.certFingerprints`, so a self-signed certificate can be - /// trusted without disabling verification. - public func setTlsFingerprints(_ fingerprints: [String]) throws { - try ffi.setTlsFingerprints(fingerprints: fingerprints) - } - - /// Set the path to a PEM certificate chain to present when the relay requires mTLS. - public func setTlsCert(_ path: String?) throws { - try ffi.setTlsCert(path: path) - } - - /// Set the path to a PEM private key to present when the relay requires mTLS. - public func setTlsKey(_ path: String?) throws { - try ffi.setTlsKey(path: path) - } - - /// Set the local UDP socket bind address (defaults to `[::]:0`). Throws if - /// the address cannot be parsed, if a connect is in flight, or after cancel. - public func bind(_ addr: String) throws { - try ffi.setBind(addr: addr) - } - - /// Cap the concurrent QUIC streams the peer may open toward this connection - /// (defaults to 1024). MoQ opens a stream per group, and for a subscriber - /// those arrive from the relay, so subscribing to many tracks may want this - /// raised. Ignored by the WebSocket fallback. - public func setQuicMaxStreams(_ maxStreams: UInt64) throws { - try ffi.setQuicMaxStreams(maxStreams: maxStreams) - } - - /// Enable or disable the WebSocket fallback (on by default), which races QUIC - /// for `http(s)` URLs. Disable it against a relay that only serves QUIC. - public func setWebsocketEnabled(_ enabled: Bool) throws { - try ffi.setWebsocketEnabled(enabled: enabled) - } - - /// Set the head start, in microseconds, QUIC gets before the WebSocket - /// fallback joins the race (defaults to 200ms). Zero races both at once. - public func setWebsocketDelay(_ delayUs: UInt64) throws { - try ffi.setWebsocketDelay(delayUs: delayUs) - } - - /// Wire the origin whose local broadcasts get advertised to the remote. If - /// left unset, `connect` auto-creates one, reachable via `Session.publish`. - public func setPublish(_ origin: OriginProducer?) throws { - try ffi.setPublish(origin: origin?.ffi) - } - - /// Wire the origin used to receive the remote's announcements. If left - /// unset, `connect` auto-creates one, reachable via `Session.consume`. - public func setConsume(_ origin: OriginProducer?) throws { - try ffi.setConsume(origin: origin?.ffi) - } - - /// Enable or disable automatic reconnecting (on by default). When enabled, the - /// session redials with backoff whenever the transport drops, and broadcasts - /// consumed through it ride out the gap. Disable for a one-shot dial whose - /// transport close ends the session. - public func setReconnect(_ enabled: Bool) throws { - try ffi.setReconnect(enabled: enabled) - } - - /// Configure retry pacing for the automatic reconnect. - public func setBackoff(_ backoff: Backoff) throws { - try ffi.setBackoff(backoff: backoff) + /// Create a client. Every argument has a default, so pass only what you need. + /// + /// - Parameters: + /// - bind: The local UDP address to bind; `nil` binds an ephemeral dual-stack port. + /// - versions: Protocol versions to offer, most preferred first (`"moq-lite-03"`); + /// empty offers every supported version. + /// - tls: Certificate trust (`insecure`, `roots`, `fingerprints`, ...) and the mTLS identity. + /// - quic: QUIC tuning, such as the peer's inbound stream cap. + /// - websocket: The WebSocket fallback raced against QUIC for `http(s)` URLs. + /// - once: Dial once instead of redialing with backoff whenever the transport drops. + /// - backoff: Retry pacing for the automatic reconnect. + /// - publish: The origin whose broadcasts are published to the remote. + /// - consume: The origin that receives the remote's broadcasts. With neither origin + /// given, both sides of each session share one, so a broadcast announced via + /// `Session.publish` is also discoverable through `Session.consume`. + /// - Throws: `MoqError.Config` for a value the native side cannot use. + public init( + bind: String? = nil, + versions: [String] = [], + tls: ClientTls = ClientTls(), + quic: QuicConfig = QuicConfig(), + websocket: WebSocketConfig = WebSocketConfig(), + once: Bool = false, + backoff: Backoff = Backoff(), + publish: OriginProducer? = nil, + consume: OriginProducer? = nil + ) throws { + ffi = try MoqClient(config: MoqClientConfig( + bind: bind, + versions: versions, + tls: tls, + quic: quic, + websocket: websocket, + once: once, + backoff: backoff, + publish: publish?.ffi, + consume: consume?.ffi + )) } /// Connect and wait for the session to be established. Cancellable via `cancel()`. - /// - /// With neither `setPublish` nor `setConsume` wired, both sides of the session share one - /// origin, so a broadcast announced via `Session.publish` is also discoverable through - /// `Session.consume`. Wiring either side opts out and isolates the two directions. public func connect(to url: String) async throws -> Session { Session(try await ffi.connect(url: url)) } @@ -122,13 +64,13 @@ public final class Session: Sendable { } /// The publish-side origin: where local broadcasts are advertised to the - /// remote. Either the one wired via `Client.setPublish`, or auto-created. + /// remote. Either the one wired via `Client(publish:)`, or auto-created. public var publish: OriginProducer { OriginProducer(ffi.publish()) } /// The subscribe-side origin: a read handle for the remote's announcements. - /// Either derived from `Client.setConsume`, or auto-created. + /// Either derived from `Client(consume:)`, or auto-created. public var consume: OriginConsumer { OriginConsumer(ffi.consume()) } diff --git a/swift/Sources/Moq/Server.swift b/swift/Sources/Moq/Server.swift index 2e4707703d..be34763923 100644 --- a/swift/Sources/Moq/Server.swift +++ b/swift/Sources/Moq/Server.swift @@ -1,50 +1,38 @@ import MoqFFI /// A MoQ server that accepts incoming QUIC/WebTransport sessions. -/// -/// Bind and TLS are captured at `listen()`; those setters throw afterwards. -/// Origins are captured at each `accept()`. Every setter throws `.Busy` while -/// listen/accept is in flight and `.Cancelled` after `cancel()`. public final class Server: Sendable { let ffi: MoqServer - /// Create a server with default settings, ready to configure and `listen()`. - public init() { - ffi = MoqServer() - } - - /// Set the address to bind, e.g. `127.0.0.1:4443`, `[::]:443`, or `localhost:0`. - /// Validated syntactically here; DNS hostnames resolve at `listen()` time. - /// Captured at `listen()`; throws afterwards. - public func bind(_ addr: String) throws { - try ffi.setBind(addr: addr) - } - - /// Load TLS certificate chains from PEM files on disk. Captured at `listen()`. - public func setTlsCert(_ paths: [String]) throws { - try ffi.setTlsCert(paths: paths) - } - - /// Load TLS private keys from PEM files on disk. Captured at `listen()`. - public func setTlsKey(_ paths: [String]) throws { - try ffi.setTlsKey(paths: paths) - } - - /// Generate self-signed TLS certificates for the given hostnames. Clients - /// must pin the fingerprint (see `certFingerprints`) or disable verification. - /// Captured at `listen()`. - public func generateTls(hostnames: [String]) throws { - try ffi.setTlsGenerate(hostnames: hostnames) - } - - /// Set the origin to publish broadcasts to incoming sessions. Captured at each `accept()`. - public func setPublish(_ origin: OriginProducer?) throws { - try ffi.setPublish(origin: origin?.ffi) - } - - /// Set the origin to consume broadcasts from incoming sessions. Captured at each `accept()`. - public func setConsume(_ origin: OriginProducer?) throws { - try ffi.setConsume(origin: origin?.ffi) + /// Create a server. Nothing binds until `listen()`. + /// + /// - Parameters: + /// - bind: The address to bind, e.g. `127.0.0.1:4443` or `localhost:0`; `nil` binds + /// `[::]:443`. DNS hostnames resolve at `listen()`. + /// - versions: Protocol versions to accept (`"moq-lite-03"`); empty accepts every + /// supported version. + /// - tls: The served identity: PEM `cert`/`key` files, or hostnames to `generate` + /// a self-signed certificate for (clients then pin `certFingerprints` or skip verification). + /// - quic: QUIC tuning, such as each peer's inbound stream cap. + /// - publish: The origin whose broadcasts are served to incoming sessions. + /// - consume: The origin that receives broadcasts published by incoming sessions. + /// - Throws: `MoqError.Config` for a value the native side cannot use. + public init( + bind: String? = nil, + versions: [String] = [], + tls: ServerTls = ServerTls(), + quic: QuicConfig = QuicConfig(), + publish: OriginProducer? = nil, + consume: OriginProducer? = nil + ) throws { + ffi = try MoqServer(config: MoqServerConfig( + bind: bind, + versions: versions, + tls: tls, + quic: quic, + publish: publish?.ffi, + consume: consume?.ffi + )) } /// Bind the listening socket. Returns the bound local address, useful when diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index 20cd57f6ca..3462c2001b 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -153,32 +153,17 @@ public final class TrackProducer: Sendable { self.ffi = ffi } - /// The track's name. - public var name: String { - get throws { try ffi.name() } - } - /// A read handle for this track (local pub/sub, no origin needed). /// `subscription` tunes delivery priority, group range, and staleness; omit for defaults. public func consume(subscription: Subscription? = nil) throws -> TrackConsumer { TrackConsumer(try ffi.consume(subscription: subscription)) } - /// A watch-only handle to whether the track has subscribers. + /// A watch-only handle to the track's name and whether it has subscribers. public func demand() throws -> TrackDemand { TrackDemand(try ffi.demand()) } - /// Suspend until the track has at least one active consumer. Prefer `demand()`. - public func used() async throws { - try await ffi.used() - } - - /// Suspend until the track has no active consumers. Prefer `demand()`. - public func unused() async throws { - try await ffi.unused() - } - /// Serve fetches for groups that are not currently cached. public func dynamic() throws -> TrackDynamic { TrackDynamic(try ffi.dynamic()) diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index c77d862e9f..dc23567744 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -36,12 +36,7 @@ final class SmokeTests: XCTestCase { /// generated API. No network needed: we just instantiate a few types and /// exercise the cancel path. func testClientConstructsAndCancels() async throws { - let client = Client() - try client.setTlsRoots([]) - try client.setTlsSystemRoots(true) - try client.setTlsFingerprints([]) - try client.setTlsCert(nil) - try client.setTlsKey(nil) + let client = try Client(tls: ClientTls(roots: [], systemRoots: true, fingerprints: [])) client.cancel() do { _ = try await client.connect(to: "https://localhost:0/test") @@ -60,19 +55,29 @@ final class SmokeTests: XCTestCase { } } + /// A value the native side cannot use throws from the constructor, not the dial. + func testInvalidConfigThrowsConfig() { + XCTAssertThrowsError(try Client(versions: ["moq-lite-99"])) { error in + guard case MoqError.Config = error else { + return XCTFail("expected Config, got \(error)") + } + } + XCTAssertThrowsError(try Server(bind: "not-an-address")) { error in + guard case MoqError.Config = error else { + return XCTFail("expected Config, got \(error)") + } + } + } + /// `cancel()` releases the listening socket before it returns, so the same /// address binds again with no retry. func testServerCloseReleasesPort() async throws { - let first = Server() - try first.bind("127.0.0.1:0") - try first.generateTls(hostnames: ["localhost"]) + let first = try Server(bind: "127.0.0.1:0", tls: ServerTls(generate: ["localhost"])) let addr = try await first.listen() first.cancel() // No retry: cancel() closed the socket, so this binds on the first try. - let second = Server() - try second.bind(addr) - try second.generateTls(hostnames: ["localhost"]) + let second = try Server(bind: addr, tls: ServerTls(generate: ["localhost"])) let rebound = try await second.listen() XCTAssertEqual(rebound, addr) second.cancel() @@ -173,7 +178,7 @@ final class SmokeTests: XCTestCase { func testBroadcastProducerOpensTracks() throws { let broadcast = try BroadcastProducer() let track = try broadcast.publishTrack(name: "events") - XCTAssertEqual(try track.name, "events") + XCTAssertEqual(try track.demand().name, "events") try track.finish() try broadcast.close() } From 740a4a728b6713adba74c7b43232300ec5c7d839 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:23:42 -0700 Subject: [PATCH 7/9] refactor(dart)!: options build config records, consume origin, announced(...).updates() replaces announcements Co-Authored-By: Claude Opus 5.5 --- dart/moq/README.md | 5 +- dart/moq/lib/src/client.dart | 142 +-- dart/moq/lib/src/durations.dart | 15 +- dart/moq/lib/src/server.dart | 47 +- dart/moq/test/moq_test.dart | 30 +- dart/moq_ffi/lib/src/moq.dart | 1389 ++++++++++++++++-------------- dart/moq_ffi/test/leak_test.dart | 6 +- doc/lib/dart/index.md | 19 +- 8 files changed, 892 insertions(+), 761 deletions(-) diff --git a/dart/moq/README.md b/dart/moq/README.md index 99e8b8335c..3cafbaa79b 100644 --- a/dart/moq/README.md +++ b/dart/moq/README.md @@ -6,9 +6,10 @@ Idiomatic Dart and Flutter bindings for Media over QUIC. import 'package:moq/moq.dart'; final connection = await Moq.connect('https://relay.example.com'); -await for (final event in connection.announcements( +final announced = connection.announced( options: const AnnounceOptions(prefix: 'live/', filter: '*/camera'), -)) { +); +await for (final event in announced.updates()) { if (event is AnnounceEventStart) { // Prefix stays origin-relative; captures reports wildcard matches. print(event.announce.prefix); diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 86e4e639d9..e94b455328 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -1,3 +1,5 @@ +import 'package:moq_ffi/moq_ffi.dart'; + import 'aliases.dart'; /// Scope for discovering announcements. @@ -18,6 +20,9 @@ final class AnnounceOptions { } /// Everything [Moq.connect] can be told beyond the URL. +/// +/// Every field is optional; a null keeps the native default. A value the native +/// side cannot use fails [Moq.connect] with `MoqException.Config`. final class ConnectOptions { /// Set false to skip certificate verification (local dev only). final bool tlsVerify; @@ -40,6 +45,10 @@ final class ConnectOptions { /// Local socket address to bind, e.g. `0.0.0.0:0`. final String? bind; + /// Protocol versions to offer, most preferred first, e.g. `moq-lite-03`. + /// Null offers every supported version. + final List? versions; + /// Cap on the concurrent QUIC streams the peer may open toward this /// connection. MoQ opens one stream per group, so a subscriber to many /// tracks may want this raised. @@ -55,7 +64,7 @@ final class ConnectOptions { /// Set false for a one-shot dial. By default the session redials with /// backoff whenever the transport drops. - final bool? reconnect; + final bool reconnect; /// Retry pacing for the automatic reconnect. final Backoff? backoff; @@ -64,7 +73,7 @@ final class ConnectOptions { final OriginProducer? publish; /// Origin to discover broadcasts through; auto-created when null. - final OriginProducer? subscribe; + final OriginProducer? consume; const ConnectOptions({ this.tlsVerify = true, @@ -74,14 +83,47 @@ final class ConnectOptions { this.tlsCert, this.tlsKey, this.bind, + this.versions, this.maxStreams, this.websocketEnabled, this.websocketDelay, - this.reconnect, + this.reconnect = true, this.backoff, this.publish, - this.subscribe, + this.consume, }); + + MoqClientConfig get _ffi { + final delay = websocketDelay; + if (delay != null && delay.isNegative) { + throw ArgumentError.value( + delay, + 'websocketDelay', + 'must not be negative', + ); + } + return MoqClientConfig( + bind: bind, + versions: versions ?? const [], + tls: MoqClientTls( + insecure: !tlsVerify, + roots: tlsRoots ?? const [], + systemRoots: tlsSystemRoots, + fingerprints: tlsFingerprints ?? const [], + cert: tlsCert, + key: tlsKey, + ), + quic: MoqQuicConfig(maxStreams: maxStreams), + websocket: MoqWebSocketConfig( + enabled: websocketEnabled, + delayUs: delay?.inMicroseconds, + ), + once: !reconnect, + backoff: backoff ?? MoqBackoff(), + publish: publish, + consume: consume, + ); + } } /// A connected MoQ session with publishing and subscription conveniences. @@ -95,58 +137,16 @@ final class Moq { /// Connect to a relay at [url]. /// - /// With neither [ConnectOptions.publish] nor [ConnectOptions.subscribe] + /// With neither [ConnectOptions.publish] nor [ConnectOptions.consume] /// given, both sides of the session share one origin, so a broadcast - /// announced here is discoverable through [announcements]. Wiring either + /// announced here is discoverable through [announced]. Wiring either /// side opts out and isolates the two directions. static Future connect( String url, { ConnectOptions options = const ConnectOptions(), }) async { - final websocketDelay = options.websocketDelay; - if (websocketDelay != null && websocketDelay.isNegative) { - throw ArgumentError.value( - websocketDelay, - 'websocketDelay', - 'must not be negative', - ); - } - - final client = Client(); + final client = Client(config: options._ffi); try { - if (!options.tlsVerify) client.setTlsVerify(verify: false); - if (options.tlsRoots != null) { - client.setTlsRoots(paths: options.tlsRoots!); - } - if (options.tlsSystemRoots != null) { - client.setTlsSystemRoots(systemRoots: options.tlsSystemRoots!); - } - if (options.tlsFingerprints != null) { - client.setTlsFingerprints(fingerprints: options.tlsFingerprints!); - } - if (options.tlsCert != null) client.setTlsCert(path: options.tlsCert); - if (options.tlsKey != null) client.setTlsKey(path: options.tlsKey); - if (options.bind != null) client.setBind(addr: options.bind!); - if (options.maxStreams != null) { - client.setQuicMaxStreams(maxStreams: options.maxStreams!); - } - if (options.websocketEnabled != null) { - client.setWebsocketEnabled(enabled: options.websocketEnabled!); - } - if (websocketDelay != null) { - client.setWebsocketDelay(delayUs: websocketDelay.inMicroseconds); - } - if (options.reconnect != null) { - client.setReconnect(enabled: options.reconnect!); - } - if (options.backoff != null) { - client.setBackoff(backoff: options.backoff!); - } - if (options.publish != null) client.setPublish(origin: options.publish); - if (options.subscribe != null) { - client.setConsume(origin: options.subscribe); - } - final session = await client.connect(url: url); return Moq._(session, client); } catch (_) { @@ -162,27 +162,10 @@ final class Moq { BroadcastProducer createBroadcast(String path) => session.publish().createBroadcast(path: path); - /// Stream announce events matching [options]; prefixes stay relative to the origin. + /// Discover routes matching [options]; prefixes stay relative to the origin. /// - /// A [AnnounceEventLive] follows the routes live at subscribe time, so a - /// listener can collect what is live and stop there. - Stream announcements({ - AnnounceOptions options = const AnnounceOptions(), - }) async* { - final announced = session.consume().announced(config: options._ffi); - try { - while (true) { - final event = await announced.next(); - if (event == null) return; - yield event; - } - } finally { - announced.cancel(); - announced.dispose(); - } - } - - /// Return the raw cursor for [options]. + /// Listen to `announced(...).updates()` for a [Stream] of [AnnounceEvent] + /// that releases the cursor when the subscription ends. AnnounceConsumer announced({ AnnounceOptions options = const AnnounceOptions(), }) => session.consume().announced(config: options._ffi); @@ -212,3 +195,24 @@ final class Moq { _client.cancel(); } } + +/// A [Stream] view over an announcement cursor. +extension AnnounceConsumerUpdates on AnnounceConsumer { + /// Stream announce events until the cursor ends. + /// + /// A [AnnounceEventLive] follows the routes live at subscribe time, so a + /// listener can collect what is live and stop there. Listen once: the cursor + /// is cancelled and released when the subscription ends. + Stream updates() async* { + try { + while (true) { + final event = await next(); + if (event == null) return; + yield event; + } + } finally { + cancel(); + dispose(); + } + } +} diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index 02b3904777..16952993ce 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -13,14 +13,17 @@ extension ConnectionStatsDuration on MoqConnectionStats { /// Duration views over the reconnect pacing. extension BackoffDuration on MoqBackoff { - /// Delay before the first reconnect attempt. - Duration get initial => Duration(microseconds: initialUs); + /// Delay before the first reconnect attempt, or null for the default. + Duration? get initial => + initialUs == null ? null : Duration(microseconds: initialUs!); - /// Maximum delay between reconnect attempts. - Duration get max => Duration(microseconds: maxUs); + /// Maximum delay between reconnect attempts, or null for the default. + Duration? get max => maxUs == null ? null : Duration(microseconds: maxUs!); - /// Time spent retrying before giving up. [Duration.zero] retries forever. - Duration get timeout => Duration(microseconds: timeoutUs); + /// Time spent retrying before giving up, or null for the default. + /// [Duration.zero] retries forever. + Duration? get timeout => + timeoutUs == null ? null : Duration(microseconds: timeoutUs!); } /// Duration views over the subscription knobs. diff --git a/dart/moq/lib/src/server.dart b/dart/moq/lib/src/server.dart index eacc0cfe62..cdbbd3f680 100644 --- a/dart/moq/lib/src/server.dart +++ b/dart/moq/lib/src/server.dart @@ -3,6 +3,9 @@ import 'package:moq_ffi/moq_ffi.dart'; import 'aliases.dart'; /// Everything [Server.listen] can be told. +/// +/// A value the native side cannot use fails [Server.listen] with +/// `MoqException.Config`. final class ListenOptions { /// Local socket address to listen on, e.g. `127.0.0.1:4443` or `[::]:443`. final String bind; @@ -16,19 +19,28 @@ final class ListenOptions { /// Hostnames to generate a self-signed certificate for. final List? tlsGenerate; + /// Protocol versions to accept, e.g. `moq-lite-03`. Null accepts every + /// supported version. + final List? versions; + + /// Cap on the concurrent QUIC streams each peer may open toward this server. + final int? maxStreams; + /// Origin whose broadcasts are served to incoming sessions; auto-created when null. final OriginProducer? publish; /// Origin that receives broadcasts published by incoming sessions; auto-created when null. - final OriginProducer? subscribe; + final OriginProducer? consume; const ListenOptions({ this.bind = '[::]:443', this.tlsCert, this.tlsKey, this.tlsGenerate, + this.versions, + this.maxStreams, this.publish, - this.subscribe, + this.consume, }); } @@ -52,30 +64,33 @@ final class Server { /// Bind a server at [ListenOptions.bind] and start accepting. /// - /// With neither [ListenOptions.publish] nor [ListenOptions.subscribe] given, + /// With neither [ListenOptions.publish] nor [ListenOptions.consume] given, /// both sides share one origin, so a broadcast created here is also visible /// to sessions publishing into this server. Wiring either side opts out and /// isolates the two directions. static Future listen({ ListenOptions options = const ListenOptions(), }) async { - final shared = options.publish == null && options.subscribe == null + final shared = options.publish == null && options.consume == null ? OriginProducer(config: OriginConfig()) : null; final publishOrigin = options.publish ?? shared; - final subscribeOrigin = options.subscribe ?? shared; - final server = MoqServer(); + final server = MoqServer( + config: MoqServerConfig( + bind: options.bind, + versions: options.versions ?? const [], + tls: MoqServerTls( + cert: options.tlsCert ?? const [], + key: options.tlsKey ?? const [], + generate: options.tlsGenerate ?? const [], + ), + quic: MoqQuicConfig(maxStreams: options.maxStreams), + publish: publishOrigin, + consume: options.consume ?? shared, + ), + ); try { - server.setBind(addr: options.bind); - if (options.tlsCert != null) server.setTlsCert(paths: options.tlsCert!); - if (options.tlsKey != null) server.setTlsKey(paths: options.tlsKey!); - if (options.tlsGenerate != null) { - server.setTlsGenerate(hostnames: options.tlsGenerate!); - } - if (publishOrigin != null) server.setPublish(origin: publishOrigin); - if (subscribeOrigin != null) server.setConsume(origin: subscribeOrigin); - final localAddr = await server.listen(); return Server._(server, localAddr, publishOrigin); } catch (_) { @@ -88,7 +103,7 @@ final class Server { /// Create a broadcast at [path], served to incoming sessions. /// /// Advertise it with `announce` after populating tracks. Throws when [listen] - /// was given a [ListenOptions.subscribe] origin but no + /// was given a [ListenOptions.consume] origin but no /// [ListenOptions.publish] one, since there is then nothing to serve from. BroadcastProducer createBroadcast(String path) { final origin = _publishOrigin; diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 039fe63203..73d230b043 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -19,11 +19,15 @@ Future nextRoute(AnnounceConsumer announced) async { void main() { test('connects, announces, subscribes, and delivers a frame', () async { final relay = MoqOriginProducer(config: MoqOriginConfig()); - final server = MoqServer(); - server.setBind(addr: '127.0.0.1:0'); - server.setTlsGenerate(hostnames: ['localhost']); - server.setPublish(origin: relay); - server.setConsume(origin: relay); + final server = MoqServer( + config: MoqServerConfig( + bind: '127.0.0.1:0', + tls: MoqServerTls(generate: ['localhost']), + quic: MoqQuicConfig(), + publish: relay, + consume: relay, + ), + ); final address = await server.listen().timeout(timeout); final accepted = () async { @@ -39,7 +43,7 @@ void main() { final serverSession = await accepted; expect(client.bandwidth(), isA()); - final announcement = client.announcements().firstWhere( + final announcement = client.announced().updates().firstWhere( (event) => event is AnnounceEventStart, ); final broadcast = relay.createBroadcast(path: 'live'); @@ -58,7 +62,7 @@ void main() { // Routed subscriptions pull their source lazily when the consumer is first read. final nextGroup = consumer.nextGroup(); - await track.used().timeout(timeout); + await track.demand().used().timeout(timeout); final producer = track.appendGroup(); producer.writeFrame( @@ -105,7 +109,7 @@ void main() { ).timeout(timeout); final serverSession = await accepted; - final announcement = client.announcements().firstWhere( + final announcement = client.announced().updates().firstWhere( (event) => event is AnnounceEventStart, ); final broadcast = server.createBroadcast('live'); @@ -135,6 +139,16 @@ void main() { ); }); + test('an invalid config fails before dialing', () { + expect( + Moq.connect( + 'https://localhost', + options: const ConnectOptions(versions: ['moq-lite-99']), + ), + throwsA(isA()), + ); + }); + test('closing a server releases its port', () async { final first = await Server.listen( options: const ListenOptions( diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index f34c91bad2..55132d4230 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -1920,16 +1920,212 @@ class FfiConverterMoqTrackInfo { } } +class MoqServerConfig { + final String? bind; + final List versions; + final MoqServerTls tls; + final MoqQuicConfig quic; + final MoqOriginProducer? publish; + final MoqOriginProducer? consume; + MoqServerConfig({ + this.bind = null, + this.versions = const [], + required this.tls, + required this.quic, + this.publish = null, + this.consume = null, + }); +} + +class FfiConverterMoqServerConfig { + static MoqServerConfig lift(RustBuffer buf) { + return FfiConverterMoqServerConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final bind_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final bind = bind_lifted.value; + new_offset += bind_lifted.bytesRead; + final versions_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final versions = versions_lifted.value; + new_offset += versions_lifted.bytesRead; + final tls_lifted = FfiConverterMoqServerTls.read( + Uint8List.view(buf.buffer, new_offset), + ); + final tls = tls_lifted.value; + new_offset += tls_lifted.bytesRead; + final quic_lifted = FfiConverterMoqQuicConfig.read( + Uint8List.view(buf.buffer, new_offset), + ); + final quic = quic_lifted.value; + new_offset += quic_lifted.bytesRead; + final publish_lifted = FfiConverterOptionalMoqOriginProducer.read( + Uint8List.view(buf.buffer, new_offset), + ); + final publish = publish_lifted.value; + new_offset += publish_lifted.bytesRead; + final consume_lifted = FfiConverterOptionalMoqOriginProducer.read( + Uint8List.view(buf.buffer, new_offset), + ); + final consume = consume_lifted.value; + new_offset += consume_lifted.bytesRead; + return LiftRetVal( + MoqServerConfig( + bind: bind, + versions: versions, + tls: tls, + quic: quic, + publish: publish, + consume: consume, + ), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqServerConfig value) { + final total_length = + FfiConverterOptionalString.allocationSize(value.bind) + + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterMoqServerTls.allocationSize(value.tls) + + FfiConverterMoqQuicConfig.allocationSize(value.quic) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqServerConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterOptionalString.write( + value.bind, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.versions, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterMoqServerTls.write( + value.tls, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterMoqQuicConfig.write( + value.quic, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalMoqOriginProducer.write( + value.publish, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalMoqOriginProducer.write( + value.consume, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqServerConfig value) { + return FfiConverterOptionalString.allocationSize(value.bind) + + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterMoqServerTls.allocationSize(value.tls) + + FfiConverterMoqQuicConfig.allocationSize(value.quic) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + + 0; + } +} + +class MoqServerTls { + final List cert; + final List key; + final List generate; + MoqServerTls({ + this.cert = const [], + this.key = const [], + this.generate = const [], + }); +} + +class FfiConverterMoqServerTls { + static MoqServerTls lift(RustBuffer buf) { + return FfiConverterMoqServerTls.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final cert_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final cert = cert_lifted.value; + new_offset += cert_lifted.bytesRead; + final key_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final key = key_lifted.value; + new_offset += key_lifted.bytesRead; + final generate_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final generate = generate_lifted.value; + new_offset += generate_lifted.bytesRead; + return LiftRetVal( + MoqServerTls(cert: cert, key: key, generate: generate), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqServerTls value) { + final total_length = + FfiConverterSequenceString.allocationSize(value.cert) + + FfiConverterSequenceString.allocationSize(value.key) + + FfiConverterSequenceString.allocationSize(value.generate) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqServerTls value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterSequenceString.write( + value.cert, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.key, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.generate, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqServerTls value) { + return FfiConverterSequenceString.allocationSize(value.cert) + + FfiConverterSequenceString.allocationSize(value.key) + + FfiConverterSequenceString.allocationSize(value.generate) + + 0; + } +} + class MoqBackoff { - final int initialUs; - final int multiplier; - final int maxUs; - final int timeoutUs; + final int? initialUs; + final int? multiplier; + final int? maxUs; + final int? timeoutUs; MoqBackoff({ - this.initialUs = 1000000, - this.multiplier = 2, - this.maxUs = 5000000, - this.timeoutUs = 10000000, + this.initialUs = null, + this.multiplier = null, + this.maxUs = null, + this.timeoutUs = null, }); } @@ -1940,22 +2136,22 @@ class FfiConverterMoqBackoff { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final initialUs_lifted = FfiConverterUInt64.read( + final initialUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final initialUs = initialUs_lifted.value; new_offset += initialUs_lifted.bytesRead; - final multiplier_lifted = FfiConverterUInt32.read( + final multiplier_lifted = FfiConverterOptionalUInt32.read( Uint8List.view(buf.buffer, new_offset), ); final multiplier = multiplier_lifted.value; new_offset += multiplier_lifted.bytesRead; - final maxUs_lifted = FfiConverterUInt64.read( + final maxUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final maxUs = maxUs_lifted.value; new_offset += maxUs_lifted.bytesRead; - final timeoutUs_lifted = FfiConverterUInt64.read( + final timeoutUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final timeoutUs = timeoutUs_lifted.value; @@ -1973,10 +2169,10 @@ class FfiConverterMoqBackoff { static RustBuffer lower(MoqBackoff value) { final total_length = - FfiConverterUInt64.allocationSize(value.initialUs) + - FfiConverterUInt32.allocationSize(value.multiplier) + - FfiConverterUInt64.allocationSize(value.maxUs) + - FfiConverterUInt64.allocationSize(value.timeoutUs) + + FfiConverterOptionalUInt64.allocationSize(value.initialUs) + + FfiConverterOptionalUInt32.allocationSize(value.multiplier) + + FfiConverterOptionalUInt64.allocationSize(value.maxUs) + + FfiConverterOptionalUInt64.allocationSize(value.timeoutUs) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1985,19 +2181,19 @@ class FfiConverterMoqBackoff { static int write(MoqBackoff value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.initialUs, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt32.write( + new_offset += FfiConverterOptionalUInt32.write( value.multiplier, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.maxUs, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.timeoutUs, Uint8List.view(buf.buffer, new_offset), ); @@ -2005,10 +2201,294 @@ class FfiConverterMoqBackoff { } static int allocationSize(MoqBackoff value) { - return FfiConverterUInt64.allocationSize(value.initialUs) + - FfiConverterUInt32.allocationSize(value.multiplier) + - FfiConverterUInt64.allocationSize(value.maxUs) + - FfiConverterUInt64.allocationSize(value.timeoutUs) + + return FfiConverterOptionalUInt64.allocationSize(value.initialUs) + + FfiConverterOptionalUInt32.allocationSize(value.multiplier) + + FfiConverterOptionalUInt64.allocationSize(value.maxUs) + + FfiConverterOptionalUInt64.allocationSize(value.timeoutUs) + + 0; + } +} + +class MoqClientConfig { + final String? bind; + final List versions; + final MoqClientTls tls; + final MoqQuicConfig quic; + final MoqWebSocketConfig websocket; + final bool once; + final MoqBackoff backoff; + final MoqOriginProducer? publish; + final MoqOriginProducer? consume; + MoqClientConfig({ + this.bind = null, + this.versions = const [], + required this.tls, + required this.quic, + required this.websocket, + this.once = false, + required this.backoff, + this.publish = null, + this.consume = null, + }); +} + +class FfiConverterMoqClientConfig { + static MoqClientConfig lift(RustBuffer buf) { + return FfiConverterMoqClientConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final bind_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final bind = bind_lifted.value; + new_offset += bind_lifted.bytesRead; + final versions_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final versions = versions_lifted.value; + new_offset += versions_lifted.bytesRead; + final tls_lifted = FfiConverterMoqClientTls.read( + Uint8List.view(buf.buffer, new_offset), + ); + final tls = tls_lifted.value; + new_offset += tls_lifted.bytesRead; + final quic_lifted = FfiConverterMoqQuicConfig.read( + Uint8List.view(buf.buffer, new_offset), + ); + final quic = quic_lifted.value; + new_offset += quic_lifted.bytesRead; + final websocket_lifted = FfiConverterMoqWebSocketConfig.read( + Uint8List.view(buf.buffer, new_offset), + ); + final websocket = websocket_lifted.value; + new_offset += websocket_lifted.bytesRead; + final once_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final once = once_lifted.value; + new_offset += once_lifted.bytesRead; + final backoff_lifted = FfiConverterMoqBackoff.read( + Uint8List.view(buf.buffer, new_offset), + ); + final backoff = backoff_lifted.value; + new_offset += backoff_lifted.bytesRead; + final publish_lifted = FfiConverterOptionalMoqOriginProducer.read( + Uint8List.view(buf.buffer, new_offset), + ); + final publish = publish_lifted.value; + new_offset += publish_lifted.bytesRead; + final consume_lifted = FfiConverterOptionalMoqOriginProducer.read( + Uint8List.view(buf.buffer, new_offset), + ); + final consume = consume_lifted.value; + new_offset += consume_lifted.bytesRead; + return LiftRetVal( + MoqClientConfig( + bind: bind, + versions: versions, + tls: tls, + quic: quic, + websocket: websocket, + once: once, + backoff: backoff, + publish: publish, + consume: consume, + ), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqClientConfig value) { + final total_length = + FfiConverterOptionalString.allocationSize(value.bind) + + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterMoqClientTls.allocationSize(value.tls) + + FfiConverterMoqQuicConfig.allocationSize(value.quic) + + FfiConverterMoqWebSocketConfig.allocationSize(value.websocket) + + FfiConverterBool.allocationSize(value.once) + + FfiConverterMoqBackoff.allocationSize(value.backoff) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqClientConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterOptionalString.write( + value.bind, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.versions, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterMoqClientTls.write( + value.tls, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterMoqQuicConfig.write( + value.quic, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterMoqWebSocketConfig.write( + value.websocket, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterBool.write( + value.once, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterMoqBackoff.write( + value.backoff, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalMoqOriginProducer.write( + value.publish, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalMoqOriginProducer.write( + value.consume, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqClientConfig value) { + return FfiConverterOptionalString.allocationSize(value.bind) + + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterMoqClientTls.allocationSize(value.tls) + + FfiConverterMoqQuicConfig.allocationSize(value.quic) + + FfiConverterMoqWebSocketConfig.allocationSize(value.websocket) + + FfiConverterBool.allocationSize(value.once) + + FfiConverterMoqBackoff.allocationSize(value.backoff) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + + 0; + } +} + +class MoqClientTls { + final bool insecure; + final List roots; + final bool? systemRoots; + final List fingerprints; + final String? cert; + final String? key; + MoqClientTls({ + this.insecure = false, + this.roots = const [], + this.systemRoots = null, + this.fingerprints = const [], + this.cert = null, + this.key = null, + }); +} + +class FfiConverterMoqClientTls { + static MoqClientTls lift(RustBuffer buf) { + return FfiConverterMoqClientTls.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final insecure_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final insecure = insecure_lifted.value; + new_offset += insecure_lifted.bytesRead; + final roots_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final roots = roots_lifted.value; + new_offset += roots_lifted.bytesRead; + final systemRoots_lifted = FfiConverterOptionalBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final systemRoots = systemRoots_lifted.value; + new_offset += systemRoots_lifted.bytesRead; + final fingerprints_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final fingerprints = fingerprints_lifted.value; + new_offset += fingerprints_lifted.bytesRead; + final cert_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final cert = cert_lifted.value; + new_offset += cert_lifted.bytesRead; + final key_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final key = key_lifted.value; + new_offset += key_lifted.bytesRead; + return LiftRetVal( + MoqClientTls( + insecure: insecure, + roots: roots, + systemRoots: systemRoots, + fingerprints: fingerprints, + cert: cert, + key: key, + ), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqClientTls value) { + final total_length = + FfiConverterBool.allocationSize(value.insecure) + + FfiConverterSequenceString.allocationSize(value.roots) + + FfiConverterOptionalBool.allocationSize(value.systemRoots) + + FfiConverterSequenceString.allocationSize(value.fingerprints) + + FfiConverterOptionalString.allocationSize(value.cert) + + FfiConverterOptionalString.allocationSize(value.key) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqClientTls value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterBool.write( + value.insecure, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.roots, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalBool.write( + value.systemRoots, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.fingerprints, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalString.write( + value.cert, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalString.write( + value.key, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqClientTls value) { + return FfiConverterBool.allocationSize(value.insecure) + + FfiConverterSequenceString.allocationSize(value.roots) + + FfiConverterOptionalBool.allocationSize(value.systemRoots) + + FfiConverterSequenceString.allocationSize(value.fingerprints) + + FfiConverterOptionalString.allocationSize(value.cert) + + FfiConverterOptionalString.allocationSize(value.key) + 0; } } @@ -2176,6 +2656,110 @@ class FfiConverterMoqConnectionStats { } } +class MoqQuicConfig { + final int? maxStreams; + MoqQuicConfig({this.maxStreams = null}); +} + +class FfiConverterMoqQuicConfig { + static MoqQuicConfig lift(RustBuffer buf) { + return FfiConverterMoqQuicConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final maxStreams_lifted = FfiConverterOptionalUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final maxStreams = maxStreams_lifted.value; + new_offset += maxStreams_lifted.bytesRead; + return LiftRetVal( + MoqQuicConfig(maxStreams: maxStreams), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqQuicConfig value) { + final total_length = + FfiConverterOptionalUInt64.allocationSize(value.maxStreams) + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqQuicConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterOptionalUInt64.write( + value.maxStreams, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqQuicConfig value) { + return FfiConverterOptionalUInt64.allocationSize(value.maxStreams) + 0; + } +} + +class MoqWebSocketConfig { + final bool? enabled; + final int? delayUs; + MoqWebSocketConfig({this.enabled = null, this.delayUs = null}); +} + +class FfiConverterMoqWebSocketConfig { + static MoqWebSocketConfig lift(RustBuffer buf) { + return FfiConverterMoqWebSocketConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final enabled_lifted = FfiConverterOptionalBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final enabled = enabled_lifted.value; + new_offset += enabled_lifted.bytesRead; + final delayUs_lifted = FfiConverterOptionalUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final delayUs = delayUs_lifted.value; + new_offset += delayUs_lifted.bytesRead; + return LiftRetVal( + MoqWebSocketConfig(enabled: enabled, delayUs: delayUs), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqWebSocketConfig value) { + final total_length = + FfiConverterOptionalBool.allocationSize(value.enabled) + + FfiConverterOptionalUInt64.allocationSize(value.delayUs) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqWebSocketConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterOptionalBool.write( + value.enabled, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalUInt64.write( + value.delayUs, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqWebSocketConfig value) { + return FfiConverterOptionalBool.allocationSize(value.enabled) + + FfiConverterOptionalUInt64.allocationSize(value.delayUs) + + 0; + } +} + abstract class MoqException implements Exception { RustBuffer lower(); int allocationSize(); @@ -2359,6 +2943,12 @@ class FfiConverterMoqException { lifted.value, lifted.bytesRead - subview.offsetInBytes + 4, ); + case 29: + final lifted = ConfigMoqException.read(subview); + return LiftRetVal( + lifted.value, + lifted.bytesRead - subview.offsetInBytes + 4, + ); default: throw UniffiInternalError( UniffiInternalError.unexpectedEnumCase, @@ -3398,18 +3988,61 @@ class InvalidPatternMoqException extends MoqException { } } -class UnresolvableBroadcastMoqException extends MoqException { +class UnresolvableBroadcastMoqException extends MoqException { + final String v0; + UnresolvableBroadcastMoqException(String this.v0); + UnresolvableBroadcastMoqException._(String this.v0); + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final v0_lifted = FfiConverterString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final v0 = v0_lifted.value; + new_offset += v0_lifted.bytesRead; + return LiftRetVal(UnresolvableBroadcastMoqException._(v0), new_offset); + } + + @override + RustBuffer lower() { + final buf = Uint8List(allocationSize()); + write(buf); + return toRustBuffer(buf); + } + + @override + int allocationSize() { + return FfiConverterString.allocationSize(v0) + 4; + } + + @override + int write(Uint8List buf) { + buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 27); + int new_offset = buf.offsetInBytes + 4; + new_offset += FfiConverterString.write( + v0, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset; + } + + @override + String toString() { + return "UnresolvableBroadcastMoqException($v0)"; + } +} + +class LogMoqException extends MoqException { final String v0; - UnresolvableBroadcastMoqException(String this.v0); - UnresolvableBroadcastMoqException._(String this.v0); - static LiftRetVal read(Uint8List buf) { + LogMoqException(String this.v0); + LogMoqException._(String this.v0); + static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; final v0_lifted = FfiConverterString.read( Uint8List.view(buf.buffer, new_offset), ); final v0 = v0_lifted.value; new_offset += v0_lifted.bytesRead; - return LiftRetVal(UnresolvableBroadcastMoqException._(v0), new_offset); + return LiftRetVal(LogMoqException._(v0), new_offset); } @override @@ -3426,7 +4059,7 @@ class UnresolvableBroadcastMoqException extends MoqException { @override int write(Uint8List buf) { - buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 27); + buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 28); int new_offset = buf.offsetInBytes + 4; new_offset += FfiConverterString.write( v0, @@ -3437,22 +4070,22 @@ class UnresolvableBroadcastMoqException extends MoqException { @override String toString() { - return "UnresolvableBroadcastMoqException($v0)"; + return "LogMoqException($v0)"; } } -class LogMoqException extends MoqException { +class ConfigMoqException extends MoqException { final String v0; - LogMoqException(String this.v0); - LogMoqException._(String this.v0); - static LiftRetVal read(Uint8List buf) { + ConfigMoqException(String this.v0); + ConfigMoqException._(String this.v0); + static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; final v0_lifted = FfiConverterString.read( Uint8List.view(buf.buffer, new_offset), ); final v0 = v0_lifted.value; new_offset += v0_lifted.bytesRead; - return LiftRetVal(LogMoqException._(v0), new_offset); + return LiftRetVal(ConfigMoqException._(v0), new_offset); } @override @@ -3469,7 +4102,7 @@ class LogMoqException extends MoqException { @override int write(Uint8List buf) { - buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 28); + buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 29); int new_offset = buf.offsetInBytes + 4; new_offset += FfiConverterString.write( v0, @@ -3480,7 +4113,7 @@ class LogMoqException extends MoqException { @override String toString() { - return "LogMoqException($v0)"; + return "ConfigMoqException($v0)"; } } @@ -7322,9 +7955,6 @@ abstract class MoqTrackProducerInterface { MoqTrackDynamic dynamic_(); void finish(); void finishAt({required int finalSequence}); - String name(); - Future unused(); - Future used(); void writeFrame({required MoqFrame frame}); } @@ -7449,42 +8079,6 @@ class MoqTrackProducer implements MoqTrackProducerInterface { }, moqExceptionErrorHandler); } - String name() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqtrackproducer_name( - uniffiClonePointer(), - status, - ), - FfiConverterString.lift, - moqExceptionErrorHandler, - ); - } - - Future unused() { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqtrackproducer_unused( - uniffiClonePointer(), - ), - ffi_moq_ffi_rust_future_poll_void, - ffi_moq_ffi_rust_future_complete_void, - ffi_moq_ffi_rust_future_free_void, - (_) {}, - moqExceptionErrorHandler, - ); - } - - Future used() { - return uniffiRustCallAsync( - () => - uniffi_moq_ffi_fn_method_moqtrackproducer_used(uniffiClonePointer()), - ffi_moq_ffi_rust_future_poll_void, - ffi_moq_ffi_rust_future_complete_void, - ffi_moq_ffi_rust_future_free_void, - (_) {}, - moqExceptionErrorHandler, - ); - } - void writeFrame({required MoqFrame frame}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqtrackproducer_write_frame( @@ -7783,12 +8377,6 @@ abstract class MoqServerInterface { void cancel(); List certFingerprints(); Future listen(); - void setBind({required String addr}); - void setConsume({required MoqOriginProducer? origin}); - void setPublish({required MoqOriginProducer? origin}); - void setTlsCert({required List paths}); - void setTlsGenerate({required List hostnames}); - void setTlsKey({required List paths}); } final _MoqServerFinalizer = Finalizer>((ptr) { @@ -7800,10 +8388,13 @@ class MoqServer implements MoqServerInterface { MoqServer._(this._ptr) { _MoqServerFinalizer.attach(this, _ptr, detach: this); } - MoqServer() + MoqServer({required MoqServerConfig config}) : _ptr = rustCall( - (status) => uniffi_moq_ffi_fn_constructor_moqserver_new(status), - null, + (status) => uniffi_moq_ffi_fn_constructor_moqserver_new( + FfiConverterMoqServerConfig.lower(config), + status, + ), + moqExceptionErrorHandler, ) { _MoqServerFinalizer.attach(this, _ptr, detach: this); } @@ -7859,66 +8450,6 @@ class MoqServer implements MoqServerInterface { moqExceptionErrorHandler, ); } - - void setBind({required String addr}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqserver_set_bind( - uniffiClonePointer(), - FfiConverterString.lower(addr), - status, - ); - }, moqExceptionErrorHandler); - } - - void setConsume({required MoqOriginProducer? origin}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqserver_set_consume( - uniffiClonePointer(), - FfiConverterOptionalMoqOriginProducer.lower(origin), - status, - ); - }, moqExceptionErrorHandler); - } - - void setPublish({required MoqOriginProducer? origin}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqserver_set_publish( - uniffiClonePointer(), - FfiConverterOptionalMoqOriginProducer.lower(origin), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsCert({required List paths}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqserver_set_tls_cert( - uniffiClonePointer(), - FfiConverterSequenceString.lower(paths), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsGenerate({required List hostnames}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqserver_set_tls_generate( - uniffiClonePointer(), - FfiConverterSequenceString.lower(hostnames), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsKey({required List paths}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqserver_set_tls_key( - uniffiClonePointer(), - FfiConverterSequenceString.lower(paths), - status, - ); - }, moqExceptionErrorHandler); - } } class FfiConverterMoqServer { @@ -7950,20 +8481,6 @@ class FfiConverterMoqServer { abstract class MoqClientInterface { void cancel(); Future connect({required String url}); - void setBackoff({required MoqBackoff backoff}); - void setBind({required String addr}); - void setConsume({required MoqOriginProducer? origin}); - void setPublish({required MoqOriginProducer? origin}); - void setQuicMaxStreams({required int maxStreams}); - void setReconnect({required bool enabled}); - void setTlsCert({required String? path}); - void setTlsFingerprints({required List fingerprints}); - void setTlsKey({required String? path}); - void setTlsRoots({required List paths}); - void setTlsSystemRoots({required bool systemRoots}); - void setTlsVerify({required bool verify}); - void setWebsocketDelay({required int delayUs}); - void setWebsocketEnabled({required bool enabled}); } final _MoqClientFinalizer = Finalizer>((ptr) { @@ -7975,10 +8492,13 @@ class MoqClient implements MoqClientInterface { MoqClient._(this._ptr) { _MoqClientFinalizer.attach(this, _ptr, detach: this); } - MoqClient() + MoqClient({required MoqClientConfig config}) : _ptr = rustCall( - (status) => uniffi_moq_ffi_fn_constructor_moqclient_new(status), - null, + (status) => uniffi_moq_ffi_fn_constructor_moqclient_new( + FfiConverterMoqClientConfig.lower(config), + status, + ), + moqExceptionErrorHandler, ) { _MoqClientFinalizer.attach(this, _ptr, detach: this); } @@ -8015,146 +8535,6 @@ class MoqClient implements MoqClientInterface { moqExceptionErrorHandler, ); } - - void setBackoff({required MoqBackoff backoff}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_backoff( - uniffiClonePointer(), - FfiConverterMoqBackoff.lower(backoff), - status, - ); - }, moqExceptionErrorHandler); - } - - void setBind({required String addr}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_bind( - uniffiClonePointer(), - FfiConverterString.lower(addr), - status, - ); - }, moqExceptionErrorHandler); - } - - void setConsume({required MoqOriginProducer? origin}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_consume( - uniffiClonePointer(), - FfiConverterOptionalMoqOriginProducer.lower(origin), - status, - ); - }, moqExceptionErrorHandler); - } - - void setPublish({required MoqOriginProducer? origin}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_publish( - uniffiClonePointer(), - FfiConverterOptionalMoqOriginProducer.lower(origin), - status, - ); - }, moqExceptionErrorHandler); - } - - void setQuicMaxStreams({required int maxStreams}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_quic_max_streams( - uniffiClonePointer(), - FfiConverterUInt64.lower(maxStreams), - status, - ); - }, moqExceptionErrorHandler); - } - - void setReconnect({required bool enabled}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_reconnect( - uniffiClonePointer(), - FfiConverterBool.lower(enabled), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsCert({required String? path}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_tls_cert( - uniffiClonePointer(), - FfiConverterOptionalString.lower(path), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsFingerprints({required List fingerprints}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_tls_fingerprints( - uniffiClonePointer(), - FfiConverterSequenceString.lower(fingerprints), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsKey({required String? path}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_tls_key( - uniffiClonePointer(), - FfiConverterOptionalString.lower(path), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsRoots({required List paths}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_tls_roots( - uniffiClonePointer(), - FfiConverterSequenceString.lower(paths), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsSystemRoots({required bool systemRoots}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_tls_system_roots( - uniffiClonePointer(), - FfiConverterBool.lower(systemRoots), - status, - ); - }, moqExceptionErrorHandler); - } - - void setTlsVerify({required bool verify}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_tls_verify( - uniffiClonePointer(), - FfiConverterBool.lower(verify), - status, - ); - }, moqExceptionErrorHandler); - } - - void setWebsocketDelay({required int delayUs}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_websocket_delay( - uniffiClonePointer(), - FfiConverterUInt64.lower(delayUs), - status, - ); - }, moqExceptionErrorHandler); - } - - void setWebsocketEnabled({required bool enabled}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqclient_set_websocket_enabled( - uniffiClonePointer(), - FfiConverterBool.lower(enabled), - status, - ); - }, moqExceptionErrorHandler); - } } class FfiConverterMoqClient { @@ -9328,7 +9708,52 @@ class FfiConverterOptionalString { return 1; } buf[0] = 1; - return FfiConverterString.write( + return FfiConverterString.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + +class FfiConverterOptionalUInt32 { + static int? lift(RustBuffer buf) { + return FfiConverterOptionalUInt32.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterUInt32.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([int? value]) { + if (value == null) { + return 1; + } + return FfiConverterUInt32.allocationSize(value) + 1; + } + + static RustBuffer lower(int? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List(FfiConverterOptionalUInt32.allocationSize(value)); + FfiConverterOptionalUInt32.write(value, buf); + return toRustBuffer(buf); + } + + static int write(int? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterUInt32.write( value, Uint8List.view(buf.buffer, buf.offsetInBytes + 1), ) + @@ -11209,24 +11634,6 @@ external void uniffi_moq_ffi_fn_method_moqtrackproducer_finish_at( Pointer uniffiStatus, ); -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external RustBuffer uniffi_moq_ffi_fn_method_moqtrackproducer_name( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtrackproducer_unused( - Pointer ptr, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtrackproducer_used( - Pointer ptr, -); - @Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) @@ -11387,10 +11794,11 @@ external void uniffi_moq_ffi_fn_free_moqserver( Pointer uniffiStatus, ); -@Native Function(Pointer)>( +@Native Function(RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) external Pointer uniffi_moq_ffi_fn_constructor_moqserver_new( + RustBuffer config, Pointer uniffiStatus, ); @@ -11420,60 +11828,6 @@ external Pointer uniffi_moq_ffi_fn_method_moqserver_listen( Pointer ptr, ); -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqserver_set_bind( - Pointer ptr, - RustBuffer addr, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqserver_set_consume( - Pointer ptr, - RustBuffer origin, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqserver_set_publish( - Pointer ptr, - RustBuffer origin, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqserver_set_tls_cert( - Pointer ptr, - RustBuffer paths, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqserver_set_tls_generate( - Pointer ptr, - RustBuffer hostnames, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqserver_set_tls_key( - Pointer ptr, - RustBuffer paths, - Pointer uniffiStatus, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -11490,10 +11844,11 @@ external void uniffi_moq_ffi_fn_free_moqclient( Pointer uniffiStatus, ); -@Native Function(Pointer)>( +@Native Function(RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) external Pointer uniffi_moq_ffi_fn_constructor_moqclient_new( + RustBuffer config, Pointer uniffiStatus, ); @@ -11513,132 +11868,6 @@ external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( RustBuffer url, ); -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_backoff( - Pointer ptr, - RustBuffer backoff, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_bind( - Pointer ptr, - RustBuffer addr, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_consume( - Pointer ptr, - RustBuffer origin, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_publish( - Pointer ptr, - RustBuffer origin, - Pointer uniffiStatus, -); - -@Native, Uint64, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_quic_max_streams( - Pointer ptr, - int max_streams, - Pointer uniffiStatus, -); - -@Native, Int8, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_reconnect( - Pointer ptr, - int enabled, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_tls_cert( - Pointer ptr, - RustBuffer path, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_tls_fingerprints( - Pointer ptr, - RustBuffer fingerprints, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_tls_key( - Pointer ptr, - RustBuffer path, - Pointer uniffiStatus, -); - -@Native, RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_tls_roots( - Pointer ptr, - RustBuffer paths, - Pointer uniffiStatus, -); - -@Native, Int8, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_tls_system_roots( - Pointer ptr, - int system_roots, - Pointer uniffiStatus, -); - -@Native, Int8, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_tls_verify( - Pointer ptr, - int verify, - Pointer uniffiStatus, -); - -@Native, Uint64, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_websocket_delay( - Pointer ptr, - int delay_us, - Pointer uniffiStatus, -); - -@Native, Int8, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqclient_set_websocket_enabled( - Pointer ptr, - int enabled, - Pointer uniffiStatus, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -12425,15 +12654,6 @@ external int uniffi_moq_ffi_checksum_method_moqtrackproducer_finish(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_finish_at(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackproducer_name(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackproducer_unused(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackproducer_used(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame(); @@ -12488,72 +12708,12 @@ external int uniffi_moq_ffi_checksum_method_moqserver_cert_fingerprints(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqserver_listen(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqserver_set_bind(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqserver_set_consume(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqserver_set_publish(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqserver_set_tls_cert(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqserver_set_tls_generate(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqserver_set_tls_key(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_connect(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_backoff(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_bind(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_consume(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_publish(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_quic_max_streams(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_reconnect(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_cert(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_fingerprints(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_key(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_roots(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_system_roots(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_verify(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_websocket_delay(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqclient_set_websocket_enabled(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_bandwidth(); @@ -13000,7 +13160,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackproducer_create_group() != 38978) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_demand() != 32311) { + if (uniffi_moq_ffi_checksum_method_moqtrackproducer_demand() != 7311) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_dynamic() != 58584) { @@ -13012,15 +13172,6 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackproducer_finish_at() != 24581) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_name() != 14598) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_unused() != 29609) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_used() != 19906) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame() != 18663) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -13069,80 +13220,16 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqserver_cancel() != 56970) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqserver_cert_fingerprints() != 32082) { + if (uniffi_moq_ffi_checksum_method_moqserver_cert_fingerprints() != 57398) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqserver_listen() != 9040) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqserver_set_bind() != 55505) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqserver_set_consume() != 13635) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqserver_set_publish() != 48695) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqserver_set_tls_cert() != 33276) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqserver_set_tls_generate() != 148) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqserver_set_tls_key() != 56395) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqclient_cancel() != 29949) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 42368) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_backoff() != 63523) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_bind() != 56346) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_consume() != 4978) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_publish() != 64932) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_quic_max_streams() != - 17062) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_reconnect() != 53736) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_cert() != 12773) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_fingerprints() != - 50038) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_key() != 19390) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_roots() != 5399) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_system_roots() != - 10239) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_verify() != 64525) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_websocket_delay() != 53033) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqclient_set_websocket_enabled() != - 65261) { + if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 61750) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqsession_bandwidth() != 8006) { @@ -13201,10 +13288,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_constructor_moqbroadcastproducer_new() != 37572) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_constructor_moqserver_new() != 42979) { + if (uniffi_moq_ffi_checksum_constructor_moqserver_new() != 49910) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_constructor_moqclient_new() != 44907) { + if (uniffi_moq_ffi_checksum_constructor_moqclient_new() != 48355) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } } diff --git a/dart/moq_ffi/test/leak_test.dart b/dart/moq_ffi/test/leak_test.dart index fe8ce19431..3c12a92243 100644 --- a/dart/moq_ffi/test/leak_test.dart +++ b/dart/moq_ffi/test/leak_test.dart @@ -17,18 +17,20 @@ void main() { name: 'x' * size, info: null, ); + final demand = track.demand(); // Warm up so one-time allocations do not count as growth. for (var i = 0; i < 100; i++) { - track.name(); + demand.name(); } final before = ProcessInfo.currentRss; for (var i = 0; i < iterations; i++) { - track.name(); + demand.name(); } final growth = ProcessInfo.currentRss - before; expect(growth, lessThan(leaked ~/ 4)); + track.dispose(); }); test('a non-null optional argument is released', () async { diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index de4bcfbf83..4c35a552a5 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -27,9 +27,12 @@ import 'package:moq/moq.dart'; final moq = await Moq.connect('https://relay.example.com'); // Subscribe. The stream is live, so listen to it rather than awaiting its end. -moq.announcements( - options: const AnnounceOptions(prefix: 'live/', filter: '*/camera'), -).listen((event) { +moq + .announced( + options: const AnnounceOptions(prefix: 'live/', filter: '*/camera'), + ) + .updates() + .listen((event) { if (event is AnnounceEventStart) { print(event.announce.prefix); print(event.announce.captures); @@ -74,7 +77,7 @@ call is a no-op); `origin.dynamic_(prefix:, route:)` claims `prefix` and every path beneath it (`''` for everything; Dart spells the origin method `dynamic_` because `dynamic` is reserved). Hold the returned handle while the claim should stay advertised, and reject the requests you will not serve. A -route is a capability, not an inventory. `announcements(options:)` takes a +route is a capability, not an inventory. `announced(options:).updates()` takes a literal prefix plus an optional relative pattern and yields `AnnounceEvent`s: `AnnounceEventStart`, `AnnounceEventUpdate`, or `AnnounceEventEnd` carrying an `Announce`, whose `prefix` stays origin-relative and whose @@ -87,7 +90,9 @@ broadcasts. `Moq.connect` and `Server.listen` take a `ConnectOptions` / `ListenOptions` struct, like Rust: `reconnect: false` makes the dial one-shot and `backoff:` re-paces the retries. `moq.epoch` counts the connections, 1 on the first, pairing with `session.status()` to log each reconnect; `maxStreams` raises the peer's -inbound stream cap for a subscriber to many tracks. +inbound stream cap for a subscriber to many tracks; and `versions:` pins the +protocol versions offered. A value the native side cannot use throws +`ConfigMoqException`; `publish:` and `consume:` wire the origins. The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after a 200 ms head start. `websocketEnabled: false` turns it off for a QUIC-only @@ -101,8 +106,8 @@ wrappers. `Container`, `Route`, and the exceptions keep theirs, because Cancelling a stream releases the native cursor. The package re-exports `moq_ffi`, so the full generated API is available without a second import. -Generated configuration setters throw if a connect, listen, or accept is in -flight, or after `cancel()`. Incoming requests report a `MoqTransport` enum. +`MoqRequest.setPublish`/`setConsume` throw if an accept is in flight, after a +response, or after `cancel()`. Incoming requests report a `MoqTransport` enum. `ProtocolMoqException` carries a `MoqProtocolException` as `details` (scope, verbatim code, kind) when the peer sent a session or stream code. From 8faa410a793b0f7853a00a746e9a9c5e5f2d08b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:24:26 -0700 Subject: [PATCH 8/9] quest: finish quest/m1/ffi-shape/net; note duration records for media and codec Co-Authored-By: Claude Opus 5.5 --- quest/m1/ffi-shape/README.md | 1 - quest/m1/ffi-shape/codec.md | 4 +++ quest/m1/ffi-shape/media.md | 4 +++ quest/m1/ffi-shape/net.md | 46 --------------------------------- test/interop/clients/go/main.go | 4 +-- 5 files changed, 10 insertions(+), 49 deletions(-) delete mode 100644 quest/m1/ffi-shape/net.md diff --git a/quest/m1/ffi-shape/README.md b/quest/m1/ffi-shape/README.md index f18042a408..ee7128e062 100644 --- a/quest/m1/ffi-shape/README.md +++ b/quest/m1/ffi-shape/README.md @@ -54,7 +54,6 @@ work no child does: ## Required -- [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 b68efe5906..65be6a669b 100644 --- a/quest/m1/ffi-shape/codec.md +++ b/quest/m1/ffi-shape/codec.md @@ -26,6 +26,10 @@ their callers construct, not one they are asked to match, so variant additively instead of breaking `gop` a second time. Go gets no uniffi default, so its zero value must read as keyframe mode. +The audio and video frame and decoder-output records carry microsecond fields +(`timestamp_us`, `max_age_us`, `frame_duration_us`); in Python and Go they +should become owned `timedelta` / `time.Duration` records like net's. + Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/ffi-shape/media.md b/quest/m1/ffi-shape/media.md index e515c63d4e..2d9553423f 100644 --- a/quest/m1/ffi-shape/media.md +++ b/quest/m1/ffi-shape/media.md @@ -24,4 +24,8 @@ once the shape is in front of you, and prefer one path. Go's `FetchMediaGroup` takes an options struct. Media producers watch subscribers through `demand()` only. +Net already gave Python and Go owned root records with `timedelta` / +`time.Duration` fields (`Frame`, `Subscription`, `TrackInfo`, ...); `MediaFrame` +and `MediaProducer`'s `timestamp_us`/`flush` arguments should follow suit. + Public API: breaking in every binding. Wire: none. diff --git a/quest/m1/ffi-shape/net.md b/quest/m1/ffi-shape/net.md deleted file mode 100644 index 8cf902b076..0000000000 --- a/quest/m1/ffi-shape/net.md +++ /dev/null @@ -1,46 +0,0 @@ -# [L] The root namespace matches moq-net - -## Goal - -What stays at the root reads like moq-net: `Client` and `Server` take config -records, getter-only handles are records, producers watch subscribers only -through `demand()`, and the verbs no additive change could rename -are renamed. - -## Plan - -- `Client::new(config)` and `Server::new(config)` replace the fallible - setters, mirroring moq-tokio's `client::Config` and `server::Config` with - nested TLS, QUIC, and backoff records. Validate in `new`. Resolve defaults - in Rust, since Go gets none; Option fields keep additions additive. This - also retires Kotlin `Moq.connect`'s twelve named parameters. -- The client config carries the protocol versions to offer, as moq-c's - `moq_client_config.versions` already does (`rs/moq-c/src/api.rs`), so every - binding can pin or restrict versions. moq-ffi has no version setter today. -- Objects that are only getters become records. - Handles with verbs (`Request`, `TrackRequest`, `GroupRequest`) stay objects. -- An enum whose variants a wrapper must name spells each variant - `` (`AnnounceEventStart`, `AnnounceEventEnd`) in Go, - Kotlin, Dart, and Python, whatever the generated name. Swift keeps its - generated `.` cases, since it cannot alias a case. -- `TrackProducer` drops `name`/`is_used`/`used`/`unused` for `demand()`. -- The renames no additive change could make: - - `subscribe` to `consume` on Python `Client`/`connect`, Kotlin - `Moq.connect`/`Server.listen`, Dart `ConnectOptions`/`ListenOptions`, and Go - `WithSubscribeOrigin`/`WithServerSubscribeOrigin`. - - Go's `All`/`Requests`/`Updates`/`Frames`/`Values` iterator helpers to one - verb. - - Kotlin and Dart `announcements` versus `announced`: one name for the - stream, one for the cursor, without the collision. One option is - `announced(config).updates()`, dropping `announcements` (prototyped with - the Dart renames in #3959). - - Microsecond fields as `timedelta` in Python and `time.Duration` in Go, - which means owning those record types in the wrapper rather than aliasing - the generated ones. -- Open: `Moq` (Kotlin, Dart) and `Client` (Python, Go) re-expose - `createBroadcast`, `announced`, `announcedBroadcast`, and `requestBroadcast` - from `session.publish()`/`session.consume()`. That is the same flat spread - this line removes, but dropping it costs every quick-start a hop (raised in - #3959). - -Public API: breaking in every binding. Wire: none. diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index e99e482112..ea48924dda 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -29,7 +29,7 @@ import ( const readChunk = 64 * 1024 // SubscribeMedia max age: how much reordering the jitter buffer tolerates. -const maxAgeUs = 1_000_000 +const maxAge = time.Second // Synthetic audio: a 48 kHz mono tone, encoded as Opus. const ( @@ -201,7 +201,7 @@ func subscribe(ctx context.Context, url, broadcast string, timeout time.Duration break } - media, err := consumer.SubscribeMedia(ctx, name, video.Container, &moq.Subscription{MaxAgeUs: maxAgeUs}) + media, err := consumer.SubscribeMedia(ctx, name, video.Container, &moq.Subscription{MaxAge: maxAge}) if err != nil { return err } From 172d929ea6ccd7da0f4a61b21529f70a1f21f52b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 1 Oct 2026 17:47:43 -0700 Subject: [PATCH 9/9] chore: format, and let stats-docs follow the owned Python/Go stats records Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/session.rs | 6 +----- rs/moq-ffi/src/test.rs | 10 ++++++++-- rs/scripts/stats-docs.py | 9 ++++++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 52b0d5a987..d9361ae770 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -189,9 +189,7 @@ impl Client { // Building the endpoint here, not at connect, is what surfaces an unreadable // certificate or a half-configured mTLS identity from `new`. - let client = connect - .init(quic) - .map_err(|err| MoqError::Config(format!("{err}")))?; + let client = connect.init(quic).map_err(|err| MoqError::Config(format!("{err}")))?; Ok(Self { client, @@ -260,7 +258,6 @@ fn map_closed_error(err: moq_tokio::Error) -> MoqError { } } - #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -578,7 +575,6 @@ impl MoqClient { } } - /// A snapshot of connection statistics for a [`MoqSession`]. /// /// Each field is `None` when the transport backend doesn't report that metric (native QUIC diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index e1f53f7b96..e9b416eb9b 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -3683,7 +3683,11 @@ async fn server_cancel_releases_the_bound_port() { // Park an accept on the server lock, the state a live server is closed in. let accepting = server.clone(); let accept = tokio::spawn(async move { accepting.accept().await }); - wait_for_config_error(|| server.cert_fingerprints().map(drop), |err| matches!(err, MoqError::Busy)).await; + wait_for_config_error( + || server.cert_fingerprints().map(drop), + |err| matches!(err, MoqError::Busy), + ) + .await; server.cancel(); @@ -4416,7 +4420,9 @@ async fn server_cert_fingerprints_busy_during_accept_and_cancelled_after() { }) .unwrap(); server.listen().await.expect("listen failed"); - server.cert_fingerprints().expect("fingerprints available between accepts"); + server + .cert_fingerprints() + .expect("fingerprints available between accepts"); let accepting = server.clone(); let accept = tokio::spawn(async move { accepting.accept().await }); diff --git a/rs/scripts/stats-docs.py b/rs/scripts/stats-docs.py index f4c95fd855..e6b6b34c5c 100644 --- a/rs/scripts/stats-docs.py +++ b/rs/scripts/stats-docs.py @@ -38,6 +38,13 @@ def pascal(field: str) -> str: "doc/lib/go/index.md": pascal, } +# Python and Go own their stats record so microseconds read as a duration type, +# which renames those fields. +RENAMED = { + "doc/lib/py/index.md": {"rtt_us": "rtt"}, + "doc/lib/go/index.md": {"rtt_us": "RTT"}, +} + def fields() -> list[str]: """The field names declared by `MoqConnectionStats`, in source order.""" @@ -57,7 +64,7 @@ def main() -> int: for page, spell in PAGES.items(): text = (ROOT / page).read_text() for field in names: - name = spell(field) + name = RENAMED.get(page, {}).get(field) or spell(field) if not re.search(rf"\b{re.escape(name)}\b", text): missing.append(f"{page}: {name}") if "_valid" not in (ROOT / C_PAGE).read_text():