diff --git a/bench/README.md b/bench/README.md index 25f0f1a07cb..0ad6d607195 100644 --- a/bench/README.md +++ b/bench/README.md @@ -970,3 +970,12 @@ OOM kill does too, even inside a Quest scope: each worker watches `local.py` through a pidfd and stops once it exits. macOS has no pidfd, so there killing `local.py` with SIGKILL frees the lock while its workers keep measuring until their own gates exit, an accepted gap. Stop it with Ctrl-C or SIGTERM instead. + +### Python socket sessions + +The Python binding runner also sweeps both TCP and track sockets over 1, 4, +and 16 accepting endpoints and 1, 4, and 16 concurrent sessions per endpoint. +Each session sends a checked payload and finishes both halves. The +`socket//endpoints=N/sessions=M` entries report complete sessions per +second, including handshake, FFI calls, reads, and teardown. Run with +`just py bench`; Nightly's binding lane runs the same sweep. diff --git a/c/moq/MIGRATE.md b/c/moq/MIGRATE.md index e12e9ed5a8d..4326d19c6d0 100644 --- a/c/moq/MIGRATE.md +++ b/c/moq/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +The generated socket endpoint, incoming dial, and stream handles now expose TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + ### Publishing goes through one `moq_route` handle `moq_origin_producer_create_broadcast(origin, path, &publication)` is `moq_origin_producer_route(origin, path, &options, &route)`, and `moq_origin_producer_requests(origin, prefix, &requests)` is `moq_origin_producer_route_prefix(origin, prefix, &options, &route)`; start `options` from `moq_route_options_default()`. A `moq_route` replaces `moq_publication`, `moq_origin_requests`, and `moq_broadcast_prepared`: diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index b915c08031a..7850dce4479 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -2,6 +2,12 @@ ## Unreleased +### Socket sessions + +The generated SocketEndpoint, SocketIncomingTcp, SocketIncomingTrack, SocketTcpStream, and SocketTrackStream handles now expose TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`moq::GroupProducer::ready(size)` is a new async call that completes once a frame of `size` bytes fits a window ahead of the fastest reader. `co_await` it before each `write_frame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following::available()` reports the path going offline ([#7678](https://github.com/moq-dev/moq/pull/7678)) `moq::Following::available()` resolves to a null `std::shared_ptr` once the path goes offline (or, for a follow pinned to an instance, once another instance takes it over), then the next broadcast when something serves the path again. The first call is never null. Check for null before using a later result, and treat it like the previous instance ending. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index ea8860a333e..1a37f827737 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +package:moq/socket.dart now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`GroupProducer.ready(size)` is a new `Future` that completes once a frame of `size` bytes fits a window ahead of the fastest reader. Await it before each `writeFrame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following.available()` returns null once the path goes offline `await following.available()` returns `BroadcastConsumer?`. diff --git a/dart/moq/lib/socket.dart b/dart/moq/lib/socket.dart new file mode 100644 index 00000000000..b815734f4da --- /dev/null +++ b/dart/moq/lib/socket.dart @@ -0,0 +1,19 @@ +/// Point-to-point socket endpoints and sessions. +library; + +import 'package:moq_ffi/moq_ffi.dart'; + +/// Announces one hidden endpoint and dials or accepts sessions. +typedef Endpoint = MoqSocketEndpoint; + +/// An incoming TCP dial, consumed by accepting or rejecting it. +typedef IncomingTcp = MoqSocketIncomingTcp; + +/// An incoming track dial, consumed by accepting or rejecting it. +typedef IncomingTrack = MoqSocketIncomingTrack; + +/// A TCP session with group producer and consumer halves. +typedef TcpStream = MoqSocketTcpStream; + +/// A track session with producer and reliably subscribed consumer halves. +typedef TrackStream = MoqSocketTrackStream; diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 8dd3c33eb30..c3864781f9b 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -14302,6 +14302,7 @@ abstract class MoqGroupProducerInterface { void abort({required int errorCode}); MoqGroupConsumer consume(); void finish(); + UniffiRustFuture ready({required int size}); int sequence(); void writeFrame({required MoqFrame frame}); } @@ -14373,6 +14374,21 @@ class MoqGroupProducer implements MoqGroupProducerInterface { }, moqExceptionErrorHandler); } + UniffiRustFuture ready({required int size}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqgroupproducer_ready( + uniffiClonePointer(), + FfiConverterUInt64.lower(size), + ), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_cancel_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } + int sequence() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( @@ -15496,37 +15512,698 @@ class MoqTokens implements MoqTokensInterface { ); } - void set_({required List tokens}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqtokens_set( + void set_({required List tokens}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqtokens_set( + uniffiClonePointer(), + FfiConverterSequenceString.lower(tokens), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqTokens { + static MoqTokens lift(Pointer ptr) { + return MoqTokens.lift(ptr); + } + + static Pointer lower(MoqTokens value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqTokens value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqTokens.lift(pointer), 8); + } + + static int write(MoqTokens value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketEndpointInterface { + UniffiRustFuture acceptTcp(); + UniffiRustFuture acceptTrack(); + void close(); + UniffiRustFuture dialTcp({required String peer}); + UniffiRustFuture dialTrack({ + required String peer, + required MoqTrackInfo? info, + }); + String id(); + String prefix(); +} + +final _MoqSocketEndpointFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqsocketendpoint(ptr, status)); +}); + +class MoqSocketEndpoint implements MoqSocketEndpointInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketEndpoint._(this._ptr) { + _MoqSocketEndpointFinalizer.attach(this, _ptr, detach: this); + } + MoqSocketEndpoint({ + required MoqOriginProducer origin, + required String prefix, + required String id, + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqsocketendpoint_new( + FfiConverterMoqOriginProducer.lower(origin), + FfiConverterString.lower(prefix), + FfiConverterString.lower(id), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqSocketEndpointFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketEndpoint.lift(Pointer ptr) { + return MoqSocketEndpoint._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsocketendpoint(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketEndpointFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsocketendpoint(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + UniffiRustFuture acceptTcp() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_tcp( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketIncomingTcp.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + UniffiRustFuture acceptTrack() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_track( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => FfiConverterMoqSocketIncomingTrack.lift( + Pointer.fromAddress(ptr), + ), + moqExceptionErrorHandler, + ); + } + + void close() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqsocketendpoint_close( + uniffiClonePointer(), + status, + ); + }, null); + } + + UniffiRustFuture dialTcp({required String peer}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_tcp( + uniffiClonePointer(), + FfiConverterString.lower(peer), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTcpStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + UniffiRustFuture dialTrack({ + required String peer, + required MoqTrackInfo? info, + }) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_track( + uniffiClonePointer(), + FfiConverterString.lower(peer), + FfiConverterOptionalMoqTrackInfo.lower(info), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTrackStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + String id() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketendpoint_id( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + String prefix() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketendpoint_prefix( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketEndpoint { + static MoqSocketEndpoint lift(Pointer ptr) { + return MoqSocketEndpoint.lift(ptr); + } + + static Pointer lower(MoqSocketEndpoint value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketEndpoint value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketEndpoint.lift(pointer), 8); + } + + static int write(MoqSocketEndpoint value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketIncomingTcpInterface { + UniffiRustFuture accept(); + String peer(); + void reject(); + String session(); +} + +final _MoqSocketIncomingTcpFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqsocketincomingtcp(ptr, status), + ); +}); + +class MoqSocketIncomingTcp implements MoqSocketIncomingTcpInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketIncomingTcp._(this._ptr) { + _MoqSocketIncomingTcpFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketIncomingTcp.lift(Pointer ptr) { + return MoqSocketIncomingTcp._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsocketincomingtcp(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketIncomingTcpFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsocketincomingtcp(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + UniffiRustFuture accept() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketincomingtcp_accept( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTcpStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtcp_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + void reject() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqsocketincomingtcp_reject( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtcp_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketIncomingTcp { + static MoqSocketIncomingTcp lift(Pointer ptr) { + return MoqSocketIncomingTcp.lift(ptr); + } + + static Pointer lower(MoqSocketIncomingTcp value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketIncomingTcp value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketIncomingTcp.lift(pointer), 8); + } + + static int write(MoqSocketIncomingTcp value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketIncomingTrackInterface { + UniffiRustFuture accept({required MoqTrackInfo? info}); + String peer(); + void reject(); + String session(); +} + +final _MoqSocketIncomingTrackFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqsocketincomingtrack(ptr, status), + ); +}); + +class MoqSocketIncomingTrack implements MoqSocketIncomingTrackInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketIncomingTrack._(this._ptr) { + _MoqSocketIncomingTrackFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketIncomingTrack.lift(Pointer ptr) { + return MoqSocketIncomingTrack._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsocketincomingtrack(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketIncomingTrackFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsocketincomingtrack(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + UniffiRustFuture accept({required MoqTrackInfo? info}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketincomingtrack_accept( + uniffiClonePointer(), + FfiConverterOptionalMoqTrackInfo.lower(info), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTrackStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtrack_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + void reject() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqsocketincomingtrack_reject( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtrack_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketIncomingTrack { + static MoqSocketIncomingTrack lift(Pointer ptr) { + return MoqSocketIncomingTrack.lift(ptr); + } + + static Pointer lower(MoqSocketIncomingTrack value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketIncomingTrack value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketIncomingTrack.lift(pointer), 8); + } + + static int write(MoqSocketIncomingTrack value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketTcpStreamInterface { + MoqGroupConsumer consumer(); + String peer(); + MoqGroupProducer producer(); + String session(); +} + +final _MoqSocketTcpStreamFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqsockettcpstream(ptr, status)); +}); + +class MoqSocketTcpStream implements MoqSocketTcpStreamInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketTcpStream._(this._ptr) { + _MoqSocketTcpStreamFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketTcpStream.lift(Pointer ptr) { + return MoqSocketTcpStream._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsockettcpstream(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketTcpStreamFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsockettcpstream(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + MoqGroupConsumer consumer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_consumer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqGroupConsumer.lift, + null, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + MoqGroupProducer producer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_producer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqGroupProducer.lift, + null, + ); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketTcpStream { + static MoqSocketTcpStream lift(Pointer ptr) { + return MoqSocketTcpStream.lift(ptr); + } + + static Pointer lower(MoqSocketTcpStream value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketTcpStream value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketTcpStream.lift(pointer), 8); + } + + static int write(MoqSocketTcpStream value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketTrackStreamInterface { + MoqTrackConsumer consumer(); + String peer(); + MoqTrackProducer producer(); + String session(); +} + +final _MoqSocketTrackStreamFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqsockettrackstream(ptr, status), + ); +}); + +class MoqSocketTrackStream implements MoqSocketTrackStreamInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketTrackStream._(this._ptr) { + _MoqSocketTrackStreamFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketTrackStream.lift(Pointer ptr) { + return MoqSocketTrackStream._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsockettrackstream(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketTrackStreamFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsockettrackstream(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + MoqTrackConsumer consumer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_consumer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTrackConsumer.lift, + null, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + MoqTrackProducer producer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_producer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTrackProducer.lift, + null, + ); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_session( uniffiClonePointer(), - FfiConverterSequenceString.lower(tokens), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterString.lift, + null, + ); } } -class FfiConverterMoqTokens { - static MoqTokens lift(Pointer ptr) { - return MoqTokens.lift(ptr); +class FfiConverterMoqSocketTrackStream { + static MoqSocketTrackStream lift(Pointer ptr) { + return MoqSocketTrackStream.lift(ptr); } - static Pointer lower(MoqTokens value) { + static Pointer lower(MoqSocketTrackStream value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqTokens value) { + static int allocationSize(MoqSocketTrackStream value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqTokens.lift(pointer), 8); + return LiftRetVal(MoqSocketTrackStream.lift(pointer), 8); } - static int write(MoqTokens value, Uint8List buf) { + static int write(MoqSocketTrackStream value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -21261,6 +21938,12 @@ external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( Pointer uniffiStatus, ); +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_ready( + Pointer ptr, + int size, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -21523,32 +22206,268 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_query( Pointer uniffiStatus, ); -@Native Function(Pointer, Uint16)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqrequest_reject( - Pointer ptr, - int code, +@Native Function(Pointer, Uint16)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqrequest_reject( + Pointer ptr, + int code, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_transport( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_url( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqserver( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqserver( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_constructor_moqserver_new( + RustBuffer config, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqserver_accept( + Pointer ptr, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqserver_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqserver_cert_fingerprints( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqserver_listen( + Pointer ptr, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqclient( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqclient( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_constructor_moqclient_new( + RustBuffer config, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqclient_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( + Pointer ptr, + RustBuffer url, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqsession( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqsession( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_allocator( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Uint32, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqsession_cancel( + Pointer ptr, + int code, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsession_closed( + Pointer ptr, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_consume( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_grants( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_publish( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_recv_bandwidth( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqsession_shutdown( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_state( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsession_state_changed( + Pointer ptr, + int after, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_stats( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_tokens( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_viewers( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqtokens( + Pointer handle, + Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_transport( - Pointer ptr, +external void uniffi_moq_ffi_fn_free_moqtokens( + Pointer handle, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqtokens_answered( + Pointer ptr, +); + +@Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_url( +external void uniffi_moq_ffi_fn_method_moqtokens_set( Pointer ptr, + RustBuffer tokens, Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqserver( +external Pointer uniffi_moq_ffi_fn_clone_moqsocketendpoint( Pointer handle, Pointer uniffiStatus, ); @@ -21556,49 +22475,81 @@ external Pointer uniffi_moq_ffi_fn_clone_moqserver( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqserver( +external void uniffi_moq_ffi_fn_free_moqsocketendpoint( Pointer handle, Pointer uniffiStatus, ); -@Native Function(RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_constructor_moqserver_new( - RustBuffer config, +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_constructor_moqsocketendpoint_new( + Pointer origin, + RustBuffer prefix, + RustBuffer id, Pointer uniffiStatus, ); @Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqserver_accept( +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_tcp( + Pointer ptr, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_track( Pointer ptr, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqserver_cancel( +external void uniffi_moq_ffi_fn_method_moqsocketendpoint_close( Pointer ptr, Pointer uniffiStatus, ); +@Native Function(Pointer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_tcp( + Pointer ptr, + RustBuffer peer, +); + +@Native Function(Pointer, RustBuffer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_track( + Pointer ptr, + RustBuffer peer, + RustBuffer info, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqserver_cert_fingerprints( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketendpoint_id( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqserver_listen( +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketendpoint_prefix( Pointer ptr, + Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqclient( +external Pointer uniffi_moq_ffi_fn_clone_moqsocketincomingtcp( Pointer handle, Pointer uniffiStatus, ); @@ -21606,39 +22557,44 @@ external Pointer uniffi_moq_ffi_fn_clone_moqclient( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqclient( +external void uniffi_moq_ffi_fn_free_moqsocketincomingtcp( Pointer handle, Pointer uniffiStatus, ); -@Native Function(RustBuffer, Pointer)>( +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsocketincomingtcp_accept( + Pointer ptr, +); + +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_constructor_moqclient_new( - RustBuffer config, +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtcp_peer( + Pointer ptr, Pointer uniffiStatus, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqclient_cancel( +external void uniffi_moq_ffi_fn_method_moqsocketincomingtcp_reject( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, RustBuffer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtcp_session( Pointer ptr, - RustBuffer url, + Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqsession( +external Pointer uniffi_moq_ffi_fn_clone_moqsocketincomingtrack( Pointer handle, Pointer uniffiStatus, ); @@ -21646,45 +22602,39 @@ external Pointer uniffi_moq_ffi_fn_clone_moqsession( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqsession( +external void uniffi_moq_ffi_fn_free_moqsocketincomingtrack( Pointer handle, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native Function(Pointer, RustBuffer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_allocator( +external Pointer uniffi_moq_ffi_fn_method_moqsocketincomingtrack_accept( Pointer ptr, - Pointer uniffiStatus, + RustBuffer info, ); -@Native, Uint32, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqsession_cancel( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtrack_peer( Pointer ptr, - int code, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqsession_closed( - Pointer ptr, -); - -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_consume( +external void uniffi_moq_ffi_fn_method_moqsocketincomingtrack_reject( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_grants( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtrack_session( Pointer ptr, Pointer uniffiStatus, ); @@ -21692,23 +22642,23 @@ external Pointer uniffi_moq_ffi_fn_method_moqsession_grants( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_publish( - Pointer ptr, +external Pointer uniffi_moq_ffi_fn_clone_moqsockettcpstream( + Pointer handle, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_recv_bandwidth( - Pointer ptr, +external void uniffi_moq_ffi_fn_free_moqsockettcpstream( + Pointer handle, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqsession_shutdown( +external Pointer uniffi_moq_ffi_fn_method_moqsockettcpstream_consumer( Pointer ptr, Pointer uniffiStatus, ); @@ -21716,68 +22666,72 @@ external void uniffi_moq_ffi_fn_method_moqsession_shutdown( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_state( +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettcpstream_peer( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqsession_state_changed( +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsockettcpstream_producer( Pointer ptr, - int after, + Pointer uniffiStatus, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_stats( +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettcpstream_session( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_tokens( - Pointer ptr, +external Pointer uniffi_moq_ffi_fn_clone_moqsockettrackstream( + Pointer handle, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_viewers( - Pointer ptr, +external void uniffi_moq_ffi_fn_free_moqsockettrackstream( + Pointer handle, Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqtokens( - Pointer handle, +external Pointer uniffi_moq_ffi_fn_method_moqsockettrackstream_consumer( + Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqtokens( - Pointer handle, +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettrackstream_peer( + Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtokens_answered( +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsockettrackstream_producer( Pointer ptr, + Pointer uniffiStatus, ); -@Native, RustBuffer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqtokens_set( +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettrackstream_session( Pointer ptr, - RustBuffer tokens, Pointer uniffiStatus, ); @@ -22871,6 +23825,9 @@ external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_ready(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); @@ -23015,6 +23972,75 @@ external int uniffi_moq_ffi_checksum_method_moqtokens_answered(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtokens_set(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_tcp(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_track(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_close(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_tcp(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_track(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_id(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_prefix(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_accept(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_reject(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_session(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_accept(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_reject(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_session(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_consumer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_producer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_session(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_consumer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_producer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_session(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel(); @@ -23151,6 +24177,9 @@ external int uniffi_moq_ffi_checksum_constructor_moqserver_new(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqclient_new(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqsocketendpoint_new(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqvideoconsumer_subscribe(); @@ -23683,6 +24712,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_ready() != 10687) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -23829,6 +24861,76 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtokens_set() != 20110) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_tcp() != 55983) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_track() != 3641) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_close() != 33296) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_tcp() != 61959) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_track() != 5972) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_id() != 2699) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_prefix() != 7033) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_accept() != 37027) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_peer() != 44709) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_reject() != 1335) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_session() != 25054) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_accept() != 43414) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_peer() != 60388) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_reject() != 3072) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_session() != + 36889) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_consumer() != 11536) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_peer() != 13339) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_producer() != 6563) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_session() != 61735) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_consumer() != 2651) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_peer() != 40311) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_producer() != 22232) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_session() != 30303) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel() != 21122) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -23976,6 +25078,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_constructor_moqclient_new() != 48355) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_constructor_moqsocketendpoint_new() != 7442) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_constructor_moqvideoconsumer_subscribe() != 7811) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); diff --git a/doc/concept/socket.md b/doc/concept/socket.md index 8073f8a1ca9..47533add9ad 100644 --- a/doc/concept/socket.md +++ b/doc/concept/socket.md @@ -107,9 +107,9 @@ track nobody serves. A dial that gives up resets `B`'s subscription in turn. ## Protocols Each side writes its own half and reads the peer's. A reader subscribes -reliably and in order: oldest group first, with no delay budget, so it waits -for a late group rather than skipping it. The protocol decides what a half -holds. +reliably and in order: from the half's first frame, oldest first, with no delay +budget, so it waits for a late group rather than skipping it. The protocol +decides what a half holds. ### `tcp` @@ -119,6 +119,9 @@ exactly like one direction of a TCP connection: - A frame is a write. The reader reads the frames in the order they were written, with nothing skipped. +- The reader joins the live group, which is the half's only one, from its + first frame. Over moq-transport, that is a `SUBSCRIBE` plus a joining `FETCH` + (or a fill) for the frames written before its answer. - Finishing the group is FIN: the reader reads to the end, then sees the end. - Resetting the group, or its track, is RST: the reader fails with the error. - A second group on a session track is a protocol error: the reader resets the @@ -129,7 +132,7 @@ budget, no order, since the newest group is never stale. A session can last indefinitely: a group has no size cap, and each copy keeps only a bounded tail of it. A writer that waits for room before each write (in -Rust, `group::Producer::ready`) runs at most a window ahead of the reader on +Rust, `group::Producer::ready`; in the bindings, `GroupProducer.ready(size)`) runs at most a window ahead of the reader on every hop, so a slow reader slows the writer, as TCP backpressure does, and nothing is lost. A writer that doesn't wait sheds the front of its half, and a reader that falls behind it fails with an eviction error instead of skipping @@ -149,7 +152,11 @@ a newer message never makes an older one stale. A reader that keeps up gets every message, but each hop's cache reclaims a message nobody read within its idle window (30 s by default), or under memory pressure, except the newest, so a reader that stalls -longer can miss messages. +longer can miss messages. Any message a hop reports it will never deliver is +skipped the same way, rather than ending the session. Over moq-transport, the +reader starts at group 0: a `SUBSCRIBE` with no filter, plus one range `FETCH` +for the groups below the answer's largest, so a publisher that refuses that +`FETCH` leaves a burst written before the answer missed. The reader's subscription starts reliable and in order, like `tcp`. An application may make it lossy for messages that go stale, such as positions or diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 472e84e7dcb..b41ec2c525d 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -28,7 +28,7 @@ subset drops. What a peer actually observes against this implementation: - **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. - **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead. - **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails. -- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust copy resuming a draft-18 subscription joins live, so once the track has moved on, a read under a finite max age gives up the history it cached before rather than fetching it again. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. +- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. An explicit absolute subscription start is served from that group on every supported draft, so a reader keeps its prefix even when its answer arrives late. A `SUBSCRIBE` without a Location Filter joins the live group rather than replaying history. A subscriber that wants the track from group 0 sends exactly that filterless `SUBSCRIBE`, the same on every draft, and fetches the groups below the answer's Largest Object with one range `FETCH`, which our publishers refuse once it spans more than one group. A live JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. - **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does. - **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. - **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index dcb3a3cc170..156063cd121 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -156,3 +156,43 @@ A catalog that needs a newer moq-hang draft than this build reads raises `Error. - API source: [py/moq-rs/moq](https://github.com/moq-dev/moq/tree/main/py/moq-rs/moq) - Source and examples: [`py/moq-rs`](https://github.com/moq-dev/moq/tree/main/py/moq-rs) - Raw bindings: [`moq-ffi`](https://pypi.org/project/moq-ffi/) on PyPI, for the unwrapped API + +## Socket sessions + +`moq.socket` connects endpoints through relays in either direction. Wire one +origin to both session directions, then hold an endpoint while its sessions run: + +```python +import asyncio, moq + +async def socket_client(): + origin = moq.OriginProducer() + versions = ["moq-lite-07-wip"] + async with moq.Client("https://cdn.moq.dev/anon", versions=versions, publish=origin, consume=origin): + async with moq.socket.Endpoint(origin, "room", "client") as endpoint: + stream = await endpoint.dial_tcp("server") + stream.producer.write_frame(b"command") + stream.producer.finish() # FIN; abort(error_code) sends RST instead. + async for frame in stream.consumer: + print(frame.payload) + +asyncio.run(socket_client()) +``` + +Sockets need lite-07 or moq-transport on every hop, so the example opts into +`moq-lite-07-wip`. No client offers it by default while its wire format is a +work in progress, and the relay must accept it too. + +A server awaits `endpoint.accept_tcp()`, then calls `await incoming.accept()` +or `incoming.reject()`. Accepting verifies that the claimed peer really dialed. +`TcpStream` exposes group `producer` and `consumer` handles, plus `peer` and +`session`. Keep the session id private. Its bounded cache reports eviction if a +reader falls behind; it never skips bytes. + +Use `dial_track(peer, info=None)` and `accept_track()` for independent groups of +messages. `TrackStream` exposes a track producer and a reliably subscribed, +ascending consumer. Omitted `info` keeps messages without a publisher age limit; +update the consumer's subscription to change delivery. The protocols have +separate accept queues. Cancelling a dial or acceptance resets that dial; +closing the endpoint resets all its sessions. See [Sockets](/concept/socket) +for routing and authorization. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 1c2553c6d9c..c43be664ec9 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +moq.dev/moq/socket now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`GroupProducer.Ready(ctx, size)` is new: it blocks until a frame of `size` bytes fits a window ahead of the fastest reader. Call it before each `WriteFrame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following.Available` reports the path going offline After the first call, `Available` returns `(nil, nil)` once the followed path goes offline, or, for a `Follow` pinned to an instance, once another instance takes it over. diff --git a/go/wrapper/bridge.go b/go/wrapper/bridge.go index 964965f008e..9d8769c457f 100644 --- a/go/wrapper/bridge.go +++ b/go/wrapper/bridge.go @@ -8,6 +8,13 @@ import ( // Hand the layer subpackages the handles behind these types // without exporting them. func init() { + bridge.OriginProducer = func(v any) *ffi.MoqOriginProducer { return v.(*OriginProducer).inner } + bridge.WrapGroupProducer = func(v *ffi.MoqGroupProducer) any { return &GroupProducer{inner: v} } + bridge.WrapGroupConsumer = func(v *ffi.MoqGroupConsumer) any { return &GroupConsumer{inner: v} } + bridge.WrapTrackProducer = func(v *ffi.MoqTrackProducer) any { return &TrackProducer{inner: v} } + bridge.WrapTrackConsumer = func(v *ffi.MoqTrackConsumer) any { return &TrackConsumer{inner: v} } + bridge.TrackInfo = func(v any) (*ffi.MoqTrackInfo, error) { return trackInfoFFI(v.(*TrackInfo)) } + bridge.BroadcastConsumer = func(v any) *ffi.MoqBroadcastConsumer { return v.(*BroadcastConsumer).inner } bridge.TrackRequest = func(v any) *ffi.MoqTrackRequest { return v.(*TrackRequest).inner } bridge.Subscription = func(v any) (*ffi.MoqSubscription, error) { return subscriptionFFI(v.(*Subscription)) } diff --git a/go/wrapper/internal/bridge/handles.go b/go/wrapper/internal/bridge/handles.go index 48fc0f38573..68421803225 100644 --- a/go/wrapper/internal/bridge/handles.go +++ b/go/wrapper/internal/bridge/handles.go @@ -12,6 +12,19 @@ import ( // moq, so they are set before a subpackage can call them. They take and return // `any` because this package cannot import moq's types without a cycle. var ( + // OriginProducer returns the handle behind a *moq.OriginProducer. + OriginProducer func(any) *ffi.MoqOriginProducer + // WrapGroupProducer wraps a socket write half. + WrapGroupProducer func(*ffi.MoqGroupProducer) any + // WrapGroupConsumer wraps a socket read half. + WrapGroupConsumer func(*ffi.MoqGroupConsumer) any + // WrapTrackProducer wraps a socket write half. + WrapTrackProducer func(*ffi.MoqTrackProducer) any + // WrapTrackConsumer retains a socket's subscribed read half. + WrapTrackConsumer func(*ffi.MoqTrackConsumer) any + // TrackInfo converts a *moq.TrackInfo to the generated record. + TrackInfo func(any) (*ffi.MoqTrackInfo, error) + // BroadcastProducer returns the handle behind a *moq.BroadcastProducer. BroadcastProducer func(any) *ffi.MoqBroadcastProducer // TrackProducer returns the handle behind a *moq.TrackProducer. diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 02c28eb680d..013ac28ad76 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -265,7 +265,18 @@ func (g *GroupProducer) Consume() (*GroupConsumer, error) { return &GroupConsumer{inner: inner}, nil } -// WriteFrame appends frame to the group. +// Ready blocks until a frame of size bytes fits a window ahead of the fastest +// reader. Call it before each WriteFrame so that reader paces the writer, as TCP +// backpressure does, instead of the group shedding frames it has not taken, which +// fails it with Evicted; slower readers are not waited on. It also waits until a reader holds +// the group, and returns once the group is closed so the next write reports why. +// It fails with Closed after Abort. +func (g *GroupProducer) Ready(ctx context.Context, size uint64) error { + return g.inner.Ready(ctx, size) +} + +// WriteFrame appends frame to the group. It never blocks: without Ready, a writer +// that outpaces its readers sheds the front of the group instead. func (g *GroupProducer) WriteFrame(frame Frame) error { return g.inner.WriteFrame(frame) } diff --git a/go/wrapper/socket/socket.go b/go/wrapper/socket/socket.go new file mode 100644 index 00000000000..4e259a77513 --- /dev/null +++ b/go/wrapper/socket/socket.go @@ -0,0 +1,160 @@ +// Package socket dials and accepts point-to-point sessions over hidden broadcasts. +package socket + +import ( + "context" + moq "moq.dev/moq" + ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" +) + +// Endpoint announces one hidden endpoint and accepts TCP and track dials separately. +type Endpoint struct{ inner *ffi.MoqSocketEndpoint } + +// NewEndpoint announces id beneath prefix, using origin to find peers. +func NewEndpoint(origin *moq.OriginProducer, prefix, id string) (*Endpoint, error) { + inner, err := ffi.NewMoqSocketEndpoint(bridge.OriginProducer(origin), prefix, id) + if err != nil { + return nil, err + } + return &Endpoint{inner: inner}, nil +} + +// ID returns this endpoint's id. +func (e *Endpoint) ID() string { return e.inner.Id() } + +// Prefix returns the shared endpoint prefix. +func (e *Endpoint) Prefix() string { return e.inner.Prefix() } + +// Close retracts the endpoint and resets pending dials and sessions. +func (e *Endpoint) Close() { e.inner.Close() } + +// DialTCP dials a new tcp session; cancelling resets only this dial. +func (e *Endpoint) DialTCP(ctx context.Context, peer string) (*TCPStream, error) { + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTcpStream, error) { return e.inner.DialTcp(ctx, peer) }) + if err != nil { + return nil, err + } + return newTCPStream(inner), nil +} + +// AcceptTCP starts listening for tcp and awaits the next dial. +func (e *Endpoint) AcceptTCP(ctx context.Context) (*IncomingTCP, error) { + inner, err := bridge.CallHandle(ctx, nil, e.inner.AcceptTcp) + if err != nil { + return nil, err + } + return &IncomingTCP{inner: inner}, nil +} + +// IncomingTCP is a tcp dial, consumed by accepting or rejecting it. +type IncomingTCP struct{ inner *ffi.MoqSocketIncomingTcp } + +// Peer returns the claimed endpoint; accepting verifies return routability. +func (i *IncomingTCP) Peer() string { return i.inner.Peer() } + +// Session returns the private session id. +func (i *IncomingTCP) Session() string { return i.inner.Session() } + +// Reject refuses the dial as not found. +func (i *IncomingTCP) Reject() error { return i.inner.Reject() } + +// Accept verifies return routability and accepts this dial. +func (i *IncomingTCP) Accept(ctx context.Context) (*TCPStream, error) { + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTcpStream, error) { return i.inner.Accept(ctx) }) + if err != nil { + return nil, err + } + return newTCPStream(inner), nil +} + +// TCPStream exposes a tcp session's two halves and identities. +type TCPStream struct { + // Producer is this side's write half. + Producer *moq.GroupProducer + // Consumer is the peer's subscribed read half. + Consumer *moq.GroupConsumer + // Peer is the peer endpoint's id. + Peer string + // Session is the private session id. + Session string +} + +func newTCPStream(inner *ffi.MoqSocketTcpStream) *TCPStream { + defer inner.Destroy() + return &TCPStream{ + Producer: bridge.WrapGroupProducer(inner.Producer()).(*moq.GroupProducer), + Consumer: bridge.WrapGroupConsumer(inner.Consumer()).(*moq.GroupConsumer), + Peer: inner.Peer(), Session: inner.Session(), + } +} + +// DialTrack dials a new track session; cancelling resets only this dial. +func (e *Endpoint) DialTrack(ctx context.Context, peer string, info *moq.TrackInfo) (*TrackStream, error) { + ffiInfo, err := bridge.TrackInfo(info) + if err != nil { + return nil, err + } + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTrackStream, error) { + return e.inner.DialTrack(ctx, peer, ffiInfo) + }) + if err != nil { + return nil, err + } + return newTrackStream(inner), nil +} + +// AcceptTrack starts listening for track and awaits the next dial. +func (e *Endpoint) AcceptTrack(ctx context.Context) (*IncomingTrack, error) { + inner, err := bridge.CallHandle(ctx, nil, e.inner.AcceptTrack) + if err != nil { + return nil, err + } + return &IncomingTrack{inner: inner}, nil +} + +// IncomingTrack is a track dial, consumed by accepting or rejecting it. +type IncomingTrack struct{ inner *ffi.MoqSocketIncomingTrack } + +// Peer returns the claimed endpoint; accepting verifies return routability. +func (i *IncomingTrack) Peer() string { return i.inner.Peer() } + +// Session returns the private session id. +func (i *IncomingTrack) Session() string { return i.inner.Session() } + +// Reject refuses the dial as not found. +func (i *IncomingTrack) Reject() error { return i.inner.Reject() } + +// Accept verifies return routability and accepts this dial. +func (i *IncomingTrack) Accept(ctx context.Context, info *moq.TrackInfo) (*TrackStream, error) { + ffiInfo, err := bridge.TrackInfo(info) + if err != nil { + return nil, err + } + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTrackStream, error) { return i.inner.Accept(ctx, ffiInfo) }) + if err != nil { + return nil, err + } + return newTrackStream(inner), nil +} + +// TrackStream exposes a track session's two halves and identities. +type TrackStream struct { + // Producer is this side's write half. + Producer *moq.TrackProducer + // Consumer is the peer's subscribed read half. + Consumer *moq.TrackConsumer + // Peer is the peer endpoint's id. + Peer string + // Session is the private session id. + Session string +} + +func newTrackStream(inner *ffi.MoqSocketTrackStream) *TrackStream { + defer inner.Destroy() + return &TrackStream{ + Producer: bridge.WrapTrackProducer(inner.Producer()).(*moq.TrackProducer), + Consumer: bridge.WrapTrackConsumer(inner.Consumer()).(*moq.TrackConsumer), + Peer: inner.Peer(), Session: inner.Session(), + } +} diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 598de2f1853..11a84bfef70 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Absolute IETF subscriptions keep their prefix + +Explicit group ranges now keep their requested start when the subscription answer +arrives after its first data. IETF publishers honor absolute starts on all +supported drafts instead of silently joining at the live edge. A start of group 0 +is a filterless SUBSCRIBE on every draft, and the groups below the answer's Largest +Object come from one range FETCH. A `Subscribe` with an unfiltered filter now omits +LOCATION\_FILTER, and a SUBSCRIBE without one still joins live. There is no API +change. + ### WebSocket receive windows match Rust The fallback now grants 4 MiB per connection and 1 MiB per stream, up from diff --git a/js/net/src/ietf/filter.test.ts b/js/net/src/ietf/filter.test.ts index 9280400cb86..be2a5d0dd1d 100644 --- a/js/net/src/ietf/filter.test.ts +++ b/js/net/src/ietf/filter.test.ts @@ -1,9 +1,10 @@ import { expect, test } from "bun:test"; import { Milli } from "@moq/clock"; import { ProtocolViolation } from "../error.ts"; -import { Reader } from "../stream.ts"; +import * as Path from "../path.ts"; +import { Reader, Writer } from "../stream.ts"; import * as Filter from "./filter.ts"; -import { joinFilter } from "./subscribe.ts"; +import { joinFilter, Subscribe } from "./subscribe.ts"; import { Version } from "./version.ts"; const OLD = Version.DRAFT_18; @@ -24,6 +25,45 @@ test("a live join is Next Object, its head left to a joining FETCH or a StartGro expect(draft22.fill).toEqual({ filter: { kind: "relative", groups: 1n }, rangeFilters: false }); }); +test.each([OLD, FIELDS, NEW])("a session start of group 0 joins live with no filter on %s", (version) => { + // Its earlier groups come from a range FETCH, so no draft spells an absolute {0, 0}. + expect(joinFilter(version, 0n)).toEqual({ filter: { kind: "unfiltered" } }); + expect(joinFilter(version, 3n)).toEqual({ filter: { kind: "absolute", startGroup: 3n, startObject: 0n } }); +}); + +async function subscribeBytes(filter: Filter.Filter, version: typeof OLD | typeof FIELDS | typeof NEW) { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ write: (chunk) => void chunks.push(chunk.slice()) }), + version, + ); + const msg = new Subscribe({ + requestId: 0n, + trackNamespace: Path.from("live"), + trackName: "video", + subscriberPriority: 128, + filter, + }); + await msg.encode(writer, version); + writer.close(); + await writer.closed; + const encoded = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { + encoded.set(chunk, offset); + offset += chunk.length; + } + return encoded; +} + +test.each([OLD, FIELDS, NEW])("a filterless SUBSCRIBE omits LOCATION_FILTER on %s", async (version) => { + const filterless = await subscribeBytes({ kind: "unfiltered" }, version); + const next = await subscribeBytes({ kind: "nextObject" }, version); + expect(next.length).toBeGreaterThan(filterless.length); + const decoded = await Subscribe.decode(new Reader(undefined, filterless, version), version); + expect(decoded.filter).toEqual({ kind: "unfiltered" }); +}); + function roundTrip(filter: Filter.Filter, version: typeof OLD | typeof FIELDS | typeof NEW): Filter.Filter { return Filter.decode(Filter.encode(filter, version), version); } diff --git a/js/net/src/ietf/join.test.ts b/js/net/src/ietf/join.test.ts index d33b3c2df0e..3474d61dbdc 100644 --- a/js/net/src/ietf/join.test.ts +++ b/js/net/src/ietf/join.test.ts @@ -58,14 +58,14 @@ async function nextStream( } /** A subscriber with one track, and the SUBSCRIBE it sent, still unanswered. */ -async function subscribed(version: IetfVersion = VERSION) { +async function subscribed(version: IetfVersion = VERSION, subscription: Partial = {}) { const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const subscriber = new Subscriber({ session }); const consumer = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ ...track.Subscription.default(), ranges: [{ start: { relative: 0n } }] }); + .subscribe({ ...track.Subscription.default(), ranges: [{ start: { relative: 0n } }], ...subscription }); onTestFinished(() => { consumer.close(); session.close(); @@ -86,8 +86,11 @@ async function subscribed(version: IetfVersion = VERSION) { * A draft-18 subscriber with one track answered with Largest `largest`, and the joining * FETCH it sent in response, still unanswered. */ -async function join(largest: MessageLocation | null = { groupId: GROUP, objectId: 1n }) { - const subscription = await subscribed(); +async function join( + largest: MessageLocation | null = { groupId: GROUP, objectId: 1n }, + options: Partial = {}, +) { + const subscription = await subscribed(VERSION, options); await subscription.answer(largest); const fetching = await nextStream(subscription.client); @@ -264,6 +267,19 @@ for (const outcome of ["a refused joining FETCH", "an empty joining FETCH"] as c }); } +test("a reader in sequence order with no delay budget skips headless groups", async () => { + const { subscriber, consumer, fetching } = await join(undefined, { order: "ascending", delay: { max: null } }); + await refuse(fetching); + + // The joined group's head was refused, and the next group's stream starts mid-group. + await subscriber.handleGroup(...liveStream(GROUP, 2, ["t2"])); + await subscriber.handleGroup(...liveStream(GROUP + 1n, 1, ["m1"])); + await subscriber.handleGroup(...liveStream(GROUP + 2n, 0, ["n0"])); + + const group = await timeout(Nano.fromMilli(Milli(1000)), consumer.ordered().nextGroup()); + expect(group?.sequence).toBe(GROUP + 2n); +}); + test("a tail parked on a refused joining FETCH is dropped", async () => { const { subscriber, consumer, fetching } = await join(); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index a8520047281..256cee7b9f4 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2444,6 +2444,51 @@ test("draft-22: an absolute subscribe is no join", async () => { } }); +/** + * A filterless SUBSCRIBE without a fill joins the latest group, as a relay cache always served + * one; only an absolute start from group 0 replays the groups before it. Ascending order sends + * history first, so the first stream tells them apart. + */ +test.each(IETF_VERSIONS)("version %d: only an absolute start replays history", async (version) => { + // Bounded, since draft 20 spells an open {0, 0} as no filter at all. + const zero: Filter.Filter = { kind: "absolute", startGroup: 0n, startObject: 0n, endGroup: 1n }; + for (const [filter, expected] of [ + [{ kind: "unfiltered" }, [1n]], + [zero, [0n, 1n]], + ] as const) { + const fx = fixture(version); + // Unlimited max age, so the first group is still served once its successor exists. + const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); + writeGroup(track, 1); + writeGroup(track, 1); + + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 7n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + groupOrder: "ascending", + filter, + }), + ); + + try { + const served: bigint[] = []; + for (const _ of expected) { + const stream = await nextUni(fx.uni); + if (!stream) throw new Error(`${version} ${filter.kind}: missing group stream`); + served.push((await GroupMessage.decode(new Reader(stream, undefined, version), version)).groupId); + } + expect(served).toEqual([...expected]); + } finally { + fx.close(); + client.close(); + } + } +}); + /** * The draft's own current-group join: a Next Object subscription for the live tail, plus a * StartGroup=1 fill for the head already published. The two must meet exactly, so the head @@ -2618,8 +2663,8 @@ test("draft-22: a backwards range within one group serves nothing and ends the s } }); -// Flaky in the workspace suite: the live group sometimes resets while read. Isolate its -// clock and stream ordering before re-enabling. +// Quarantined: the live group intermittently resets while read. +// Investigation: quest/m0/test-flakes-2/ietf-wider-fill.md. test.skip.each(["ascending", "descending"] as const)("draft-22: a wider fill is refused in %s order", async (order) => { const fx = fixture(); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 7e7d2c25fbc..607a4755356 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -485,7 +485,7 @@ export class Publisher { const subscribe = (source: broadcast.Consumer) => { // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the refresh ratio never judges it. - const fixed = subscribeRange(msg, {}, version); + const fixed = filterRange(msg.filter, {}); // Parked past a bounded window's end: the peer reads this stream's end as the track's. return hooks.parked( source.track(msg.trackName).subscribe({ @@ -685,7 +685,7 @@ export class Publisher { msg.fill && msg.forward && Filter.isDraft20(version) ? fillRange(msg.fill, msg.filter, edge.largest) : undefined; - const requested = subscribeRange(msg, edge, version); + const requested = filterRange(msg.filter, edge); // A copy, since an update moves the cursor in place and must not move the bounds too. const range = { ...afterFill(requested, fill, edge) }; // What the subscriber asked for, which only a narrowing changes: the fill's split @@ -2869,31 +2869,13 @@ function liveEdge(track: TrackSubscriber): LiveEdge { }; } -/** - * Resolve a SUBSCRIBE's Location Filter into the range to serve. - * - * Draft-22 is honored whole. Earlier drafts honor only Largest Object, which a joining - * FETCH pairs with to recover the group's head: their Filter Type tag has absolute forms - * we never served, and starting to interpret them now would change what an existing peer - * receives. - */ -function subscribeRange(msg: Subscribe, edge: LiveEdge, version: IetfVersion): ServeRange { - if (!Filter.isDraft20(version) && msg.filter.kind !== "nextObject") { - if (msg.filter.kind !== "unfiltered") { - console.warn(`filter not supported before draft-22, ignoring: ${msg.filter.kind}`); - } - return {}; - } - - return filterRange(msg.filter, edge); -} - /** The Locations a single Location Filter selects, resolved against the live edge. */ function filterRange(filter: Filter.Filter, edge: LiveEdge): ServeRange { switch (filter.kind) { // No restriction. moq-lite starts at the beginning of the latest group, which is the // join point it is built around; a subscription passes objects as they are published, - // so an absent filter is not a request to replay history. + // so an absent filter is not a request to replay history. A subscriber that wants the + // track from its start fetches the groups before the live one. case "unfiltered": return {}; // `{Largest.Group, Largest.Object + 1}`. Everything below it, including the already diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index 41984268004..1e619f8be90 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -45,16 +45,25 @@ function decodeGroupOrder(value: number): Order { } /** - * How this implementation joins a live track: at the current group, from its first object, - * as the Rust subscriber does. + * Join at a known absolute start, or at the current group's first object as Rust does. * - * The subscription itself starts at the next object (draft 18's Largest Object), and the + * A live subscription starts at the next object (draft 18's Largest Object), and the * group's head comes on a fetch stream. Draft 22 asks for it with a fill one group back from * the next, its current-group join (section 3.5); earlier drafts have no fill, so the * subscriber sends a Relative Joining FETCH once SUBSCRIBE_OK names Largest. Spelling the * filter keeps a peer from reading an absent one its own way. + * + * A start of group 0 is filterless on every draft, which our publishers serve from the start + * of the latest group; the copy's fills fetch the groups below it as one range FETCH. Draft 20 + * has no absolute {0, 0} distinct from no filter, so no draft spells one. */ -export function joinFilter(version: IetfVersion): { filter: Filter.Filter; fill?: Filter.Fill } { +export function joinFilter(version: IetfVersion, start?: bigint): { filter: Filter.Filter; fill?: Filter.Fill } { + if (start === 0n) return { filter: { kind: "unfiltered" } }; + // Open-ended, since REQUEST_UPDATE carries no range here: a finite end would hold the + // subscription short when another reader later widens the track's demand. + if (start !== undefined) { + return { filter: { kind: "absolute", startGroup: start, startObject: 0n } }; + } const filter: Filter.Filter = { kind: "nextObject" }; if (!Filter.isDraft20(version)) return { filter }; return { filter, fill: { filter: { kind: "relative", groups: 1n }, rangeFilters: false } }; @@ -141,7 +150,10 @@ export class Subscribe { params.maxAge = this.maxAge; params.groupOrder = encodeGroupOrder(this.groupOrder); params.forward = this.forward; - params.subscriptionFilter = this.filter; + // Unfiltered is the absent parameter, which a publisher reads as a live join. + if (this.filter.kind !== "unfiltered") { + params.subscriptionFilter = this.filter; + } // FILL_PARAMETERS and INCLUDE_PROPERTIES arrived in draft-22. An older peer reads // either as an unknown parameter, which is a protocol violation, so they are diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 2a6b0ac0769..7d6ef74ce39 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -14,6 +14,7 @@ import * as track from "../track.ts"; import * as demandUtil from "../util/demand.ts"; import type * as Cluster from "./cluster.ts"; import { Connection } from "./connection.ts"; +import { Fetch } from "./fetch.ts"; import { type GroupFlags, Group as GroupMessage } from "./object.ts"; import { Parameters } from "./parameters.ts"; import type { Properties } from "./properties.ts"; @@ -1153,6 +1154,166 @@ test("early group waits for SUBSCRIBE_OK priority before track acceptance", asyn track.close(); }); +test.each([Version.DRAFT_18, Version.DRAFT_20, Version.DRAFT_22])( + "an absolute prefix survives an answer behind its first burst (%s)", + async (version) => { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, version, true) }); + const consumer = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ + ...track.Subscription.default(), + ranges: [{ start: { absolute: 1n } }], + order: "ascending", + delay: { max: null }, + }); + const peer = await nextStream(pair.client, version); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + try { + expect(await peer.reader.u53()).toBe(Subscribe.id); + expect((await Subscribe.decode(peer.reader, version)).filter).toEqual({ + kind: "absolute", + startGroup: 1n, + startObject: 0n, + }); + const arrivals = [1n, 2n].map((groupId) => + subscriber.handleGroup( + new GroupMessage({ + trackAlias: ALIAS, + groupId, + subGroupId: 0n, + publisherPriority: 0, + flags: groupFlags(true), + }), + new Reader(undefined, encodeObjects([0, 0]), version), + ), + ); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ trackAlias: ALIAS, largest: { groupId: 2n, objectId: 1n } }).encode( + peer.writer, + version, + ); + await Promise.all(arrivals); + const ordered = consumer.ordered(); + try { + for (const sequence of [1n, 2n]) { + const group = await ordered.nextGroup(); + expect(group?.sequence).toBe(sequence); + if (!group) throw new Error("missing prefix group"); + expect(await group.readString()).toBe("object 0"); + expect(await group.readString()).toBe("object 0"); + expect(await group.readString()).toBeUndefined(); + } + } finally { + ordered.close(); + } + } finally { + peer.close(); + consumer.close(); + } + }, +); + +test.each([Version.DRAFT_18, Version.DRAFT_20, Version.DRAFT_22])( + "a start of group 0 joins live with no filter and fetches the groups below Largest (%s)", + async (version) => { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, version, true) }); + const consumer = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ + ...track.Subscription.default(), + ranges: [{ start: { absolute: 0n } }], + order: "ascending", + delay: { max: null }, + }); + const peer = await nextStream(pair.client, version); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + const ordered = consumer.ordered(); + let fetch: Awaited> | undefined; + try { + expect(await peer.reader.u53()).toBe(Subscribe.id); + const msg = await Subscribe.decode(peer.reader, version); + expect(msg.filter).toEqual({ kind: "unfiltered" }); + expect(msg.fill).toBeUndefined(); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ trackAlias: ALIAS, largest: { groupId: 2n, objectId: 1n } }).encode( + peer.writer, + version, + ); + // Our publisher serves the filterless join from the start of the latest group. + await subscriber.handleGroup( + new GroupMessage({ + trackAlias: ALIAS, + groupId: 2n, + subGroupId: 0n, + publisherPriority: 0, + flags: groupFlags(true), + }), + new Reader(undefined, encodeObjects([0, 0]), version), + ); + // The reader waits on group 0, which only a FETCH can bring. + void ordered.nextGroup().catch(() => {}); + fetch = await nextStream(pair.client, version); + if (!fetch) throw new Error("missing FETCH stream"); + expect(await fetch.reader.u53()).toBe(Fetch.id); + const { type } = await Fetch.decode(fetch.reader, version); + // One range for groups 0 and 1, below the live group 2. + if (type.kind === "standalone") { + expect(version).toBe(Version.DRAFT_18); + expect([type.start, type.end.group]).toEqual([{ group: 0n, object: 0n }, 1n]); + } else { + expect(type).toMatchObject({ + kind: "filtered", + filter: { kind: "absolute", startGroup: 0n, startObject: 0n, endGroup: 1n }, + }); + } + } finally { + ordered.close(); + fetch?.close(); + peer.close(); + consumer.close(); + } + }, +); + +// SUBSCRIBE_UPDATE carries no range here, so a finite end would hold the upstream copy short +// once another reader widens the track's demand. +test("a bounded subscription asks upstream from its start without an end", async () => { + const version = Version.DRAFT_22; + const pair = createMockTransportPair(ALPN.DRAFT_18); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, version, true) }); + const consumer = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ + ...track.Subscription.default(), + ranges: [{ start: { absolute: 4n }, end: { absolute: 10n } }], + order: "ascending", + delay: { max: null }, + }); + // The range reaches past the live edge TRACK_STATUS names, so it subscribes. + const status = await nextStream(pair.client, version); + if (!status) throw new Error("missing TRACK_STATUS stream"); + expect(await status.reader.u53()).toBe(TrackStatusRequest.id); + await TrackStatusRequest.decode(status.reader, version); + await status.writer.u53(TrackStatusOk.id); + await new TrackStatusOk({ largest: { groupId: 5n, objectId: 0n } }).encode(status.writer, version); + const peer = await nextStream(pair.client, version); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + try { + expect(await peer.reader.u53()).toBe(Subscribe.id); + const { filter } = await Subscribe.decode(peer.reader, version); + expect(filter).toEqual({ kind: "absolute", startGroup: 4n, startObject: 0n, endGroup: undefined }); + } finally { + peer.close(); + status.close(); + consumer.close(); + } +}); + /** * A group is the unit an application resyncs on, so one served from partway through is * unusable: the objects on the stream do not decode without the head the filter excluded, diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index bfa6103b42b..4379a2b5fce 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1536,6 +1536,7 @@ export class Subscriber { // that the stream is recorded, so settling this promise hands it back to be torn down. if (cancelled.aborted) throw new Error("subscribe cancelled before it was sent"); + const bounds = wireSpan(request.subscription.ranges ?? [{}]); const msg = new Subscribe({ requestId, trackNamespace: broadcast, @@ -1545,10 +1546,10 @@ export class Subscriber { maxAge: this.#maxAge ? maxDelayOf(request.subscription) : undefined, // Fixed for the subscription's life: no REQUEST_UPDATE parameter carries it. groupOrder: request.subscription.order, - // Joins at the current group, from its first object: the subscription delivers the - // objects after Largest, and the group's head before it comes on a fetch stream - // (see {@link Join}). Fixed for the subscription's life. - ...joinFilter(version), + // A known start is absolute, except group 0, which joins live with no filter. A Next + // Object join recovers its head through a fetch stream (see {@link Join}). Fixed for + // the subscription's life. + ...joinFilter(version, bounds.start), }); if (msg.fill) { // The fill names this request and can overtake SUBSCRIBE_OK, so it waits on it here. @@ -1605,10 +1606,12 @@ export class Subscriber { if (!known) state.requests = producer.requests(); const tail = new Tail(); const subscription: Subscription = { track: producer, tail, timescale: ok.properties.timescale, fetchIds: [] }; - // The subscription starts after Largest, so the joining FETCH or the fill brings its - // group's head. With no Largest the track is empty and every group whole. A draft-22 fill - // is one of the subscription's own streams, so the Stream Count already waits for it. - if (ok.largest) { + // A live join starts at Largest's group: a filterless one from its first object, and Next + // Object after Largest, so the joining FETCH or the fill brings that group's head. With no + // Largest the track is empty and every group whole. A draft-22 fill is one of the + // subscription's own streams, so the Stream Count already waits for it. + const live = msg.filter.kind !== "absolute"; + if (msg.filter.kind === "nextObject" && ok.largest) { subscription.join = new Join(ok.largest.groupId, tail, { counted: msg.fill !== undefined }); const join = subscription.join; this.#joins.set(producer, join); @@ -1624,12 +1627,11 @@ export class Subscriber { // no object, so a Next Object join served onward still starts at the group's first. hooks.reportedGroup(producer, ok.largest.groupId); } - // The live join starts at the publisher's edge, whatever floor the demand asked for. - // With no Largest it skips nothing, so every group is owed: a lower one's stream may - // still arrive after a higher one's. + // Absolute subscriptions keep their prefix even when the answer follows its first data. A + // live join's earlier start is left to the copy's fills, one range FETCH per run. hooks.startTrack(producer, { - start: ok.largest ? ok.largest.groupId : 0n, - requested: wireSpan(request.subscription.ranges ?? [{}]).start, + start: live ? (ok.largest?.groupId ?? 0n) : (bounds.start ?? 0n), + requested: bounds.start, }); staged?.commit(); state.fill?.(subscription); @@ -1856,6 +1858,14 @@ export class Subscriber { } return producer; }; + // A group that will never arrive whole is dropped, so readers stop owing it. + const dropHeadless = () => + hooks.dropTrackGroups( + track, + group.groupId, + group.groupId + 1n, + new StreamError(StreamCode.NotFound, { message: "the group's head was lost" }), + ); // After an END_OF_GROUP or END_OF_TRACK status only the stream's end may follow: an // object past the group's final one makes the track malformed. const last = async () => { @@ -1919,6 +1929,7 @@ export class Subscriber { console.debug( `dropping a group with no head: alias=${group.trackAlias} group=${group.groupId} start=${start}`, ); + dropHeadless(); stream.stop(new Error("no head to stitch a mid-group stream onto")); return; } @@ -1932,6 +1943,7 @@ export class Subscriber { console.debug( `dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`, ); + dropHeadless(); stream.stop(new Error("a group must start at object 0")); return; } @@ -1992,6 +2004,7 @@ export class Subscriber { // as the keyframe the group opens with. if (producer === undefined && e instanceof ObjectIdGap && !group.flags.firstObject) { console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); + dropHeadless(); stream.stop(new Error("a group must start at object 0")); return; } diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 73bb90e54be..04008a715c3 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +dev.moq.socket now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. Like broadcasts, endpoint `end()` forces shutdown while AutoCloseable `close()` releases the handle. + +`GroupProducer.ready(size)` is a new suspending call that waits until a frame of `size` bytes fits a window ahead of the fastest reader. Call it before each `writeFrame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following.available()` returns null once the path goes offline `following.available()` returns `BroadcastConsumer?`. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/socket/Socket.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/socket/Socket.kt new file mode 100644 index 00000000000..d8e911da89e --- /dev/null +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/socket/Socket.kt @@ -0,0 +1,16 @@ +package dev.moq.socket + +/** Announces one hidden endpoint and dials or accepts sessions. */ +typealias Endpoint = uniffi.moq.MoqSocketEndpoint + +/** An incoming TCP dial, consumed by accepting or rejecting it. */ +typealias IncomingTcp = uniffi.moq.MoqSocketIncomingTcp + +/** An incoming track dial, consumed by accepting or rejecting it. */ +typealias IncomingTrack = uniffi.moq.MoqSocketIncomingTrack + +/** A TCP session with group producer and consumer halves. */ +typealias TcpStream = uniffi.moq.MoqSocketTcpStream + +/** A track session with producer and reliably subscribed consumer halves. */ +typealias TrackStream = uniffi.moq.MoqSocketTrackStream diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index e4974c0906f..deb7664f508 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +moq.socket now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`await GroupProducer.ready(size)` is new: it waits until a frame of `size` bytes fits a window ahead of the fastest reader. Await it before each `write_frame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a `TcpStream` producer needs it to behave like TCP. Existing writers are unchanged. + ### Announcements are a listing of present and absent paths `AnnounceConsumer` yields `AnnounceEventPresent` when a route starts covering a path and `AnnounceEventAbsent` when none covers it any more, each carrying an `Announce` as `announce`. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index b09a547121e..bede3ee40b8 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -178,6 +178,7 @@ client = moq.Client( - Async iterator yielding `TrackRequest` - **`TrackProducer` / `GroupProducer`**. Write raw payloads with no codec parsing. - `.write_frame(payload, timestamp=None)` writes a payload with optional capture time. + - `await .ready(size)` (`GroupProducer`) waits until a frame of `size` bytes fits a window ahead of the fastest reader. Await it before each write for backpressure; a writer that doesn't sheds the front of the group instead. - `.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. @@ -248,6 +249,35 @@ same either way. Opaque bytes go on a plain `TrackProducer`. Every handle whose cleanup is `cancel()` is an async context manager, so exiting `async with` releases it: the consumers (`media.CatalogConsumer`, `media.TrackConsumer`, `media.GroupConsumer`, `TrackConsumer`, `audio.Consumer`, `video.Consumer`, `GroupConsumer`, `json.SnapshotConsumer`, `json.StreamConsumer`, `json.WindowConsumer`, `AnnounceConsumer`, `Following`) and the dynamic sources (`Route`, `BroadcastRequests`, `TrackRequests`). +### Sockets + +`from moq import socket` exposes `socket.Endpoint(origin, prefix, id)`. Use the +same origin as the session's publish and consume sides. Hold the endpoint while +sessions run, or use it as an async context manager. It announces only +`/.socket/`; keep each session id private. + +- `await endpoint.dial_tcp(peer)` and `await endpoint.accept_tcp()` use the TCP queue. +- `await endpoint.dial_track(peer, info=None)` and `await endpoint.accept_track()` use the track queue. +- An incoming dial has `peer` and `session`; `await incoming.accept()` verifies + return routability, and `incoming.reject()` refuses it. Track accepts also take `info=`. +- `TcpStream` exposes `producer: GroupProducer`, `consumer: GroupConsumer`, `peer`, + and `session`. Write frames, finish for FIN, and abort for RST. Await + `producer.ready(len(payload))` before each write so a slow reader slows the + writer, as TCP backpressure does. A writer that doesn't wait sheds the front + of its half, and a reader that falls behind fails with eviction rather than + skipping bytes. +- `TrackStream` exposes `producer: TrackProducer`, `consumer: TrackConsumer`, `peer`, + and `session`. Its subscription starts reliable and ascending, and omitted + `info` keeps messages without a publisher age limit. Hold or cancel the consumer + to retain or release that subscription. A message no hop will deliver, such as + one a cache reclaimed after 30 s unread or a refused fetch, is skipped rather + than ending the session. + +Cancelling a dial or its acceptance resets that dial. Cancelling an accept wait +leaves the endpoint listening. `endpoint.close()` resets sessions and pending dials. +The [socket convention](https://doc.moq.dev/concept/socket) needs lite-07 or +moq-transport on every relay hop; this API does not add pre-07 capability detection. + ### Origin (advanced) - **`OriginProducer(*, cache_capacity_bytes=None)`**. Manage broadcast announcements. Set `cache_capacity_bytes` to bound cached groups under this origin. diff --git a/py/moq-rs/benches/ffi.py b/py/moq-rs/benches/ffi.py index 120441b4228..3ddac852d18 100644 --- a/py/moq-rs/benches/ffi.py +++ b/py/moq-rs/benches/ffi.py @@ -108,8 +108,70 @@ def call(name: str, fn: Callable[[], object], results: list[dict]) -> None: results.append({"name": f"call/{name}", "unit": "ns", "value": (time.perf_counter() - start) * 1e9 / calls}) +async def sockets(results: list[dict]) -> None: + """Sweep listening endpoints and simultaneous sessions per endpoint separately.""" + for protocol in ("tcp", "track"): + for endpoints in (1, 4, 16): + for sessions in (1, 4, 16): + origin = moq.OriginProducer() + client = moq.socket.Endpoint(origin, "bench", "client") + servers = [moq.socket.Endpoint(origin, "bench", f"server-{i}") for i in range(endpoints)] + + async def drain(stream: moq.socket.TcpStream | moq.socket.TrackStream) -> None: + if isinstance(stream, moq.socket.TcpStream): + assert [frame.payload async for frame in stream.consumer] == [b"echo"] + else: + assert [[frame.payload async for frame in group] async for group in stream.consumer] == [ + [b"echo"] + ] + stream.producer.finish() + + async def accept(server: moq.socket.Endpoint) -> None: + readers = [] + for _ in range(sessions): + incoming = await (server.accept_tcp() if protocol == "tcp" else server.accept_track()) + readers.append(asyncio.create_task(drain(await incoming.accept()))) + await asyncio.gather(*readers) + + async def dial(server: moq.socket.Endpoint) -> None: + stream = await (client.dial_tcp(server.id) if protocol == "tcp" else client.dial_track(server.id)) + stream.producer.write_frame(b"echo") + stream.producer.finish() + assert [item async for item in stream.consumer] == [] + + async def step() -> None: + accepts = [asyncio.create_task(accept(server)) for server in servers] + # Each accept reaches its first suspension before any dial can run. + polled = asyncio.get_running_loop().create_future() + asyncio.get_running_loop().call_soon(polled.set_result, None) + await polled + await asyncio.wait_for( + asyncio.gather(*(dial(server) for server in servers for _ in range(sessions)), *accepts), + ROUND_TIMEOUT, + ) + + for _ in range(WARMUP): + await step() + start, rounds = time.perf_counter(), 0 + while time.perf_counter() - start < BUDGET: + await step() + rounds += 1 + elapsed = time.perf_counter() - start + results.append( + { + "name": f"socket/{protocol}/endpoints={endpoints}/sessions={sessions}", + "unit": "sessions/s", + "value": rounds * endpoints * sessions / elapsed, + } + ) + client.close() + for server in servers: + server.close() + + async def main() -> None: results: list[dict] = [] + await sockets(results) for case in CASES: await throughput(case, results) diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index af3f45afdfd..ca99b9bde53 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -241,3 +241,22 @@ Rust side ([`moq-ffi`](https://crates.io/crates/moq-ffi)). self ``` + +## Sockets + +`moq.socket` connects endpoints through relays, using the same origin for both +publishing and consuming. See the [socket convention](https://doc.moq.dev/concept/socket). + +```{eval-rst} +.. currentmodule:: moq.socket + +.. autosummary:: + :toctree: api + :nosignatures: + + Endpoint + IncomingTcp + IncomingTrack + TcpStream + TrackStream +``` diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 713abae9278..76c391c65e4 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -9,6 +9,7 @@ # Layer namespaces stay out of __all__; json would shadow the stdlib module. from . import json as json +from . import socket as socket from . import video as video from .client import Client, CongestionControl, connect, qlog_supported, versions from .datagram import DatagramConsumer, DatagramProducer, DatagramRequest, DatagramRequests diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 96cc160a038..ef3530a1799 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -50,8 +50,24 @@ def consume(self) -> GroupConsumer: return GroupConsumer(self._inner.consume()) + async def ready(self, size: int) -> None: + """Wait until a frame of ``size`` bytes fits a window ahead of the fastest reader. + + Await it before each :meth:`write_frame` so that reader paces the writer, + as TCP backpressure does, instead of the group shedding frames it has not + taken, which fails it with ``moq.Error.Evicted``; slower readers are not + waited on. It also + waits until a reader holds the group, and resolves once the group is closed + so the next write raises why. Raises ``moq.Error.Closed`` after :meth:`abort`. + """ + await self._inner.ready(size) + def write_frame(self, payload: bytes, timestamp: Timestamp | None = None) -> None: - """Write a frame with its presentation timestamp.""" + """Write a frame with its presentation timestamp. + + Never blocks: without :meth:`ready`, a writer that outpaces its readers + sheds the front of the group instead. + """ self._inner.write_frame(Frame(payload, timestamp)._ffi()) def finish(self) -> None: diff --git a/py/moq-rs/moq/socket.py b/py/moq-rs/moq/socket.py new file mode 100644 index 00000000000..505f60e1660 --- /dev/null +++ b/py/moq-rs/moq/socket.py @@ -0,0 +1,177 @@ +"""Point-to-point TCP and track sessions over hidden endpoint broadcasts.""" + +from __future__ import annotations + +from moq_ffi import ( + MoqSocketEndpoint, + MoqSocketIncomingTcp, + MoqSocketIncomingTrack, + MoqSocketTcpStream, + MoqSocketTrackStream, +) + +from ._records import _track_info +from .origin import OriginProducer +from .publish import GroupProducer, TrackProducer +from .subscribe import GroupConsumer, TrackConsumer +from .types import TrackInfo + +__all__ = ["Endpoint", "IncomingTcp", "IncomingTrack", "TcpStream", "TrackStream"] + + +class Endpoint: + """Announces one hidden endpoint and dials or accepts independent sessions. + + Hold it while sessions run. Closing it resets pending dials and sessions. + Cancelling a dial cancels only that dial; cancelling an accept wait leaves + the endpoint listening. Usable as an async context manager. + """ + + def __init__(self, origin: OriginProducer, prefix: str, id: str) -> None: + self._inner = MoqSocketEndpoint(origin._inner, prefix, id) + + @property + def id(self) -> str: + """This endpoint's id.""" + return self._inner.id() + + @property + def prefix(self) -> str: + """The prefix beneath which this endpoint finds peers.""" + return self._inner.prefix() + + async def __aenter__(self) -> Endpoint: + return self + + async def __aexit__(self, *exc) -> None: + self.close() + + def close(self) -> None: + """Retract the endpoint and reset its pending dials and sessions.""" + self._inner.close() + + async def dial_tcp(self, peer: str) -> TcpStream: + """Dial a new TCP session, waiting until the peer accepts it.""" + return TcpStream(await self._inner.dial_tcp(peer)) + + async def accept_tcp(self) -> IncomingTcp: + """Start listening for TCP and return the next incoming TCP dial.""" + return IncomingTcp(await self._inner.accept_tcp()) + + async def dial_track(self, peer: str, *, info: TrackInfo | None = None) -> TrackStream: + """Dial a track session; omitted info keeps messages without an age limit.""" + return TrackStream(await self._inner.dial_track(peer, _track_info(info))) + + async def accept_track(self) -> IncomingTrack: + """Start listening for tracks and return the next incoming track dial.""" + return IncomingTrack(await self._inner.accept_track()) + + +class IncomingTcp: + """An incoming tcp dial, consumed by accepting or rejecting it.""" + + def __init__(self, inner: MoqSocketIncomingTcp) -> None: + self._inner = inner + + @property + def peer(self) -> str: + """The claimed peer id; accepting verifies return routability.""" + return self._inner.peer() + + @property + def session(self) -> str: + """The random session id, which must stay private to its endpoints.""" + return self._inner.session() + + async def accept(self) -> TcpStream: + """Accept after verifying that the peer really dialed this session.""" + return TcpStream(await self._inner.accept()) + + def reject(self) -> None: + """Refuse this dial as not found.""" + self._inner.reject() + + +class TcpStream: + """A tcp session's two halves and its peer and session identities.""" + + def __init__(self, inner: MoqSocketTcpStream) -> None: + self._producer = GroupProducer(inner.producer()) + self._consumer = GroupConsumer(inner.consumer()) + self._peer = inner.peer() + self._session = inner.session() + + @property + def producer(self) -> GroupProducer: + """This side's write half.""" + return self._producer + + @property + def consumer(self) -> GroupConsumer: + """The peer's read half, retaining its reliable ascending subscription.""" + return self._consumer + + @property + def peer(self) -> str: + """The peer endpoint's id.""" + return self._peer + + @property + def session(self) -> str: + """The random session id, which must stay private to its endpoints.""" + return self._session + + +class IncomingTrack: + """An incoming track dial, consumed by accepting or rejecting it.""" + + def __init__(self, inner: MoqSocketIncomingTrack) -> None: + self._inner = inner + + @property + def peer(self) -> str: + """The claimed peer id; accepting verifies return routability.""" + return self._inner.peer() + + @property + def session(self) -> str: + """The random session id, which must stay private to its endpoints.""" + return self._inner.session() + + async def accept(self, *, info: TrackInfo | None = None) -> TrackStream: + """Accept after verifying that the peer really dialed this session.""" + return TrackStream(await self._inner.accept(_track_info(info))) + + def reject(self) -> None: + """Refuse this dial as not found.""" + self._inner.reject() + + +class TrackStream: + """A track session's two halves and its peer and session identities.""" + + def __init__(self, inner: MoqSocketTrackStream) -> None: + self._producer = TrackProducer(inner.producer()) + self._consumer = TrackConsumer(inner.consumer()) + self._peer = inner.peer() + self._session = inner.session() + + @property + def producer(self) -> TrackProducer: + """This side's write half.""" + return self._producer + + @property + def consumer(self) -> TrackConsumer: + """The peer's read half, retaining its reliable ascending subscription.""" + return self._consumer + + @property + def peer(self) -> str: + """The peer endpoint's id.""" + return self._peer + + @property + def session(self) -> str: + """The random session id, which must stay private to its endpoints.""" + return self._session diff --git a/py/moq-rs/tests/test_socket.py b/py/moq-rs/tests/test_socket.py new file mode 100644 index 00000000000..3821ace915b --- /dev/null +++ b/py/moq-rs/tests/test_socket.py @@ -0,0 +1,256 @@ +"""Socket lifetime and cancellation tests use explicit task barriers, without timers.""" + +import asyncio +import gc +import subprocess +import sys +from collections.abc import Coroutine +from typing import Any, TypeVar, cast + +import moq +import pytest +from moq import socket + +T = TypeVar("T") + + +async def start(coro: Coroutine[Any, Any, T]) -> asyncio.Task[T]: + """Return after the coroutine has reached its first suspension.""" + loop = asyncio.get_running_loop() + polled = loop.create_future() + + async def run() -> T: + loop.call_soon(polled.set_result, None) + return await coro + + task = asyncio.create_task(run()) + await polled + return task + + +def endpoints() -> tuple[socket.Endpoint, socket.Endpoint]: + origin = moq.OriginProducer() + return socket.Endpoint(origin, "room", "a"), socket.Endpoint(origin, "room", "b") + + +async def tcp(a: socket.Endpoint, b: socket.Endpoint) -> tuple[socket.TcpStream, socket.TcpStream]: + incoming = await start(b.accept_tcp()) + dial = await start(a.dial_tcp(b.id)) + accepted = await (await incoming).accept() + return await dial, accepted + + +async def test_tcp_halves_fin_and_peer_identity(): + a, b = endpoints() + client, server = await tcp(a, b) + assert (a.id, a.prefix, client.peer, server.peer) == ("a", "room", "b", "a") + assert client.session == server.session + assert len(client.session) == 32 + producer, reader, consumer = client.producer, client.consumer, server.consumer + # Releasing the stream cannot release either half kept by the application. + del client + gc.collect() + producer.write_frame(b"one") + producer.write_frame(b"") + producer.finish() + del producer + gc.collect() + assert [frame.payload async for frame in consumer] == [b"one", b""] + server.producer.finish() + assert await reader.read_frame() is None + a.close() + b.close() + + +async def test_refusal_and_cancelled_wait_leave_endpoint_usable(): + a, b = endpoints() + pending = await start(b.accept_tcp()) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + dial = await start(a.dial_tcp("b")) + incoming = await b.accept_tcp() + assert incoming.peer == "a" + incoming.reject() + with pytest.raises(moq.Error.NotFound): + await dial + with pytest.raises(moq.Error.Closed): + incoming.reject() + client, server = await tcp(a, b) + assert client.session == server.session + a.close() + b.close() + + +async def test_cancelled_dial_releases_the_original_subscription(): + a, b = endpoints() + incoming = await start(b.accept_tcp()) + dial = await start(a.dial_tcp("b")) + offered = await incoming + dial.cancel() + with pytest.raises(asyncio.CancelledError): + await dial + with pytest.raises(moq.Error.Cancelled): + await offered.accept() + client, server = await tcp(a, b) + assert client.session == server.session + a.close() + b.close() + + +async def test_tcp_rst_and_endpoint_close_are_errors(): + a, b = endpoints() + client, server = await tcp(a, b) + client.producer.abort(17) + with pytest.raises(moq.Error.Protocol) as reset: + await server.consumer.read_frame() + assert reset.value.details.kind == moq.ProtocolKind.APP + assert reset.value.details.code == 64 + 17 + client, server = await tcp(a, b) + a.close() + with pytest.raises(moq.Error.Cancelled): + await server.consumer.read_frame() + b.close() + + +async def test_track_default_retains_ascending_messages_and_subscription(): + a, b = endpoints() + incoming = await start(b.accept_track()) + dial = await start(a.dial_track("b")) + server = await (await incoming).accept() + client = await dial + assert (client.peer, server.peer, client.session) == ("b", "a", server.session) + producer, reverse, consumer = client.producer, server.producer, server.consumer + del server + gc.collect() + assert consumer.info().max_age is None + for sequence, payload in ((1, b"later"), (0, b"first")): + group = producer.create_group(sequence) + group.write_frame(payload) + group.finish() + producer.finish() + assert [(group.sequence, [frame.payload async for frame in group]) async for group in consumer] == [ + (0, [b"first"]), + (1, [b"later"]), + ] + reverse.finish() + assert await client.consumer.read_frame() is None + a.close() + b.close() + + +async def test_accept_queues_are_separate_and_unaccepted_protocol_is_unsupported(): + a, b = endpoints() + tcp_wait = await start(b.accept_tcp()) + with pytest.raises(moq.Error.Unsupported): + await a.dial_track("b") + track_wait = await start(b.accept_track()) + tcp_dial = await start(a.dial_tcp("b")) + track_dial = await start(a.dial_track("b")) + tcp_offer, track_offer = await tcp_wait, await track_wait + assert tcp_offer.session != track_offer.session + tcp_server = await tcp_offer.accept() + track_server = await track_offer.accept() + tcp_client, track_client = await tcp_dial, await track_dial + assert tcp_client.session == tcp_server.session + assert track_client.session == track_server.session + a.close() + b.close() + + +def test_a_dial_waits_for_a_late_peer_without_a_deadline(): + subprocess.run([sys.executable, __file__, "clock"], check=True) + + +async def late_peer(clock): + origin = moq.OriginProducer() + async with socket.Endpoint(origin, "room", "a") as a: + dial = await start(a.dial_tcp("b")) + clock.advance(24 * 60 * 60) + async with socket.Endpoint(origin, "room", "b") as b: + incoming = await b.accept_tcp() + accepted = await incoming.accept() + connected = await dial + assert connected.session == accepted.session + connected.producer.finish() + accepted.producer.finish() + assert await connected.consumer.read_frame() is None + assert await accepted.consumer.read_frame() is None + + +async def test_cancelling_acceptance_drops_the_original_dial_while_proving_routability(): + origin = moq.OriginProducer() + endpoint = socket.Endpoint(origin, "room", "server") + forged = origin.route("room/.socket/forged", hidden=True) + returning = forged.broadcast.requests() + forged.announce() + incoming = await start(endpoint.accept_tcp()) + broadcast = await origin.consume().request("room/.socket/server") + original = await start(broadcast.subscribe_track("tcp/forged/server/0123456789abcdef0123456789abcdef")) + offered = await incoming + accepting = await start(offered.accept()) + # Proof that accept is parked on the return path, before cancelling it. + returned = await returning.requested_track() + assert returned.name.startswith("tcp/forged/server/") + accepting.cancel() + with pytest.raises(asyncio.CancelledError): + await accepting + with pytest.raises(moq.Error.Cancelled): + await original + endpoint.close() + forged.broadcast.close() + + +async def test_dropping_the_endpoint_resets_a_live_session(): + a, b = endpoints() + client, server = await tcp(a, b) + del a + gc.collect() + # Withdrawal and the session's cancellation can reach either reader first; + # the Rust contract guarantees a reset, with no clean EOF or promised cause. + reset = cast(type[Exception], moq.Error) + with pytest.raises(reset): + await server.consumer.read_frame() + with pytest.raises(reset): + await client.consumer.read_frame() + b.close() + + +async def test_tcp_ready_paces_a_writer_behind_a_slow_reader(): + a, b = endpoints() + client, server = await tcp(a, b) + mib, frames = 1024 * 1024, 48 + written = read = 0 + ahead = asyncio.Event() + + async def write() -> None: + nonlocal written + for _ in range(frames): + await client.producer.ready(mib) + # Paced, the writer stays a window ahead of the reader, well short of the 32 MiB + # cache. Unpaced, it would shed frames the reader has not taken and evict it. + assert written - read < 32 + client.producer.write_frame(bytes(mib)) + written += 1 + if written == 2: + ahead.set() + client.producer.finish() + + writer = await start(write()) + async for frame in server.consumer: + assert len(frame.payload) == mib + read += 1 + if read == 1: + # Stall one frame in until the writer has run ahead of the reader. + await ahead.wait() + assert read == frames + await writer + server.producer.finish() + a.close() + b.close() + + +if __name__ == "__main__": + from moq.testing import Clock + + asyncio.run(late_peer(Clock())) diff --git a/quest/m0/test-flakes-2/README.md b/quest/m0/test-flakes-2/README.md index ebde3f4b9e0..0f3622170f7 100644 --- a/quest/m0/test-flakes-2/README.md +++ b/quest/m0/test-flakes-2/README.md @@ -100,6 +100,7 @@ Public API: none. Wire: none. ## Required - [Fallback playback](/quest/m0/test-flakes-2/ietf22-fallback.md) - a browser viewer follows a fallback within the media lane's bound without dropping its session +- [The draft-22 wider-fill refusal test is deterministic](/quest/m0/test-flakes-2/ietf-wider-fill.md) - fix its intermittent live-group reset and re-enable both ordering cases without weakening their assertions - [moq-gst's interleaved restart test passes under load](/quest/m0/test-flakes-2/gst-reconnect-interleave.md) - `restarts_under_reconnects_interleave_on_one_pad` passes in loaded runs and the merge queue, its missed delivery and its stray `Eos` each fixed at the cause - [reload.test.ts's close-drain tests pass under load](/quest/m0/test-flakes-2/reload-close-drain.md) - the drain cases in `js/net`'s reload tests pass in loaded multi-file runs - [dispatch-query's terminate test survives load](/quest/m0/test-flakes-2/dispatch-query-terminate.md) - `test_terminating_the_run_stops_the_running_phase` passes reliably under host load, re-enabled once fixed diff --git a/quest/m0/test-flakes-2/ietf-wider-fill.md b/quest/m0/test-flakes-2/ietf-wider-fill.md new file mode 100644 index 00000000000..29023600e18 --- /dev/null +++ b/quest/m0/test-flakes-2/ietf-wider-fill.md @@ -0,0 +1,35 @@ +# [S] The draft-22 wider-fill refusal test is deterministic + +## Goal + +`draft-22: a wider fill is refused in %s order` in +`js/net/src/ietf/publisher.test.ts` passes reliably in both ascending and +descending order, alone and in the loaded suite. Find and fix why its live +group intermittently resets while being read, then remove its quarantine +without weakening the refusal or live-delivery assertions. + +## Plan + +Planned 2026-11-04 under the maintainer's rule to quarantine a known flaky +test immediately and plan its investigation. This is one fixture's flake, +so it is one child of this epic; m0 follows the epic's standing rule that a +flake fails an entire merge batch. + +- The unchanged-main reproduction recorded in + [moq-dev/moq#7860](https://github.com/moq-dev/moq/pull/7860) failed 30/200 + runs. [moq-dev/moq#7851](https://github.com/moq-dev/moq/pull/7851) already + quarantines the same named case. The socket work in + [moq-dev/moq#7883](https://github.com/moq-dev/moq/pull/7883) carries the + precise investigation reference; its absolute-start adapter correction + and controlled early-burst tests do not investigate this separate flake. +- Start from the recorded reproduction and trace the live group's reset in + both orders. Establish whether fixture ownership, clock advancement, + stream ordering, or production behavior causes it before choosing a fix. + Control the responsible clock or event when the fixture is at fault; + fix the production source when it is not. +- Restore the existing test in both orders with its assertions intact. + Neither retries, longer timeouts, nor disabling production behavior is a + fix. Record the original reproduction and loaded-suite results before + and after, including the commands and limits of that evidence. + +Public API: none expected. Wire: none. diff --git a/quest/m1/socket/README.md b/quest/m1/socket/README.md index 9c6cb7a616f..b35051f3fdf 100644 --- a/quest/m1/socket/README.md +++ b/quest/m1/socket/README.md @@ -189,7 +189,6 @@ announce-watch-request pairing remains in `rs/moq-boy`, `js/moq-boy`, or ## Required - [JS sockets](/quest/m1/socket/js.md) - the same surface in `@moq/net`, interoperating with Rust -- [Python sockets](/quest/m1/socket/py.md) - moq-ffi and `py/moq-rs` bindings, interoperating with Rust - [moq-boy on sockets](/quest/m1/socket/boy.md) - viewers dial the emulator's endpoint and send commands and receive latency on their session - [CARLA on sockets](/quest/m1/socket/carla.md) - drivers dial the truck's endpoint and send control and receive acks on their session diff --git a/quest/m1/socket/carla.md b/quest/m1/socket/carla.md index 085c577d653..d605acc33cb 100644 --- a/quest/m1/socket/carla.md +++ b/quest/m1/socket/carla.md @@ -42,5 +42,4 @@ See the [epic](/quest/m1/socket/README.md) for the convention. ## Required -- [Python sockets](/quest/m1/socket/py.md) - the truck's endpoint - [JS sockets](/quest/m1/socket/js.md) - the driver's dial diff --git a/quest/m1/socket/py.md b/quest/m1/socket/py.md deleted file mode 100644 index efc7e42eb6c..00000000000 --- a/quest/m1/socket/py.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] Python sockets - -## Goal - -moq-ffi exposes the socket `Endpoint`, `TcpStream`, and `TrackStream` beside -origin, and `py/moq-rs` wraps them, so a Python endpoint accepts a Rust -endpoint's dial and the reverse, in both protocols, carrying data both ways. - -## Plan - -Bind the Rust surface (`moq_net::socket`): `Endpoint::new`, `dial_tcp`, -`accept_tcp`, `dial_track`, `accept_track`, an incoming dial's `accept` and -`reject`, and each stream's `producer`, `consumer`, `peer`, and `session` -(group handles for `TcpStream`, track handles for `TrackStream`), through -moq-ffi the way origin's request -and announce APIs are bound today (`rs/moq-ffi/src/origin.rs`, -`py/moq-rs/moq/origin.py`). Add a Python/Rust interop test, and document the -API wherever `py/moq-rs` documents origin. diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 8ef89cc238b..cbcbdff05b3 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -209,5 +209,9 @@ path = "../../test/interop/clients/rust/hls.rs" name = "interop-audio" path = "../../test/interop/clients/rust/audio.rs" +[[example]] +name = "interop-socket" +path = "../../test/interop/clients/rust/socket.rs" + [lints] workspace = true diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index f6addc7a020..09a6cc2c73c 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,12 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Socket sessions + +MoqSocketEndpoint, MoqSocketIncomingTcp, MoqSocketIncomingTrack, MoqSocketTcpStream, and MoqSocketTrackStream now expose TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. Dropping an unfinished group producer now resets it with `Unroutable` instead of leaking Rust's programming-error `Dropped`; finished groups still end cleanly. + +`MoqGroupProducer::ready(size)` is a new async call that waits until a frame of `size` bytes fits a window ahead of the group's fastest reader. Await it before each `write_frame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket session needs it to behave like TCP. Existing writers are unchanged and still shed the group's front when they outpace their readers. + ### Decoders refuse a disabled rendition ([#7942](https://github.com/moq-dev/moq/pull/7942)) `MoqAudioConsumer::subscribe` and `MoqVideoConsumer::subscribe` fail with `MoqError::NotFound` when the rendition's `enabled` is false, instead of subscribing and waiting for a re-enable. Pick an enabled rendition from the latest catalog, and close a decoder whose rendition a newer catalog disables. diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 2b68dd8e6e6..3eca58814ef 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -27,6 +27,7 @@ pub mod origin; pub mod producer; pub mod server; pub mod session; +pub mod socket; #[cfg(feature = "video")] pub mod video; diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index a6a86b2d7c5..a6bc644d0d5 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -549,10 +549,7 @@ impl MoqFill { pub fn accept(&self, sequence: u64) -> Result, MoqError> { let _guard = crate::ffi::enter(); let group = self.inner.accept(sequence, None)?; - Ok(Arc::new(MoqGroupProducer { - sequence: group.sequence, - inner: std::sync::Mutex::new(Some(group)), - })) + Ok(Arc::new(MoqGroupProducer::from_inner(group))) } /// Name groups `start` up to the exclusive `end` as never to be delivered. @@ -698,6 +695,14 @@ impl Drop for MoqTrackProducer { } impl MoqTrackProducer { + /// Wrap a socket's track without a broadcast family. + pub(crate) fn from_socket(track: moq_net::track::Producer) -> Self { + Self { + inner: std::sync::Mutex::new(Some(track)), + keep: std::sync::Mutex::new(None), + } + } + /// Wrap `raw` as a child of the broadcast `children` belongs to. fn child(raw: moq_net::track::Producer, children: &Children) -> Result, MoqError> { children.adopt(Arc::new(Self { @@ -780,10 +785,7 @@ impl MoqTrackProducer { let mut guard = self.inner.lock().unwrap(); let raw = guard.as_mut().ok_or(MoqError::Closed)?; let group = raw.append_group()?; - Ok(Arc::new(MoqGroupProducer { - sequence: group.sequence, - inner: std::sync::Mutex::new(Some(group)), - })) + Ok(Arc::new(MoqGroupProducer::from_inner(group))) } /// Create a group with an explicit sequence number. @@ -795,10 +797,7 @@ impl MoqTrackProducer { let mut guard = self.inner.lock().unwrap(); let raw = guard.as_mut().ok_or(MoqError::Closed)?; let group = raw.create_group(moq_net::group::Info { sequence })?; - Ok(Arc::new(MoqGroupProducer { - sequence: group.sequence, - inner: std::sync::Mutex::new(Some(group)), - })) + Ok(Arc::new(MoqGroupProducer::from_inner(group))) } /// Write `frame` as a single-frame group. @@ -873,7 +872,31 @@ impl MoqTrackProducer { #[derive(uniffi::Object)] pub struct MoqGroupProducer { sequence: u64, - inner: std::sync::Mutex>, + /// The group keeps one writer waker, so every [`ready`](Self::ready) call polls it with the + /// bridge's, which wakes them all. Every other call locks it through the bridge too, so a + /// wake it causes reaches a foreign continuation only after the lock is released. + inner: kio::Bridge>, +} + +impl Drop for MoqGroupProducer { + fn drop(&mut self) { + let _guard = crate::ffi::enter(); + if let Some(group) = self.inner.get_mut().take() + && group.poll_closed(&kio::Waiter::noop()).is_pending() + { + let _ = group.abort(moq_net::Error::Unroutable); + } + } +} + +impl MoqGroupProducer { + /// Wrap `group`, for a track's writer or a socket's write half. + pub(crate) fn from_inner(group: moq_net::group::Producer) -> Self { + Self { + sequence: group.sequence, + inner: kio::Bridge::new(Some(group)), + } + } } #[uniffi::export] @@ -886,7 +909,7 @@ impl MoqGroupProducer { /// Create a consumer that reads frames from this group. pub fn consume(&self) -> Result, MoqError> { let _guard = crate::ffi::enter(); - let guard = self.inner.lock().unwrap(); + let guard = self.inner.lock(); let group = guard.as_ref().ok_or(MoqError::Closed)?; Ok(Arc::new(MoqGroupConsumer::new(group.consume(), Background::default()))) } @@ -899,18 +922,39 @@ impl MoqGroupProducer { pub fn write_frame(&self, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; - let mut guard = self.inner.lock().unwrap(); + let mut guard = self.inner.lock(); let group = guard.as_mut().ok_or(MoqError::Closed)?; group.write_frame(timestamp, frame.payload)?; Ok(()) } + /// Wait until a frame of `size` bytes fits, so a writer runs no further ahead of its + /// fastest reader than the group's window. + /// + /// Await this before each [`write_frame`](Self::write_frame) for backpressure: that reader + /// paces the writer instead of the group shedding frames it has not taken, which fails it + /// with `Evicted`; slower readers are not waited on. Writing without waiting never fails; the group + /// sheds its front instead. Resolves once a reader holds the group and the frame fits, at + /// once while a reader has taken every frame, and once the group is closed, so the next + /// write reports why. Fails with `Closed` after [`abort`](Self::abort). + pub async fn ready(&self, size: u64) -> Result<(), MoqError> { + let frame = moq_net::frame::Info { size, timestamp: None }; + let mut park = kio::Park::default(); + crate::ffi::run(kio::wait(|waiter| { + self.inner.poll(waiter, |group, cx| match group { + Some(group) => group.poll_ready(park.hold(cx), &frame).map(Ok), + None => Poll::Ready(Err(MoqError::Closed)), + }) + })) + .await + } + /// Mark the group as complete. No more frames can be written. /// /// The handle remains so a later [`abort`](Self::abort) can still run. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let mut guard = self.inner.lock().unwrap(); + let mut guard = self.inner.lock(); let group = guard.as_mut().ok_or(MoqError::Closed)?; group.finish()?; Ok(()) @@ -919,8 +963,7 @@ impl MoqGroupProducer { /// Abort this group with an application error code. pub fn abort(&self, error_code: u16) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let mut guard = self.inner.lock().unwrap(); - let group = guard.take().ok_or(MoqError::Closed)?; + let group = self.inner.lock().take().ok_or(MoqError::Closed)?; group.abort(moq_net::Error::App(error_code))?; Ok(()) } @@ -930,7 +973,7 @@ impl MoqGroupProducer { impl MoqGroupProducer { /// Wait until a consumer wants this group. pub(crate) async fn wanted(&self) -> Result<(), MoqError> { - let mut demand = self.inner.lock().unwrap().as_ref().ok_or(MoqError::Closed)?.demand(); + let mut demand = self.inner.lock().as_ref().ok_or(MoqError::Closed)?.demand(); while !demand.wanted_changed().await? {} Ok(()) } @@ -946,14 +989,11 @@ mod metadata_tests { let track = broadcast.create_track("test", moq_net::track::Info::default()).unwrap(); let group = track.append_group().unwrap(); let mut consumer = group.consume(); - let binding = MoqGroupProducer { - sequence: group.sequence, - inner: std::sync::Mutex::new(Some(group)), - }; + let binding = MoqGroupProducer::from_inner(group); drop(binding); assert!(matches!( consumer.poll_read_frame(&moq_net::kio::Waiter::noop()), - std::task::Poll::Ready(Err(moq_net::Error::Dropped)) + std::task::Poll::Ready(Err(moq_net::Error::Unroutable)) )); } diff --git a/rs/moq-ffi/src/socket.rs b/rs/moq-ffi/src/socket.rs new file mode 100644 index 00000000000..aead16417df --- /dev/null +++ b/rs/moq-ffi/src/socket.rs @@ -0,0 +1,439 @@ +//! Point-to-point socket endpoints and their two protocol-specific streams. + +use std::sync::{Arc, Mutex}; +use std::task::{Poll, ready}; + +use crate::consumer::{MoqGroupConsumer, MoqTrackConsumer}; +use crate::error::MoqError; +use crate::ffi::{Background, Polled}; +use crate::origin::MoqOriginProducer; +use crate::producer::{MoqGroupProducer, MoqTrackInfo, MoqTrackProducer}; + +/// An endpoint announcing one hidden broadcast and accepting TCP and track dials separately. +#[derive(uniffi::Object)] +pub struct MoqSocketEndpoint { + inner: moq_net::socket::Endpoint, + driver: Background, +} + +#[uniffi::export] +impl MoqSocketEndpoint { + /// Announce `id` beneath `prefix`, using `origin` for publishing and finding peers. + #[uniffi::constructor] + pub fn new(origin: &MoqOriginProducer, prefix: String, id: String) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + let origin = origin.inner(); + origin.driver.check()?; + let inner = moq_net::socket::Endpoint::new(origin, prefix, &id)?; + origin.driver.check()?; + Ok(Arc::new(Self { + inner, + driver: origin.driver.clone(), + })) + } + + /// This endpoint's id. + pub fn id(&self) -> String { + self.inner.id().into() + } + + /// The prefix beneath which this endpoint finds peers. + pub fn prefix(&self) -> String { + self.inner.prefix().to_string() + } + + /// Retract the endpoint and reset its pending dials and sessions. + pub fn close(&self) { + let _guard = crate::ffi::enter(); + self.inner.close(); + } + + /// Dial a new `tcp` session; cancelling this call resets only this dial. + pub async fn dial_tcp(&self, peer: String) -> Result, MoqError> { + self.driver + .watch(async { + let stream = self.inner.dial_tcp(&peer)?.await?; + Ok(Arc::new(MoqSocketTcpStream::new(stream, self.driver.clone()))) + }) + .await + } + + /// Start accepting `tcp` dials and await the next one, independently of the other protocol. + pub async fn accept_tcp(&self) -> Result, MoqError> { + self.driver + .watch(async { + let inner = self.inner.accept_tcp().await?; + Ok(Arc::new(MoqSocketIncomingTcp { + peer: inner.peer().into(), + session: inner.session().into(), + inner: Mutex::new(Some(inner)), + driver: self.driver.clone(), + })) + }) + .await + } + + /// Dial a new `track` session; cancelling this call resets only this dial. + pub async fn dial_track( + &self, + peer: String, + info: Option, + ) -> Result, MoqError> { + self.driver + .watch(async { + let stream = self + .inner + .dial_track(&peer, info.map(TryInto::try_into).transpose()?)? + .await?; + Ok(Arc::new(MoqSocketTrackStream::new(stream, self.driver.clone()))) + }) + .await + } + + /// Start accepting `track` dials and await the next one, independently of the other protocol. + pub async fn accept_track(&self) -> Result, MoqError> { + self.driver + .watch(async { + let inner = self.inner.accept_track().await?; + Ok(Arc::new(MoqSocketIncomingTrack { + peer: inner.peer().into(), + session: inner.session().into(), + inner: Mutex::new(Some(inner)), + driver: self.driver.clone(), + })) + }) + .await + } +} + +impl Drop for MoqSocketEndpoint { + fn drop(&mut self) { + self.close(); + } +} + +/// An incoming `tcp` dial, consumed by accepting or rejecting it. +#[derive(uniffi::Object)] +pub struct MoqSocketIncomingTcp { + inner: Mutex>>, + peer: String, + session: String, + driver: Background, +} + +#[uniffi::export] +impl MoqSocketIncomingTcp { + /// The endpoint claiming this dial; accepting verifies it really dialed. + pub fn peer(&self) -> String { + self.peer.clone() + } + /// The random id of this session. + pub fn session(&self) -> String { + self.session.clone() + } + /// Verify return routability and accept; cancelling resets the dial. + pub async fn accept(&self) -> Result, MoqError> { + self.driver.check()?; + let incoming = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let accepting = Polled::new(incoming.accept()).supervised(self.driver.clone()); + let stream = accepting + .wait(|inner, waiter| Poll::Ready(Ok(ready!(inner.poll_ok(waiter))?))) + .await?; + Ok(Arc::new(MoqSocketTcpStream::new(stream, self.driver.clone()))) + } + /// Refuse the dial as not found. + pub fn reject(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + self.driver.check()?; + self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?.reject(); + Ok(()) + } +} + +/// A `tcp` session exposing its existing producer and subscribed consumer handles. +#[derive(uniffi::Object)] +pub struct MoqSocketTcpStream { + producer: Arc, + consumer: Arc, + peer: String, + session: String, +} + +impl MoqSocketTcpStream { + fn new(inner: moq_net::socket::TcpStream, driver: Background) -> Self { + let peer = inner.peer().into(); + let session = inner.session().into(); + Self { + producer: Arc::new(MoqGroupProducer::from_inner(inner.producer)), + consumer: Arc::new(MoqGroupConsumer::new(inner.consumer, driver)), + peer, + session, + } + } +} + +#[uniffi::export] +impl MoqSocketTcpStream { + /// This side's write half. + pub fn producer(&self) -> Arc { + self.producer.clone() + } + /// The peer's read half, retaining its reliable ascending subscription. + pub fn consumer(&self) -> Arc { + self.consumer.clone() + } + /// The peer endpoint's id. + pub fn peer(&self) -> String { + self.peer.clone() + } + /// The random id of this session. + pub fn session(&self) -> String { + self.session.clone() + } +} + +/// An incoming `track` dial, consumed by accepting or rejecting it. +#[derive(uniffi::Object)] +pub struct MoqSocketIncomingTrack { + inner: Mutex>>, + peer: String, + session: String, + driver: Background, +} + +#[uniffi::export] +impl MoqSocketIncomingTrack { + /// The endpoint claiming this dial; accepting verifies it really dialed. + pub fn peer(&self) -> String { + self.peer.clone() + } + /// The random id of this session. + pub fn session(&self) -> String { + self.session.clone() + } + /// Verify return routability and accept; cancelling resets the dial. + pub async fn accept(&self, info: Option) -> Result, MoqError> { + self.driver.check()?; + let info = info.map(TryInto::try_into).transpose()?; + let incoming = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let accepting = Polled::new(incoming.accept(info)).supervised(self.driver.clone()); + let stream = accepting + .wait(|inner, waiter| Poll::Ready(Ok(ready!(inner.poll_ok(waiter))?))) + .await?; + Ok(Arc::new(MoqSocketTrackStream::new(stream, self.driver.clone()))) + } + /// Refuse the dial as not found. + pub fn reject(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + self.driver.check()?; + self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?.reject(); + Ok(()) + } +} + +/// A `track` session exposing its existing producer and subscribed consumer handles. +#[derive(uniffi::Object)] +pub struct MoqSocketTrackStream { + producer: Arc, + consumer: Arc, + peer: String, + session: String, +} + +impl MoqSocketTrackStream { + fn new(inner: moq_net::socket::TrackStream, driver: Background) -> Self { + let peer = inner.peer().into(); + let session = inner.session().into(); + Self { + producer: Arc::new(MoqTrackProducer::from_socket(inner.producer)), + consumer: Arc::new(MoqTrackConsumer::new(inner.consumer, driver)), + peer, + session, + } + } +} + +#[uniffi::export] +impl MoqSocketTrackStream { + /// This side's write half. + pub fn producer(&self) -> Arc { + self.producer.clone() + } + /// The peer's read half, retaining its reliable ascending subscription. + pub fn consumer(&self) -> Arc { + self.consumer.clone() + } + /// The peer endpoint's id. + pub fn peer(&self) -> String { + self.peer.clone() + } + /// The random id of this session. + pub fn session(&self) -> String { + self.session.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::MoqProtocolKind; + use crate::media::MoqFrame; + use std::future::Future; + use std::pin::Pin; + use std::task::{Context, Waker}; + + fn pending(future: Pin<&mut impl Future>) { + assert!(future.poll(&mut Context::from_waker(Waker::noop())).is_pending()); + } + + fn endpoints() -> (Arc, Arc) { + let origin = MoqOriginProducer::new(Default::default()); + let a = MoqSocketEndpoint::new(&origin, "room".into(), "a".into()).unwrap(); + let b = MoqSocketEndpoint::new(&origin, "room".into(), "b".into()).unwrap(); + (a, b) + } + + fn frame(payload: &[u8]) -> MoqFrame { + MoqFrame { + payload: payload.into(), + timestamp: None, + } + } + + async fn tcp(a: &MoqSocketEndpoint, b: &MoqSocketEndpoint) -> (Arc, Arc) { + let mut incoming = Box::pin(b.accept_tcp()); + pending(incoming.as_mut()); + let (dialed, accepted) = tokio::join!(a.dial_tcp(b.id()), async { incoming.await.unwrap().accept().await }); + (dialed.unwrap(), accepted.unwrap()) + } + + #[tokio::test] + async fn tcp_halves_outlive_the_stream_and_preserve_fin() { + let (a, b) = endpoints(); + let (client, server) = tcp(&a, &b).await; + assert_eq!((client.peer(), server.peer()), ("b".into(), "a".into())); + assert_eq!(client.session(), server.session()); + let producer = client.producer(); + let reader = client.consumer(); + let consumer = server.consumer(); + drop(client); + producer.write_frame(frame(b"one")).unwrap(); + producer.write_frame(frame(b"two")).unwrap(); + producer.finish().unwrap(); + drop(producer); + assert_eq!(consumer.read_frame().await.unwrap().unwrap().payload, b"one"); + assert_eq!(consumer.read_frame().await.unwrap().unwrap().payload, b"two"); + assert!(consumer.read_frame().await.unwrap().is_none()); + server.producer().finish().unwrap(); + assert!(reader.read_frame().await.unwrap().is_none()); + } + + #[tokio::test] + async fn tcp_reset_is_not_fin_and_does_not_close_another_session() { + let (a, b) = endpoints(); + let (client, server) = tcp(&a, &b).await; + let (other, peer) = tcp(&a, &b).await; + assert_ne!(client.session(), other.session()); + client.producer().abort(42).unwrap(); + let error = server.consumer().read_frame().await.err().unwrap(); + assert!(matches!(error, MoqError::Protocol { details } if details.kind == MoqProtocolKind::App)); + other.producer().write_frame(frame(b"independent")).unwrap(); + other.producer().finish().unwrap(); + assert_eq!( + peer.consumer().read_frame().await.unwrap().unwrap().payload, + b"independent" + ); + assert!(peer.consumer().read_frame().await.unwrap().is_none()); + } + + #[tokio::test] + async fn dropping_an_unfinished_tcp_writer_resets_with_a_deliberate_cause() { + let (a, b) = endpoints(); + let (client, server) = tcp(&a, &b).await; + let consumer = client.consumer(); + // Keep the read half: dropping the write half must cause the reset itself. + drop(client); + let error = server.consumer().read_frame().await.err().unwrap(); + assert!(matches!(error, MoqError::Protocol { details } if details.kind == MoqProtocolKind::Unroutable)); + drop(consumer); + } + + #[tokio::test] + async fn cancelled_accept_wait_leaves_the_queue_and_rejection_is_one_shot() { + let (a, b) = endpoints(); + let mut abandoned = Box::pin(b.accept_tcp()); + pending(abandoned.as_mut()); + drop(abandoned); + let (dialed, ()) = tokio::join!(a.dial_tcp(b.id()), async { + let incoming = b.accept_tcp().await.unwrap(); + assert_eq!(incoming.peer(), "a"); + incoming.reject().unwrap(); + assert!(matches!(incoming.reject(), Err(MoqError::Closed))); + }); + assert!(matches!(dialed, Err(MoqError::NotFound))); + let _ = tcp(&a, &b).await; + } + + #[tokio::test] + async fn cancelling_a_dial_resets_its_incoming_but_the_endpoint_can_dial_again() { + let (a, b) = endpoints(); + let mut incoming = Box::pin(b.accept_tcp()); + pending(incoming.as_mut()); + let mut dial = Box::pin(a.dial_tcp(b.id())); + pending(dial.as_mut()); + let incoming = tokio::select! { + incoming = incoming => incoming.unwrap(), + _ = &mut dial => panic!("dial resolved without acceptance"), + }; + drop(dial); + assert!(incoming.accept().await.is_err()); + let _ = tcp(&a, &b).await; + } + + #[tokio::test] + async fn track_subscription_survives_stream_release_and_has_no_default_age_limit() { + let (a, b) = endpoints(); + let mut incoming = Box::pin(b.accept_track()); + pending(incoming.as_mut()); + let (client, server) = tokio::join!(a.dial_track(b.id(), None), async { + incoming.await.unwrap().accept(None).await + }); + let (client, server) = (client.unwrap(), server.unwrap()); + assert_eq!(client.session(), server.session()); + let producer = client.producer(); + let reverse = server.producer(); + let consumer = server.consumer(); + drop(server); + assert!(consumer.info().max_age_unlimited); + // Create the later group first: the reliable ascending reader must wait for 0. + let later = producer.create_group(1).unwrap(); + later.write_frame(frame(b"later")).unwrap(); + later.finish().unwrap(); + let first = producer.create_group(0).unwrap(); + first.write_frame(frame(b"first")).unwrap(); + first.finish().unwrap(); + producer.finish().unwrap(); + assert_eq!(consumer.read_frame().await.unwrap().unwrap().payload, b"first"); + assert_eq!(consumer.read_frame().await.unwrap().unwrap().payload, b"later"); + assert!(consumer.read_frame().await.unwrap().is_none()); + reverse.finish().unwrap(); + assert!(client.consumer().read_frame().await.unwrap().is_none()); + } + + #[tokio::test] + async fn protocols_are_independent_and_endpoint_close_wakes_both_queues() { + let (a, b) = endpoints(); + let mut incoming = Box::pin(b.accept_tcp()); + pending(incoming.as_mut()); + tokio::select! { + result = a.dial_track(b.id(), None) => assert!(matches!(result, Err(MoqError::Unsupported))), + _ = &mut incoming => panic!("track dial arrived in the TCP queue"), + } + let mut tracks = Box::pin(b.accept_track()); + pending(tracks.as_mut()); + b.close(); + assert!(matches!(incoming.await, Err(MoqError::Closed))); + assert!(matches!(tracks.await, Err(MoqError::Closed))); + assert!(matches!(b.dial_tcp(a.id()).await, Err(MoqError::Closed))); + } +} diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 3c30ea5b164..7f5c2e452cd 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -9330,3 +9330,251 @@ async fn datagram_cancel_releases_demand_and_wakes_reads() { assert!(matches!(pending.await.unwrap(), Err(MoqError::Cancelled))); assert_eq!(producer.priority_changed(Some(0)).await.unwrap(), None); } + +/// One MiB of frame, so a few dozen run past a group's 32 MiB cache. +const MIB: usize = 1024 * 1024; + +/// Write `frames` MiB frames into a fresh group behind a reader that took only the first, +/// waiting on `ready` before each later one when `paced`. Returns the writer's progress, its task, and +/// the reader. +async fn write_ahead_of_a_slow_reader( + frames: usize, + paced: bool, +) -> ( + Arc, + tokio::task::JoinHandle>, + Arc, + Arc, + Arc, +) { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("tcp".into(), None).unwrap(); + let group = track.append_group().unwrap(); + let reader = group.consume().unwrap(); + let frame = || MoqFrame { + payload: vec![7; MIB], + timestamp: None, + }; + + // The reader holds the group and takes the first frame, then stalls. + group.write_frame(frame()).unwrap(); + let first = kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .expect("no first frame") + .unwrap(); + assert_eq!(first.map(|frame| frame.payload.len()), Some(MIB)); + + let written = Arc::new(std::sync::atomic::AtomicUsize::new(1)); + let writer = { + let group = group.clone(); + let written = written.clone(); + tokio::spawn(async move { + for _ in 1..frames { + if paced { + group.ready(MIB as u64).await?; + } + group.write_frame(frame())?; + written.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + group.finish() + }) + }; + (written, writer, reader, broadcast, track) +} + +/// A writer awaiting `ready` stops a window ahead of a stalled reader instead of running +/// past the cache, and the reader then takes every frame, as TCP backpressure would. +#[tokio::test] +async fn ready_paces_a_writer_behind_a_slow_reader() { + const FRAMES: usize = 48; + let (written, writer, reader, _broadcast, _track) = write_ahead_of_a_slow_reader(FRAMES, true).await; + + // Let the writer run as far as it can while the reader stalls. + for _ in 0..50 { + ffi_caught_up().await; + kio::clock::sleep(Duration::from_millis(2)).await; + } + let ahead = written.load(std::sync::atomic::Ordering::SeqCst); + assert!(!writer.is_finished(), "the writer never waited"); + assert!( + ahead > 1 && ahead < 32, + "{ahead} MiB written past a reader one frame in" + ); + + let mut read = 1; + while let Some(frame) = kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .expect("the read stalled") + .expect("a paced reader is never evicted") + { + assert_eq!(frame.payload.len(), MIB); + read += 1; + } + assert_eq!(read, FRAMES); + kio::clock::timeout(TIMEOUT, writer) + .await + .expect("the writer stalled") + .unwrap() + .unwrap(); +} + +/// Without `ready`, the same writer runs past the cache and the stalled reader is evicted. +#[tokio::test] +async fn an_unpaced_writer_evicts_a_slow_reader() { + let (_written, writer, reader, _broadcast, _track) = write_ahead_of_a_slow_reader(48, false).await; + kio::clock::timeout(TIMEOUT, writer) + .await + .expect("the writer stalled") + .unwrap() + .unwrap(); + let failed = kio::clock::timeout(TIMEOUT, async { + loop { + match reader.read_frame().await { + Ok(Some(_)) => continue, + Ok(None) => return None, + Err(err) => return Some(err), + } + } + }) + .await + .expect("the read stalled"); + assert!(failed.is_some(), "the stalled reader kept every frame"); +} + +/// `ready` fails once the group is aborted through its handle. +#[tokio::test] +async fn ready_after_abort_is_closed() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("tcp".into(), None).unwrap(); + let group = track.append_group().unwrap(); + group.abort(1).unwrap(); + assert!(matches!(group.ready(1).await, Err(MoqError::Closed))); +} + +/// Two calls waiting on the same group both wake once the reader drains, though the group +/// keeps only one writer waker. +#[tokio::test] +async fn concurrent_ready_calls_all_wake() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("tcp".into(), None).unwrap(); + let group = track.append_group().unwrap(); + let reader = group.consume().unwrap(); + let frame = || MoqFrame { + payload: vec![7; MIB], + timestamp: None, + }; + group.write_frame(frame()).unwrap(); + assert!( + kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .unwrap() + .unwrap() + .is_some() + ); + // Run the window full while the reader stalls one frame in. + for _ in 0..24 { + group.write_frame(frame()).unwrap(); + } + + let waits = [ + spawn_parked(ready(group.clone())).await, + spawn_parked(ready(group.clone())).await, + ]; + assert!(waits.iter().all(|wait| !wait.is_finished()), "the window was not full"); + + while let Some(_frame) = kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .expect("the read stalled") + .unwrap() + { + if waits.iter().all(|wait| wait.is_finished()) { + break; + } + ffi_caught_up().await; + } + for wait in waits { + kio::clock::timeout(TIMEOUT, wait) + .await + .expect("a ready call was stranded") + .unwrap() + .unwrap(); + } + + async fn ready(group: Arc) -> Result<(), MoqError> { + group.ready(MIB as u64).await + } +} + +/// A call waiting for room ends with `Closed` once the group is aborted. +#[tokio::test] +async fn a_waiting_ready_ends_on_abort() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("tcp".into(), None).unwrap(); + let group = track.append_group().unwrap(); + // No reader holds the group, so the call waits. + let wait = { + let group = group.clone(); + spawn_parked(async move { group.ready(1).await }).await + }; + assert!(!wait.is_finished()); + group.abort(1).unwrap(); + let result = kio::clock::timeout(TIMEOUT, wait) + .await + .expect("ready stranded") + .unwrap(); + assert!(matches!(result, Err(MoqError::Closed)), "{result:?}"); +} + +/// UniFFI's waker runs the foreign continuation on the waking thread, and a binding may poll +/// the woken call right there. A group call that wakes a parked `ready` must release the group +/// first, or that inline poll deadlocks on it. +#[tokio::test] +async fn group_calls_wake_ready_after_unlocking() { + type Ready = std::pin::Pin> + Send>>; + + /// Polls its call inline on every wake, as an unconfined coroutine would. + struct Inline { + ready: std::sync::Mutex, + result: std::sync::Mutex>>, + } + impl Inline { + fn park(group: &Arc) -> Arc { + let group = group.clone(); + let inline = Arc::new(Self { + ready: std::sync::Mutex::new(Box::pin(async move { group.ready(1).await })), + result: Default::default(), + }); + inline.poll(); + assert!(inline.result.lock().unwrap().is_none(), "no reader holds the group yet"); + inline + } + fn poll(self: &Arc) { + // The call wakes itself while it ends; that wake has nothing left to poll. + let Ok(mut ready) = self.ready.try_lock() else { return }; + let waker = std::task::Waker::from(self.clone()); + if let std::task::Poll::Ready(result) = ready.as_mut().poll(&mut std::task::Context::from_waker(&waker)) { + *self.result.lock().unwrap() = Some(result); + } + } + } + impl std::task::Wake for Inline { + fn wake(self: Arc) { + self.poll(); + } + } + + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("tcp".into(), None).unwrap(); + + let group = track.append_group().unwrap(); + let parked = Inline::park(&group); + let _reader = group.consume().unwrap(); + let result = parked.result.lock().unwrap().take(); + assert!(matches!(result, Some(Ok(()))), "{result:?}"); + + let group = track.append_group().unwrap(); + let parked = Inline::park(&group); + group.abort(1).unwrap(); + let result = parked.result.lock().unwrap().take(); + assert!(matches!(result, Some(Err(MoqError::Closed))), "{result:?}"); +} diff --git a/rs/moq-ffi/uniffi.toml b/rs/moq-ffi/uniffi.toml index ddaf3cc8ec6..08b5bd52d35 100644 --- a/rs/moq-ffi/uniffi.toml +++ b/rs/moq-ffi/uniffi.toml @@ -3,5 +3,6 @@ # generated forced end is `end()` so it doesn't collide with it. [bindings.kotlin.rename] "MoqBroadcastProducer.close" = "end" +"MoqSocketEndpoint.close" = "end" # See MoqRoute::instance_id: the C++ generator reserves `instance`, Kotlin does not. "MoqRoute.instance_id" = "instance" diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 45e1fc292b1..13e37ae6884 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,18 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket readers start at the beginning + +New `track` socket sessions subscribe from group 0, and `tcp` sessions join their +half's only group from its first frame, so an acceptor's first burst is preserved +when its subscription answer arrives after its writes. Existing +callers need no migration; socket naming and the public API are unchanged. +IETF subscribers and publishers also honor explicit starts on every supported +draft, keeping a prefix written before the answer arrived. A start of group 0 is a +filterless SUBSCRIBE on every draft, and the groups below the answer's Largest +Object come from one range FETCH. A SUBSCRIBE that omits the filter still joins +live, so third-party readers and relay caches see no change. + ### Origins prove a route key ([#7937](https://github.com/moq-dev/moq/pull/7937)) `origin::Config::route_key` takes an `origin::RouteKey`, which every lite-07 session on the origin proves to its peer in SETUP. Origins sharing a key fail over as one to a peer that scopes them; `None`, the default, proves nothing. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 0ed10727bfb..d5394ffbe45 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1802,7 +1802,7 @@ impl SubscribeTrack { // bound the subscription before subscribing so the ratio never judges it. From the // start of its group, so a relay's copy holds the group's head for its other readers, // as a live join would; the resolved range trims below. - let fixed: ServeRange = subscribe_range(msg.filter, LiveEdge::default(), publisher.version); + let fixed: ServeRange = filter_range(msg.filter, LiveEdge::default()); if let Some(start) = fixed.start { ServeRange { start: Some(Location { object: 0, ..start }), @@ -1846,7 +1846,7 @@ impl SubscribeTrack { // current-group join (Next Object plus a StartGroup=1 fill) cover the group with no gap // and no overlap. let edge = live_edge(track); - let mut range = subscribe_range(msg.filter, edge, publisher.version); + let mut range = filter_range(msg.filter, edge); let fill = msg .fill .filter(|_| msg.forward && Filter::is_draft20(publisher.version)) @@ -11929,6 +11929,114 @@ mod serve_tests { } } + /// A filterless SUBSCRIBE without a fill joins the latest group, as a relay cache always + /// served one; only an absolute start from group 0 replays the groups before it. + #[kio::test] + async fn only_an_absolute_start_replays_history() { + // Bounded, since draft 20 spells an open {0, 0} as no filter at all. + let zero = Filter::Absolute { + start: Location::default(), + end: Some(EndLocation { group: 1, object: None }), + }; + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + for (filter, history) in [(Filter::Unfiltered, 0), (zero, 1)] { + let mut h = serve(version); + for (sequence, payload) in [(0, b"history"), (1, b"latest!")] { + let mut group = h.track.create_group(group::Info { sequence }).unwrap(); + group.write_frame(timestamp(), payload.as_slice()).unwrap(); + group.finish().unwrap(); + } + + run_live(&mut h, subscribe(filter, None)).await; + + assert_eq!(occurrences(&h.log, b"latest!"), 1, "{version} {filter:?}"); + assert_eq!(occurrences(&h.log, b"history"), history, "{version} {filter:?}"); + assert_eq!(occurrences(&h.log, FETCH_STREAM), 0, "{version} {filter:?}"); + } + } + } + + /// The same through a relay: once an earlier reader warmed the relay's copy from group 0, + /// a filterless SUBSCRIBE still joins the latest group, and only an absolute start from + /// group 0 replays the cached history. + #[cfg(feature = "async")] + #[kio::test] + async fn a_relay_copy_replays_history_only_to_an_absolute_start() { + const TIMEOUT: Duration = Duration::from_secs(5); + // Bounded, since draft 20 spells an open {0, 0} as no filter at all. + let zero = Filter::Absolute { + start: Location::default(), + end: Some(EndLocation { group: 1, object: None }), + }; + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + for (filter, history) in [(Filter::Unfiltered, 0), (zero, 1)] { + let upstream = crate::origin::Config::new(crate::Hop::new(2).unwrap()).produce(); + let broadcast = upstream.route("room", crate::origin::RouteOptions::default()).unwrap(); + let track = broadcast + .broadcast() + .create_track("video", crate::track::Info::timed().with_max_age(None)) + .unwrap(); + for (sequence, payload) in [(0, b"history"), (1, b"latest!")] { + let mut group = track.create_group(group::Info { sequence }).unwrap(); + group.write_frame(timestamp(), payload.as_slice()).unwrap(); + group.finish().unwrap(); + } + let (relay, _sessions) = crate::lite::ietf::test_hop(&upstream, "room", [false, false]).await; + + // An earlier reader of the relay asked from group 0, so its copy holds both. + let warm = kio::clock::timeout(TIMEOUT, relay.consume().request("room", None)) + .await + .expect("no broadcast") + .unwrap(); + let mut reader = kio::clock::timeout( + TIMEOUT, + warm.track("video").unwrap().subscribe( + crate::track::Subscription::default() + .with_range(0..) + .with_order(crate::track::Order::Ascending) + .with_delay(crate::track::Delay::default().with_max(None)), + ), + ) + .await + .expect("no subscription") + .unwrap(); + for expected in [0, 1] { + let group = kio::clock::timeout(TIMEOUT, reader.recv_group()) + .await + .expect("no group") + .unwrap() + .expect("track finished"); + assert_eq!(group.sequence, expected, "{version} {filter:?}"); + } + + let session = ScriptedSession::per_stream(vec![Vec::new()]); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let mut h = Serve { + publisher: Publisher::new( + crate::time::sim(), + crate::driver::Transport::new(session.clone()), + relay.consume(), + Control::new(false), + None, + peer_setup, + version, + ), + log: session.log.clone(), + session, + track, + _origin: relay, + _broadcast: broadcast, + }; + run_live(&mut h, subscribe(filter, None)).await; + + assert_eq!(occurrences(&h.log, b"latest!"), 1, "{version} {filter:?}"); + assert_eq!(occurrences(&h.log, b"history"), history, "{version} {filter:?}"); + drop(reader); + } + } + } + /// A wider fill resets its promised stream and leaves the live subscription open. #[kio::test] async fn a_multi_group_fill_is_refused_without_ending_the_subscription() { @@ -20028,27 +20136,13 @@ struct GroupSlice { until: Option, } -/// Resolve a SUBSCRIBE's Location Filter into the range to serve. -/// -/// Next Object is honored on every draft so a joining FETCH owns the cached prefix. -/// Other draft-18 filters retain their existing serving behavior. -fn subscribe_range(filter: Filter, edge: LiveEdge, version: Version) -> ServeRange { - if !Filter::is_draft20(version) && filter != Filter::NextObject { - if !matches!(filter, Filter::NextObject | Filter::Unfiltered) { - tracing::warn!(?filter, "filter not supported before draft-22, ignoring"); - } - return ServeRange::default(); - } - - filter_range(filter, edge) -} - /// The Locations a single Location Filter selects, resolved against the live edge. fn filter_range(filter: Filter, edge: LiveEdge) -> ServeRange { match filter { // No restriction. moq-lite starts at the beginning of the latest group, which is // the join point it is built around; a subscription passes objects as they are - // published, so an absent filter is not a request to replay history. + // published, so an absent filter is not a request to replay history. A subscriber + // that wants the track from its start fetches the groups before the live one. Filter::Unfiltered => ServeRange::default(), // `{Largest.Group, Largest.Object + 1}`. Everything below it, including the // already-published head of the current group, is outside the requested range, @@ -20223,17 +20317,19 @@ mod range_tests { assert_eq!(group.sequence, 9); } - /// Earlier drafts never had their absolute filters served, so honoring one now would - /// change what an existing peer receives. + /// An absolute start and end retain their bounds on every draft. #[test] - fn older_drafts_are_ignored() { + fn absolute_filters_keep_their_bounds() { let msg = subscribe(Filter::Absolute { start: Location { group: 4, object: 0 }, end: Some(EndLocation { group: 9, object: None }), }); assert_eq!( - subscribe_range(msg.filter, EDGE, Version::Draft18), - ServeRange::default() + filter_range(msg.filter, EDGE), + ServeRange { + start: Some(Location { group: 4, object: 0 }), + end: Some(EndLocation { group: 9, object: None }) + } ); } @@ -20242,9 +20338,22 @@ mod range_tests { #[test] fn an_unfiltered_subscription_stays_live() { let msg = subscribe(Filter::Unfiltered); + assert_eq!(filter_range(msg.filter, EDGE), ServeRange::default()); + } + + /// An absolute start from group 0 is the request for history the absent filter is not. + #[test] + fn an_absolute_zero_start_includes_history() { + let msg = subscribe(Filter::Absolute { + start: Location::default(), + end: None, + }); assert_eq!( - subscribe_range(msg.filter, EDGE, Version::Draft22), - ServeRange::default() + filter_range(msg.filter, EDGE), + ServeRange { + start: Some(Location::default()), + end: None + } ); } @@ -20254,7 +20363,7 @@ mod range_tests { fn next_object_starts_past_the_largest_object() { let msg = subscribe(Filter::NextObject); assert_eq!( - subscribe_range(msg.filter, EDGE, Version::Draft22), + filter_range(msg.filter, EDGE), ServeRange { start: Some(Location { group: 100, object: 5 }), end: None, @@ -20273,7 +20382,7 @@ mod range_tests { }; let msg = subscribe(Filter::NextObject); assert_eq!( - subscribe_range(msg.filter, edge, Version::Draft22), + filter_range(msg.filter, edge), ServeRange { start: Some(Location { group: 101, object: 0 }), end: None, @@ -20288,7 +20397,7 @@ mod range_tests { for (groups, expected) in [(0, 101), (1, 100), (2, 99), (5, 96)] { let msg = subscribe(Filter::Relative(groups)); assert_eq!( - subscribe_range(msg.filter, EDGE, Version::Draft22), + filter_range(msg.filter, EDGE), ServeRange { start: Some(Location { group: expected, @@ -20312,7 +20421,7 @@ mod range_tests { }; let msg = subscribe(Filter::Relative(1)); assert_eq!( - subscribe_range(msg.filter, edge, Version::Draft22), + filter_range(msg.filter, edge), ServeRange { start: Some(Location { group: 0, object: 0 }), end: None, @@ -20325,7 +20434,7 @@ mod range_tests { fn relative_saturates_at_the_start() { let msg = subscribe(Filter::Relative(500)); assert_eq!( - subscribe_range(msg.filter, EDGE, Version::Draft22), + filter_range(msg.filter, EDGE), ServeRange { start: Some(Location { group: 0, object: 0 }), end: None, @@ -20337,10 +20446,7 @@ mod range_tests { #[test] fn relative_without_an_edge_stays_live() { let msg = subscribe(Filter::Relative(3)); - assert_eq!( - subscribe_range(msg.filter, LiveEdge::default(), Version::Draft22), - ServeRange::default() - ); + assert_eq!(filter_range(msg.filter, LiveEdge::default()), ServeRange::default()); } /// A group created but not yet written has no objects, so the largest sits in an @@ -20478,7 +20584,7 @@ mod range_tests { }), }); assert_eq!( - subscribe_range(msg.filter, EDGE, Version::Draft22), + filter_range(msg.filter, EDGE), ServeRange { start: Some(Location { group: 4, object: 3 }), end: Some(EndLocation { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 601aed8db01..71a51e3b941 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3571,7 +3571,9 @@ struct SubscribeOnce { deferred: bool, start: Option, join: Join, - /// A live join delivers nothing below the group SUBSCRIBE_OK names as Largest. + /// A live join delivers nothing below the group SUBSCRIBE_OK names as Largest; the + /// copy's fills fetch an earlier start's groups. An absolute start keeps its requested + /// prefix even if the answer arrives late. live: bool, /// A listed track's paused subscription, which this one resumes rather than open a /// second one. @@ -3772,9 +3774,7 @@ impl SubscribeOnce { }; self.start = subscription.as_ref().and_then(|s| s.start()); self.join = subscribe_join(self.start, subscription.as_ref().and_then(|s| s.end()), this.version); - // Every draft-18 join is a live one, since its joining FETCH carries that - // group alone. - self.live = self.start.is_none() || self.join.fetch; + self.live = !matches!(self.join.filter, Filter::Absolute { .. }); self.subscription = subscription; self.target = Some(target); @@ -4367,6 +4367,16 @@ impl SubscribeOnce { held.producer = Some(track.clone()); held.timescale = timescale; held.fill_ok(largest); + // A group-0 start joins filterless with no fill for the Largest group's head, + // so a stream there that starts mid-group is a cut message: drop it, and the + // readers skip that group, rather than surface its tail as the whole group. + // Largest's object is inclusive, so even object 0 may already be gone. + if self.live + && self.start == Some(0) + && let Some(largest) = largest + { + held.headless_through = held.headless_through.max(Some(largest.group)); + } } } this.entry_info(&self.key, request_id, track.info()); @@ -6088,11 +6098,15 @@ impl OpenGroup { // Without a fill, a relay preserves the subgroup's first Object ID and // FIRST_OBJECT claim independently. Plain readers see only frame ordinals. A // head that was owed and never came is the exception: the stream is the same - // unstitchable tail it is when it arrives before the fill settles, below. + // unstitchable tail it is when it arrives before the fill settles, below, + // unless it claims FIRST_OBJECT: a group may start past object 0. if !fill.read().outstanding() { let start = first.map_or(0, |first| first.id); - if start != 0 && (self.headless || fill.read().headless(sequence)) { + if start != 0 && !header.flags.first_object && (self.headless || fill.read().headless(sequence)) + { tracing::warn!(sequence, start, "no fill to stitch a mid-group stream onto"); + // Never arriving whole, the group is dropped so readers stop owing it. + track.drop_groups(sequence..sequence + 1, crate::StreamError::NotFound); return Poll::Ready(Err(Error::Unsupported)); } return Poll::Ready(Ok(Opened::Group(create(track)?, start))); @@ -6135,6 +6149,7 @@ impl OpenGroup { (Some(start), Some((producer, _))) => Ok(Opened::Group(producer, start)), (Some(start), None) => { tracing::warn!(sequence, start, "no fill to stitch a mid-group stream onto"); + track.drop_groups(sequence..sequence + 1, crate::StreamError::NotFound); Err(Error::Unsupported) } (None, Some((producer, next))) => Ok(Opened::Group(producer, next)), @@ -13944,19 +13959,25 @@ struct Join { /// to honor. It splits the group across two streams, the fill carrying the head and the /// subscription the tail, which `claim_fill` stitches back into one group producer. /// -/// Earlier drafts have no fill parameter. Every subscription is a live join: Largest -/// Object, followed by a joining FETCH relative at offset 0 for that group's head. An -/// earlier start's missing groups are left to the copy's fills, one FETCH per run of them, -/// and a later start skips the head locally. A join landing past a gap after a resumed -/// copy's cache leaves those cached groups unjudgeable, so a finite budget gives them up -/// rather than fetching them again. A bounded end has no joining-FETCH spelling -/// either, so the join stays open and readers stop at their own end: refusing it would -/// abort a resumed copy for every reader it serves. +/// Earlier drafts have no fill parameter, so a live join uses Largest Object followed +/// by a joining FETCH relative at offset 0 for that group's head. +/// +/// A start of group 0 with no end is a filterless SUBSCRIBE on every draft, which our +/// publishers serve from the start of the latest group, and the copy's fills fetch the +/// groups below it as one range FETCH. Draft 20 has no absolute {0, 0} distinct from no +/// filter, so no draft spells one. /// -/// A start group we already know is absolute on draft-22 and needs no fill: the -/// subscription's own range covers it, which is what our publisher serves from its cache. +/// Any other known start group is absolute on every draft and needs no fill: the +/// subscription's own range covers it, including a first burst that overtook its answer. fn subscribe_join(start: Option, end: Option, version: Version) -> Join { - if !Filter::is_draft20(version) { + if start == Some(0) && end.is_none() { + return Join { + filter: Filter::Unfiltered, + fill: None, + fetch: false, + }; + } + if !Filter::is_draft20(version) && start.is_none() { return Join { filter: Filter::NextObject, fill: None, @@ -13976,12 +13997,6 @@ fn subscribe_join(start: Option, end: Option, version: Version) -> Joi }), fetch: false, }, - // An absolute {0, 0} with no end is defined as unfiltered, so it spells itself. - Some(0) if end.is_none() => Join { - filter: Filter::Unfiltered, - fill: None, - fetch: false, - }, Some(start) => Join { filter: Filter::Absolute { start: ietf::Location { @@ -14076,28 +14091,28 @@ mod filter_tests { ); } - /// The whole track has a spelling of its own: an absent filter is unrestricted. + /// The whole track joins live with no filter on every draft, and fetches what came before. #[test] fn the_whole_track_is_unfiltered() { - assert_eq!( - subscribe_join(Some(0), None, Version::Draft22), - Join { - filter: Filter::Unfiltered, - fill: None, - fetch: false, - } - ); + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + assert_eq!( + subscribe_join(Some(0), None, version), + Join { + filter: Filter::Unfiltered, + fill: None, + fetch: false, + } + ); + } } const JOINING_DRAFTS: [Version; 1] = [Version::Draft18]; - /// Every draft-18 join is Largest Object plus a joining FETCH, whatever its start or - /// end, so the current group's head arrives on the fetch stream and the live tail on the - /// subscription. + /// A draft-18 live join gets its current group's head through a joining FETCH. #[test] - fn older_drafts_always_join_live() { + fn older_drafts_join_live_without_an_absolute_start() { for version in JOINING_DRAFTS { - for (start, end) in [(None, None), (Some(7), None), (Some(7), Some(10))] { + for (start, end) in [(None, None), (None, Some(10))] { assert_eq!( subscribe_join(start, end, version), Join { @@ -14197,7 +14212,7 @@ mod stitch_tests { /// The subscription's own subgroup stream, starting at `start` because a strict /// publisher delivers nothing before it: that head is the fill's job. Each object /// carries the Timestamp of its Object ID. - fn tail_stream(sequence: u64, start: u64, payloads: &[&[u8]]) -> Vec { + pub(super) fn tail_stream(sequence: u64, start: u64, payloads: &[&[u8]]) -> Vec { let mut buf = Vec::new(); ietf::GroupHeader { track_alias: ALIAS, @@ -15841,6 +15856,71 @@ mod stitch_tests { assert!(matches!(group.read_frame().await, Err(Error::Cancel))); } + /// A stream for a headless group that claims FIRST_OBJECT is whole even though its first + /// Object ID is past zero, so it is delivered rather than dropped as a cut tail. + #[kio::test] + async fn a_headless_group_claiming_its_first_object_is_delivered() { + let h = Harness::new(Fill::Done, vec![draft18_subgroup(SEQUENCE, true, 6, &[b"six"])]); + h.subscriber + .state + .lock() + .subscribes + .get_mut(&REQUEST) + .unwrap() + .headless_through = Some(SEQUENCE); + let mut consumer = h.track.subscribe(None); + + let mut stream = h.stream().await; + h.subscriber.clone().recv_group(&mut stream).await.expect("group"); + let (sequence, frames) = read_group(&mut consumer).await; + assert_eq!(sequence, SEQUENCE); + assert_eq!(frames.len(), 1); + } + + /// A refused headless group is reported dropped, so a reader in sequence order, as a + /// `track` socket session reads, moves on to the next group rather than wait on it. + #[kio::test] + async fn a_refused_headless_group_does_not_stall_an_ordered_reader() { + let h = Harness::new( + Fill::Done, + vec![ + tail_stream(SEQUENCE, 2, &[b"tail-2"]), + tail_stream(SEQUENCE + 1, 0, &[b"next-0"]), + ], + ); + // Subscribed before the answer, as the socket does. + let mut ordered = h + .track + .subscribe(Some( + track::Subscription::default() + .with_order(crate::track::Order::Ascending) + .with_delay(track::Delay::default().with_max(None)), + )) + .with_range(0..) + .ordered(); + h.track.received(); + h.track.anchored_cached(); + h.track.clone().start_at(SEQUENCE).unwrap(); + h.subscriber + .state + .lock() + .subscribes + .get_mut(&REQUEST) + .unwrap() + .headless_through = Some(SEQUENCE); + + h.dispatch_refused().await; + + let mut next = h.stream().await; + h.subscriber.clone().recv_group(&mut next).await.expect("next group"); + let group = match ordered.poll_next_group(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(group))) => group, + Poll::Ready(other) => panic!("no next group: {:?}", other.map(|group| group.map(|g| g.sequence))), + Poll::Pending => panic!("the ordered reader still waits on the refused group"), + }; + assert_eq!(group.sequence, SEQUENCE + 1); + } + /// Widening updates replaced fills that failed to head this group and the next, and the /// current fill has settled. A mid-group tail at or below the last failed group is still /// headless, so it is dropped, while a whole group there stands alone. @@ -17315,31 +17395,133 @@ mod joining_fetch_tests { } } - /// A start below, at, or past the Largest group joins that group alone: a group below it - /// is left to the copy's fills. + /// A known start stays absolute even below or past Largest, so it needs no live head fetch. #[kio::test] - async fn a_join_from_any_start_fetches_only_the_largest_group() { + async fn an_absolute_start_never_becomes_a_largest_group_join() { let largest = Some(ietf::Location { group: 9, object: 0 }); for version in JOINING_DRAFTS { for start in [7, 9, 11] { let run = JoinRun::start(version, Some(start), subscribe_ok(version, largest), fetch_ok(version)).await; let messages = decode_messages(&run.session.log, version); - let fetch = messages.iter().find(|(id, _)| *id == ietf::Fetch::ID).expect("FETCH"); - let body = &fetch.1; - let msg = crate::coding::decode_from(&mut &body[..], version, ietf::Fetch::decode_msg).unwrap(); + let subscribe = messages + .iter() + .find(|(id, _)| *id == ietf::Subscribe::ID) + .expect("SUBSCRIBE"); + let body = &subscribe.1; + let msg = crate::coding::decode_from(&mut &body[..], version, ietf::Subscribe::decode_msg).unwrap(); assert_eq!( - msg.fetch_type, - FetchType::RelativeJoining { - subscriber_request_id: RequestId(1), - group_offset: 0, + msg.filter, + Filter::Absolute { + start: ietf::Location { + group: start, + object: 0, + }, + end: None, }, "{version} from {start}" ); + assert!(msg.fill.is_none(), "{version} from {start}"); + assert!( + !messages.iter().any(|(id, _)| *id == ietf::Fetch::ID), + "{version} from {start}: an absolute stream includes its head" + ); + } + } + } + + /// A start of group 0 joins live with no filter on every draft and sends no joining + /// FETCH: the whole latest group arrives on the subscription, and the copy's fills fetch + /// the groups below it once a reader misses them. + #[kio::test] + async fn a_zero_start_joins_unfiltered_without_a_joining_fetch() { + let largest = Some(ietf::Location { group: 9, object: 0 }); + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let run = JoinRun::start(version, Some(0), subscribe_ok(version, largest), fetch_ok(version)).await; + + let messages = decode_messages(&run.session.log, version); + let subscribe = messages + .iter() + .find(|(id, _)| *id == ietf::Subscribe::ID) + .expect("SUBSCRIBE"); + let msg = crate::coding::decode_from(&mut &subscribe.1[..], version, ietf::Subscribe::decode_msg).unwrap(); + assert_eq!(msg.filter, Filter::Unfiltered, "{version}"); + assert!(msg.fill.is_none(), "{version}"); + assert!( + !messages.iter().any(|(id, _)| *id == ietf::Fetch::ID), + "{version}: no head to join" + ); + assert!(!run.fill_outstanding(), "{version}"); + } + } + + /// A group-0 start fetches only the groups below Largest, so when the publisher is + /// already partway through the Largest group, that group's mid-group stream is a cut + /// message. It is marked headless, so the stream is dropped and readers skip the group + /// (`a_headless_tail_after_its_fill_was_replaced_is_dropped`), rather than reading its + /// tail as a whole group. Largest's object is inclusive, so a Largest at object 0 marks + /// the group too. + #[kio::test] + async fn a_zero_start_drops_the_largest_groups_cut_tail() { + for (object, headless) in [(2, Some(4)), (0, Some(4))] { + let largest = Some(ietf::Location { group: 4, object }); + for version in [Version::Draft18, Version::Draft20, Version::Draft22] { + let run = JoinRun::start(version, Some(0), subscribe_ok(version, largest), fetch_ok(version)).await; + let state = run.subscriber.state.lock(); + let held = state + .subscribes + .values() + .next() + .expect("the subscription is registered"); + assert_eq!(held.headless_through, headless, "{version}: Largest object {object}"); } } } + /// A Largest at object 0 already sent that object, so the Largest group's stream starting + /// at object 1 without FIRST_OBJECT is a cut tail: it is refused and readers get the next + /// group instead. + #[kio::test] + async fn a_zero_start_drops_a_tail_cut_after_object_0() { + let version = Version::Draft22; + let largest = Some(ietf::Location { group: 4, object: 0 }); + let session = ScriptedSession::per_stream(vec![ + subscribe_ok(version, largest), + super::stitch_tests::tail_stream(4, 1, &[b"tail-1"]), + super::stitch_tests::tail_stream(5, 0, &[b"next-0"]), + ]); + let run = JoinRun::subscribe(version, Some(0), session.clone(), Control::new(false)).await; + let track = { + let state = run.subscriber.state.lock(); + let held = state + .subscribes + .values() + .next() + .expect("the subscription is registered"); + held.producer.clone().expect("SUBSCRIBE_OK bound the track") + }; + let mut consumer = track.subscribe(None); + + let mut streams = Vec::new(); + for _ in 0..2 { + let (_, recv) = session.open_bi(Default::default()).await.unwrap(); + streams.push(Reader::new(crate::transport::boxed::RecvStream::new(recv), version)); + } + let [tail, next] = &mut streams[..] else { unreachable!() }; + // Accepted, the tail's stream would park unended, so it must be refused at once. + let refused = run.subscriber.clone().recv_group(tail).now_or_never(); + assert!(matches!(refused, Some(Err(Error::Unsupported))), "{refused:?}"); + + // The next group's stream never ends, so read it while it is still ingesting. + let mut ingesting = run.subscriber.clone(); + let ingest = ingesting.recv_group(next); + let group = futures::select_biased! { + group = consumer.recv_group().fuse() => group.expect("track aborted").expect("track finished"), + res = ingest.fuse() => panic!("the next group's stream ended: {res:?}"), + }; + assert_eq!(group.sequence, 5, "the cut group was skipped"); + } + /// A publisher that serves no FETCH refuses the joining FETCH. That costs the head of /// the Largest group and nothing more, as a failed draft-22 fill does: the fill settles, /// so a stream starting mid-group no longer waits on it, and the subscription keeps diff --git a/rs/moq-net/src/model/socket.rs b/rs/moq-net/src/model/socket.rs index 41428a77e03..e910eaef005 100644 --- a/rs/moq-net/src/model/socket.rs +++ b/rs/moq-net/src/model/socket.rs @@ -100,13 +100,6 @@ fn messages() -> track::Info { track::Info::default().with_max_age(None) } -/// What a session's reader asks for: every group, oldest first, however late it is. -fn reliable() -> Subscription { - Subscription::default() - .with_order(Order::Ascending) - .with_delay(Delay::default().with_max(None)) -} - mod sealed { use super::*; @@ -170,6 +163,21 @@ impl Protocol { _ => None, } } + + /// What a session's reader asks for: every message, oldest first, however late it is. + fn subscription(self) -> Subscription { + let reliable = Subscription::default() + .with_order(Order::Ascending) + .with_delay(Delay::default().with_max(None)); + match self { + // The half's only group is the live one, so a live join reads it from its first + // frame, even one written before the answer: over moq-transport, a SUBSCRIBE + // plus a joining FETCH or fill for that group's head. + Self::Tcp => reliable, + // Starts at group 0, so a burst written before the answer is kept. + Self::Track => reliable.with_range(0..), + } + } } /// A session's stream: a [`TcpStream`] or a [`TrackStream`]. Names the protocol a dial, @@ -271,6 +279,8 @@ impl sealed::Open for TcpStream { /// delay budget, so a late group is waited for rather than skipped. Each hop's cache /// still reclaims a group nobody read within its idle window (30 s by default), or under /// memory pressure, except the newest, so a reader that stalls longer can miss groups. +/// Any group the track reports it will never deliver, such as one a moq-transport +/// publisher refused to fetch, is skipped the same way rather than ending the session. /// Update its subscription, such as with a finite /// [`Delay`] or [`Order::Descending`], to make it lossy or newest first, and read it in /// group order with [`track::Subscriber::ordered`]. Groups are written with @@ -825,13 +835,13 @@ enum Step { impl Step { /// Advance until the peer serves its half, subscribing to `track` in its endpoint. - fn poll(&mut self, waiter: &kio::Waiter, track: &str) -> Poll> { + fn poll(&mut self, waiter: &kio::Waiter, protocol: Protocol, track: &str) -> Poll> { loop { match self { Self::Resolving(following) => { // Only the first call is made here, and it never reports the path offline. let broadcast = ready!(following.poll_available(waiter))?.expect("the first call is never offline"); - let subscribing = broadcast.track(track)?.subscribe(reliable()).into_inner(); + let subscribing = broadcast.track(track)?.subscribe(protocol.subscription()).into_inner(); *self = Self::Subscribing(subscribing); } Self::Subscribing(subscribing) => { @@ -954,7 +964,7 @@ impl Dialing { } } - ready!(self.step.poll(waiter, &self.name))?; + ready!(self.step.poll(waiter, S::PROTOCOL, &self.name))?; // The peer serves its half only after asking for this side's, so a conforming peer // asked already; wait for its request regardless. let Some(Answer::Served(_)) = &self.answer else { @@ -1026,7 +1036,7 @@ impl Accepting { if self.demand.poll_state(waiter) != track::DemandState::Active { return Poll::Ready(Err(Error::Cancel)); } - ready!(self.step.poll(waiter, &self.name))?; + ready!(self.step.poll(waiter, S::PROTOCOL, &self.name))?; // Accepted outside the lock, since it wakes the dialer. let request = self.request.take().ok_or(Error::Closed)?; diff --git a/rs/moq-net/tests/integration/fetch_only.rs b/rs/moq-net/tests/integration/fetch_only.rs index c8974aa6ab6..0b6a0e9933b 100644 --- a/rs/moq-net/tests/integration/fetch_only.rs +++ b/rs/moq-net/tests/integration/fetch_only.rs @@ -433,9 +433,8 @@ async fn history_through_fetched_copy(upstream: Version, version: Version) { } /// A history SUBSCRIBE reaching a relay's copy kept after a fetch is answered from the -/// upstream's SUBSCRIBE_OK, never the edge the fetch learned, and reads its history. A -/// draft-18 upstream has no bounded join, so the relay joins it live and serves the history -/// from fills, keeping the copy for its other readers. +/// upstream's SUBSCRIBE_OK, never the edge the fetch learned, and reads its requested +/// history, keeping the copy for its other readers. #[kio::test] async fn a_relay_answers_a_history_subscribe_on_a_fetched_copy() { for (upstream, version) in [ @@ -476,8 +475,7 @@ async fn a_relay_answers_a_live_subscribe_on_a_fetched_copy() { /// A copy kept after a fetch resumes a bounded subscription that ends at or below the /// upstream's new Largest Object, and reads it, though no group past its end arrives. -/// A draft-18 join fills the missing run with one FETCH, which our publisher refuses -/// until hole markers are supported. That refusal ends the read instead of hanging. +/// Every supported draft serves the bounded absolute range on the subscription itself. #[kio::test] async fn a_resumed_bounded_subscription_reads_below_the_answered_edge() { for version in ["moq-transport-18", "moq-transport-20", "moq-transport-22"] { @@ -510,18 +508,13 @@ async fn a_resumed_bounded_subscription_reads_below_the_answered_edge() { .await .expect("subscribe timed out") .expect("subscribe"); - // Read in arrival order, whichever group lands first. A refused multi-group - // FETCH gives up the whole run; it never falls back to fetching each group. + // Read in arrival order, whichever group lands first. let mut read = Vec::new(); while let Some(group) = next_frames(&mut sub).await { read.push(group); } read.sort(); - if version == "moq-transport-18".parse().unwrap() { - assert!(read.is_empty(), "the unsupported multi-group FETCH was refused"); - } else { - assert_eq!(read, [frames(1), frames(2)]); - } + assert_eq!(read, [frames(1), frames(2)], "{version}"); assert!(track.subscription().is_some(), "the read never subscribed"); drop((sub, copy, remote, pair, track, broadcast, client, publisher)); @@ -534,9 +527,8 @@ async fn a_resumed_bounded_subscription_reads_below_the_answered_edge() { /// A copy that went idle holding a group resumes a bounded subscription of that group after /// the upstream moved on. With an unlimited max age every version reads it from the cache. /// Under a finite one the copy cannot judge a group below its live floor, so it reads the -/// group only where the resumed feed starts where it asked, not at the answer's Largest. -/// A draft-18 SUBSCRIBE has no such start: its live join puts the floor above the cached -/// group, which the read gives up at once rather than waiting for a group that won't come. +/// group because the resumed feed starts where it asked, not at the answer's Largest: an +/// absolute start on every draft, draft 18 included. #[kio::test] async fn a_resumed_bounded_subscription_keeps_its_cached_start() { let versions = moq_net::Versions::all(); @@ -580,14 +572,8 @@ async fn a_resumed_bounded_subscription_keeps_its_cached_start() { .await .expect("subscribe timed out") .expect("subscribe"); - let started = kio::clock::now(); let read = next_frames(&mut sub).await; - if max_age.is_some() && version == "moq-transport-18".parse().unwrap() { - assert_eq!(read, None, "{version}: the group below the live floor was given up"); - assert_eq!(kio::clock::now(), started, "{version}: the read waited"); - } else { - assert_eq!(read, Some(frames(0)), "{version}, max age {max_age:?}"); - } + assert_eq!(read, Some(frames(0)), "{version}, max age {max_age:?}"); drop((sub, copy, remote, pair, track, broadcast, client, publisher)); }) diff --git a/rs/moq-net/tests/integration/socket.rs b/rs/moq-net/tests/integration/socket.rs index 40cac69da67..03b6a92d353 100644 --- a/rs/moq-net/tests/integration/socket.rs +++ b/rs/moq-net/tests/integration/socket.rs @@ -864,6 +864,64 @@ async fn an_endpoint_not_accepting_resets_dials() { assert!(dialed.is_err(), "a dial to an endpoint not accepting connected"); } +/// An acceptor can finish its initial burst before the dialer gets the answer. +/// +/// Over moq-lite only: moq-net endpoints never speak moq-transport to each other, and +/// over it the groups below the answer's Largest come from one range FETCH, which our +/// own publishers refuse past a single group. +#[kio::test] +async fn the_first_burst_survives_a_late_subscribe_answer() { + let mut fixture = Fixture::new(); + let alice = fixture.endpoint(LITE, "alice").await; + let bob = fixture.endpoint(LITE, "bob").await; + let dial = alice.dial_track("bob", None).unwrap(); + let accept = async { + let stream = bob.accept_track().await.unwrap().accept(None).await.unwrap(); + for payload in [b"one", b"two", b"six"] { + send(&stream.producer, payload); + } + stream.producer.finish().unwrap(); + stream + }; + let (dialed, accepted) = futures::join!(dial, accept); + let dialed = dialed.unwrap(); + dialed.producer.finish().unwrap(); + let mut reader = dialed.consumer.ordered(); + for payload in [b"one", b"two", b"six"] { + assert_eq!(recv(&mut reader).await.unwrap().unwrap(), payload); + } + assert_eq!(recv(&mut reader).await.unwrap(), None); + assert_eq!(recv(&mut accepted.consumer.ordered()).await.unwrap(), None); +} + +/// TCP must also recover every frame when FIN overtakes the subscription answer. +#[kio::test] +async fn the_first_tcp_writes_survive_a_late_subscribe_answer() { + for version in [LITE, IETF, "moq-transport-20", "moq-transport-22"] { + let mut fixture = Fixture::new(); + let alice = fixture.endpoint(version, "alice").await; + let bob = fixture.endpoint(version, "bob").await; + let dial = alice.dial_tcp("bob").unwrap(); + let accept = async { + let mut stream = bob.accept_tcp().await.unwrap().accept().await.unwrap(); + for payload in [b"first".as_slice(), b"", b"last"] { + stream.producer.write_frame(None, payload).unwrap(); + } + stream.producer.finish().unwrap(); + stream + }; + let (dialed, accepted) = futures::join!(dial, accept); + let mut dialed = dialed.unwrap(); + let mut accepted = accepted; + for payload in [b"first".as_slice(), b"", b"last"] { + assert_eq!(read(&mut dialed.consumer).await.unwrap().unwrap(), payload, "{version}"); + } + assert_eq!(read(&mut dialed.consumer).await.unwrap(), None); + dialed.producer.finish().unwrap(); + assert_eq!(read(&mut accepted.consumer).await.unwrap(), None); + } +} + /// The moq-lite versions before hidden broadcasts. const OLDER: [&str; 2] = ["moq-lite-06", "moq-lite-03"]; diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 08d065383f9..606337b1b3d 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +Socket now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`GroupProducer.ready(size:)` is a new `async throws` call that waits until a frame of `size` bytes fits a window ahead of the fastest reader. Await it before each `writeFrame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following.available()` returns nil once the path goes offline `try await following.available()` returns `BroadcastConsumer?`. diff --git a/swift/Sources/Moq/Socket.swift b/swift/Sources/Moq/Socket.swift new file mode 100644 index 00000000000..ce39e3456be --- /dev/null +++ b/swift/Sources/Moq/Socket.swift @@ -0,0 +1,109 @@ +import MoqFFI + +/// Point-to-point sessions over hidden endpoint broadcasts. +public enum Socket {} + +extension Socket { + /// Announces one endpoint and dials or accepts independent TCP and track sessions. + public final class Endpoint: Sendable { + let ffi: MoqSocketEndpoint + + /// Announce `id` beneath `prefix`, using `origin` to find peers. + public init(origin: OriginProducer, prefix: String, id: String) throws { + ffi = try MoqSocketEndpoint(origin: origin.ffi, prefix: prefix, id: id) + } + + /// This endpoint's id. + public var id: String { ffi.id() } + /// The shared endpoint prefix. + public var prefix: String { ffi.prefix() } + /// Retract the endpoint and reset its pending dials and sessions. + public func close() { ffi.close() } + + /// Dial a new tcp session; cancelling resets this dial. + public func dialTcp(peer: String) async throws -> TcpStream { + TcpStream(try await ffi.dialTcp(peer: peer)) + } + /// Await the next tcp dial, independently of the other protocol. + public func acceptTcp() async throws -> IncomingTcp { + IncomingTcp(try await ffi.acceptTcp()) + } + + /// Dial a new track session; cancelling resets this dial. + public func dialTrack(peer: String, info: TrackInfo? = nil) async throws -> TrackStream { + TrackStream(try await ffi.dialTrack(peer: peer, info: info)) + } + /// Await the next track dial, independently of the other protocol. + public func acceptTrack() async throws -> IncomingTrack { + IncomingTrack(try await ffi.acceptTrack()) + } + } + + /// An incoming tcp dial, consumed by accepting or rejecting it. + public final class IncomingTcp: Sendable { + let ffi: MoqSocketIncomingTcp + init(_ ffi: MoqSocketIncomingTcp) { self.ffi = ffi } + /// The claimed peer; accepting verifies return routability. + public var peer: String { ffi.peer() } + /// The private session id. + public var session: String { ffi.session() } + /// Verify return routability and accept the dial. + public func accept() async throws -> TcpStream { + TcpStream(try await ffi.accept()) + } + /// Refuse this dial as not found. + public func reject() throws { try ffi.reject() } + } + + /// A tcp session's two halves and identities. + public final class TcpStream: Sendable { + /// This side's write half. + public let producer: GroupProducer + /// The peer's read half, retaining its subscription. + public let consumer: GroupConsumer + /// The peer endpoint's id. + public let peer: String + /// The private session id. + public let session: String + init(_ ffi: MoqSocketTcpStream) { + producer = GroupProducer(ffi.producer()) + consumer = GroupConsumer(ffi.consumer()) + peer = ffi.peer() + session = ffi.session() + } + } + + /// An incoming track dial, consumed by accepting or rejecting it. + public final class IncomingTrack: Sendable { + let ffi: MoqSocketIncomingTrack + init(_ ffi: MoqSocketIncomingTrack) { self.ffi = ffi } + /// The claimed peer; accepting verifies return routability. + public var peer: String { ffi.peer() } + /// The private session id. + public var session: String { ffi.session() } + /// Verify return routability and accept the dial. + public func accept(info: TrackInfo? = nil) async throws -> TrackStream { + TrackStream(try await ffi.accept(info: info)) + } + /// Refuse this dial as not found. + public func reject() throws { try ffi.reject() } + } + + /// A track session's two halves and identities. + public final class TrackStream: Sendable { + /// This side's write half. + public let producer: TrackProducer + /// The peer's read half, retaining its subscription. + public let consumer: TrackConsumer + /// The peer endpoint's id. + public let peer: String + /// The private session id. + public let session: String + init(_ ffi: MoqSocketTrackStream) { + producer = TrackProducer(ffi.producer()) + consumer = TrackConsumer(ffi.consumer()) + peer = ffi.peer() + session = ffi.session() + } + } +} diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index edcaa8def56..d2d58cda5c9 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -208,7 +208,19 @@ public final class GroupProducer: Sendable { GroupConsumer(try ffi.consume()) } - /// Write a frame with a presentation timestamp, or untimed when nil. + /// Wait until a frame of `size` bytes fits a window ahead of the fastest reader. + /// + /// Await it before each `writeFrame` so that reader paces the writer, as TCP + /// backpressure does, instead of the group shedding frames it has not taken, which + /// fails it with `Evicted`; slower readers are not waited on. It also waits until a reader holds the + /// group, and returns once the group is closed so the next write throws why. Throws + /// `Closed` after `abort`. + public func ready(size: UInt64) async throws { + try await ffi.ready(size: size) + } + + /// Write a frame with a presentation timestamp, or untimed when nil. Never blocks: + /// without `ready`, a writer that outpaces its readers sheds the group's front instead. public func writeFrame(_ payload: Data, timestamp: Timestamp? = nil) throws { try ffi.writeFrame(frame: Frame(payload: payload, timestamp: timestamp)) } diff --git a/test/interop/README.md b/test/interop/README.md index ced348ff8e0..9c4d8e8060c 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -52,6 +52,14 @@ Rust CLI swap its channels' tones, and every subscriber must refuse every rendit as the wrong tone, not fail some other way. `interop.sh` is the one place the tones and each publisher's codecs are chosen. +When Python is selected, the in-tree run also connects Python and Rust socket +endpoints through the relay in both dial directions and both `tcp` and `track` +protocols. Each direction checks exact frames, empty writes, ordered groups, +and clean FIN. A stdin drain acknowledgement keeps Rust alive until Python has +read its entire half. The lane runs only in the normal pass, pinned to lite-07: +moq-net endpoints speak moq-lite to each other, never moq-transport. Older lite +hops are outside this lane. + The normal run also checks a finite raw track through the relay. Its publisher announces end 4 before writing four 256 KiB groups; the reader verifies every byte, exactly groups 0 through 3, and a clean end at 4. Rust-to-Rust always runs. Selecting diff --git a/test/interop/clients/python/sockets.py b/test/interop/clients/python/sockets.py new file mode 100644 index 00000000000..fc9a121e469 --- /dev/null +++ b/test/interop/clients/python/sockets.py @@ -0,0 +1,102 @@ +"""Python/Rust socket interop through a relay, in both directions and protocols.""" + +import argparse +import asyncio +import contextlib +from collections.abc import Coroutine +from typing import Any, TypeVar + +import moq +from moq import socket + +PAYLOADS = [b"first", b"", b"last"] +T = TypeVar("T") + + +async def start(coro: Coroutine[Any, Any, T]) -> asyncio.Task[T]: + """Enable an accept queue before another process can dial it.""" + loop = asyncio.get_running_loop() + polled = loop.create_future() + + async def run() -> T: + loop.call_soon(polled.set_result, None) + return await coro + + task = asyncio.create_task(run()) + await polled + return task + + +async def exchange(stream: socket.TcpStream | socket.TrackStream) -> None: + assert stream.peer == "rust" + if isinstance(stream, socket.TcpStream): + for payload in PAYLOADS: + stream.producer.write_frame(payload) + stream.producer.finish() + assert [frame.payload async for frame in stream.consumer] == PAYLOADS + else: + assert stream.consumer.info().max_age is None + for _ in range(3): + group = stream.producer.append_group() + for payload in PAYLOADS: + group.write_frame(payload) + group.finish() + stream.producer.finish() + received = [(group.sequence, [frame.payload async for frame in group]) async for group in stream.consumer] + assert received == [(sequence, PAYLOADS) for sequence in range(3)], received + + +async def main(args: argparse.Namespace) -> None: + origin = moq.OriginProducer() + async with moq.Client( + args.url, publish=origin, consume=origin, versions=[args.version], tls=moq.ClientTls(insecure=True) + ): + for protocol in ("tcp", "track"): + for role in ("dial", "accept"): + prefix = f"{args.prefix}/{protocol}/{role}" + async with socket.Endpoint(origin, prefix, "python") as endpoint: + incoming = None + if role == "dial": + incoming = await start(endpoint.accept_tcp() if protocol == "tcp" else endpoint.accept_track()) + peer = await asyncio.create_subprocess_exec( + args.rust, + args.url, + prefix, + args.version, + protocol, + role, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + assert peer.stdout is not None and peer.stdin is not None + try: + if incoming is not None: + stream = await (await incoming).accept() + else: + assert await peer.stdout.readline() == b"listening\n" + stream = await ( + endpoint.dial_tcp("rust") if protocol == "tcp" else endpoint.dial_track("rust") + ) + await exchange(stream) + assert await peer.stdout.readline() == b"drained\n" + peer.stdin.write(b"drained\n") + await peer.stdin.drain() + assert await peer.wait() == 0 + print(f"socket {protocol}, Rust {role}: passed", flush=True) + finally: + if incoming is not None and not incoming.done(): + incoming.cancel() + with contextlib.suppress(asyncio.CancelledError): + await incoming + if peer.returncode is None: + peer.kill() + await peer.wait() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("url") + parser.add_argument("prefix") + parser.add_argument("version") + parser.add_argument("rust") + asyncio.run(main(parser.parse_args())) diff --git a/test/interop/clients/rust/socket.rs b/test/interop/clients/rust/socket.rs new file mode 100644 index 00000000000..4f33ceae8ca --- /dev/null +++ b/test/interop/clients/rust/socket.rs @@ -0,0 +1,112 @@ +//! Socket interop peer; stdin acknowledges that the other process drained its half. +use anyhow::{Context, ensure}; +use moq_tokio::moq_net::{self, socket::Endpoint}; +use std::future::Future; +use std::task::Poll; +use tokio::io::AsyncBufReadExt; + +const PAYLOADS: [&[u8]; 3] = [b"first", b"", b"last"]; + +async fn group(group: &mut moq_net::group::Consumer) -> anyhow::Result<()> { + for expected in PAYLOADS { + let frame = group.read_frame().await?.context("short group")?; + ensure!(frame.payload.as_ref() == expected, "wrong frame"); + ensure!(frame.timestamp.is_none(), "unexpected timestamp"); + } + ensure!(group.read_frame().await?.is_none(), "extra frame"); + Ok(()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args: Vec<_> = std::env::args().collect(); + ensure!( + args.len() == 6, + "usage: interop-socket URL PREFIX VERSION tcp|track dial|accept" + ); + let mut config = moq_tokio::connect::Config::default(); + config.version.push(args[3].parse().map_err(anyhow::Error::msg)?); + config.websocket.enabled = Some(false); + let client = config.init(Default::default())?.with_reconnect(false); + let origin = moq_tokio::origin::spawn(); + let endpoint = Endpoint::new(&origin, args[2].as_str(), "rust")?; + let session = client + .with_publisher(&origin) + .with_subscriber(origin.clone()) + .connect(args[1].parse::()?) + .established() + .await?; + let dial = args[5] == "dial"; + ensure!(dial || args[5] == "accept", "unknown role"); + match args[4].as_str() { + "tcp" => { + let mut stream = if dial { + endpoint.dial_tcp("python")?.await? + } else { + let mut incoming = Box::pin(endpoint.accept_tcp()); + std::future::poll_fn(|cx| { + assert!(incoming.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + println!("listening"); + incoming.await?.accept().await? + }; + ensure!(stream.peer() == "python", "wrong peer"); + for payload in PAYLOADS { + stream.producer.write_frame(None, payload)?; + } + stream.producer.finish()?; + group(&mut stream.consumer).await?; + println!("drained"); + ack().await?; + } + "track" => { + let stream = if dial { + endpoint.dial_track("python", None)?.await? + } else { + let mut incoming = Box::pin(endpoint.accept_track()); + std::future::poll_fn(|cx| { + assert!(incoming.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + println!("listening"); + incoming.await?.accept(None).await? + }; + ensure!(stream.peer() == "python", "wrong peer"); + ensure!(stream.consumer.info().max_age.is_none(), "track has an age limit"); + let producer = stream.producer; + let mut consumer = stream.consumer.ordered(); + for _ in 0..3 { + let mut group = producer.append_group()?; + for payload in PAYLOADS { + group.write_frame(None, payload)?; + } + group.finish()?; + } + producer.finish()?; + for sequence in 0..3 { + let mut next = consumer.next_group().await?.context("short track")?; + ensure!(next.sequence == sequence, "wrong group order"); + group(&mut next).await?; + } + ensure!(consumer.next_group().await?.is_none(), "extra group"); + println!("drained"); + ack().await?; + } + _ => anyhow::bail!("unknown protocol"), + } + endpoint.close(); + session.close().await?; + Ok(()) +} + +async fn ack() -> anyhow::Result<()> { + let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines(); + ensure!( + lines.next_line().await?.as_deref() == Some("drained"), + "peer did not drain" + ); + Ok(()) +} diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 6ca445e9897..4f5c0186925 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -498,6 +498,9 @@ plan_pass() { done fi [[ "$FINISH_ONLY" -eq 0 ]] || return 0 + if needs python; then + [[ "$VERSION" != auto ]] || record "not run" case="Python Rust sockets" publisher=python subscriber=rust + fi for pub in rust js-native-node js-native-bun; do [[ "$pub" == rust ]] || needs "$pub" || continue for sub in rust js-native-node js-native-bun; do @@ -631,7 +634,7 @@ build_relay_cli() { INTEROP_SUB_MOQ="$HARNESS_RUN/bin/moq-sub" fi if [[ "$build_examples" -eq 1 && "$MEDIA" -eq 0 ]]; then - local example args=() examples=(interop-tail interop-catalog interop-audio interop-hls interop-control interop-goaway interop-goaway-client interop-finish) + local example args=() examples=(interop-socket interop-tail interop-catalog interop-audio interop-hls interop-control interop-goaway interop-goaway-client interop-finish) for example in "${examples[@]}"; do args+=(--example "$example"); done (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-cli "${args[@]}") for example in "${examples[@]}"; do @@ -2493,6 +2496,24 @@ run_hls_grid() { } # This checkout's CLI FETCH and finite track tails in this pass, then the lite drop lanes. +# Both roles and protocols share this lane; Python acknowledges Rust's drain before teardown. +# Lite only: moq-net endpoints speak moq-lite to each other, never moq-transport. +run_sockets() { + needs python || return 0 + [[ "$VERSION" == auto ]] || return 0 + local version=moq-lite-07-wip log="$LOGS/sockets.log" prefix="sockets-$$-$RANDOM" + if is_broken python || ! timeout -k 3 "$TIMEOUT" "$PY" "$CLIENTS/python/sockets.py" \ + "$(token_url --publish "$prefix/**" --subscribe "$prefix/**")" "$prefix" "$version" \ + "$EXAMPLES/interop-socket" >"$log" 2>&1; then + echo " FAIL Python/Rust sockets ($version): $log" + record fail case="Python Rust sockets" publisher=python subscriber=rust log="$log" + overall=1 + return + fi + echo " PASS Python/Rust sockets ($version), both roles and protocols" + record pass case="Python Rust sockets" publisher=python subscriber=rust log="$log" +} + run_tails() { local runtime version transport echo "=== CLI FETCH close ===" @@ -2589,7 +2610,10 @@ else [[ "$TAIL_ONLY" -eq 1 || "$FINISH_ONLY" -eq 1 ]] || run_matrix if [[ "$IN_TREE" -eq 1 ]]; then [[ "$TAIL_ONLY" -eq 1 ]] || run_finishes - [[ "$FINISH_ONLY" -eq 1 ]] || run_tails + if [[ "$FINISH_ONLY" -eq 0 ]]; then + run_tails + run_sockets + fi fi done fi