From 28a701f65b6a13a76d41ce6673b005c915e976f9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:18:23 -0700 Subject: [PATCH 1/6] chore(quest): claim quest/m1/binding-peer-version.md Quest-Claim: 14c2526b915d6ee1 From f349675100bf90ddf8ea55f9eea21a2063494ca5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:24:19 -0700 Subject: [PATCH 2/6] feat(ffi): prefix the advertised implementation and expose the peer's Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/api.rs | 32 +++++++++++++++++++++++++++++ rs/moq-c/src/client.rs | 23 +++++++++++++++++++++ rs/moq-c/src/server.rs | 11 ++++++++++ rs/moq-c/src/test.rs | 27 ++++++++++++++++++++++++ rs/moq-ffi/src/server.rs | 17 +++++++++++++++- rs/moq-ffi/src/session.rs | 43 ++++++++++++++++++++++++++++++++++----- rs/moq-ffi/src/test.rs | 6 ++++++ 7 files changed, 153 insertions(+), 6 deletions(-) diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 6df6a978883..fbd7610459e 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -1239,6 +1239,12 @@ pub struct moq_client_config { /// Danger: write TLS session secrets to the file `SSLKEYLOGFILE` names, for /// decrypting a capture. Off when false; on without `SSLKEYLOGFILE` fails the dial. pub quic_keylog: bool, + + /// Your own `name/version` product tokens, space-separated, advertised in SETUP + /// ahead of this library's (`my-app/1.0 moq-c/0.7.0 moq-net-rs/0.3.10`), or NULL for none. + pub implementation: *const c_char, + /// The length in bytes of `implementation`. + pub implementation_len: usize, } /// The settings [moq_session_connect] dials with when given NULL. @@ -1507,6 +1513,12 @@ pub struct moq_server_config { /// Danger: write TLS session secrets to the file `SSLKEYLOGFILE` names, for /// decrypting a capture. Off when false; on without `SSLKEYLOGFILE` fails the listen. pub quic_keylog: bool, + + /// Your own `name/version` product tokens, advertised in SETUP ahead of this + /// library's, like [moq_client_config]'s `implementation`. NULL for none. + pub implementation: *const c_char, + /// The length in bytes of `implementation`. + pub implementation_len: usize, } /// Build the listener `config` describes, binding its socket. @@ -1515,6 +1527,8 @@ pub struct moq_server_config { /// - Every non-NULL pointer in `config` must be valid for its length. unsafe fn parse_server(config: &moq_server_config) -> Result { let mut listen = moq_tokio::listen::Config::default(); + let prefix = unsafe { ffi::parse_str_optional(config.implementation, config.implementation_len)? }; + listen.implementation = Some(crate::client::implementation(prefix)?); if let Some(bind) = unsafe { ffi::parse_str_optional(config.bind, config.bind_len)? } { let bind = moq_tokio::listen::Bind::from_str(bind) .map_err(|_| Error::InvalidConfig(format!("invalid bind address: {bind}")))?; @@ -1658,6 +1672,24 @@ pub unsafe extern "C" fn moq_session_request_query(request: u32, dst: *mut moq_s }) } +/// The implementation the client advertised in its SETUP, such as +/// `moq-c/0.7.0 moq-net-rs/0.3.10`, or a NULL `data` if it advertised none. +/// +/// Peer-controlled: use it for logs and rollout counts, never to authorize. The +/// destination borrows the request's storage, like [moq_session_request_path]. +/// Returns a zero on success, or a negative code on failure. +/// +/// # Safety +/// - `dst` must point at a writable [moq_string]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_session_request_peer_implementation(request: u32, dst: *mut moq_string) -> i32 { + ffi::enter(move || { + let request = ffi::parse_id(request)?; + let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?; + State::lock().server.request_peer_implementation(request, dst) + }) +} + /// Accept a session request, completing the MoQ handshake. /// /// Takes origin handles like [moq_session_connect]: broadcasts in `origin_publish` are diff --git a/rs/moq-c/src/client.rs b/rs/moq-c/src/client.rs index f2fdf99e8f1..09a512b3850 100644 --- a/rs/moq-c/src/client.rs +++ b/rs/moq-c/src/client.rs @@ -13,6 +13,25 @@ pub struct Config { pub quic: moq_tokio::quic::Config, } +/// What sessions opened through this library advertise in SETUP: the caller's `prefix`, +/// then moq-c, then its core. +pub fn implementation(prefix: Option<&str>) -> Result { + let core = format!("moq-c/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION); + let Some(prefix) = prefix else { + return Ok(core); + }; + // An empty token or stray whitespace would corrupt the list every peer splits on spaces. + if prefix + .split(' ') + .any(|token| token.is_empty() || token.contains(|c: char| c.is_whitespace() || c.is_control())) + { + return Err(Error::InvalidConfig(format!( + "implementation must be space-separated product tokens: {prefix:?}" + ))); + } + Ok(format!("{prefix} {core}")) +} + /// Build a client configuration from what C handed us. /// /// `None` (a NULL pointer) is the defaults. Otherwise every knob is read through @@ -28,11 +47,15 @@ pub struct Config { /// - Every non-NULL pointer in `config` must be valid for its length. pub unsafe fn parse_client(config: Option<&moq_client_config>) -> Result { let mut out = Config::default(); + out.connect.implementation = Some(implementation(None)?); let Some(config) = config else { return Ok(out); }; + let prefix = unsafe { ffi::parse_str_optional(config.implementation, config.implementation_len)? }; + out.connect.implementation = Some(implementation(prefix)?); + // Protocol let versions = unsafe { ffi::parse_strings(config.versions, config.versions_len)? }; if !versions.is_empty() { diff --git a/rs/moq-c/src/server.rs b/rs/moq-c/src/server.rs index 669bd68c0bd..fb7e140f1fb 100644 --- a/rs/moq-c/src/server.rs +++ b/rs/moq-c/src/server.rs @@ -127,6 +127,17 @@ impl Server { Ok(()) } + pub fn request_peer_implementation(&self, request: Id, dst: &mut moq_string) -> Result<(), Error> { + *dst = match self.request(request)?.peer_implementation() { + Some(implementation) => borrow(implementation), + None => moq_string { + data: std::ptr::null(), + len: 0, + }, + }; + Ok(()) + } + /// Remove a request from the table, for the call that answers it. pub fn request_take(&mut self, request: Id) -> Result { self.request.remove(request).ok_or(Error::NotFound) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 731d4bc3920..42ac17b27e0 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5117,6 +5117,24 @@ fn a_null_config_dials_with_the_defaults() { ); } +/// The caller's product tokens go ahead of the library's, and a malformed list fails the dial. +#[test] +fn implementation_prefixes_the_library() { + let core = format!("moq-c/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION); + assert_eq!(parsed(&client_config()).connect.implementation, Some(core.clone())); + + let mut config = client_config(); + let prefix = "moq-obs/0.7.0"; + config.implementation = prefix.as_ptr() as *const c_char; + config.implementation_len = prefix.len(); + assert_eq!(parsed(&config).connect.implementation, Some(format!("{prefix} {core}"))); + + let bad = "moq-obs/0.7.0 "; + config.implementation = bad.as_ptr() as *const c_char; + config.implementation_len = bad.len(); + assert!(unsafe { crate::parse_client(Some(&config)) }.is_err()); +} + /// The whole point of the `has_*` flags: a caller who zeroes the struct and sets /// nothing must land on the defaults, not on zero. The backoff and the WebSocket /// fallback are the ones with non-zero defaults, so they are what would break. @@ -5737,6 +5755,15 @@ fn server_accepts_a_session() { let mut query = moq_str(""); assert_eq!(unsafe { moq_session_request_query(request, &mut query) }, 0); assert_eq!(borrowed_string(query.data, query.len).as_deref(), Some("jwt=abc")); + let mut implementation = moq_str(""); + assert_eq!( + unsafe { moq_session_request_peer_implementation(request, &mut implementation) }, + 0 + ); + assert_eq!( + borrowed_string(implementation.data, implementation.len), + Some(format!("moq-c/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION)) + ); let session_cb = States::new(); let session = id(unsafe { moq_session_request_accept(request, served, 0, Some(state_callback), session_cb.ptr) }); diff --git a/rs/moq-ffi/src/server.rs b/rs/moq-ffi/src/server.rs index 8934eacae97..84436b40060 100644 --- a/rs/moq-ffi/src/server.rs +++ b/rs/moq-ffi/src/server.rs @@ -28,6 +28,10 @@ pub struct MoqServerConfig { /// QUIC transport tuning. #[uniffi(default)] pub quic: MoqQuicConfig, + /// Your own `name/version` product tokens, space-separated, advertised in SETUP ahead of + /// moq-ffi's, as [`MoqClientConfig::implementation`](crate::session::MoqClientConfig::implementation) does. + #[uniffi(default = None)] + pub implementation: Option, /// The origin whose broadcasts are served to incoming sessions. /// /// With neither `publish` nor `consume` set, each session's two sides share one fresh @@ -155,7 +159,7 @@ async fn serve( impl ServerState { fn new(config: MoqServerConfig) -> Result { let mut listen = moq_tokio::listen::Config::default(); - listen.implementation = Some(crate::session::implementation()); + listen.implementation = Some(crate::session::implementation(config.implementation.as_deref())?); if let Some(bind) = config.bind { let parsed = bind.parse().map_err(|_| MoqError::Config { reason: format!("invalid bind address: {bind}"), @@ -403,6 +407,7 @@ pub struct MoqRequest { url: Option, path: String, query: Option, + peer_implementation: Option, } impl MoqRequest { @@ -415,6 +420,7 @@ impl MoqRequest { let url = request.url().map(|u| u.to_string()); let path = request.path().to_string(); let query = request.query().map(str::to_string); + let peer_implementation = request.peer_implementation().map(str::to_string); Ok(Arc::new(Self { task: Task::new(RequestState { request: Some(request), @@ -425,6 +431,7 @@ impl MoqRequest { url, path, query, + peer_implementation, })) } } @@ -467,6 +474,14 @@ impl MoqRequest { self.query.clone() } + /// The implementation the client advertised in its SETUP, such as + /// `moq-py/0.6.0 moq-ffi/0.4.12 moq-net-rs/0.3.10`, or null when it advertised none. + /// + /// Peer-controlled: use it for logs and rollout counts, never to authorize. + pub fn peer_implementation(&self) -> Option { + self.peer_implementation.clone() + } + /// The network transport carrying this session. pub fn transport(&self) -> MoqTransport { self.transport diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 88f99bbeee8..4c31ee782c3 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -59,6 +59,10 @@ pub struct MoqClientConfig { /// Retry pacing for the automatic reconnect. #[uniffi(default)] pub backoff: MoqBackoff, + /// Your own `name/version` product tokens, space-separated, advertised in SETUP ahead of + /// moq-ffi's: `my-app/1.0` advertises `my-app/1.0 moq-ffi/0.4.12 moq-net-rs/0.3.10`. + #[uniffi(default = None)] + pub implementation: Option, /// The origin whose broadcasts are published to the remote. /// /// With neither `publish` nor `consume` set, each session's two sides share one fresh @@ -219,9 +223,23 @@ pub struct MoqBackoff { pub timeout_us: Option, } -/// What sessions opened through this crate advertise in SETUP: moq-ffi ahead of its core. -pub(crate) fn implementation() -> String { - format!("moq-ffi/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION) +/// What sessions opened through this crate advertise in SETUP: the caller's `prefix`, then +/// moq-ffi, then its core. +pub(crate) fn implementation(prefix: Option<&str>) -> Result { + let core = format!("moq-ffi/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION); + let Some(prefix) = prefix else { + return Ok(core); + }; + // An empty token or stray whitespace would corrupt the list every peer splits on spaces. + if prefix + .split(' ') + .any(|token| token.is_empty() || token.contains(|c: char| c.is_whitespace() || c.is_control())) + { + return Err(MoqError::Config { + reason: format!("implementation must be space-separated product tokens: {prefix:?}"), + }); + } + Ok(format!("{prefix} {core}")) } /// The settings a default [`MoqClientConfig`] dials with, for a settings UI to show. @@ -312,7 +330,7 @@ impl Client { let _guard = crate::ffi::enter(); let mut connect = moq_tokio::connect::Config::default(); - connect.implementation = Some(implementation()); + connect.implementation = Some(implementation(config.implementation.as_deref())?); if let Some(bind) = config.bind { let bind = bind.parse().map_err(|err| MoqError::Config { reason: format!("invalid bind address {bind}: {err}"), @@ -563,6 +581,17 @@ mod tests { ..Default::default() }; assert!(matches!(MoqClient::new(missing), Err(MoqError::Config { .. }))); + + for prefix in ["", " app/1", "app/1 ", "app/1 lib/2", "app/1\tlib/2", "app/1\n"] { + let implementation = MoqClientConfig { + implementation: Some(prefix.into()), + ..Default::default() + }; + assert!( + matches!(MoqClient::new(implementation), Err(MoqError::Config { .. })), + "{prefix:?}" + ); + } } #[test] @@ -592,6 +621,8 @@ struct Client { fingerprints: Vec>, /// Offered as ALPNs to WebTransport and then enforced by moq-net, so the two agree. versions: moq_net::Versions, + /// What SETUP advertises, with the caller's prefix joined. + implementation: String, publish: Option>, consume: Option>, } @@ -611,6 +642,7 @@ impl Client { // A browser session never redials, so it dials once either way. once: _, backoff, + implementation: prefix, publish, consume, } = config; @@ -641,6 +673,7 @@ impl Client { true => moq_net::Versions::default(), false => parse_versions(&versions)?.into(), }, + implementation: implementation(prefix.as_deref())?, publish, consume, }) @@ -659,7 +692,7 @@ impl Client { }; let client = moq_net::Client::new() .with_versions(self.versions.clone()) - .with_implementation(implementation()) + .with_implementation(self.implementation.clone()) .with_publisher(&publish.inner) .with_subscriber(subscribe.inner.clone()); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index c0bede188d1..0a71c414fb6 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -5119,6 +5119,11 @@ async fn server_client_roundtrip() { .expect("accept returned None"); assert_eq!(request.path(), "/test"); assert_eq!(request.query().as_deref(), Some("foo=bar")); + let core = format!("moq-ffi/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION); + assert_eq!( + request.peer_implementation(), + Some(format!("app/1 wrapper/2 {core}")) + ); request.accept(None, None).await.expect("handshake failed") }); @@ -5128,6 +5133,7 @@ async fn server_client_roundtrip() { tls: insecure_tls(), bind: Some("127.0.0.1:0".into()), consume: Some(client_origin.clone()), + implementation: Some("app/1 wrapper/2".into()), ..Default::default() }) .unwrap(); From 1eaa3a345c51ea8150e4d7128ffceeb046c8e8ff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:32:10 -0700 Subject: [PATCH 3/6] feat(bindings): each wrapper names itself ahead of moq-ffi in SETUP Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/client.dart | 2 + dart/moq/lib/src/server.dart | 2 + dart/moq/lib/src/version.dart | 7 +++ dart/moq/test/moq_test.dart | 2 + dart/moq_ffi/lib/src/moq.dart | 57 +++++++++++++++++++ go/wrapper/auth_test.go | 13 +++++ go/wrapper/client.go | 26 +++++++-- go/wrapper/client_internal_test.go | 3 +- go/wrapper/server.go | 10 +++- kt/moq/build.gradle.kts | 15 +++++ .../kotlin/dev/moq/Client.kt | 4 ++ .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 2 +- .../kotlin/dev/moq/Server.kt | 7 ++- .../kotlin/dev/moq/SmokeTest.kt | 12 ++++ py/moq-rs/moq/_records.py | 10 ++++ py/moq-rs/moq/client.py | 3 +- py/moq-rs/moq/server.py | 11 +++- py/moq-rs/tests/test_server.py | 5 ++ sh/dart/package.sh | 5 ++ sh/swift/package.sh | 10 ++++ swift/Sources/Moq/Client.swift | 1 + swift/Sources/Moq/Server.swift | 7 +++ swift/Sources/Moq/Version.swift | 7 +++ swift/Tests/MoqTests/SmokeTests.swift | 2 + 24 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 dart/moq/lib/src/version.dart create mode 100644 swift/Sources/Moq/Version.swift diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 6b10a5d5386..32f73da450c 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -2,6 +2,7 @@ import 'package:moq_ffi/moq_ffi.dart'; import 'aliases.dart'; import 'stream.dart'; +import 'version.dart'; /// Scope for discovering announcements. final class AnnounceOptions { @@ -91,6 +92,7 @@ final class ConnectOptions { websocket: (websocket ?? const WebSocketConfig())._ffi, once: once, backoff: backoff ?? MoqBackoff(), + implementation: implementation, publish: publish, consume: consume, ); diff --git a/dart/moq/lib/src/server.dart b/dart/moq/lib/src/server.dart index c470e3f5877..161ed8531ca 100644 --- a/dart/moq/lib/src/server.dart +++ b/dart/moq/lib/src/server.dart @@ -2,6 +2,7 @@ import 'package:moq_ffi/moq_ffi.dart'; import 'aliases.dart'; import 'stream.dart'; +import 'version.dart'; /// Everything [Server.listen] can be told. /// @@ -95,6 +96,7 @@ final class Server { maxStreams: options.maxStreams, keylog: options.keylog, ), + implementation: implementation, publish: publishOrigin, consume: options.consume ?? shared, ), diff --git a/dart/moq/lib/src/version.dart b/dart/moq/lib/src/version.dart new file mode 100644 index 00000000000..c3d1bbf5993 --- /dev/null +++ b/dart/moq/lib/src/version.dart @@ -0,0 +1,7 @@ +/// This package's version, which its sessions advertise in SETUP ahead of moq-ffi's. +/// +/// `sh/dart/package.sh` stamps the released version; a source checkout reports `dev`. +const packageVersion = 'dev'; + +/// This package's product token. +const implementation = 'moq-dart/$packageVersion'; diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 709e28701fb..c69205368cf 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -278,6 +278,8 @@ void main() { final accepted = () async { final request = await server.requests().first.timeout(timeout); + // The wrapper names itself ahead of moq-ffi, so a server can tell bindings apart. + expect(request.peerImplementation(), startsWith('moq-dart/')); return request.accept().timeout(timeout); }(); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 1ccba994936..f489534b5fc 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,7 +8,9 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; + import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -3106,6 +3108,7 @@ class MoqServerConfig { final List versions; final MoqServerTls tls; final MoqQuicConfig quic; + final String? implementation; final MoqOriginProducer? publish; final MoqOriginProducer? consume; MoqServerConfig({ @@ -3113,6 +3116,7 @@ class MoqServerConfig { this.versions = const [], required this.tls, required this.quic, + this.implementation = null, this.publish = null, this.consume = null, }); @@ -3145,6 +3149,11 @@ class FfiConverterMoqServerConfig { ); final quic = quic_lifted.value; new_offset += quic_lifted.bytesRead; + final implementation_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final implementation = implementation_lifted.value; + new_offset += implementation_lifted.bytesRead; final publish_lifted = FfiConverterOptionalMoqOriginProducer.read( Uint8List.view(buf.buffer, new_offset), ); @@ -3161,6 +3170,7 @@ class FfiConverterMoqServerConfig { versions: versions, tls: tls, quic: quic, + implementation: implementation, publish: publish, consume: consume, ), @@ -3174,6 +3184,7 @@ class FfiConverterMoqServerConfig { FfiConverterSequenceString.allocationSize(value.versions) + FfiConverterMoqServerTls.allocationSize(value.tls) + FfiConverterMoqQuicConfig.allocationSize(value.quic) + + FfiConverterOptionalString.allocationSize(value.implementation) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + 0; @@ -3200,6 +3211,10 @@ class FfiConverterMoqServerConfig { value.quic, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.implementation, + Uint8List.view(buf.buffer, new_offset), + ); new_offset += FfiConverterOptionalMoqOriginProducer.write( value.publish, Uint8List.view(buf.buffer, new_offset), @@ -3216,6 +3231,7 @@ class FfiConverterMoqServerConfig { FfiConverterSequenceString.allocationSize(value.versions) + FfiConverterMoqServerTls.allocationSize(value.tls) + FfiConverterMoqQuicConfig.allocationSize(value.quic) + + FfiConverterOptionalString.allocationSize(value.implementation) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + 0; @@ -3401,6 +3417,7 @@ class MoqClientConfig { final MoqWebSocketConfig websocket; final bool once; final MoqBackoff backoff; + final String? implementation; final MoqOriginProducer? publish; final MoqOriginProducer? consume; MoqClientConfig({ @@ -3414,6 +3431,7 @@ class MoqClientConfig { required this.websocket, this.once = false, required this.backoff, + this.implementation = null, this.publish = null, this.consume = null, }); @@ -3476,6 +3494,11 @@ class FfiConverterMoqClientConfig { ); final backoff = backoff_lifted.value; new_offset += backoff_lifted.bytesRead; + final implementation_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final implementation = implementation_lifted.value; + new_offset += implementation_lifted.bytesRead; final publish_lifted = FfiConverterOptionalMoqOriginProducer.read( Uint8List.view(buf.buffer, new_offset), ); @@ -3498,6 +3521,7 @@ class FfiConverterMoqClientConfig { websocket: websocket, once: once, backoff: backoff, + implementation: implementation, publish: publish, consume: consume, ), @@ -3517,6 +3541,7 @@ class FfiConverterMoqClientConfig { FfiConverterMoqWebSocketConfig.allocationSize(value.websocket) + FfiConverterBool.allocationSize(value.once) + FfiConverterMoqBackoff.allocationSize(value.backoff) + + FfiConverterOptionalString.allocationSize(value.implementation) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + 0; @@ -3567,6 +3592,10 @@ class FfiConverterMoqClientConfig { value.backoff, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.implementation, + Uint8List.view(buf.buffer, new_offset), + ); new_offset += FfiConverterOptionalMoqOriginProducer.write( value.publish, Uint8List.view(buf.buffer, new_offset), @@ -3589,6 +3618,7 @@ class FfiConverterMoqClientConfig { FfiConverterMoqWebSocketConfig.allocationSize(value.websocket) + FfiConverterBool.allocationSize(value.once) + FfiConverterMoqBackoff.allocationSize(value.backoff) + + FfiConverterOptionalString.allocationSize(value.implementation) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.publish) + FfiConverterOptionalMoqOriginProducer.allocationSize(value.consume) + 0; @@ -12953,6 +12983,7 @@ abstract class MoqRequestInterface { }); void cancel(); String path(); + String? peerImplementation(); String? query(); UniffiRustFuture reject({required int code}); MoqTransport transport(); @@ -13026,6 +13057,17 @@ class MoqRequest implements MoqRequestInterface { ); } + String? peerImplementation() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqrequest_peer_implementation( + uniffiClonePointer(), + status, + ), + FfiConverterOptionalString.lift, + null, + ); + } + String? query() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqrequest_query( @@ -18558,6 +18600,14 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_path( Pointer uniffiStatus, ); +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_peer_implementation( + Pointer ptr, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -19868,6 +19918,9 @@ external int uniffi_moq_ffi_checksum_method_moqrequest_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqrequest_path(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqrequest_peer_implementation(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqrequest_query(); @@ -20599,6 +20652,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqrequest_path() != 48052) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqrequest_peer_implementation() != + 48833) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqrequest_query() != 23842) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/go/wrapper/auth_test.go b/go/wrapper/auth_test.go index 4a7c27b6eda..39cf9f56023 100644 --- a/go/wrapper/auth_test.go +++ b/go/wrapper/auth_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "reflect" + "strings" "testing" "time" @@ -39,11 +40,14 @@ func TestSessionAuthReadsTheGrant(t *testing.T) { defer server.Close() accepted := make(chan *moq.Session, 1) + implementation := make(chan *string, 1) go func() { req, err := server.Accept(ctx) if err != nil || req == nil { return } + // The wrapper names itself ahead of moq-ffi, so a server can tell bindings apart. + implementation <- req.PeerImplementation() session, err := req.Accept(ctx, nil, nil) if err != nil { return @@ -61,6 +65,15 @@ func TestSessionAuthReadsTheGrant(t *testing.T) { } defer client.Close() + select { + case got := <-implementation: + if got == nil || !strings.HasPrefix(*got, "moq-go/") { + t.Fatalf("peer implementation = %v, want a moq-go/ prefix", got) + } + case <-ctx.Done(): + t.Fatal("no request") + } + everything := moq.Grant{Publish: []string{"**"}, Subscribe: []string{"**"}} auth := client.Session().Auth() awaitGrant(t, ctx, auth, everything) diff --git a/go/wrapper/client.go b/go/wrapper/client.go index 714ce60e4a6..b71b8724d5b 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -2,6 +2,8 @@ package moq import ( "context" + "runtime/debug" + "strings" "sync" "time" @@ -9,6 +11,21 @@ import ( "moq.dev/moq/internal/bridge" ) +// implementation is this module's product token, which its sessions advertise +// ahead of moq-ffi's. The module version comes from the build info Go embeds in +// every binary; a source-tree build, which has none, reports dev. +var implementation = func() string { + version := "dev" + if info, ok := debug.ReadBuildInfo(); ok { + for _, module := range append([]*debug.Module{&info.Main}, info.Deps...) { + if module.Path == "moq.dev/moq" && module.Replace == nil && strings.HasPrefix(module.Version, "v") { + version = strings.TrimPrefix(module.Version, "v") + } + } + } + return "moq-go/" + version +}() + // ClientConfig configures a client created with Dial, mirroring moq-ffi's // MoqClientConfig. The zero value dials with the native defaults: an empty // string, a zero MaxStreams, or a nil pointer keeps each one. @@ -160,9 +177,10 @@ func (c ClientConfig) ffi() (ffi.MoqClientConfig, error) { Qlog: str(c.QUIC.Qlog), Keylog: keylog, }, - Websocket: ffi.MoqWebSocketConfig{Enabled: c.WebSocket.Enabled, DelayUs: us("websocket delay", c.WebSocket.Delay)}, - Once: c.Once, - Backoff: backoff, + Websocket: ffi.MoqWebSocketConfig{Enabled: c.WebSocket.Enabled, DelayUs: us("websocket delay", c.WebSocket.Delay)}, + Once: c.Once, + Backoff: backoff, + Implementation: &implementation, } if err != nil { return ffi.MoqClientConfig{}, err @@ -317,7 +335,7 @@ func (c *Client) Close() error { c.closeOnce.Do(func() { if c.session != nil { c.session.Shutdown() - c.closeErr = c.session.Closed(context.Background()) + c.closeErr = c.session.Closed(context.Background()) } if c.inner != nil { c.inner.Cancel() diff --git a/go/wrapper/client_internal_test.go b/go/wrapper/client_internal_test.go index 06951769507..34ea7af48b7 100644 --- a/go/wrapper/client_internal_test.go +++ b/go/wrapper/client_internal_test.go @@ -16,7 +16,8 @@ func TestClientConfigFfiMapsZeroValuesToNativeDefaults(t *testing.T) { if err != nil { t.Fatalf("ClientConfig{}.ffi() failed: %v", err) } - if !reflect.DeepEqual(got, ffi.MoqClientConfig{}) { + // The wrapper's own product token is the one field it always sets. + if !reflect.DeepEqual(got, ffi.MoqClientConfig{Implementation: &implementation}) { t.Errorf("ClientConfig{}.ffi() = %+v, want the native defaults", got) } diff --git a/go/wrapper/server.go b/go/wrapper/server.go index 1e9e5ea22a7..082f7105dbf 100644 --- a/go/wrapper/server.go +++ b/go/wrapper/server.go @@ -48,6 +48,13 @@ func (r *Request) Query() *string { return r.inner.Query() } +// PeerImplementation is the implementation the client advertised, like +// "moq-go/0.7.0 moq-ffi/0.4.12 moq-net-rs/0.3.10", or nil if it advertised none. +// Peer-controlled: use it for logs and rollout counts, never to authorize. +func (r *Request) PeerImplementation() *string { + return r.inner.PeerImplementation() +} + // Transport is the wire transport the request arrived over, e.g. TransportQUIC. func (r *Request) Transport() Transport { return r.inner.Transport() @@ -173,7 +180,8 @@ func Listen(ctx context.Context, bind string, opts ...ServerOption) (*Server, er Key: cfg.tlsKey, Generate: cfg.tlsGenerate, }, - Quic: ffi.MoqQuicConfig{MaxStreams: cfg.quicMaxStreams, Keylog: cfg.quicKeylog}, + Quic: ffi.MoqQuicConfig{MaxStreams: cfg.quicMaxStreams, Keylog: cfg.quicKeylog}, + Implementation: &implementation, } if cfg.publish != nil { config.Publish = &cfg.publish.inner diff --git a/kt/moq/build.gradle.kts b/kt/moq/build.gradle.kts index 9e42886818b..cb839fa2545 100644 --- a/kt/moq/build.gradle.kts +++ b/kt/moq/build.gradle.kts @@ -41,6 +41,20 @@ plugins { version = providers.gradleProperty("moq.version").get() +// Sessions advertise the wrapper's version in SETUP (see Client.kt), so compile it in +// from the same property rather than keeping a second copy in source. +val generateVersion = tasks.register("generateVersion") { + val output = layout.buildDirectory.dir("generated/version") + val wrapperVersion = version.toString() + inputs.property("version", wrapperVersion) + outputs.dir(output) + doLast { + val file = output.get().file("dev/moq/Version.kt").asFile + file.parentFile.mkdirs() + file.writeText("package dev.moq\n\n/** This wrapper's version. */\ninternal const val VERSION = \"$wrapperVersion\"\n") + } +} + // Compatible-patch range for the bindings: 0.4.3 (the WebSocket fallback // setters) or newer, never 0.5.0. Published into the wrapper's POM so consumers // float to the newest bindings patch. @@ -89,6 +103,7 @@ kotlin { val jvmAndAndroidMain = create("jvmAndAndroidMain") { dependsOn(commonMain) + kotlin.srcDir(generateVersion) dependencies { // api: the wrapper re-exports the FFI types, so consumers get // `uniffi.moq.*` (and the native libs) transitively. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Client.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Client.kt index 33a92c6abe0..0cebf55adcb 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Client.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Client.kt @@ -5,3 +5,7 @@ fun versions(): List = uniffi.moq.moqVersions() /** Whether this build can write qlog traces for [QuicConfig.qlog]. */ fun qlogSupported(): Boolean = uniffi.moq.moqQlogSupported() + +/** The caller's product tokens, if any, then this package's, which moq-ffi advertises ahead of its own. */ +internal fun implementation(prefix: String?): String = + listOfNotNull(prefix, "moq-kt/$VERSION").joinToString(" ") diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 4fd1f624deb..2b30d0c7acd 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -99,7 +99,7 @@ class Moq internal constructor( * two directions. */ suspend fun connect(url: String, config: ClientConfig = ClientConfig()): Moq { - val client = Client(config) + val client = Client(config.copy(implementation = implementation(config.implementation))) try { return Moq(client.connect(url), client) } catch (e: Throwable) { diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index 30ec1344892..52e52f55469 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -97,11 +97,12 @@ class Server internal constructor( * visible to sessions publishing into it. Mirrors [Moq.connect]. */ suspend fun listen(config: ServerConfig): Server { - val wired = if (config.publish == null && config.consume == null) { + val named = config.copy(implementation = implementation(config.implementation)) + val wired = if (named.publish == null && named.consume == null) { val shared = OriginProducer(OriginConfig()) - config.copy(publish = shared, consume = shared) + named.copy(publish = shared, consume = shared) } else { - config + named } val server = MoqServer(wired) diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index e9d7321f5d5..cf155c67e0b 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -547,6 +547,18 @@ class SmokeTest { } } + @Test + fun `sessions advertise the wrapper ahead of moq-ffi`() = runTest { + Server.listen(ServerConfig(bind = "127.0.0.1:0", tls = ServerTls(generate = listOf("localhost")))).use { server -> + Moq.connect("https://${server.localAddr}", ClientConfig(tls = ClientTls(insecure = true))).use { + server.requests().first().use { request -> + val advertised = request.peerImplementation() + assertTrue(advertised?.startsWith("moq-kt/") == true, "advertised: $advertised") + } + } + } + } + @Test fun `closing a server releases its port`() = runTest { val first = Server.listen(ServerConfig(bind = "127.0.0.1:0", tls = ServerTls(generate = listOf("localhost")))) diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index adf88116330..11d114df313 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -12,6 +12,7 @@ from collections.abc import Sequence from dataclasses import dataclass from datetime import timedelta +from importlib import metadata from moq_ffi import ( MoqBackoff, @@ -32,6 +33,15 @@ _MICROSECOND = timedelta(microseconds=1) +def _implementation() -> str: + """This package's product token, which its sessions advertise ahead of moq-ffi's.""" + try: + return f"moq-py/{metadata.version('moq-rs')}" + except metadata.PackageNotFoundError: + # Imported from a source tree rather than an installed distribution. + return "moq-py/dev" + + def _to_us(value: timedelta, name: str) -> int: if value < timedelta(0): raise ValueError(f"{name} must not be negative: {value}") diff --git a/py/moq-rs/moq/client.py b/py/moq-rs/moq/client.py index 399a1ea2408..30e714c6344 100644 --- a/py/moq-rs/moq/client.py +++ b/py/moq-rs/moq/client.py @@ -13,7 +13,7 @@ moq_versions, ) -from ._records import Backoff, ClientTls, QuicConfig, WebSocketConfig, _opt_us, _strs +from ._records import Backoff, ClientTls, QuicConfig, WebSocketConfig, _implementation, _opt_us, _strs from .origin import AnnounceConsumer, AnnouncedBroadcast, OriginConsumer, OriginProducer from .publish import BroadcastProducer from .session import Session @@ -102,6 +102,7 @@ def __init__( websocket=(websocket or WebSocketConfig())._ffi(), once=once, backoff=(backoff or Backoff())._ffi(), + implementation=_implementation(), publish=None if publish is None else publish._inner, consume=None if consume is None else consume._inner, ) diff --git a/py/moq-rs/moq/server.py b/py/moq-rs/moq/server.py index 50f9d77d242..cd2e72474f7 100644 --- a/py/moq-rs/moq/server.py +++ b/py/moq-rs/moq/server.py @@ -7,7 +7,7 @@ from moq_ffi import MoqQuicConfig, MoqRequest, MoqServer, MoqServerConfig, MoqServerTls, MoqTransport -from ._records import _strs +from ._records import _implementation, _strs from .origin import OriginProducer from .publish import BroadcastProducer from .session import Session @@ -44,6 +44,14 @@ def query(self) -> str | None: """The encoded request query without `?`; it may contain credentials.""" return self._inner.query() + @property + def peer_implementation(self) -> str | None: + """The implementation the client advertised, like ``moq-py/0.6.0 moq-ffi/0.4.12 ...``. + + Peer-controlled: use it for logs and rollout counts, never to authorize. + """ + return self._inner.peer_implementation() + @property def transport(self) -> Transport: """The network transport carrying this session.""" @@ -146,6 +154,7 @@ def __init__( generate=_strs(tls_generate, "tls_generate"), ), quic=MoqQuicConfig(max_streams=max_streams, keylog=keylog), + implementation=_implementation(), publish=None if publish is None else publish._inner, consume=None if consume is None else consume._inner, ) diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index 6f821891c1d..fa2140ae444 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -53,9 +53,11 @@ async def test_server_client_roundtrip(): # completes from the server side. Hold references so the sessions # outlive the test. sessions: list = [] + implementations: list = [] async def accept_loop() -> None: async for request in server: + implementations.append(request.peer_implementation) sessions.append(await request.accept()) accept_task = asyncio.create_task(accept_loop()) @@ -86,6 +88,9 @@ async def accept_loop() -> None: break break + + # The wrapper names itself ahead of moq-ffi, so a server can tell bindings apart. + assert implementations[0].startswith("moq-py/") finally: accept_task.cancel() try: diff --git a/sh/dart/package.sh b/sh/dart/package.sh index cb984776957..87cef9c4237 100755 --- a/sh/dart/package.sh +++ b/sh/dart/package.sh @@ -57,6 +57,11 @@ if [[ "$PACKAGE" == moq_ffi ]]; then sed -i.bak "s/^## 0\.0\.0-dev$/## $VERSION/" "$destination/CHANGELOG.md" rm -f "$destination/CHANGELOG.md.bak" else + # Sessions advertise the wrapper's version in SETUP; the source tree says `dev`. + sed -i.bak "s/^const packageVersion = .*/const packageVersion = '$VERSION';/" \ + "$destination/lib/src/version.dart" + rm -f "$destination/lib/src/version.dart.bak" + # The override points moq_ffi at ../moq_ffi, which only exists in this # checkout. Dropping it also invalidates the committed lock, which resolved # moq_ffi by path, so remove that too rather than ship a lie: the published diff --git a/sh/swift/package.sh b/sh/swift/package.sh index 4d281925ee1..77b14543cd5 100755 --- a/sh/swift/package.sh +++ b/sh/swift/package.sh @@ -100,6 +100,16 @@ mkdir -p "$PKG_STAGE/Sources/Moq" "$PKG_STAGE/Tests/MoqTests" cp -R "$SWIFT_DIR/Sources/Moq/." "$PKG_STAGE/Sources/Moq/" cp -R "$SWIFT_DIR/Tests/MoqTests/." "$PKG_STAGE/Tests/MoqTests/" +# Sessions advertise the wrapper's version in SETUP; the source tree says `dev`. +VERSION_SWIFT="$PKG_STAGE/Sources/Moq/Version.swift" +PLACEHOLDER='let packageVersion = "dev"' +[[ $(grep -cxF "$PLACEHOLDER" "$VERSION_SWIFT") == 1 ]] || { + echo "Error: $VERSION_SWIFT must contain exactly one \`$PLACEHOLDER\`" >&2 + exit 1 +} +sed -i.bak "s|^${PLACEHOLDER}\$|let packageVersion = \"${VERSION}\"|" "$VERSION_SWIFT" +rm -f "$VERSION_SWIFT.bak" + # Swift Package Index reads .spi.yml from the package root to build and host the # DocC docs. Ship it in the mirror so SPI documents the tagged release. cp "$SWIFT_DIR/.spi.yml" "$PKG_STAGE/.spi.yml" diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index c4e602ecb85..ea5f0c9a582 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -49,6 +49,7 @@ public final class Client: Sendable { websocket: websocket, once: once, backoff: backoff, + implementation: implementation, publish: publish?.ffi, consume: consume?.ffi )) diff --git a/swift/Sources/Moq/Server.swift b/swift/Sources/Moq/Server.swift index 98bb78d0558..addc17e7a20 100644 --- a/swift/Sources/Moq/Server.swift +++ b/swift/Sources/Moq/Server.swift @@ -30,6 +30,7 @@ public final class Server: Sendable { versions: versions, tls: tls, quic: quic, + implementation: implementation, publish: publish?.ffi, consume: consume?.ffi )) @@ -85,6 +86,12 @@ public final class Request: Sendable { ffi.query() } + /// The implementation the client advertised, like `moq-swift/0.5.1 moq-ffi/0.4.12 ...`. + /// Peer-controlled: use it for logs and rollout counts, never to authorize. + public var peerImplementation: String? { + ffi.peerImplementation() + } + /// The network transport carrying this session. public var transport: Transport { ffi.transport() diff --git a/swift/Sources/Moq/Version.swift b/swift/Sources/Moq/Version.swift new file mode 100644 index 00000000000..4476c6ba3c7 --- /dev/null +++ b/swift/Sources/Moq/Version.swift @@ -0,0 +1,7 @@ +/// This package's version, which its sessions advertise in SETUP ahead of moq-ffi's. +/// +/// `sh/swift/package.sh` stamps the released version; a source checkout reports `dev`. +let packageVersion = "dev" + +/// This package's product token. +let implementation = "moq-swift/\(packageVersion)" diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index af8e2deeaba..0f2350b33f3 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -114,6 +114,8 @@ final class SmokeTests: XCTestCase { defer { client.cancel() } let session = try await client.connect(to: "https://\(addr)") guard let request = try await accepted else { return XCTFail("server closed before accept") } + // The wrapper names itself ahead of moq-ffi, so a server can tell bindings apart. + XCTAssertTrue(request.peerImplementation?.hasPrefix("moq-swift/") == true, "\(String(describing: request.peerImplementation))") let peer = try await request.accept() // Ending both sessions ends any wait below, so a grant that never arrives fails From d00813b07e20ed1e9cfec99976d39507c8c2a67f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:38:41 -0700 Subject: [PATCH 4/6] feat(cpp): name the C++ package and the OBS plugin in SETUP Co-Authored-By: Claude Opus 5.5 --- cpp/moq/CMakeLists.txt | 4 ++++ cpp/moq/cmake/version.hpp.in | 5 +++++ cpp/moq/include/moq/moq.hpp | 5 +++++ cpp/moq/test/probe.cpp | 4 ++++ cpp/obs/CMakeLists.txt | 8 ++++---- cpp/obs/src/moq-implementation.h | 17 +++++++++++++++++ cpp/obs/src/moq-output.cpp | 2 ++ cpp/obs/src/moq-source.cpp | 2 ++ cpp/obs/test/moq-output-test.cpp | 2 ++ cpp/obs/test/moq-source-test.cpp | 2 ++ cpp/obs/test/moq-test-relay.h | 12 ++++++++++++ 11 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 cpp/moq/cmake/version.hpp.in create mode 100644 cpp/obs/src/moq-implementation.h diff --git a/cpp/moq/CMakeLists.txt b/cpp/moq/CMakeLists.txt index 0ff2dbe5afd..569acec4d8f 100644 --- a/cpp/moq/CMakeLists.txt +++ b/cpp/moq/CMakeLists.txt @@ -87,6 +87,9 @@ find_program(UNIFFI_BINDGEN_CPP uniffi-bindgen-cpp REQUIRED) # Rendered to a scratch directory and copied only when changed, so a no-op cargo build does # not recompile the bindings. set(_generated "${CMAKE_CURRENT_BINARY_DIR}/include/moq/ffi") +# moq::IMPLEMENTATION names this package's version, which lives in VERSION. +set(_version_header "${CMAKE_CURRENT_BINARY_DIR}/include/moq/version.hpp") +configure_file(cmake/version.hpp.in "${_version_header}" @ONLY) set(_generated_files moq.hpp moq.cpp moq_scaffolding.hpp uniffi_expected.hpp) list(TRANSFORM _generated_files PREPEND "${_generated}/" OUTPUT_VARIABLE _generated_paths) set(_render "${CMAKE_CURRENT_BINARY_DIR}/render") @@ -151,6 +154,7 @@ if(PROJECT_IS_TOP_LEVEL) install(DIRECTORY include/moq DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(FILES ${_generated_paths} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/moq/ffi) + install(FILES "${_version_header}" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/moq) install(FILES src/moq.cpp DESTINATION ${CMAKE_INSTALL_DATADIR}/moq-cpp) install(FILES "${MOQ_FFI_LIB}" DESTINATION ${CMAKE_INSTALL_LIBDIR}) diff --git a/cpp/moq/cmake/version.hpp.in b/cpp/moq/cmake/version.hpp.in new file mode 100644 index 00000000000..bfccdf5610c --- /dev/null +++ b/cpp/moq/cmake/version.hpp.in @@ -0,0 +1,5 @@ +// Generated by CMake from cpp/moq/VERSION. +#pragma once + +// The moq C++ package version. +#define MOQ_CPP_VERSION "@PROJECT_VERSION@" diff --git a/cpp/moq/include/moq/moq.hpp b/cpp/moq/include/moq/moq.hpp index 58dc5805260..d7de3515984 100644 --- a/cpp/moq/include/moq/moq.hpp +++ b/cpp/moq/include/moq/moq.hpp @@ -3,6 +3,7 @@ #pragma once #include +#include #include #include @@ -23,6 +24,10 @@ extern "C" void moq_ffi_shutdown(void); namespace moq { +// This package's product token. Every type is generated, so nothing sets it for you: put it in +// ClientConfig::implementation and ServerConfig::implementation, after your own tokens. +inline constexpr const char *IMPLEMENTATION = "moq-cpp/" MOQ_CPP_VERSION; + // The value of a fallible call, or the Error that stopped it. tl::expected at every standard. template using expected = ::uniffi::expected; diff --git a/cpp/moq/test/probe.cpp b/cpp/moq/test/probe.cpp index 027d0b25b50..4721ff298ec 100644 --- a/cpp/moq/test/probe.cpp +++ b/cpp/moq/test/probe.cpp @@ -217,11 +217,15 @@ int main() { // thread blocks on one at a time. moq::ClientConfig client_config; client_config.tls.insecure = true; + client_config.implementation = moq::IMPLEMENTATION; auto client = ok(moq::Client::init(client_config), "client init"); auto accepting = server->accept(); auto connecting = client->connect("https://" + addr); auto request = ok(accepting.get(), "accept"); CHECK(request != nullptr); + // The package names itself ahead of moq-ffi, so a server can tell bindings apart. + auto implementation = request->peer_implementation(); + CHECK(implementation && implementation->rfind("moq-cpp/", 0) == 0); auto served = ok(request->accept(nullptr, nullptr).get(), "request accept"); auto session = ok(connecting.get(), "connect"); diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt index 159ff2df9e1..9d3ba878e54 100644 --- a/cpp/obs/CMakeLists.txt +++ b/cpp/obs/CMakeLists.txt @@ -160,6 +160,7 @@ target_sources( src/obs-moq.cpp src/moq-audio-encoder.cpp src/moq-audio-encoder.h + src/moq-implementation.h src/moq-worker.h src/moq-output.h src/moq-service.h @@ -170,16 +171,15 @@ target_sources( src/moq-source.cpp src/moq-source.h ) +# Sessions advertise it in SETUP (moq-implementation.h); the dock also displays it. +target_compile_definitions(obs-moq PRIVATE PLUGIN_VERSION_STRING="${PLUGIN_VERSION}") # The dock requires both the frontend API (to register it) and Qt (to build it). # The advanced settings dialog is Qt-only, so it ships with the dock that opens it; # without Qt the same settings are still reachable through the service properties. if(ENABLE_FRONTEND_API AND ENABLE_QT) target_sources(obs-moq PRIVATE src/moq-dock.cpp src/moq-dock.h src/moq-advanced-dialog.cpp src/moq-advanced-dialog.h) - target_compile_definitions( - obs-moq - PRIVATE MOQ_FRONTEND_ENABLED MOQ_VERSION_STRING="${_moq_link_version}" PLUGIN_VERSION_STRING="${PLUGIN_VERSION}" - ) + target_compile_definitions(obs-moq PRIVATE MOQ_FRONTEND_ENABLED MOQ_VERSION_STRING="${_moq_link_version}") endif() if(${BUILD_PLUGIN}) diff --git a/cpp/obs/src/moq-implementation.h b/cpp/obs/src/moq-implementation.h new file mode 100644 index 00000000000..883f5aa4791 --- /dev/null +++ b/cpp/obs/src/moq-implementation.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include + +#include + +// CMake defines it from the plugin's VERSION; a build outside CMake reports dev. +#ifndef PLUGIN_VERSION_STRING +#define PLUGIN_VERSION_STRING "dev" +#endif + +// What the plugin's sessions advertise in SETUP ahead of moq-ffi's: itself, then the C++ package. +inline std::string MoQImplementation() +{ + return std::string("moq-obs/" PLUGIN_VERSION_STRING " ") + moq::IMPLEMENTATION; +} diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 0792dd17939..01bf3dac425 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -3,6 +3,7 @@ #include "moq-output.h" #include "moq-error.h" +#include "moq-implementation.h" #include "moq-settings.h" #include "moq-url.h" #include "logger.h" @@ -186,6 +187,7 @@ bool MoQOutput::Start() // switched off the client keeps the library defaults. moq::ClientConfig config; config.publish = next_origin; + config.implementation = MoQImplementation(); OBSDataAutoRelease service_settings = obs_service_get_settings(service); std::string invalid = MoQSettings::Configure(service_settings, config); std::shared_ptr client; diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index dd77081d51b..57e6e024b19 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -29,6 +29,7 @@ extern "C" { #include } +#include "moq-implementation.h" #include "moq-source.h" #include "moq-url.h" #include "moq-worker.h" @@ -361,6 +362,7 @@ static moq::ClientConfig moq_source_client_config(bool keylog) { moq::ClientConfig config; config.quic.keylog = keylog; + config.implementation = MoQImplementation(); return config; } diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index 6634e4acbff..e502016ecd6 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -461,6 +461,8 @@ int main() CHECK(first.has_value()); CHECK(first == output.GetEpoch()); CHECK(WaitFor([&] { return output.IsLiveSession() && relay.Accepted() == 1; })); + // The plugin names itself ahead of the C++ package, so a relay can tell it apart. + CHECK(relay.Advertised("moq-obs/dev moq-cpp/")); relay.DropSessions(); CHECK(WaitFor([&] { return output.GetReconnectCount() == 1 && output.IsLiveSession(); })); CHECK(MoQOutputStatsTest::Epoch(output) == first); diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index 5c18eb879fd..4ad29a3f13d 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -672,6 +672,8 @@ int main() }; CHECK(on_grid(g_last_timestamp)); CHECK(on_grid(audio.timestamp)); + // The plugin names itself ahead of the C++ package, so a relay can tell it apart. + CHECK(relay.Advertised("moq-obs/dev moq-cpp/")); destroySource(source); } report("plays video and audio"); diff --git a/cpp/obs/test/moq-test-relay.h b/cpp/obs/test/moq-test-relay.h index 08287ee6dfb..cc5807cba4e 100644 --- a/cpp/obs/test/moq-test-relay.h +++ b/cpp/obs/test/moq-test-relay.h @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -134,6 +135,14 @@ class TestRelay { return accepted; } + // Whether a session the relay accepted advertised an implementation starting with `prefix`. + bool Advertised(const std::string &prefix) + { + std::lock_guard lock(mutex); + return std::any_of(implementations.begin(), implementations.end(), + [&](const std::string &value) { return value.rfind(prefix, 0) == 0; }); + } + std::shared_ptr origin; private: @@ -143,11 +152,13 @@ class TestRelay { auto request = server->accept().get(); if (!request || !*request) return; + const auto implementation = (*request)->peer_implementation(); auto session = (*request)->accept(nullptr, nullptr).get(); if (!session) continue; std::lock_guard lock(mutex); sessions.push_back(*session); + implementations.push_back(implementation.value_or("")); accepted++; } } @@ -196,6 +207,7 @@ class TestRelay { std::mutex mutex; std::vector> sessions; + std::vector implementations; size_t accepted = 0; std::thread sessions_thread; From ccf261d8b426a848b7b84557c4260c64eb6e8752 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:39:28 -0700 Subject: [PATCH 5/6] docs: name the C++ package in SETUP; finish the binding-peer-version quest Co-Authored-By: Claude Opus 5.5 --- doc/lib/cpp/index.md | 5 +++++ quest/m1/README.md | 1 - quest/m1/binding-peer-version.md | 34 -------------------------------- 3 files changed, 5 insertions(+), 35 deletions(-) delete mode 100644 quest/m1/binding-peer-version.md diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index d26680b96e6..9824120a11b 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -98,6 +98,11 @@ if (auto catalog = ok(catalogs->next().get())) { // std::nullopt once the cata } ``` +A session advertises its implementation in SETUP, which a relay's auth service sees. +The config is generated, so nothing names this package for you: set +`ClientConfig::implementation` (and `ServerConfig::implementation`) to `moq::IMPLEMENTATION`, +after any product tokens of your own, and moq-ffi appends its own. + Call `available()` again to wait for the next publisher instance at that path, such as a restarted publisher, and drop what was read from the previous one then: its tracks end when its session does. diff --git a/quest/m1/README.md b/quest/m1/README.md index 9ad0bfc1971..e70bb82cb58 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -162,7 +162,6 @@ Catalog HLS field. - [HLS dot-only names](/quest/m1/hls-dot-names.md) - a `.` or `..` rendition is refused instead of advertising a URL outside its directory - [MSF duplicate init](/quest/m1/msf-duplicate-init.md) - an MSF catalog refuses a repeated `initDataList` id instead of resolving it last-wins - [Image hardening](/quest/m1/image-hardening.md) - the relay image runs unprivileged and its builder stage is pinned -- [Binding peer version](/quest/m1/binding-peer-version.md) - every binding names itself ahead of the Rust core in the SETUP Implementation parameter - [Calibrate the paired nightly benchmarks](/quest/m1/bench-ci.md) - A/A and deliberately slowed Nightly runs set the paired comparison's threshold, so its regression alerts can be trusted - [Refuse unknown MOQ_* environment variables](/quest/m1/env-unknown.md) - every binary exits at startup naming a `MOQ_*` variable it does not read - [HLS export tests over a session](/quest/m1/hls-session-tests.md) - an integration suite records and serves through a real moq-net session, so a publisher disconnect, finish, or takeover ends the cursor and playlists as specified diff --git a/quest/m1/binding-peer-version.md b/quest/m1/binding-peer-version.md deleted file mode 100644 index ac12c460863..00000000000 --- a/quest/m1/binding-peer-version.md +++ /dev/null @@ -1,34 +0,0 @@ -# [M] Binding peer version - -## Goal - -A session opened through any binding advertises that binding and its version -ahead of the Rust core, so an auth service can tell a Python client from a -Swift one: for example `moq-py/0.3.0 moq-ffi/0.4.12 moq-net-rs/0.3.10`. - -Covers the py, swift, kt, go and dart wrappers, moq-c, cpp/moq, and the OBS -plugin (cpp/obs). Wire: none beyond the core quest. Public API: one -additive moq-ffi config field. - -## Plan - -Decided with the maintainer on 2026-10-22, alongside the core work that added the -SETUP Implementation parameter, `moq_net::IMPLEMENTATION`, and moq-ffi's default token: - -- moq-ffi's client and server config gain an `implementation` prefix: the - caller's own product tokens, which moq-ffi joins ahead of its default - `moq-ffi/ moq-net-rs/`. A prefix rather than a full override keeps - the core tokens truthful for rollout counts, and every caller here only - needs to prepend. -- Each hand-written wrapper sets it from its own package version, read at - build time where the language allows. moq-c exposes the field so cpp/moq - and the OBS plugin can set theirs; the OBS plugin reports itself - (`moq-obs/`) ahead of `moq-cpp`. -- The generated `go/ffi` and `dart/moq_ffi` layers regenerate; the - hand-written `go/wrapper` and `dart/moq` set the value. -- Update `doc/lib/{py,swift,kt,go,dart,c,cpp}` and `doc/bin/obs.md` only - where they list connect options. - -Tests: each binding's existing connect test asserts the advertised value -starts with its own token, read back through the relay or a local server's -peer accessor. From 6d1f206196e879f73ae0e22ef4d686291f43c394 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:45:14 -0700 Subject: [PATCH 6/6] style: format the implementation tests Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/test.rs | 6 +++++- rs/moq-ffi/src/test.rs | 5 +---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 42ac17b27e0..23268dd7299 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5762,7 +5762,11 @@ fn server_accepts_a_session() { ); assert_eq!( borrowed_string(implementation.data, implementation.len), - Some(format!("moq-c/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION)) + Some(format!( + "moq-c/{} {}", + env!("CARGO_PKG_VERSION"), + moq_net::IMPLEMENTATION + )) ); let session_cb = States::new(); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 0a71c414fb6..9647e81f6fd 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -5120,10 +5120,7 @@ async fn server_client_roundtrip() { assert_eq!(request.path(), "/test"); assert_eq!(request.query().as_deref(), Some("foo=bar")); let core = format!("moq-ffi/{} {}", env!("CARGO_PKG_VERSION"), moq_net::IMPLEMENTATION); - assert_eq!( - request.peer_implementation(), - Some(format!("app/1 wrapper/2 {core}")) - ); + assert_eq!(request.peer_implementation(), Some(format!("app/1 wrapper/2 {core}"))); request.accept(None, None).await.expect("handshake failed") });